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| {
101            p.file_name()
102                .unwrap_or_default()
103                .to_string_lossy()
104                .into_owned()
105        };
106        parse_nvm_version(&name_of(b)).cmp(&parse_nvm_version(&name_of(a)))
107    });
108
109    for dir in versions {
110        if let Some(p) = runnable_in(&dir.join("bin"), binary) {
111            return Some(p.to_string_lossy().into_owned());
112        }
113    }
114
115    None
116}
117
118/// `"v20.11.0"` → `(20, 11, 0)`. Unparseable components sort as 0.
119///
120/// A FREE FUNCTION SO THE TEST CAN CALL IT. It used to be a closure inside
121/// `find_nvm_binary`, and the test for it re-implemented the same logic inline
122/// — so `nvm_version_sorting` passed regardless of what the real sort did, and
123/// would have kept passing if the production copy were deleted.
124pub(crate) fn parse_nvm_version(name: &str) -> (u64, u64, u64) {
125    let s = name.strip_prefix('v').unwrap_or(name);
126    let mut parts = s.split('.').map(|n| n.parse::<u64>().unwrap_or(0));
127    (
128        parts.next().unwrap_or(0),
129        parts.next().unwrap_or(0),
130        parts.next().unwrap_or(0),
131    )
132}
133
134/// WHY THERE ARE FALLBACKS AT ALL, given `which` searches PATH: a macOS GUI app
135/// does not inherit the shell's PATH. An `npm -g` install lands somewhere the
136/// Finder-launched process has never heard of, so PATH lookup alone reports a
137/// CLI the user demonstrably has as missing. These are that gap, and they are
138/// per-platform because the gap is.
139#[cfg(unix)]
140const SEARCH_PATHS: &[&str] = &["/opt/homebrew/bin", "/usr/local/bin"];
141/// Windows inherits the system PATH into GUI processes, so `which` covers the
142/// normal case; this is for a per-user npm prefix that PATH may lag behind.
143#[cfg(windows)]
144const SEARCH_PATHS: &[&str] = &[];
145
146#[cfg(unix)]
147const HOME_RELATIVE_PATHS: &[&str] = &[".local/bin", ".bun/bin", ".npm-global/bin"];
148/// `%APPDATA%` and nvm-windows both sit under the user profile, which is what
149/// `home::home_dir()` returns here.
150#[cfg(windows)]
151const HOME_RELATIVE_PATHS: &[&str] = &["AppData/Roaming/npm", "AppData/Roaming/nvm", ".bun/bin"];
152
153const CLAUDE_EXTRA_PATHS: &[&str] = &[".claude/local/claude"];
154
155fn search_for_binary(cli: CliName) -> Option<String> {
156    let binary = cli.to_string();
157
158    // 1. PATH
159    if let Some(path) = which_on_path(&binary) {
160        return Some(path);
161    }
162
163    // 2. NVM paths (node-based CLIs)
164    if let Some(path) = find_nvm_binary(&binary) {
165        return Some(path);
166    }
167
168    // 3. Common install locations
169    for dir in SEARCH_PATHS {
170        if let Some(p) = runnable_in(Path::new(dir), &binary) {
171            return Some(p.to_string_lossy().into_owned());
172        }
173    }
174
175    // 4. Home-relative paths
176    if let Some(home) = home_dir() {
177        for rel in HOME_RELATIVE_PATHS {
178            if let Some(p) = runnable_in(&home.join(rel), &binary) {
179                return Some(p.to_string_lossy().into_owned());
180            }
181        }
182
183        // 5. CLI-specific paths
184        if cli == CliName::Claude {
185            for rel in CLAUDE_EXTRA_PATHS {
186                let p = home.join(rel);
187                if is_executable(&p) {
188                    return Some(p.to_string_lossy().into_owned());
189                }
190            }
191        }
192    }
193
194    None
195}
196
197/// Discover a specific CLI binary, caching the result.
198pub async fn discover_binary(cli: CliName) -> Option<String> {
199    // Check cache
200    {
201        let guard = CACHE.lock().unwrap_or_else(|e| e.into_inner());
202        if let Some(cache) = guard.as_ref() {
203            if let Some(path) = cache.get(&cli) {
204                if is_executable(Path::new(path)) {
205                    return Some(path.clone());
206                }
207            }
208        }
209    }
210
211    let path = search_for_binary(cli)?;
212
213    // Cache result
214    {
215        let mut guard = CACHE.lock().unwrap_or_else(|e| e.into_inner());
216        let cache = guard.get_or_insert_with(HashMap::new);
217        cache.insert(cli, path.clone());
218    }
219
220    Some(path)
221}
222
223/// Discover all available CLI binaries (concurrent).
224pub async fn discover_all() -> Vec<(CliName, String)> {
225    let (claude, codex, gemini) = tokio::join!(
226        discover_binary(CliName::Claude),
227        discover_binary(CliName::Codex),
228        discover_binary(CliName::Gemini),
229    );
230
231    let mut results = Vec::new();
232    if let Some(path) = claude {
233        results.push((CliName::Claude, path));
234    }
235    if let Some(path) = codex {
236        results.push((CliName::Codex, path));
237    }
238    if let Some(path) = gemini {
239        results.push((CliName::Gemini, path));
240    }
241    results
242}
243
244/// Discover the first available CLI binary (preference: Claude > Codex > Gemini).
245///
246/// Runs all lookups concurrently and returns the highest-priority match.
247pub async fn discover_first() -> Option<(CliName, String)> {
248    let (claude, codex, gemini) = tokio::join!(
249        discover_binary(CliName::Claude),
250        discover_binary(CliName::Codex),
251        discover_binary(CliName::Gemini),
252    );
253
254    if let Some(path) = claude {
255        return Some((CliName::Claude, path));
256    }
257    if let Some(path) = codex {
258        return Some((CliName::Codex, path));
259    }
260    if let Some(path) = gemini {
261        return Some((CliName::Gemini, path));
262    }
263    None
264}
265
266/// Clear the binary discovery cache.
267pub fn clear_cache() {
268    let mut guard = CACHE.lock().unwrap_or_else(|e| e.into_inner());
269    *guard = None;
270}
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275
276    /// The REAL `parse_nvm_version`, not a copy of it.
277    ///
278    /// This test used to declare its own `parse_ver` closure and assert against
279    /// that — so it passed no matter what `find_nvm_binary` actually did, and
280    /// would have gone on passing if the production sort were deleted outright.
281    /// It now calls the function the code calls.
282    #[test]
283    fn nvm_version_sorting() {
284        assert_eq!(parse_nvm_version("v20.11.0"), (20, 11, 0));
285        assert_eq!(parse_nvm_version("v18.17.1"), (18, 17, 1));
286        assert_eq!(parse_nvm_version("v22.0.0"), (22, 0, 0));
287        assert_eq!(parse_nvm_version("invalid"), (0, 0, 0));
288        assert_eq!(parse_nvm_version("v1"), (1, 0, 0));
289
290        let mut versions = vec!["v18.17.1", "v22.0.0", "v20.11.0"];
291        versions.sort_by_key(|v| std::cmp::Reverse(parse_nvm_version(v)));
292        assert_eq!(versions, vec!["v22.0.0", "v20.11.0", "v18.17.1"]);
293    }
294
295    /// THE WINDOWS BUG, PINNED. An `npm -g install` writes three shims for one
296    /// CLI: `claude` (a bash script for git-bash), `claude.cmd`, and
297    /// `claude.ps1`. Only the `.cmd` is runnable by `CreateProcess`.
298    ///
299    /// The old probe was `path.is_file()` on Windows, which is TRUE for the
300    /// bash script — so discovery would succeed and return a path Windows
301    /// cannot execute. A failure at spawn time, phrased as though the CLI were
302    /// broken rather than as though we had picked the wrong file.
303    ///
304    /// One test, both platforms, opposite expectations — which is the point:
305    /// each side asserts what "runnable" means where it runs.
306    #[test]
307    fn runnable_in_picks_a_file_the_platform_can_actually_execute() {
308        let dir = tempfile::tempdir().unwrap();
309
310        // The extensionless shim npm writes for git-bash. Present on both
311        // platforms in this test so the Windows assertion is about CHOICE, not
312        // about absence.
313        let bare = dir.path().join("agentcli");
314        std::fs::write(&bare, "#!/bin/sh\necho hi").unwrap();
315        #[cfg(unix)]
316        {
317            use std::os::unix::fs::PermissionsExt;
318            std::fs::set_permissions(&bare, std::fs::Permissions::from_mode(0o755)).unwrap();
319        }
320
321        #[cfg(windows)]
322        {
323            // Nothing runnable yet: the bare file exists but has no executable
324            // extension, and that is exactly the case that used to pass.
325            assert!(
326                runnable_in(dir.path(), "agentcli").is_none(),
327                "a bash shim with no extension is not runnable on Windows"
328            );
329
330            std::fs::write(dir.path().join("agentcli.cmd"), "@echo hi").unwrap();
331            let found = runnable_in(dir.path(), "agentcli").expect("the .cmd shim");
332            assert_eq!(found.extension().unwrap(), "cmd");
333        }
334
335        #[cfg(unix)]
336        {
337            let found = runnable_in(dir.path(), "agentcli").expect("the executable");
338            assert_eq!(found, bare);
339
340            // …and a file without the executable bit is not a find.
341            let dir2 = tempfile::tempdir().unwrap();
342            std::fs::write(dir2.path().join("agentcli"), "#!/bin/sh").unwrap();
343            assert!(runnable_in(dir2.path(), "agentcli").is_none());
344        }
345    }
346
347    /// PATH lookup goes through the `which` crate, so it exists on every
348    /// platform. Uses the toolchain's own binary — present wherever these tests
349    /// run, including the Windows CI job, where it must resolve `cargo.exe`.
350    #[test]
351    fn path_lookup_works_on_every_platform() {
352        let found = which_on_path("cargo").expect("cargo is on PATH wherever cargo test runs");
353        assert!(
354            Path::new(&found).is_file(),
355            "resolved to a real file: {found}"
356        );
357        assert!(which_on_path("definitely-not-a-real-binary-xyz").is_none());
358    }
359
360    #[cfg(unix)]
361    #[test]
362    fn is_executable_checks_permission_bits() {
363        use std::os::unix::fs::PermissionsExt;
364        let dir = tempfile::tempdir().unwrap();
365
366        let non_exec = dir.path().join("not-exec");
367        std::fs::write(&non_exec, "#!/bin/sh").unwrap();
368        std::fs::set_permissions(&non_exec, std::fs::Permissions::from_mode(0o644)).unwrap();
369        assert!(!is_executable(&non_exec));
370
371        let exec = dir.path().join("exec");
372        std::fs::write(&exec, "#!/bin/sh").unwrap();
373        std::fs::set_permissions(&exec, std::fs::Permissions::from_mode(0o755)).unwrap();
374        assert!(is_executable(&exec));
375
376        assert!(!is_executable(Path::new("/does/not/exist")));
377    }
378
379    #[test]
380    fn clear_cache_resets_state() {
381        // Populate cache
382        {
383            let mut guard = CACHE.lock().unwrap();
384            let cache = guard.get_or_insert_with(HashMap::new);
385            cache.insert(CliName::Claude, "/usr/bin/claude".into());
386        }
387
388        clear_cache();
389
390        let guard = CACHE.lock().unwrap();
391        assert!(guard.is_none());
392    }
393
394    #[test]
395    fn cli_name_display() {
396        assert_eq!(CliName::Claude.to_string(), "claude");
397        assert_eq!(CliName::Codex.to_string(), "codex");
398        assert_eq!(CliName::Gemini.to_string(), "gemini");
399    }
400}