claude_cli_sdk/
discovery.rs1use std::path::{Path, PathBuf};
28use std::time::Duration;
29
30use crate::errors::{Error, Result};
31
32pub const MIN_CLI_VERSION: &str = "1.0.0";
34
35const CLI_NAME: &str = "claude";
37
38pub fn find_cli() -> Result<PathBuf> {
63 if let Ok(path) = which::which(CLI_NAME) {
65 return Ok(path);
66 }
67
68 let home = home_dir();
70
71 #[cfg(unix)]
72 let candidates: Vec<PathBuf> = vec![
73 home.as_ref()
74 .map(|h| h.join(".npm-global/bin").join(CLI_NAME)),
75 Some(PathBuf::from("/usr/local/bin").join(CLI_NAME)),
76 home.as_ref().map(|h| h.join(".local/bin").join(CLI_NAME)),
77 Some(PathBuf::from("node_modules/.bin").join(CLI_NAME)),
78 home.as_ref().map(|h| h.join(".yarn/bin").join(CLI_NAME)),
79 home.as_ref()
80 .map(|h| h.join(".claude/local").join(CLI_NAME)),
81 ]
82 .into_iter()
83 .flatten()
84 .collect();
85
86 #[cfg(windows)]
87 let cli_exe = format!("{CLI_NAME}.exe");
88 #[cfg(windows)]
89 let candidates: Vec<PathBuf> = vec![
90 home.as_ref()
91 .map(|h| h.join("AppData/Roaming/npm").join(&cli_exe)),
92 Some(PathBuf::from("node_modules/.bin").join(&cli_exe)),
93 home.as_ref().map(|h| h.join("scoop/shims").join(&cli_exe)),
94 home.as_ref()
95 .map(|h| h.join(".claude/local").join(&cli_exe)),
96 ]
97 .into_iter()
98 .flatten()
99 .collect();
100
101 for candidate in candidates {
102 if candidate.is_file() {
103 return Ok(candidate);
104 }
105 }
106
107 Err(Error::CliNotFound)
108}
109
110pub async fn check_cli_version(cli_path: &Path, timeout: Option<Duration>) -> Result<String> {
124 let mut child = tokio::process::Command::new(cli_path)
125 .arg("--version")
126 .stdout(std::process::Stdio::piped())
127 .stderr(std::process::Stdio::piped())
128 .spawn()
129 .map_err(Error::SpawnFailed)?;
130
131 if let Some(d) = timeout {
132 if tokio::time::timeout(d, child.wait()).await.is_err() {
133 let _ = child.kill().await;
134 return Err(Error::Timeout(format!(
135 "version check timed out after {}s",
136 d.as_secs_f64()
137 )));
138 }
139 }
140
141 let output = child.wait_with_output().await.map_err(Error::SpawnFailed)?;
142
143 if !output.status.success() {
144 return Err(Error::ProcessExited {
145 code: output.status.code(),
146 stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
147 });
148 }
149
150 let stdout = String::from_utf8_lossy(&output.stdout);
151 parse_version(&stdout).ok_or_else(|| {
152 Error::ControlProtocol(format!("could not parse version from CLI output: {stdout}"))
153 })
154}
155
156#[must_use]
160pub fn version_satisfies(version: &str, minimum: &str) -> bool {
161 let parse = |s: &str| -> (u32, u32, u32) {
162 let mut parts = s.split('.').map(|p| p.parse::<u32>().unwrap_or(0));
163 let major = parts.next().unwrap_or(0);
164 let minor = parts.next().unwrap_or(0);
165 let patch = parts.next().unwrap_or(0);
166 (major, minor, patch)
167 };
168 parse(version) >= parse(minimum)
169}
170
171fn parse_version(output: &str) -> Option<String> {
177 for word in output.split_whitespace() {
179 let trimmed = word.strip_prefix('v').unwrap_or(word);
180 if trimmed.chars().next().is_some_and(|c| c.is_ascii_digit()) && trimmed.contains('.') {
181 let version: String = trimmed
183 .chars()
184 .take_while(|c| c.is_ascii_alphanumeric() || *c == '.' || *c == '-')
185 .collect();
186 if version.split('.').count() >= 2 {
187 return Some(version);
188 }
189 }
190 }
191 None
192}
193
194fn home_dir() -> Option<PathBuf> {
199 #[cfg(unix)]
200 {
201 std::env::var_os("HOME").map(PathBuf::from)
202 }
203 #[cfg(windows)]
204 {
205 std::env::var_os("USERPROFILE")
206 .map(PathBuf::from)
207 .or_else(|| {
208 let drive = std::env::var_os("HOMEDRIVE")?;
209 let path = std::env::var_os("HOMEPATH")?;
210 Some(PathBuf::from(drive).join(path))
211 })
212 }
213}
214
215#[cfg(test)]
218mod tests {
219 use super::*;
220
221 #[test]
222 fn parse_version_standard() {
223 assert_eq!(parse_version("1.2.3"), Some("1.2.3".into()));
224 }
225
226 #[test]
227 fn parse_version_with_v_prefix() {
228 assert_eq!(parse_version("v1.2.3"), Some("1.2.3".into()));
229 }
230
231 #[test]
232 fn parse_version_with_claude_prefix() {
233 assert_eq!(parse_version("claude v1.0.12"), Some("1.0.12".into()));
234 }
235
236 #[test]
237 fn parse_version_prerelease() {
238 assert_eq!(parse_version("v2.0.0-beta.1"), Some("2.0.0-beta.1".into()));
239 }
240
241 #[test]
242 fn parse_version_empty_input() {
243 assert_eq!(parse_version(""), None);
244 }
245
246 #[test]
247 fn parse_version_no_version() {
248 assert_eq!(parse_version("no version here"), None);
249 }
250
251 #[test]
252 fn version_satisfies_equal() {
253 assert!(version_satisfies("1.0.0", "1.0.0"));
254 }
255
256 #[test]
257 fn version_satisfies_greater() {
258 assert!(version_satisfies("2.0.0", "1.0.0"));
259 assert!(version_satisfies("1.1.0", "1.0.0"));
260 assert!(version_satisfies("1.0.1", "1.0.0"));
261 }
262
263 #[test]
264 fn version_satisfies_less() {
265 assert!(!version_satisfies("0.9.0", "1.0.0"));
266 assert!(!version_satisfies("1.0.0", "1.0.1"));
267 }
268
269 #[test]
270 fn version_satisfies_major_priority() {
271 assert!(version_satisfies("2.0.0", "1.9.9"));
272 assert!(!version_satisfies("1.9.9", "2.0.0"));
273 }
274
275 #[test]
276 fn find_cli_returns_path_or_not_found() {
277 let result = find_cli();
279 match result {
280 Ok(path) => assert!(path.is_file()),
281 Err(Error::CliNotFound) => {} Err(e) => panic!("unexpected error: {e}"),
283 }
284 }
285
286 #[tokio::test]
287 async fn check_cli_version_with_timeout() {
288 let dir = tempfile::tempdir().unwrap();
290
291 #[cfg(unix)]
292 let script = {
293 let s = dir.path().join("slow_cli");
294 std::fs::write(&s, "#!/bin/sh\nsleep 999\n").unwrap();
295 use std::os::unix::fs::PermissionsExt;
296 std::fs::set_permissions(&s, std::fs::Permissions::from_mode(0o755)).unwrap();
297 s
298 };
299
300 #[cfg(windows)]
301 let script = {
302 let s = dir.path().join("slow_cli.bat");
303 std::fs::write(&s, "@ping -n 999 127.0.0.1 >nul\r\n").unwrap();
304 s
305 };
306
307 let result = check_cli_version(&script, Some(Duration::from_millis(50))).await;
308 assert!(
309 matches!(result, Err(Error::Timeout(_))),
310 "expected Timeout, got: {result:?}"
311 );
312 }
313
314 #[tokio::test]
315 async fn check_cli_version_no_timeout() {
316 let dir = tempfile::tempdir().unwrap();
321
322 #[cfg(unix)]
323 let bin = {
324 use std::os::unix::fs::PermissionsExt;
325 let s = dir.path().join("noop.sh");
326 std::fs::write(&s, "#!/bin/sh\nexit 0\n").unwrap();
327 std::fs::set_permissions(&s, std::fs::Permissions::from_mode(0o755)).unwrap();
328 s
329 };
330
331 #[cfg(windows)]
332 let bin = {
333 let s = dir.path().join("noop.bat");
334 std::fs::write(&s, "@exit /b 0\r\n").unwrap();
335 s
336 };
337
338 let result = check_cli_version(bin.as_ref(), None).await;
339 assert!(
340 matches!(result, Err(Error::ControlProtocol(_))),
341 "expected ControlProtocol (no version), got: {result:?}"
342 );
343 }
344}