1use std::sync::OnceLock;
4use tracing::info;
5
6pub const SDK_VERSION: &str = env!("CARGO_PKG_VERSION");
8
9pub const MIN_CLI_VERSION: &str = "2.0.0";
11
12pub const CLI_VERSION: &str = "2.1.41";
14
15pub(crate) const BUNDLED_CLI_DIR: &str = ".claude/sdk/bundled";
17
18pub 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
33pub const SKIP_VERSION_CHECK_ENV: &str = "CLAUDE_AGENT_SDK_SKIP_VERSION_CHECK";
35
36pub const ENTRYPOINT: &str = "sdk-rs";
38
39pub 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
53pub 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 if cli_min > req_min {
72 return true;
73 }
74 if cli_min < req_min {
75 return false;
76 }
77
78 cli_patch >= req_patch
80}
81
82static CLAUDE_CODE_VERSION: OnceLock<Option<String>> = OnceLock::new();
84
85pub 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
109fn 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
124pub(crate) fn download_cli() -> std::result::Result<std::path::PathBuf, String> {
141 validate_version_string(CLI_VERSION)?;
143
144 let bundled_path = bundled_cli_path().ok_or("Cannot determine home directory")?;
145
146 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 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 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 #[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 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#[cfg(unix)]
206fn acquire_file_lock(file: &std::fs::File) -> std::result::Result<(), String> {
207 use std::os::unix::io::AsRawFd;
208 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#[cfg(not(unix))]
223fn acquire_file_lock(_file: &std::fs::File) -> std::result::Result<(), String> {
224 Ok(())
228}
229
230fn unique_tmp_name(prefix: &str, ext: &str) -> String {
232 format!("{prefix}.{pid}{ext}", pid = std::process::id())
233}
234
235#[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 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 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 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 #[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 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_backup(had_existing, &backup_path, &default_install_path);
325
326 Ok(())
327}
328
329#[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#[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 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 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 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}