use crate::error::ClawError;
use std::path::{Path, PathBuf};
pub struct CliDiscovery;
impl CliDiscovery {
pub async fn find(cli_path: Option<&Path>) -> Result<PathBuf, ClawError> {
if let Some(path) = cli_path
&& path.exists()
{
return Ok(path.to_path_buf());
}
if let Ok(env_path) = std::env::var("CLAUDE_CLI_PATH") {
let path = PathBuf::from(env_path);
if path.exists() {
return Ok(path);
}
}
if let Ok(path) = Self::search_path().await {
return Ok(path);
}
let common_locations = Self::common_locations();
for location in common_locations {
if location.exists() {
return Ok(location);
}
}
Err(ClawError::CliNotFound)
}
pub async fn validate_version(cli: &Path) -> Result<String, ClawError> {
let output = tokio::process::Command::new(cli)
.arg("--version")
.output()
.await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(ClawError::InvalidCliVersion {
version: format!("Failed to run --version: {}", stderr),
});
}
let stdout = String::from_utf8_lossy(&output.stdout);
let version_str =
stdout
.split_whitespace()
.next()
.ok_or_else(|| ClawError::InvalidCliVersion {
version: "empty output".to_string(),
})?;
let version =
semver::Version::parse(version_str).map_err(|_| ClawError::InvalidCliVersion {
version: version_str.to_string(),
})?;
let min_version = semver::Version::new(2, 0, 0);
if version < min_version {
return Err(ClawError::InvalidCliVersion {
version: version.to_string(),
});
}
Ok(version.to_string())
}
async fn search_path() -> Result<PathBuf, ClawError> {
let path_env = std::env::var("PATH").map_err(|_| ClawError::CliNotFound)?;
let separator = if cfg!(windows) { ';' } else { ':' };
let names: &[&str] = if cfg!(windows) {
&["claude.exe", "claude.cmd", "claude"]
} else {
&["claude"]
};
for dir in path_env.split(separator) {
for name in names {
let candidate = PathBuf::from(dir).join(name);
if candidate.exists() {
return Ok(candidate);
}
}
}
Err(ClawError::CliNotFound)
}
fn common_locations() -> Vec<PathBuf> {
let mut locations = Vec::new();
let home = std::env::var("HOME").ok();
locations.push(PathBuf::from("/opt/homebrew/bin/claude"));
locations.push(PathBuf::from("/usr/local/bin/claude"));
locations.push(PathBuf::from("/usr/bin/claude"));
if let Some(home_dir) = home {
let home_path = PathBuf::from(home_dir);
locations.push(home_path.join(".local/bin/claude"));
locations.push(home_path.join(".npm/bin/claude"));
locations.push(home_path.join(".claude/local/claude"));
}
locations
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_find_with_explicit_path() {
let exe = std::env::current_exe().unwrap();
let result = CliDiscovery::find(Some(&exe)).await;
assert!(result.is_ok());
assert_eq!(result.unwrap(), exe);
}
#[tokio::test]
async fn test_find_with_nonexistent_explicit_path() {
let fake_path = PathBuf::from("/nonexistent/fake/claude");
let result = CliDiscovery::find(Some(&fake_path)).await;
match result {
Ok(_) => {} Err(ClawError::CliNotFound) => {} Err(e) => panic!("Unexpected error: {}", e),
}
}
#[tokio::test]
async fn test_find_in_path() {
let result = CliDiscovery::find(None).await;
match result {
Ok(path) => {
assert!(path.is_absolute());
assert!(path.exists());
}
Err(ClawError::CliNotFound) => {
}
Err(e) => panic!("Unexpected error: {}", e),
}
}
#[tokio::test]
async fn test_validate_version_invalid_path() {
let fake_path = Path::new("/nonexistent/fake/claude");
let result = CliDiscovery::validate_version(fake_path).await;
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), ClawError::Io(_)));
}
#[tokio::test]
async fn test_search_path_separator() {
let result = CliDiscovery::search_path().await;
match result {
Ok(path) => assert!(path.is_absolute()),
Err(ClawError::CliNotFound) => {} Err(e) => panic!("Unexpected error: {}", e),
}
}
#[tokio::test]
async fn test_common_locations_returns_paths() {
let locations = CliDiscovery::common_locations();
assert!(!locations.is_empty());
for location in locations {
assert!(location.is_absolute());
}
}
#[tokio::test]
async fn test_validate_version_with_valid_cli() {
if let Ok(cli_path) = CliDiscovery::find(None).await {
let result = CliDiscovery::validate_version(&cli_path).await;
match result {
Ok(version) => {
assert!(semver::Version::parse(&version).is_ok());
let ver = semver::Version::parse(&version).unwrap();
assert!(ver >= semver::Version::new(2, 0, 0));
}
Err(e) => {
assert!(matches!(e, ClawError::InvalidCliVersion { .. }));
}
}
}
}
}