use crate::types::CliName;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
static CACHE: Mutex<Option<HashMap<CliName, String>>> = Mutex::new(None);
fn home_dir() -> Option<PathBuf> {
home::home_dir()
}
#[cfg(windows)]
const EXE_EXTENSIONS: &[&str] = &["cmd", "exe", "bat"];
#[cfg(not(windows))]
const EXE_EXTENSIONS: &[&str] = &[];
fn runnable_in(dir: &Path, stem: &str) -> Option<PathBuf> {
if EXE_EXTENSIONS.is_empty() {
let p = dir.join(stem);
return is_executable(&p).then_some(p);
}
EXE_EXTENSIONS.iter().find_map(|ext| {
let p = dir.join(format!("{stem}.{ext}"));
is_executable(&p).then_some(p)
})
}
fn is_executable(path: &Path) -> bool {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
path.is_file()
&& std::fs::metadata(path)
.map(|m| m.permissions().mode() & 0o111 != 0)
.unwrap_or(false)
}
#[cfg(not(unix))]
{
path.is_file()
}
}
fn which_on_path(binary: &str) -> Option<String> {
which::which(binary)
.ok()
.map(|p| p.to_string_lossy().into_owned())
}
fn find_nvm_binary(binary: &str) -> Option<String> {
if let Ok(nvm_bin) = std::env::var("NVM_BIN") {
let p = PathBuf::from(&nvm_bin).join(binary);
if is_executable(&p) {
return Some(p.to_string_lossy().into_owned());
}
}
let home = home_dir()?;
let nvm_versions = home.join(".nvm/versions/node");
if !nvm_versions.is_dir() {
return None;
}
let mut versions: Vec<PathBuf> = std::fs::read_dir(&nvm_versions)
.ok()?
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| p.is_dir())
.collect();
versions.sort_by(|a, b| {
let name_of = |p: &Path| {
p.file_name()
.unwrap_or_default()
.to_string_lossy()
.into_owned()
};
parse_nvm_version(&name_of(b)).cmp(&parse_nvm_version(&name_of(a)))
});
for dir in versions {
if let Some(p) = runnable_in(&dir.join("bin"), binary) {
return Some(p.to_string_lossy().into_owned());
}
}
None
}
pub(crate) fn parse_nvm_version(name: &str) -> (u64, u64, u64) {
let s = name.strip_prefix('v').unwrap_or(name);
let mut parts = s.split('.').map(|n| n.parse::<u64>().unwrap_or(0));
(
parts.next().unwrap_or(0),
parts.next().unwrap_or(0),
parts.next().unwrap_or(0),
)
}
#[cfg(unix)]
const SEARCH_PATHS: &[&str] = &["/opt/homebrew/bin", "/usr/local/bin"];
#[cfg(windows)]
const SEARCH_PATHS: &[&str] = &[];
#[cfg(unix)]
const HOME_RELATIVE_PATHS: &[&str] = &[".local/bin", ".bun/bin", ".npm-global/bin"];
#[cfg(windows)]
const HOME_RELATIVE_PATHS: &[&str] = &["AppData/Roaming/npm", "AppData/Roaming/nvm", ".bun/bin"];
const CLAUDE_EXTRA_PATHS: &[&str] = &[".claude/local/claude"];
fn search_for_binary(cli: CliName) -> Option<String> {
let binary = cli.to_string();
if let Some(path) = which_on_path(&binary) {
return Some(path);
}
if let Some(path) = find_nvm_binary(&binary) {
return Some(path);
}
for dir in SEARCH_PATHS {
if let Some(p) = runnable_in(Path::new(dir), &binary) {
return Some(p.to_string_lossy().into_owned());
}
}
if let Some(home) = home_dir() {
for rel in HOME_RELATIVE_PATHS {
if let Some(p) = runnable_in(&home.join(rel), &binary) {
return Some(p.to_string_lossy().into_owned());
}
}
if cli == CliName::Claude {
for rel in CLAUDE_EXTRA_PATHS {
let p = home.join(rel);
if is_executable(&p) {
return Some(p.to_string_lossy().into_owned());
}
}
}
}
None
}
pub async fn discover_binary(cli: CliName) -> Option<String> {
{
let guard = CACHE.lock().unwrap_or_else(|e| e.into_inner());
if let Some(cache) = guard.as_ref() {
if let Some(path) = cache.get(&cli) {
if is_executable(Path::new(path)) {
return Some(path.clone());
}
}
}
}
let path = search_for_binary(cli)?;
{
let mut guard = CACHE.lock().unwrap_or_else(|e| e.into_inner());
let cache = guard.get_or_insert_with(HashMap::new);
cache.insert(cli, path.clone());
}
Some(path)
}
pub async fn discover_all() -> Vec<(CliName, String)> {
let (claude, codex, gemini) = tokio::join!(
discover_binary(CliName::Claude),
discover_binary(CliName::Codex),
discover_binary(CliName::Gemini),
);
let mut results = Vec::new();
if let Some(path) = claude {
results.push((CliName::Claude, path));
}
if let Some(path) = codex {
results.push((CliName::Codex, path));
}
if let Some(path) = gemini {
results.push((CliName::Gemini, path));
}
results
}
pub async fn discover_first() -> Option<(CliName, String)> {
let (claude, codex, gemini) = tokio::join!(
discover_binary(CliName::Claude),
discover_binary(CliName::Codex),
discover_binary(CliName::Gemini),
);
if let Some(path) = claude {
return Some((CliName::Claude, path));
}
if let Some(path) = codex {
return Some((CliName::Codex, path));
}
if let Some(path) = gemini {
return Some((CliName::Gemini, path));
}
None
}
pub fn clear_cache() {
let mut guard = CACHE.lock().unwrap_or_else(|e| e.into_inner());
*guard = None;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn nvm_version_sorting() {
assert_eq!(parse_nvm_version("v20.11.0"), (20, 11, 0));
assert_eq!(parse_nvm_version("v18.17.1"), (18, 17, 1));
assert_eq!(parse_nvm_version("v22.0.0"), (22, 0, 0));
assert_eq!(parse_nvm_version("invalid"), (0, 0, 0));
assert_eq!(parse_nvm_version("v1"), (1, 0, 0));
let mut versions = vec!["v18.17.1", "v22.0.0", "v20.11.0"];
versions.sort_by_key(|v| std::cmp::Reverse(parse_nvm_version(v)));
assert_eq!(versions, vec!["v22.0.0", "v20.11.0", "v18.17.1"]);
}
#[test]
fn runnable_in_picks_a_file_the_platform_can_actually_execute() {
let dir = tempfile::tempdir().unwrap();
let bare = dir.path().join("agentcli");
std::fs::write(&bare, "#!/bin/sh\necho hi").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&bare, std::fs::Permissions::from_mode(0o755)).unwrap();
}
#[cfg(windows)]
{
assert!(
runnable_in(dir.path(), "agentcli").is_none(),
"a bash shim with no extension is not runnable on Windows"
);
std::fs::write(dir.path().join("agentcli.cmd"), "@echo hi").unwrap();
let found = runnable_in(dir.path(), "agentcli").expect("the .cmd shim");
assert_eq!(found.extension().unwrap(), "cmd");
}
#[cfg(unix)]
{
let found = runnable_in(dir.path(), "agentcli").expect("the executable");
assert_eq!(found, bare);
let dir2 = tempfile::tempdir().unwrap();
std::fs::write(dir2.path().join("agentcli"), "#!/bin/sh").unwrap();
assert!(runnable_in(dir2.path(), "agentcli").is_none());
}
}
#[test]
fn path_lookup_works_on_every_platform() {
let found = which_on_path("cargo").expect("cargo is on PATH wherever cargo test runs");
assert!(
Path::new(&found).is_file(),
"resolved to a real file: {found}"
);
assert!(which_on_path("definitely-not-a-real-binary-xyz").is_none());
}
#[cfg(unix)]
#[test]
fn is_executable_checks_permission_bits() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let non_exec = dir.path().join("not-exec");
std::fs::write(&non_exec, "#!/bin/sh").unwrap();
std::fs::set_permissions(&non_exec, std::fs::Permissions::from_mode(0o644)).unwrap();
assert!(!is_executable(&non_exec));
let exec = dir.path().join("exec");
std::fs::write(&exec, "#!/bin/sh").unwrap();
std::fs::set_permissions(&exec, std::fs::Permissions::from_mode(0o755)).unwrap();
assert!(is_executable(&exec));
assert!(!is_executable(Path::new("/does/not/exist")));
}
#[test]
fn clear_cache_resets_state() {
{
let mut guard = CACHE.lock().unwrap();
let cache = guard.get_or_insert_with(HashMap::new);
cache.insert(CliName::Claude, "/usr/bin/claude".into());
}
clear_cache();
let guard = CACHE.lock().unwrap();
assert!(guard.is_none());
}
#[test]
fn cli_name_display() {
assert_eq!(CliName::Claude.to_string(), "claude");
assert_eq!(CliName::Codex.to_string(), "codex");
assert_eq!(CliName::Gemini.to_string(), "gemini");
}
}