use crate::ai_agents::AiAgentAvailability; use std::path::{Path, PathBuf}; pub(crate) fn check_cli() -> AiAgentAvailability { match find_binary() { Ok(binary) => AiAgentAvailability { installed: true, version: version_for_binary(&binary), }, Err(_) => AiAgentAvailability { installed: false, version: None, }, } } pub(crate) fn find_binary() -> Result { if let Some(binary) = find_binary_on_path() { return Ok(binary); } if let Some(binary) = find_binary_in_user_shell() { return Ok(binary); } if let Some(binary) = find_existing_binary(opencode_binary_candidates()) { return Ok(binary); } Err("OpenCode CLI not found. Install it: https://opencode.ai/docs/".into()) } fn version_for_binary(binary: &PathBuf) -> Option { crate::hidden_command(binary) .arg("--version") .output() .ok() .filter(|output| output.status.success()) .map(|output| String::from_utf8_lossy(&output.stdout).trim().to_string()) } fn find_binary_on_path() -> Option { crate::hidden_command("which") .arg("opencode") .output() .ok() .and_then(|output| path_from_successful_output(&output)) } fn find_binary_in_user_shell() -> Option { user_shell_candidates() .into_iter() .filter(|shell| shell.exists()) .find_map(|shell| command_path_from_shell(&shell, "opencode")) } fn user_shell_candidates() -> Vec { let mut shells = Vec::new(); if let Some(shell) = std::env::var_os("SHELL") { if !shell.is_empty() { shells.push(PathBuf::from(shell)); } } shells.push(PathBuf::from("/bin/zsh")); shells.push(PathBuf::from("/bin/bash")); shells } fn command_path_from_shell(shell: &Path, command: &str) -> Option { crate::hidden_command(shell) .arg("-lc") .arg(format!("command -v {command}")) .output() .ok() .and_then(|output| path_from_successful_output(&output)) } fn path_from_successful_output(output: &std::process::Output) -> Option { if output.status.success() { first_existing_path(&String::from_utf8_lossy(&output.stdout)) } else { None } } fn first_existing_path(stdout: &str) -> Option { stdout.lines().find_map(|line| { let trimmed = line.trim(); if trimmed.is_empty() { return None; } let candidate = PathBuf::from(trimmed); candidate.exists().then_some(candidate) }) } fn find_existing_binary(candidates: Vec) -> Option { candidates.into_iter().find(|candidate| candidate.exists()) } fn opencode_binary_candidates() -> Vec { dirs::home_dir() .map(|home| opencode_binary_candidates_for_home(&home)) .unwrap_or_default() } fn opencode_binary_candidates_for_home(home: &Path) -> Vec { vec![ home.join(".local/bin/opencode"), home.join(".opencode/bin/opencode"), home.join(".local/share/mise/shims/opencode"), home.join(".asdf/shims/opencode"), home.join(".npm-global/bin/opencode"), home.join(".npm/bin/opencode"), home.join(".bun/bin/opencode"), PathBuf::from("/usr/local/bin/opencode"), PathBuf::from("/opt/homebrew/bin/opencode"), ] } #[cfg(test)] mod tests { use super::*; #[test] fn binary_candidates_include_supported_local_installs() { let home = PathBuf::from("/Users/alex"); let candidates = opencode_binary_candidates_for_home(&home); let expected = [ home.join(".local/bin/opencode"), home.join(".opencode/bin/opencode"), home.join(".local/share/mise/shims/opencode"), home.join(".asdf/shims/opencode"), home.join(".npm-global/bin/opencode"), home.join(".bun/bin/opencode"), PathBuf::from("/opt/homebrew/bin/opencode"), ]; for candidate in expected { assert!( candidates.contains(&candidate), "missing {}", candidate.display() ); } } #[test] fn first_existing_path_skips_empty_and_missing_lines() { let dir = tempfile::tempdir().unwrap(); let missing = dir.path().join("missing-opencode"); let opencode = dir.path().join("opencode"); std::fs::write(&opencode, "#!/bin/sh\n").unwrap(); let stdout = format!("\n{}\n{}\n", missing.display(), opencode.display()); assert_eq!(first_existing_path(&stdout), Some(opencode)); } }