Skip to main content

cli_agents/
discovery.rs

1use crate::types::CliName;
2use std::collections::HashMap;
3use std::path::{Path, PathBuf};
4use std::sync::Mutex;
5use tokio::process::Command;
6
7/// Process-wide cache of discovered binary paths. Call [`clear_cache`] to reset.
8///
9/// NOTE: Discovery relies on unix-specific APIs (`which`, permission bits, NVM paths)
10/// and is not fully functional on Windows.
11static CACHE: Mutex<Option<HashMap<CliName, String>>> = Mutex::new(None);
12
13fn home_dir() -> Option<PathBuf> {
14    std::env::var("HOME").ok().map(PathBuf::from)
15}
16
17fn is_executable(path: &Path) -> bool {
18    #[cfg(unix)]
19    {
20        use std::os::unix::fs::PermissionsExt;
21        path.is_file()
22            && std::fs::metadata(path)
23                .map(|m| m.permissions().mode() & 0o111 != 0)
24                .unwrap_or(false)
25    }
26    #[cfg(not(unix))]
27    {
28        path.is_file()
29    }
30}
31
32async fn which(binary: &str) -> Option<String> {
33    let output = Command::new("which").arg(binary).output().await.ok()?;
34    if output.status.success() {
35        let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
36        if !path.is_empty() {
37            return Some(path);
38        }
39    }
40    None
41}
42
43fn find_nvm_binary(binary: &str) -> Option<String> {
44    // Check $NVM_BIN
45    if let Ok(nvm_bin) = std::env::var("NVM_BIN") {
46        let p = PathBuf::from(&nvm_bin).join(binary);
47        if is_executable(&p) {
48            return Some(p.to_string_lossy().into_owned());
49        }
50    }
51
52    // Check ~/.nvm/versions/node/*/bin/ (newest first)
53    let home = home_dir()?;
54    let nvm_versions = home.join(".nvm/versions/node");
55    if !nvm_versions.is_dir() {
56        return None;
57    }
58
59    let mut versions: Vec<PathBuf> = std::fs::read_dir(&nvm_versions)
60        .ok()?
61        .filter_map(|e| e.ok())
62        .map(|e| e.path())
63        .filter(|p| p.is_dir())
64        .collect();
65
66    // Sort descending by semver (newest first).
67    // NVM dirs are named like "v20.11.0", "v18.17.1", etc.
68    versions.sort_by(|a, b| {
69        let parse_ver = |p: &Path| -> (u64, u64, u64) {
70            let name = p.file_name().unwrap_or_default().to_string_lossy();
71            let s = name.strip_prefix('v').unwrap_or(&name);
72            let mut parts = s.split('.').map(|n| n.parse::<u64>().unwrap_or(0));
73            (
74                parts.next().unwrap_or(0),
75                parts.next().unwrap_or(0),
76                parts.next().unwrap_or(0),
77            )
78        };
79        parse_ver(b).cmp(&parse_ver(a))
80    });
81
82    for dir in versions {
83        let p = dir.join("bin").join(binary);
84        if is_executable(&p) {
85            return Some(p.to_string_lossy().into_owned());
86        }
87    }
88
89    None
90}
91
92const SEARCH_PATHS: &[&str] = &["/opt/homebrew/bin", "/usr/local/bin"];
93
94const HOME_RELATIVE_PATHS: &[&str] = &[".local/bin", ".bun/bin", ".npm-global/bin"];
95
96const CLAUDE_EXTRA_PATHS: &[&str] = &[".claude/local/claude"];
97
98async fn search_for_binary(cli: CliName) -> Option<String> {
99    let binary = cli.to_string();
100
101    // 1. which (PATH)
102    if let Some(path) = which(&binary).await {
103        return Some(path);
104    }
105
106    // 2. NVM paths (node-based CLIs)
107    if let Some(path) = find_nvm_binary(&binary) {
108        return Some(path);
109    }
110
111    // 3. Common install locations
112    for dir in SEARCH_PATHS {
113        let p = PathBuf::from(dir).join(&binary);
114        if is_executable(&p) {
115            return Some(p.to_string_lossy().into_owned());
116        }
117    }
118
119    // 4. Home-relative paths
120    if let Some(home) = home_dir() {
121        for rel in HOME_RELATIVE_PATHS {
122            let p = home.join(rel).join(&binary);
123            if is_executable(&p) {
124                return Some(p.to_string_lossy().into_owned());
125            }
126        }
127
128        // 5. CLI-specific paths
129        if cli == CliName::Claude {
130            for rel in CLAUDE_EXTRA_PATHS {
131                let p = home.join(rel);
132                if is_executable(&p) {
133                    return Some(p.to_string_lossy().into_owned());
134                }
135            }
136        }
137    }
138
139    None
140}
141
142/// Discover a specific CLI binary, caching the result.
143pub async fn discover_binary(cli: CliName) -> Option<String> {
144    // Check cache
145    {
146        let guard = CACHE.lock().unwrap_or_else(|e| e.into_inner());
147        if let Some(cache) = guard.as_ref() {
148            if let Some(path) = cache.get(&cli) {
149                if is_executable(Path::new(path)) {
150                    return Some(path.clone());
151                }
152            }
153        }
154    }
155
156    let path = search_for_binary(cli).await?;
157
158    // Cache result
159    {
160        let mut guard = CACHE.lock().unwrap_or_else(|e| e.into_inner());
161        let cache = guard.get_or_insert_with(HashMap::new);
162        cache.insert(cli, path.clone());
163    }
164
165    Some(path)
166}
167
168/// Discover all available CLI binaries (concurrent).
169pub async fn discover_all() -> Vec<(CliName, String)> {
170    let (claude, codex, gemini) = tokio::join!(
171        discover_binary(CliName::Claude),
172        discover_binary(CliName::Codex),
173        discover_binary(CliName::Gemini),
174    );
175
176    let mut results = Vec::new();
177    if let Some(path) = claude {
178        results.push((CliName::Claude, path));
179    }
180    if let Some(path) = codex {
181        results.push((CliName::Codex, path));
182    }
183    if let Some(path) = gemini {
184        results.push((CliName::Gemini, path));
185    }
186    results
187}
188
189/// Discover the first available CLI binary (preference: Claude > Codex > Gemini).
190///
191/// Runs all lookups concurrently and returns the highest-priority match.
192pub async fn discover_first() -> Option<(CliName, String)> {
193    let (claude, codex, gemini) = tokio::join!(
194        discover_binary(CliName::Claude),
195        discover_binary(CliName::Codex),
196        discover_binary(CliName::Gemini),
197    );
198
199    if let Some(path) = claude {
200        return Some((CliName::Claude, path));
201    }
202    if let Some(path) = codex {
203        return Some((CliName::Codex, path));
204    }
205    if let Some(path) = gemini {
206        return Some((CliName::Gemini, path));
207    }
208    None
209}
210
211/// Clear the binary discovery cache.
212pub fn clear_cache() {
213    let mut guard = CACHE.lock().unwrap_or_else(|e| e.into_inner());
214    *guard = None;
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220
221    #[test]
222    fn nvm_version_sorting() {
223        // Simulate the version sorting logic used in find_nvm_binary
224        let parse_ver = |name: &str| -> (u64, u64, u64) {
225            let s = name.strip_prefix('v').unwrap_or(name);
226            let mut parts = s.split('.').map(|n| n.parse::<u64>().unwrap_or(0));
227            (
228                parts.next().unwrap_or(0),
229                parts.next().unwrap_or(0),
230                parts.next().unwrap_or(0),
231            )
232        };
233
234        assert_eq!(parse_ver("v20.11.0"), (20, 11, 0));
235        assert_eq!(parse_ver("v18.17.1"), (18, 17, 1));
236        assert_eq!(parse_ver("v22.0.0"), (22, 0, 0));
237        assert_eq!(parse_ver("invalid"), (0, 0, 0));
238        assert_eq!(parse_ver("v1"), (1, 0, 0));
239
240        // Sorting: newer versions should come first
241        let mut versions = vec!["v18.17.1", "v22.0.0", "v20.11.0"];
242        versions.sort_by(|a, b| parse_ver(b).cmp(&parse_ver(a)));
243        assert_eq!(versions, vec!["v22.0.0", "v20.11.0", "v18.17.1"]);
244    }
245
246    #[cfg(unix)]
247    #[test]
248    fn is_executable_checks_permission_bits() {
249        use std::os::unix::fs::PermissionsExt;
250        let dir = tempfile::tempdir().unwrap();
251
252        let non_exec = dir.path().join("not-exec");
253        std::fs::write(&non_exec, "#!/bin/sh").unwrap();
254        std::fs::set_permissions(&non_exec, std::fs::Permissions::from_mode(0o644)).unwrap();
255        assert!(!is_executable(&non_exec));
256
257        let exec = dir.path().join("exec");
258        std::fs::write(&exec, "#!/bin/sh").unwrap();
259        std::fs::set_permissions(&exec, std::fs::Permissions::from_mode(0o755)).unwrap();
260        assert!(is_executable(&exec));
261
262        assert!(!is_executable(Path::new("/does/not/exist")));
263    }
264
265    #[test]
266    fn clear_cache_resets_state() {
267        // Populate cache
268        {
269            let mut guard = CACHE.lock().unwrap();
270            let cache = guard.get_or_insert_with(HashMap::new);
271            cache.insert(CliName::Claude, "/usr/bin/claude".into());
272        }
273
274        clear_cache();
275
276        let guard = CACHE.lock().unwrap();
277        assert!(guard.is_none());
278    }
279
280    #[test]
281    fn cli_name_display() {
282        assert_eq!(CliName::Claude.to_string(), "claude");
283        assert_eq!(CliName::Codex.to_string(), "codex");
284        assert_eq!(CliName::Gemini.to_string(), "gemini");
285    }
286}