claude_code_sdk_rust/internal/
cli_discovery.rs1use std::path::{Path, PathBuf};
2
3use crate::error::{CLINotFoundError, ClaudeSDKError, Result};
4
5const DEFAULT_CLI_NAME: &str = "claude";
6const MINIMUM_CLAUDE_CODE_VERSION: (u64, u64, u64) = (2, 0, 0);
7
8pub(crate) fn find_cli_path(explicit: Option<&str>) -> Result<String> {
9 if let Some(path) = explicit {
10 return Ok(path.to_string());
11 }
12
13 if let Some(path) = bundled_cli_path().filter(|path| path.is_file()) {
14 return Ok(path.to_string_lossy().to_string());
15 }
16
17 if let Ok(path) = which::which(DEFAULT_CLI_NAME) {
18 return Ok(path.to_string_lossy().to_string());
19 }
20
21 if let Some(home) = dirs::home_dir() {
22 for path in fallback_cli_locations(&home) {
23 if path.is_file() {
24 return Ok(path.to_string_lossy().to_string());
25 }
26 }
27 }
28
29 Err(ClaudeSDKError::CLINotFound(CLINotFoundError::new(
30 "Claude Code not found. Install with npm install -g @anthropic-ai/claude-code, ensure it is on PATH, or set cli_path.",
31 DEFAULT_CLI_NAME,
32 )))
33}
34
35pub(crate) fn fallback_cli_locations(home: &Path) -> Vec<PathBuf> {
36 vec![
37 home.join(".npm-global/bin/claude"),
38 PathBuf::from("/usr/local/bin/claude"),
39 home.join(".local/bin/claude"),
40 home.join("node_modules/.bin/claude"),
41 home.join(".yarn/bin/claude"),
42 home.join(".claude/local/claude"),
43 ]
44}
45
46pub(crate) fn parse_cli_version(output: &str) -> Option<(u64, u64, u64)> {
47 let version = output.split_whitespace().next()?;
48 let mut parts = version.split('.');
49 let major = parts.next()?.parse().ok()?;
50 let minor = parts.next()?.parse().ok()?;
51 let patch = parts.next()?.parse().ok()?;
52 Some((major, minor, patch))
53}
54
55pub(crate) fn is_supported_cli_version(version: (u64, u64, u64)) -> bool {
56 version >= MINIMUM_CLAUDE_CODE_VERSION
57}
58
59pub(crate) fn unsupported_cli_version_warning(cli_path: &str, version: (u64, u64, u64)) -> String {
60 format!(
61 "Claude Code version {}.{}.{} at {} is unsupported in the Agent SDK. Minimum required version is {}.{}.{}.",
62 version.0,
63 version.1,
64 version.2,
65 cli_path,
66 MINIMUM_CLAUDE_CODE_VERSION.0,
67 MINIMUM_CLAUDE_CODE_VERSION.1,
68 MINIMUM_CLAUDE_CODE_VERSION.2,
69 )
70}
71
72pub(crate) async fn check_cli_version(cli_path: &str) -> Option<bool> {
73 let output = tokio::time::timeout(
74 std::time::Duration::from_secs(2),
75 tokio::process::Command::new(cli_path).arg("-v").output(),
76 )
77 .await
78 .ok()?
79 .ok()?;
80 let stdout = String::from_utf8_lossy(&output.stdout);
81 let version = parse_cli_version(stdout.trim())?;
82 let supported = is_supported_cli_version(version);
83 if !supported {
84 tracing::warn!("{}", unsupported_cli_version_warning(cli_path, version));
85 }
86 Some(supported)
87}
88
89fn bundled_cli_path() -> Option<PathBuf> {
90 let cli_name = if cfg!(windows) {
91 "claude.exe"
92 } else {
93 DEFAULT_CLI_NAME
94 };
95 let exe = std::env::current_exe().ok()?;
96 let dir = exe.parent()?;
97 Some(dir.join("_bundled").join(cli_name))
98}
99
100#[cfg(test)]
101mod tests {
102 use super::*;
103
104 #[test]
105 fn fallback_locations_match_python_sdk_order() {
106 let home = PathBuf::from("/home/alice");
107 let locations = fallback_cli_locations(&home);
108
109 assert_eq!(
110 locations[0],
111 PathBuf::from("/home/alice/.npm-global/bin/claude")
112 );
113 assert_eq!(locations[1], PathBuf::from("/usr/local/bin/claude"));
114 assert_eq!(locations[2], PathBuf::from("/home/alice/.local/bin/claude"));
115 assert_eq!(
116 locations[3],
117 PathBuf::from("/home/alice/node_modules/.bin/claude")
118 );
119 assert_eq!(locations[4], PathBuf::from("/home/alice/.yarn/bin/claude"));
120 assert_eq!(
121 locations[5],
122 PathBuf::from("/home/alice/.claude/local/claude")
123 );
124 }
125
126 #[test]
127 fn parses_semver_prefix_from_cli_version_output() {
128 assert_eq!(parse_cli_version("2.1.110"), Some((2, 1, 110)));
129 assert_eq!(parse_cli_version("2.0.0 (Claude Code)"), Some((2, 0, 0)));
130 assert_eq!(parse_cli_version("not-a-version"), None);
131 }
132
133 #[test]
134 fn checks_minimum_supported_version() {
135 assert!(!is_supported_cli_version((1, 9, 99)));
136 assert!(is_supported_cli_version((2, 0, 0)));
137 assert!(is_supported_cli_version((2, 1, 0)));
138 }
139
140 #[test]
141 fn unsupported_version_warning_includes_version_path_and_minimum() {
142 let warning = unsupported_cli_version_warning("/usr/bin/claude", (1, 9, 99));
143
144 assert!(warning.contains("1.9.99"));
145 assert!(warning.contains("/usr/bin/claude"));
146 assert!(warning.contains("2.0.0"));
147 }
148}