cc_sdk/
cli_download.rs

1//! Automatic Claude Code CLI download and management
2//!
3//! This module provides functionality to automatically download and manage
4//! the Claude Code CLI binary, similar to Python SDK's bundling approach.
5//!
6//! # Download Strategy
7//!
8//! 1. First, check if CLI is already installed (PATH, common locations)
9//! 2. If not found, check the SDK's local cache directory
10//! 3. If not cached, download from official source and cache locally
11//!
12//! # Cache Location
13//!
14//! - Unix: `~/.cache/cc-sdk/cli/`
15//! - macOS: `~/Library/Caches/cc-sdk/cli/`
16//! - Windows: `%LOCALAPPDATA%\cc-sdk\cli\`
17//!
18//! # Feature Flag
19//!
20//! The download functionality requires the `auto-download` feature (enabled by default).
21//! To disable, use `default-features = false` in your Cargo.toml.
22
23use crate::errors::{Result, SdkError};
24use std::path::PathBuf;
25#[allow(unused_imports)]
26use tracing::{debug, info, warn};
27
28/// Minimum CLI version required by this SDK
29pub const MIN_CLI_VERSION: &str = "2.0.0";
30
31/// Default CLI version to download if not specified
32pub const DEFAULT_CLI_VERSION: &str = "latest";
33
34/// Get the cache directory for the SDK
35pub fn get_cache_dir() -> Option<PathBuf> {
36    #[cfg(target_os = "macos")]
37    {
38        dirs::home_dir().map(|h| h.join("Library/Caches/cc-sdk/cli"))
39    }
40    #[cfg(target_os = "windows")]
41    {
42        dirs::cache_dir().map(|c| c.join("cc-sdk").join("cli"))
43    }
44    #[cfg(all(unix, not(target_os = "macos")))]
45    {
46        dirs::cache_dir().map(|c| c.join("cc-sdk").join("cli"))
47    }
48}
49
50/// Get the path to the cached CLI binary
51pub fn get_cached_cli_path() -> Option<PathBuf> {
52    let cache_dir = get_cache_dir()?;
53    let cli_name = if cfg!(windows) { "claude.exe" } else { "claude" };
54    Some(cache_dir.join(cli_name))
55}
56
57/// Check if the cached CLI exists and is executable
58#[allow(dead_code)]
59pub fn is_cli_cached() -> bool {
60    if let Some(path) = get_cached_cli_path() {
61        if path.exists() && path.is_file() {
62            #[cfg(unix)]
63            {
64                use std::os::unix::fs::PermissionsExt;
65                if let Ok(metadata) = path.metadata() {
66                    return metadata.permissions().mode() & 0o111 != 0;
67                }
68            }
69            #[cfg(not(unix))]
70            {
71                return true;
72            }
73        }
74    }
75    false
76}
77
78/// Download the Claude Code CLI to the cache directory
79///
80/// # Arguments
81///
82/// * `version` - Version to download ("latest" or specific version like "2.0.62")
83/// * `on_progress` - Optional callback for download progress (bytes_downloaded, total_bytes)
84///
85/// # Returns
86///
87/// Path to the downloaded CLI binary
88///
89/// # Feature Flag
90///
91/// This function requires the `auto-download` feature to be enabled.
92/// When disabled, it returns an error directing users to install manually.
93#[cfg(feature = "auto-download")]
94pub async fn download_cli(
95    version: Option<&str>,
96    on_progress: Option<Box<dyn Fn(u64, Option<u64>) + Send + Sync>>,
97) -> Result<PathBuf> {
98    let version = version.unwrap_or(DEFAULT_CLI_VERSION);
99    info!("Downloading Claude Code CLI version: {}", version);
100
101    let cache_dir = get_cache_dir().ok_or_else(|| {
102        SdkError::ConfigError("Cannot determine cache directory for CLI download".to_string())
103    })?;
104
105    // Create cache directory if it doesn't exist
106    std::fs::create_dir_all(&cache_dir).map_err(|e| {
107        SdkError::ConfigError(format!("Failed to create cache directory: {}", e))
108    })?;
109
110    let cli_path = get_cached_cli_path().ok_or_else(|| {
111        SdkError::ConfigError("Cannot determine CLI path".to_string())
112    })?;
113
114    // Determine platform-specific download URL and installation method
115    let install_result = install_cli_for_platform(version, &cli_path, on_progress).await?;
116
117    info!("Claude Code CLI installed to: {}", install_result.display());
118    Ok(install_result)
119}
120
121/// Stub for download_cli when auto-download feature is disabled
122#[cfg(not(feature = "auto-download"))]
123pub async fn download_cli(
124    _version: Option<&str>,
125    _on_progress: Option<Box<dyn Fn(u64, Option<u64>) + Send + Sync>>,
126) -> Result<PathBuf> {
127    Err(SdkError::ConfigError(
128        "Auto-download feature is not enabled. \
129        Either enable it with `features = [\"auto-download\"]` in Cargo.toml, \
130        or install Claude CLI manually: npm install -g @anthropic-ai/claude-code".to_string()
131    ))
132}
133
134/// Install CLI using platform-specific method
135#[cfg(feature = "auto-download")]
136async fn install_cli_for_platform(
137    version: &str,
138    target_path: &PathBuf,
139    on_progress: Option<Box<dyn Fn(u64, Option<u64>) + Send + Sync>>,
140) -> Result<PathBuf> {
141    #[cfg(unix)]
142    {
143        install_cli_unix(version, target_path, on_progress).await
144    }
145    #[cfg(windows)]
146    {
147        install_cli_windows(version, target_path, on_progress).await
148    }
149}
150
151/// Install CLI on Unix systems (macOS, Linux)
152#[cfg(all(unix, feature = "auto-download"))]
153async fn install_cli_unix(
154    version: &str,
155    target_path: &PathBuf,
156    on_progress: Option<Box<dyn Fn(u64, Option<u64>) + Send + Sync>>,
157) -> Result<PathBuf> {
158    use tokio::process::Command;
159
160    if let Some(ref progress) = on_progress {
161        progress(0, None);
162    }
163
164    // Method 1: Try using npm to install and copy
165    if which::which("npm").is_ok() {
166        debug!("Attempting to install via npm...");
167
168        let npm_package = if version == "latest" {
169            "@anthropic-ai/claude-code".to_string()
170        } else {
171            format!("@anthropic-ai/claude-code@{}", version)
172        };
173
174        let temp_dir = std::env::temp_dir().join("cc-sdk-npm-install");
175        let _ = std::fs::remove_dir_all(&temp_dir);
176        std::fs::create_dir_all(&temp_dir).map_err(|e| {
177            SdkError::ConfigError(format!("Failed to create temp directory: {}", e))
178        })?;
179
180        let output = Command::new("npm")
181            .args(["install", "--prefix", temp_dir.to_str().unwrap(), &npm_package])
182            .output()
183            .await
184            .map_err(SdkError::ProcessError)?;
185
186        if output.status.success() {
187            let npm_bin_path = temp_dir.join("node_modules/.bin/claude");
188            if npm_bin_path.exists() {
189                std::fs::copy(&npm_bin_path, target_path).map_err(|e| {
190                    SdkError::ConfigError(format!("Failed to copy CLI to cache: {}", e))
191                })?;
192
193                #[cfg(unix)]
194                {
195                    use std::os::unix::fs::PermissionsExt;
196                    let mut perms = std::fs::metadata(target_path)
197                        .map_err(|e| {
198                            SdkError::ConfigError(format!("Failed to get file permissions: {}", e))
199                        })?
200                        .permissions();
201                    perms.set_mode(0o755);
202                    std::fs::set_permissions(target_path, perms).map_err(|e| {
203                        SdkError::ConfigError(format!("Failed to set file permissions: {}", e))
204                    })?;
205                }
206
207                let _ = std::fs::remove_dir_all(&temp_dir);
208
209                if let Some(ref progress) = on_progress {
210                    progress(100, Some(100));
211                }
212
213                return Ok(target_path.clone());
214            }
215        } else {
216            let stderr = String::from_utf8_lossy(&output.stderr);
217            warn!("npm install failed: {}", stderr);
218        }
219
220        let _ = std::fs::remove_dir_all(&temp_dir);
221    }
222
223    // Method 2: Try using the official install script
224    debug!("Attempting to install via official script...");
225
226    let install_script_url = "https://claude.ai/install.sh";
227
228    let client = reqwest::Client::new();
229    let response = client
230        .get(install_script_url)
231        .send()
232        .await
233        .map_err(|e| SdkError::ConnectionError(format!("Failed to download install script: {}", e)))?;
234
235    if !response.status().is_success() {
236        return Err(SdkError::ConnectionError(format!(
237            "Failed to download install script: HTTP {}",
238            response.status()
239        )));
240    }
241
242    let script_content = response
243        .text()
244        .await
245        .map_err(|e| SdkError::ConnectionError(format!("Failed to read install script: {}", e)))?;
246
247    let parent_dir = target_path.parent().ok_or_else(|| {
248        SdkError::ConfigError("Invalid target path".to_string())
249    })?;
250
251    let output = Command::new("bash")
252        .arg("-c")
253        .arg(&script_content)
254        .env("CLAUDE_INSTALL_DIR", parent_dir)
255        .output()
256        .await
257        .map_err(SdkError::ProcessError)?;
258
259    if output.status.success() && target_path.exists() {
260        if let Some(ref progress) = on_progress {
261            progress(100, Some(100));
262        }
263        return Ok(target_path.clone());
264    }
265
266    Err(SdkError::CliNotFound {
267        searched_paths: format!(
268            "Failed to automatically download Claude Code CLI.\n\
269            Please install manually:\n\n\
270            Option 1 (npm):\n\
271            npm install -g @anthropic-ai/claude-code\n\n\
272            Option 2 (official script):\n\
273            curl -fsSL https://claude.ai/install.sh | bash\n\n\
274            Error details: {}",
275            String::from_utf8_lossy(&output.stderr)
276        ),
277    })
278}
279
280/// Install CLI on Windows systems
281#[cfg(all(windows, feature = "auto-download"))]
282async fn install_cli_windows(
283    version: &str,
284    target_path: &PathBuf,
285    on_progress: Option<Box<dyn Fn(u64, Option<u64>) + Send + Sync>>,
286) -> Result<PathBuf> {
287    use tokio::process::Command;
288
289    if let Some(ref progress) = on_progress {
290        progress(0, None);
291    }
292
293    // Method 1: Try using npm
294    if which::which("npm").is_ok() {
295        debug!("Attempting to install via npm...");
296
297        let npm_package = if version == "latest" {
298            "@anthropic-ai/claude-code".to_string()
299        } else {
300            format!("@anthropic-ai/claude-code@{}", version)
301        };
302
303        let temp_dir = std::env::temp_dir().join("cc-sdk-npm-install");
304        let _ = std::fs::remove_dir_all(&temp_dir);
305        std::fs::create_dir_all(&temp_dir).map_err(|e| {
306            SdkError::ConfigError(format!("Failed to create temp directory: {}", e))
307        })?;
308
309        let output = Command::new("npm")
310            .args(["install", "--prefix", temp_dir.to_str().unwrap(), &npm_package])
311            .output()
312            .await
313            .map_err(SdkError::ProcessError)?;
314
315        if output.status.success() {
316            let npm_bin_path = temp_dir.join("node_modules/.bin/claude.cmd");
317            if npm_bin_path.exists() {
318                std::fs::copy(&npm_bin_path, target_path).map_err(|e| {
319                    SdkError::ConfigError(format!("Failed to copy CLI to cache: {}", e))
320                })?;
321
322                let _ = std::fs::remove_dir_all(&temp_dir);
323
324                if let Some(ref progress) = on_progress {
325                    progress(100, Some(100));
326                }
327
328                return Ok(target_path.clone());
329            }
330        }
331
332        let _ = std::fs::remove_dir_all(&temp_dir);
333    }
334
335    // Method 2: Try PowerShell install script
336    debug!("Attempting to install via PowerShell script...");
337
338    let install_script_url = "https://claude.ai/install.ps1";
339
340    let parent_dir = target_path.parent().ok_or_else(|| {
341        SdkError::ConfigError("Invalid target path".to_string())
342    })?;
343
344    let output = Command::new("powershell")
345        .args([
346            "-NoProfile",
347            "-ExecutionPolicy", "Bypass",
348            "-Command",
349            &format!(
350                "$env:CLAUDE_INSTALL_DIR='{}'; iex (iwr -useb {})",
351                parent_dir.display(),
352                install_script_url
353            ),
354        ])
355        .output()
356        .await
357        .map_err(SdkError::ProcessError)?;
358
359    if output.status.success() && target_path.exists() {
360        if let Some(ref progress) = on_progress {
361            progress(100, Some(100));
362        }
363        return Ok(target_path.clone());
364    }
365
366    Err(SdkError::CliNotFound {
367        searched_paths: format!(
368            "Failed to automatically download Claude Code CLI.\n\
369            Please install manually:\n\n\
370            Option 1 (npm):\n\
371            npm install -g @anthropic-ai/claude-code\n\n\
372            Option 2 (PowerShell):\n\
373            iwr -useb https://claude.ai/install.ps1 | iex\n\n\
374            Error details: {}",
375            String::from_utf8_lossy(&output.stderr)
376        ),
377    })
378}
379
380/// Ensure the CLI is available, downloading if necessary
381///
382/// This is the main entry point for CLI management.
383#[allow(dead_code)]
384pub async fn ensure_cli(auto_download: bool) -> Result<PathBuf> {
385    // First, try to find existing CLI
386    if let Ok(path) = crate::transport::subprocess::find_claude_cli() {
387        return Ok(path);
388    }
389
390    // Check cached CLI
391    if let Some(cached_path) = get_cached_cli_path() {
392        if cached_path.exists() {
393            debug!("Using cached CLI at: {}", cached_path.display());
394            return Ok(cached_path);
395        }
396    }
397
398    // Download if auto_download is enabled
399    if auto_download {
400        info!("Claude Code CLI not found, downloading...");
401        return download_cli(None, None).await;
402    }
403
404    Err(SdkError::CliNotFound {
405        searched_paths: "Claude Code CLI not found.\n\n\
406            To automatically download, create the client with auto_download enabled:\n\
407            ```rust\n\
408            let options = ClaudeCodeOptions::builder()\n\
409                .auto_download_cli(true)\n\
410                .build();\n\
411            ```\n\n\
412            Or install manually:\n\
413            npm install -g @anthropic-ai/claude-code".to_string(),
414    })
415}
416
417#[cfg(test)]
418mod tests {
419    use super::*;
420
421    #[test]
422    fn test_get_cache_dir() {
423        let cache_dir = get_cache_dir();
424        assert!(cache_dir.is_some());
425        let dir = cache_dir.unwrap();
426        assert!(dir.to_string_lossy().contains("cc-sdk"));
427    }
428
429    #[test]
430    fn test_get_cached_cli_path() {
431        let cli_path = get_cached_cli_path();
432        assert!(cli_path.is_some());
433        let path = cli_path.unwrap();
434        if cfg!(windows) {
435            assert!(path.to_string_lossy().ends_with("claude.exe"));
436        } else {
437            assert!(path.to_string_lossy().ends_with("claude"));
438        }
439    }
440
441    #[test]
442    fn test_cli_version_constants() {
443        // Verify version constants are set
444        assert!(!MIN_CLI_VERSION.is_empty());
445        assert!(!DEFAULT_CLI_VERSION.is_empty());
446        assert_eq!(DEFAULT_CLI_VERSION, "latest");
447
448        // Verify MIN_CLI_VERSION is valid semver-ish format
449        let parts: Vec<&str> = MIN_CLI_VERSION.split('.').collect();
450        assert_eq!(parts.len(), 3, "MIN_CLI_VERSION should be semver format x.y.z");
451    }
452
453    #[test]
454    fn test_cache_dir_platform_specific() {
455        let cache_dir = get_cache_dir().expect("Should get cache dir");
456
457        #[cfg(target_os = "macos")]
458        {
459            assert!(cache_dir.to_string_lossy().contains("Library/Caches"));
460            assert!(cache_dir.to_string_lossy().contains("cc-sdk/cli"));
461        }
462
463        #[cfg(all(unix, not(target_os = "macos")))]
464        {
465            assert!(cache_dir.to_string_lossy().contains(".cache") || cache_dir.to_string_lossy().contains("cache"));
466            assert!(cache_dir.to_string_lossy().contains("cc-sdk"));
467        }
468
469        #[cfg(target_os = "windows")]
470        {
471            assert!(cache_dir.to_string_lossy().contains("cc-sdk"));
472        }
473    }
474
475    #[test]
476    fn test_is_cli_cached_when_not_cached() {
477        // Since we haven't downloaded anything, CLI should not be cached
478        // (unless running on a machine where it was already downloaded)
479        // We can't assert false because it might be cached on some machines
480        // Just verify the function doesn't panic
481        let _ = is_cli_cached();
482    }
483
484    #[test]
485    fn test_cached_cli_path_is_in_cache_dir() {
486        let cache_dir = get_cache_dir().expect("Should get cache dir");
487        let cli_path = get_cached_cli_path().expect("Should get cli path");
488
489        // CLI path should be inside cache dir
490        assert!(cli_path.starts_with(&cache_dir));
491
492        // CLI should be the executable name
493        let cli_name = cli_path.file_name().expect("Should have file name");
494        if cfg!(windows) {
495            assert_eq!(cli_name, "claude.exe");
496        } else {
497            assert_eq!(cli_name, "claude");
498        }
499    }
500}