1use crate::types::CliName;
2use std::collections::HashMap;
3use std::path::{Path, PathBuf};
4use std::sync::Mutex;
5
6static CACHE: Mutex<Option<HashMap<CliName, String>>> = Mutex::new(None);
15
16fn home_dir() -> Option<PathBuf> {
17 home::home_dir()
18}
19
20#[cfg(windows)]
31const EXE_EXTENSIONS: &[&str] = &["cmd", "exe", "bat"];
32#[cfg(not(windows))]
33const EXE_EXTENSIONS: &[&str] = &[];
34
35fn 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
62fn 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 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 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 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
113pub(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#[cfg(unix)]
135const SEARCH_PATHS: &[&str] = &["/opt/homebrew/bin", "/usr/local/bin"];
136#[cfg(windows)]
139const SEARCH_PATHS: &[&str] = &[];
140
141#[cfg(unix)]
142const HOME_RELATIVE_PATHS: &[&str] = &[".local/bin", ".bun/bin", ".npm-global/bin"];
143#[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 if let Some(path) = which_on_path(&binary) {
159 return Some(path);
160 }
161
162 if let Some(path) = find_nvm_binary(&binary) {
164 return Some(path);
165 }
166
167 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 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 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
196pub async fn discover_binary(cli: CliName) -> Option<String> {
198 {
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 {
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
222pub 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
243pub 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
265pub 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 #[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 #[test]
306 fn runnable_in_picks_a_file_the_platform_can_actually_execute() {
307 let dir = tempfile::tempdir().unwrap();
308
309 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 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 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 #[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 {
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}