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;
5
6/// Process-wide cache of discovered binary paths. Call [`clear_cache`] to reset.
7///
8/// WORKS ON WINDOWS NOW, and the note that used to sit here saying otherwise was
9/// accurate: this module shelled out to `which` (no such binary on Windows),
10/// read `HOME` (Windows uses `USERPROFILE`), and searched `~/.nvm` and
11/// `/opt/homebrew/bin`. Two crates replaced all three — `which` for PATH
12/// lookup and `home` for the home directory — which is a net DELETION of
13/// platform-specific code rather than an addition.
14static CACHE: Mutex<Option<HashMap<CliName, String>>> = Mutex::new(None);
15
16fn home_dir() -> Option<PathBuf> {
17    home::home_dir()
18}
19
20/// Executable file extensions to try when probing a directory directly.
21///
22/// EMPTY ON UNIX — the binary is the bare name. On Windows an npm global
23/// install writes THREE shims for one CLI: `claude` (a bash script, for
24/// git-bash), `claude.cmd`, and `claude.ps1`. Only the `.cmd` is runnable by
25/// `CreateProcess`, and the bare `claude` is a real file — so a naive
26/// `path.is_file()` probe finds the bash script and hands back something
27/// Windows cannot execute. That is worse than finding nothing, because the
28/// failure surfaces at spawn time as a confusing error instead of as
29/// "not installed".
30#[cfg(windows)]
31const EXE_EXTENSIONS: &[&str] = &["cmd", "exe", "bat"];
32#[cfg(not(windows))]
33const EXE_EXTENSIONS: &[&str] = &[];
34
35/// The runnable file for `stem` inside `dir`, if there is one.
36fn runnable_in(dir: &Path, stem: &str) -> Option<PathBuf> {
37    if EXE_EXTENSIONS.is_empty() {
38        let p = dir.join(stem);
39        return is_executable(&p).then_some(p);
40    }
41    EXE_EXTENSIONS.iter().find_map(|ext| {
42        let p = dir.join(format!("{stem}.{ext}"));
43        is_executable(&p).then_some(p)
44    })
45}
46
47fn is_executable(path: &Path) -> bool {
48    #[cfg(unix)]
49    {
50        use std::os::unix::fs::PermissionsExt;
51        path.is_file()
52            && std::fs::metadata(path)
53                .map(|m| m.permissions().mode() & 0o111 != 0)
54                .unwrap_or(false)
55    }
56    #[cfg(not(unix))]
57    {
58        path.is_file()
59    }
60}
61
62/// Resolve `binary` on PATH.
63///
64/// The `which` CRATE, not a `which` PROCESS. Besides working on Windows at all,
65/// it applies `PATHEXT` there, so `claude` resolves to `claude.cmd`. It is
66/// synchronous and does no I/O beyond stat-ing PATH entries, so it does not
67/// need `spawn_blocking` — and it removes a process spawn from a hot path that
68/// used to fork a shell utility three times at startup (once per CLI).
69fn which_on_path(binary: &str) -> Option<String> {
70    which::which(binary)
71        .ok()
72        .map(|p| p.to_string_lossy().into_owned())
73}
74
75fn find_nvm_binary(binary: &str) -> Option<String> {
76    // Check $NVM_BIN
77    if let Ok(nvm_bin) = std::env::var("NVM_BIN") {
78        let p = PathBuf::from(&nvm_bin).join(binary);
79        if is_executable(&p) {
80            return Some(p.to_string_lossy().into_owned());
81        }
82    }
83
84    // Check ~/.nvm/versions/node/*/bin/ (newest first)
85    let home = home_dir()?;
86    let nvm_versions = home.join(".nvm/versions/node");
87    if !nvm_versions.is_dir() {
88        return None;
89    }
90
91    let mut versions: Vec<PathBuf> = std::fs::read_dir(&nvm_versions)
92        .ok()?
93        .filter_map(|e| e.ok())
94        .map(|e| e.path())
95        .filter(|p| p.is_dir())
96        .collect();
97
98    // Sort descending by semver (newest first).
99    versions.sort_by(|a, b| {
100        let name_of = |p: &Path| p.file_name().unwrap_or_default().to_string_lossy().into_owned();
101        parse_nvm_version(&name_of(b)).cmp(&parse_nvm_version(&name_of(a)))
102    });
103
104    for dir in versions {
105        if let Some(p) = runnable_in(&dir.join("bin"), binary) {
106            return Some(p.to_string_lossy().into_owned());
107        }
108    }
109
110    None
111}
112
113/// `"v20.11.0"` → `(20, 11, 0)`. Unparseable components sort as 0.
114///
115/// A FREE FUNCTION SO THE TEST CAN CALL IT. It used to be a closure inside
116/// `find_nvm_binary`, and the test for it re-implemented the same logic inline
117/// — so `nvm_version_sorting` passed regardless of what the real sort did, and
118/// would have kept passing if the production copy were deleted.
119pub(crate) fn parse_nvm_version(name: &str) -> (u64, u64, u64) {
120    let s = name.strip_prefix('v').unwrap_or(name);
121    let mut parts = s.split('.').map(|n| n.parse::<u64>().unwrap_or(0));
122    (
123        parts.next().unwrap_or(0),
124        parts.next().unwrap_or(0),
125        parts.next().unwrap_or(0),
126    )
127}
128
129/// WHY THERE ARE FALLBACKS AT ALL, given `which` searches PATH: a macOS GUI app
130/// does not inherit the shell's PATH. An `npm -g` install lands somewhere the
131/// Finder-launched process has never heard of, so PATH lookup alone reports a
132/// CLI the user demonstrably has as missing. These are that gap, and they are
133/// per-platform because the gap is.
134#[cfg(unix)]
135const SEARCH_PATHS: &[&str] = &["/opt/homebrew/bin", "/usr/local/bin"];
136/// Windows inherits the system PATH into GUI processes, so `which` covers the
137/// normal case; this is for a per-user npm prefix that PATH may lag behind.
138#[cfg(windows)]
139const SEARCH_PATHS: &[&str] = &[];
140
141#[cfg(unix)]
142const HOME_RELATIVE_PATHS: &[&str] = &[".local/bin", ".bun/bin", ".npm-global/bin"];
143/// `%APPDATA%` and nvm-windows both sit under the user profile, which is what
144/// `home::home_dir()` returns here.
145#[cfg(windows)]
146const HOME_RELATIVE_PATHS: &[&str] = &[
147    "AppData/Roaming/npm",
148    "AppData/Roaming/nvm",
149    ".bun/bin",
150];
151
152const CLAUDE_EXTRA_PATHS: &[&str] = &[".claude/local/claude"];
153
154fn search_for_binary(cli: CliName) -> Option<String> {
155    let binary = cli.to_string();
156
157    // 1. PATH
158    if let Some(path) = which_on_path(&binary) {
159        return Some(path);
160    }
161
162    // 2. NVM paths (node-based CLIs)
163    if let Some(path) = find_nvm_binary(&binary) {
164        return Some(path);
165    }
166
167    // 3. Common install locations
168    for dir in SEARCH_PATHS {
169        if let Some(p) = runnable_in(Path::new(dir), &binary) {
170            return Some(p.to_string_lossy().into_owned());
171        }
172    }
173
174    // 4. Home-relative paths
175    if let Some(home) = home_dir() {
176        for rel in HOME_RELATIVE_PATHS {
177            if let Some(p) = runnable_in(&home.join(rel), &binary) {
178                return Some(p.to_string_lossy().into_owned());
179            }
180        }
181
182        // 5. CLI-specific paths
183        if cli == CliName::Claude {
184            for rel in CLAUDE_EXTRA_PATHS {
185                let p = home.join(rel);
186                if is_executable(&p) {
187                    return Some(p.to_string_lossy().into_owned());
188                }
189            }
190        }
191    }
192
193    None
194}
195
196/// Discover a specific CLI binary, caching the result.
197pub async fn discover_binary(cli: CliName) -> Option<String> {
198    // Check cache
199    {
200        let guard = CACHE.lock().unwrap_or_else(|e| e.into_inner());
201        if let Some(cache) = guard.as_ref() {
202            if let Some(path) = cache.get(&cli) {
203                if is_executable(Path::new(path)) {
204                    return Some(path.clone());
205                }
206            }
207        }
208    }
209
210    let path = search_for_binary(cli)?;
211
212    // Cache result
213    {
214        let mut guard = CACHE.lock().unwrap_or_else(|e| e.into_inner());
215        let cache = guard.get_or_insert_with(HashMap::new);
216        cache.insert(cli, path.clone());
217    }
218
219    Some(path)
220}
221
222/// Discover all available CLI binaries (concurrent).
223pub async fn discover_all() -> Vec<(CliName, String)> {
224    let (claude, codex, gemini) = tokio::join!(
225        discover_binary(CliName::Claude),
226        discover_binary(CliName::Codex),
227        discover_binary(CliName::Gemini),
228    );
229
230    let mut results = Vec::new();
231    if let Some(path) = claude {
232        results.push((CliName::Claude, path));
233    }
234    if let Some(path) = codex {
235        results.push((CliName::Codex, path));
236    }
237    if let Some(path) = gemini {
238        results.push((CliName::Gemini, path));
239    }
240    results
241}
242
243/// Discover the first available CLI binary (preference: Claude > Codex > Gemini).
244///
245/// Runs all lookups concurrently and returns the highest-priority match.
246pub async fn discover_first() -> Option<(CliName, String)> {
247    let (claude, codex, gemini) = tokio::join!(
248        discover_binary(CliName::Claude),
249        discover_binary(CliName::Codex),
250        discover_binary(CliName::Gemini),
251    );
252
253    if let Some(path) = claude {
254        return Some((CliName::Claude, path));
255    }
256    if let Some(path) = codex {
257        return Some((CliName::Codex, path));
258    }
259    if let Some(path) = gemini {
260        return Some((CliName::Gemini, path));
261    }
262    None
263}
264
265/// Clear the binary discovery cache.
266pub fn clear_cache() {
267    let mut guard = CACHE.lock().unwrap_or_else(|e| e.into_inner());
268    *guard = None;
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274
275    /// The REAL `parse_nvm_version`, not a copy of it.
276    ///
277    /// This test used to declare its own `parse_ver` closure and assert against
278    /// that — so it passed no matter what `find_nvm_binary` actually did, and
279    /// would have gone on passing if the production sort were deleted outright.
280    /// It now calls the function the code calls.
281    #[test]
282    fn nvm_version_sorting() {
283        assert_eq!(parse_nvm_version("v20.11.0"), (20, 11, 0));
284        assert_eq!(parse_nvm_version("v18.17.1"), (18, 17, 1));
285        assert_eq!(parse_nvm_version("v22.0.0"), (22, 0, 0));
286        assert_eq!(parse_nvm_version("invalid"), (0, 0, 0));
287        assert_eq!(parse_nvm_version("v1"), (1, 0, 0));
288
289        let mut versions = vec!["v18.17.1", "v22.0.0", "v20.11.0"];
290        versions.sort_by_key(|v| std::cmp::Reverse(parse_nvm_version(v)));
291        assert_eq!(versions, vec!["v22.0.0", "v20.11.0", "v18.17.1"]);
292    }
293
294    /// THE WINDOWS BUG, PINNED. An `npm -g install` writes three shims for one
295    /// CLI: `claude` (a bash script for git-bash), `claude.cmd`, and
296    /// `claude.ps1`. Only the `.cmd` is runnable by `CreateProcess`.
297    ///
298    /// The old probe was `path.is_file()` on Windows, which is TRUE for the
299    /// bash script — so discovery would succeed and return a path Windows
300    /// cannot execute. A failure at spawn time, phrased as though the CLI were
301    /// broken rather than as though we had picked the wrong file.
302    ///
303    /// One test, both platforms, opposite expectations — which is the point:
304    /// each side asserts what "runnable" means where it runs.
305    #[test]
306    fn runnable_in_picks_a_file_the_platform_can_actually_execute() {
307        let dir = tempfile::tempdir().unwrap();
308
309        // The extensionless shim npm writes for git-bash. Present on both
310        // platforms in this test so the Windows assertion is about CHOICE, not
311        // about absence.
312        let bare = dir.path().join("agentcli");
313        std::fs::write(&bare, "#!/bin/sh\necho hi").unwrap();
314        #[cfg(unix)]
315        {
316            use std::os::unix::fs::PermissionsExt;
317            std::fs::set_permissions(&bare, std::fs::Permissions::from_mode(0o755)).unwrap();
318        }
319
320        #[cfg(windows)]
321        {
322            // Nothing runnable yet: the bare file exists but has no executable
323            // extension, and that is exactly the case that used to pass.
324            assert!(
325                runnable_in(dir.path(), "agentcli").is_none(),
326                "a bash shim with no extension is not runnable on Windows"
327            );
328
329            std::fs::write(dir.path().join("agentcli.cmd"), "@echo hi").unwrap();
330            let found = runnable_in(dir.path(), "agentcli").expect("the .cmd shim");
331            assert_eq!(found.extension().unwrap(), "cmd");
332        }
333
334        #[cfg(unix)]
335        {
336            let found = runnable_in(dir.path(), "agentcli").expect("the executable");
337            assert_eq!(found, bare);
338
339            // …and a file without the executable bit is not a find.
340            let dir2 = tempfile::tempdir().unwrap();
341            std::fs::write(dir2.path().join("agentcli"), "#!/bin/sh").unwrap();
342            assert!(runnable_in(dir2.path(), "agentcli").is_none());
343        }
344    }
345
346    /// PATH lookup goes through the `which` crate, so it exists on every
347    /// platform. Uses the toolchain's own binary — present wherever these tests
348    /// run, including the Windows CI job, where it must resolve `cargo.exe`.
349    #[test]
350    fn path_lookup_works_on_every_platform() {
351        let found = which_on_path("cargo").expect("cargo is on PATH wherever cargo test runs");
352        assert!(Path::new(&found).is_file(), "resolved to a real file: {found}");
353        assert!(which_on_path("definitely-not-a-real-binary-xyz").is_none());
354    }
355
356    #[cfg(unix)]
357    #[test]
358    fn is_executable_checks_permission_bits() {
359        use std::os::unix::fs::PermissionsExt;
360        let dir = tempfile::tempdir().unwrap();
361
362        let non_exec = dir.path().join("not-exec");
363        std::fs::write(&non_exec, "#!/bin/sh").unwrap();
364        std::fs::set_permissions(&non_exec, std::fs::Permissions::from_mode(0o644)).unwrap();
365        assert!(!is_executable(&non_exec));
366
367        let exec = dir.path().join("exec");
368        std::fs::write(&exec, "#!/bin/sh").unwrap();
369        std::fs::set_permissions(&exec, std::fs::Permissions::from_mode(0o755)).unwrap();
370        assert!(is_executable(&exec));
371
372        assert!(!is_executable(Path::new("/does/not/exist")));
373    }
374
375    #[test]
376    fn clear_cache_resets_state() {
377        // Populate cache
378        {
379            let mut guard = CACHE.lock().unwrap();
380            let cache = guard.get_or_insert_with(HashMap::new);
381            cache.insert(CliName::Claude, "/usr/bin/claude".into());
382        }
383
384        clear_cache();
385
386        let guard = CACHE.lock().unwrap();
387        assert!(guard.is_none());
388    }
389
390    #[test]
391    fn cli_name_display() {
392        assert_eq!(CliName::Claude.to_string(), "claude");
393        assert_eq!(CliName::Codex.to_string(), "codex");
394        assert_eq!(CliName::Gemini.to_string(), "gemini");
395    }
396}