use std::path::{Path, PathBuf};
use std::time::Duration;
use crate::errors::{Error, Result};
pub const MIN_CLI_VERSION: &str = "1.0.0";
const CLI_NAME: &str = "claude";
pub fn find_cli() -> Result<PathBuf> {
if let Ok(path) = which::which(CLI_NAME) {
return Ok(path);
}
let home = home_dir();
#[cfg(unix)]
let candidates: Vec<PathBuf> = vec![
home.as_ref()
.map(|h| h.join(".npm-global/bin").join(CLI_NAME)),
Some(PathBuf::from("/usr/local/bin").join(CLI_NAME)),
home.as_ref().map(|h| h.join(".local/bin").join(CLI_NAME)),
Some(PathBuf::from("node_modules/.bin").join(CLI_NAME)),
home.as_ref().map(|h| h.join(".yarn/bin").join(CLI_NAME)),
home.as_ref()
.map(|h| h.join(".claude/local").join(CLI_NAME)),
]
.into_iter()
.flatten()
.collect();
#[cfg(windows)]
let cli_exe = format!("{CLI_NAME}.exe");
#[cfg(windows)]
let candidates: Vec<PathBuf> = vec![
home.as_ref()
.map(|h| h.join("AppData/Roaming/npm").join(&cli_exe)),
Some(PathBuf::from("node_modules/.bin").join(&cli_exe)),
home.as_ref().map(|h| h.join("scoop/shims").join(&cli_exe)),
home.as_ref()
.map(|h| h.join(".claude/local").join(&cli_exe)),
]
.into_iter()
.flatten()
.collect();
for candidate in candidates {
if candidate.is_file() {
return Ok(candidate);
}
}
Err(Error::CliNotFound)
}
pub async fn check_cli_version(cli_path: &Path, timeout: Option<Duration>) -> Result<String> {
let mut child = tokio::process::Command::new(cli_path)
.arg("--version")
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.map_err(Error::SpawnFailed)?;
if let Some(d) = timeout {
if tokio::time::timeout(d, child.wait()).await.is_err() {
let _ = child.kill().await;
return Err(Error::Timeout(format!(
"version check timed out after {}s",
d.as_secs_f64()
)));
}
}
let output = child.wait_with_output().await.map_err(Error::SpawnFailed)?;
if !output.status.success() {
return Err(Error::ProcessExited {
code: output.status.code(),
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
});
}
let stdout = String::from_utf8_lossy(&output.stdout);
parse_version(&stdout).ok_or_else(|| {
Error::ControlProtocol(format!("could not parse version from CLI output: {stdout}"))
})
}
#[must_use]
pub fn version_satisfies(version: &str, minimum: &str) -> bool {
let parse = |s: &str| -> (u32, u32, u32) {
let mut parts = s.split('.').map(|p| p.parse::<u32>().unwrap_or(0));
let major = parts.next().unwrap_or(0);
let minor = parts.next().unwrap_or(0);
let patch = parts.next().unwrap_or(0);
(major, minor, patch)
};
parse(version) >= parse(minimum)
}
fn parse_version(output: &str) -> Option<String> {
for word in output.split_whitespace() {
let trimmed = word.strip_prefix('v').unwrap_or(word);
if trimmed.chars().next().is_some_and(|c| c.is_ascii_digit()) && trimmed.contains('.') {
let version: String = trimmed
.chars()
.take_while(|c| c.is_ascii_alphanumeric() || *c == '.' || *c == '-')
.collect();
if version.split('.').count() >= 2 {
return Some(version);
}
}
}
None
}
fn home_dir() -> Option<PathBuf> {
#[cfg(unix)]
{
std::env::var_os("HOME").map(PathBuf::from)
}
#[cfg(windows)]
{
std::env::var_os("USERPROFILE")
.map(PathBuf::from)
.or_else(|| {
let drive = std::env::var_os("HOMEDRIVE")?;
let path = std::env::var_os("HOMEPATH")?;
Some(PathBuf::from(drive).join(path))
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_version_standard() {
assert_eq!(parse_version("1.2.3"), Some("1.2.3".into()));
}
#[test]
fn parse_version_with_v_prefix() {
assert_eq!(parse_version("v1.2.3"), Some("1.2.3".into()));
}
#[test]
fn parse_version_with_claude_prefix() {
assert_eq!(parse_version("claude v1.0.12"), Some("1.0.12".into()));
}
#[test]
fn parse_version_prerelease() {
assert_eq!(parse_version("v2.0.0-beta.1"), Some("2.0.0-beta.1".into()));
}
#[test]
fn parse_version_empty_input() {
assert_eq!(parse_version(""), None);
}
#[test]
fn parse_version_no_version() {
assert_eq!(parse_version("no version here"), None);
}
#[test]
fn version_satisfies_equal() {
assert!(version_satisfies("1.0.0", "1.0.0"));
}
#[test]
fn version_satisfies_greater() {
assert!(version_satisfies("2.0.0", "1.0.0"));
assert!(version_satisfies("1.1.0", "1.0.0"));
assert!(version_satisfies("1.0.1", "1.0.0"));
}
#[test]
fn version_satisfies_less() {
assert!(!version_satisfies("0.9.0", "1.0.0"));
assert!(!version_satisfies("1.0.0", "1.0.1"));
}
#[test]
fn version_satisfies_major_priority() {
assert!(version_satisfies("2.0.0", "1.9.9"));
assert!(!version_satisfies("1.9.9", "2.0.0"));
}
#[test]
fn find_cli_returns_path_or_not_found() {
let result = find_cli();
match result {
Ok(path) => assert!(path.is_file()),
Err(Error::CliNotFound) => {} Err(e) => panic!("unexpected error: {e}"),
}
}
#[tokio::test]
async fn check_cli_version_with_timeout() {
let dir = tempfile::tempdir().unwrap();
#[cfg(unix)]
let script = {
let s = dir.path().join("slow_cli");
std::fs::write(&s, "#!/bin/sh\nsleep 999\n").unwrap();
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&s, std::fs::Permissions::from_mode(0o755)).unwrap();
s
};
#[cfg(windows)]
let script = {
let s = dir.path().join("slow_cli.bat");
std::fs::write(&s, "@ping -n 999 127.0.0.1 >nul\r\n").unwrap();
s
};
let result = check_cli_version(&script, Some(Duration::from_millis(50))).await;
assert!(
matches!(result, Err(Error::Timeout(_))),
"expected Timeout, got: {result:?}"
);
}
#[tokio::test]
async fn check_cli_version_no_timeout() {
let dir = tempfile::tempdir().unwrap();
#[cfg(unix)]
let bin = {
use std::os::unix::fs::PermissionsExt;
let s = dir.path().join("noop.sh");
std::fs::write(&s, "#!/bin/sh\nexit 0\n").unwrap();
std::fs::set_permissions(&s, std::fs::Permissions::from_mode(0o755)).unwrap();
s
};
#[cfg(windows)]
let bin = {
let s = dir.path().join("noop.bat");
std::fs::write(&s, "@exit /b 0\r\n").unwrap();
s
};
let result = check_cli_version(bin.as_ref(), None).await;
assert!(
matches!(result, Err(Error::ControlProtocol(_))),
"expected ControlProtocol (no version), got: {result:?}"
);
}
}