use std::path::PathBuf;
use std::sync::Arc;
use tokio::process::Command;
use tracing::{info, warn, debug};
use crate::errors::{ClaudeError, Result};
#[derive(Debug, Clone)]
pub enum InstallProgress {
Checking(String),
Downloading {
current: u64,
total: Option<u64>,
},
Installing(String),
Done(PathBuf),
Failed(String),
}
pub struct CliInstaller {
pub auto_install: bool,
progress_callback: Option<Arc<dyn Fn(InstallProgress) + Send + Sync>>,
}
impl CliInstaller {
pub fn new(auto_install: bool) -> Self {
Self {
auto_install,
progress_callback: None,
}
}
pub fn with_progress_callback(
mut self,
callback: Arc<dyn Fn(InstallProgress) + Send + Sync>,
) -> Self {
self.progress_callback = Some(callback);
self
}
fn report_progress(&self, event: InstallProgress) {
if let Some(ref callback) = self.progress_callback {
callback(event);
}
}
pub async fn install_if_needed(&self) -> Result<PathBuf> {
if !self.auto_install {
return Err(ClaudeError::InternalError(
"Auto-install is disabled".to_string(),
));
}
self.report_progress(InstallProgress::Checking(
"Checking if Claude CLI is already installed...".to_string(),
));
if let Ok(path) = Self::find_existing_cli().await {
info!("Claude CLI already installed at: {}", path.display());
return Ok(path);
}
info!("Claude CLI not found, attempting auto-install...");
self.report_progress(InstallProgress::Checking(
"Claude CLI not found, starting installation...".to_string(),
));
match self.install_via_npm().await {
Ok(path) => {
self.report_progress(InstallProgress::Done(path.clone()));
return Ok(path);
}
Err(e) => {
warn!("npm installation failed: {}, trying direct download...", e);
}
}
match self.install_via_direct_download().await {
Ok(path) => {
self.report_progress(InstallProgress::Done(path.clone()));
Ok(path)
}
Err(e) => {
let error_msg = format!(
"Failed to install Claude CLI automatically. \
Please install manually: npm install -g @anthropic-ai/claude-code\n\nError: {}",
e
);
self.report_progress(InstallProgress::Failed(error_msg.clone()));
Err(ClaudeError::InternalError(error_msg))
}
}
}
async fn find_existing_cli() -> Result<PathBuf> {
if let Ok(output) = Command::new("claude")
.arg("--version")
.output()
.await
{
if output.status.success() {
return Ok(PathBuf::from("claude"));
}
}
Err(ClaudeError::InternalError("CLI not found".to_string()))
}
async fn install_via_npm(&self) -> Result<PathBuf> {
info!("Attempting installation via npm...");
self.report_progress(InstallProgress::Installing(
"Installing via npm...".to_string(),
));
let npm_check = Command::new("npm")
.arg("--version")
.output()
.await;
let npm_available = match npm_check {
Ok(output) => output.status.success(),
Err(_) => false,
};
if !npm_available {
return Err(ClaudeError::InternalError(
"npm is not available".to_string(),
));
}
debug!("npm is available, proceeding with installation");
let output = Command::new("npm")
.args(["install", "-g", "@anthropic-ai/claude-code"])
.output()
.await
.map_err(|e| ClaudeError::InternalError(format!("Failed to run npm: {}", e)))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(ClaudeError::InternalError(format!(
"npm install failed: {}",
stderr
)));
}
info!("npm install completed successfully");
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
if let Ok(output) = Command::new("claude")
.arg("--version")
.output()
.await
{
if output.status.success() {
let version = String::from_utf8_lossy(&output.stdout);
info!("✅ Claude CLI installed successfully via npm: {}", version.trim());
return Ok(PathBuf::from("claude"));
}
}
if let Ok(npm_path) = Self::find_npm_global_path().await {
info!("Found CLI at npm global path: {}", npm_path.display());
return Ok(npm_path);
}
Err(ClaudeError::InternalError(
"Installation appeared to succeed but CLI not found in PATH".to_string(),
))
}
async fn find_npm_global_path() -> Result<PathBuf> {
let output = Command::new("npm")
.args(["config", "get", "prefix"])
.output()
.await
.map_err(|e| ClaudeError::InternalError(format!("Failed to get npm config: {}", e)))?;
if !output.status.success() {
return Err(ClaudeError::InternalError(
"Failed to get npm global prefix".to_string(),
));
}
let prefix_str = String::from_utf8_lossy(&output.stdout);
let prefix = prefix_str.trim();
let bin_path = if cfg!(windows) {
PathBuf::from(prefix).join("claude.cmd")
} else {
PathBuf::from(prefix).join("bin").join("claude")
};
if bin_path.exists() {
Ok(bin_path)
} else {
Err(ClaudeError::InternalError(format!(
"CLI not found at npm path: {}",
bin_path.display()
)))
}
}
async fn install_via_direct_download(&self) -> Result<PathBuf> {
info!("Attempting installation via direct download...");
self.report_progress(InstallProgress::Downloading {
current: 0,
total: None,
});
let (platform, arch) = Self::detect_platform();
if platform == "unknown" || arch == "unknown" {
return Err(ClaudeError::InternalError(format!(
"Unsupported platform: {}-{}",
platform, arch
)));
}
let filename = if cfg!(windows) {
format!("claude-{}-{}.exe", platform, arch)
} else {
format!("claude-{}-{}", platform, arch)
};
let url = format!(
"https://github.com/anthropics/claude-code/releases/latest/download/{}",
filename
);
info!("Downloading from: {}", url);
self.report_progress(InstallProgress::Downloading {
current: 0,
total: None,
});
let response = reqwest::get(&url).await.map_err(|e| {
ClaudeError::InternalError(format!("Failed to download CLI: {}", e))
})?;
if !response.status().is_success() {
return Err(ClaudeError::InternalError(format!(
"Download failed with status: {}",
response.status()
)));
}
let total_bytes = response.content_length();
let bytes = response.bytes().await.map_err(|e| {
ClaudeError::InternalError(format!("Failed to download bytes: {}", e))
})?;
self.report_progress(InstallProgress::Downloading {
current: bytes.len() as u64,
total: total_bytes,
});
let install_dir = Self::get_install_dir()?;
std::fs::create_dir_all(&install_dir).map_err(|e| {
ClaudeError::InternalError(format!("Failed to create install directory: {}", e))
})?;
let exe_name = if cfg!(windows) { "claude.exe" } else { "claude" };
let install_path = install_dir.join(exe_name);
std::fs::write(&install_path, bytes)
.map_err(|e| ClaudeError::InternalError(format!("Failed to write CLI: {}", e)))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(&install_path)
.map_err(|e| ClaudeError::InternalError(format!("Failed to get metadata: {}", e)))?
.permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&install_path, perms).map_err(|e| {
ClaudeError::InternalError(format!("Failed to set permissions: {}", e))
})?;
}
info!("✅ Claude CLI installed to: {}", install_path.display());
Ok(install_path)
}
fn detect_platform() -> (&'static str, &'static str) {
let platform = if cfg!(target_os = "macos") {
"darwin"
} else if cfg!(target_os = "linux") {
"linux"
} else if cfg!(target_os = "windows") {
"windows"
} else {
"unknown"
};
let arch = if cfg!(target_arch = "x86_64") {
"x64"
} else if cfg!(target_arch = "aarch64") {
"arm64"
} else if cfg!(target_arch = "x86") {
"ia32"
} else {
"unknown"
};
(platform, arch)
}
fn get_install_dir() -> Result<PathBuf> {
let home = std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.map_err(|_| ClaudeError::InternalError("Cannot determine home directory".to_string()))?;
let home_path = PathBuf::from(home);
let dir = if cfg!(windows) {
home_path.join("AppData\\Local\\Programs\\Claude")
} else {
home_path.join(".local/bin")
};
Ok(dir)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_platform_detection() {
let (platform, arch) = CliInstaller::detect_platform();
println!("Detected platform: {}-{}", platform, arch);
if cfg!(any(
target_os = "macos",
target_os = "linux",
target_os = "windows"
)) {
assert_ne!(platform, "unknown");
}
if cfg!(any(
target_arch = "x86_64",
target_arch = "aarch64",
target_arch = "x86"
)) {
assert_ne!(arch, "unknown");
}
}
#[test]
fn test_install_dir() {
let dir = CliInstaller::get_install_dir().unwrap();
println!("Install directory: {}", dir.display());
let home = std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.unwrap();
assert!(dir.starts_with(home));
}
}