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| {
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
118pub(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#[cfg(unix)]
140const SEARCH_PATHS: &[&str] = &["/opt/homebrew/bin", "/usr/local/bin"];
141#[cfg(windows)]
144const SEARCH_PATHS: &[&str] = &[];
145
146#[cfg(unix)]
147const HOME_RELATIVE_PATHS: &[&str] = &[".local/bin", ".bun/bin", ".npm-global/bin"];
148#[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 if let Some(path) = which_on_path(&binary) {
160 return Some(path);
161 }
162
163 if let Some(path) = find_nvm_binary(&binary) {
165 return Some(path);
166 }
167
168 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 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 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
197pub async fn discover_binary(cli: CliName) -> Option<String> {
199 {
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 {
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
223pub 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
244pub 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
266pub 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 #[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 #[test]
307 fn runnable_in_picks_a_file_the_platform_can_actually_execute() {
308 let dir = tempfile::tempdir().unwrap();
309
310 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 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 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 #[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 {
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}