Skip to main content

claude_cli_sdk/
discovery.rs

1//! CLI discovery — locating the Claude Code binary on the system.
2//!
3//! The SDK requires the Claude Code CLI to be installed on the host.  This
4//! module searches a priority-ordered list of platform-specific paths and
5//! optionally verifies the installed version against [`MIN_CLI_VERSION`].
6//!
7//! On all platforms, `which::which("claude")` is tried first (`$PATH` lookup).
8//! Fallback paths are platform-specific:
9//!
10//! - **Unix** (macOS/Linux): `~/.npm-global/bin/claude`, `/usr/local/bin/claude`, etc.
11//! - **Windows**: `AppData/Roaming/npm/claude.exe`, `scoop/shims/claude.exe`, etc.
12//!
13//! # Example
14//!
15//! ```rust,no_run
16//! use std::time::Duration;
17//! use claude_cli_sdk::discovery::{find_cli, check_cli_version};
18//!
19//! # async fn example() -> claude_cli_sdk::Result<()> {
20//! let cli = find_cli()?;
21//! let version = check_cli_version(&cli, Some(Duration::from_secs(5))).await?;
22//! println!("Found Claude CLI v{version} at {}", cli.display());
23//! # Ok(())
24//! # }
25//! ```
26
27use std::path::{Path, PathBuf};
28use std::time::Duration;
29
30use crate::errors::{Error, Result};
31
32/// Minimum CLI version required by this SDK.
33pub const MIN_CLI_VERSION: &str = "1.0.0";
34
35/// Binary name to search for.
36const CLI_NAME: &str = "claude";
37
38/// Locate the Claude Code CLI binary, searching a priority-ordered list of
39/// platform-specific paths.
40///
41/// # Discovery Strategy (priority order)
42///
43/// 1. `which claude` — whatever is first on `$PATH` (cross-platform)
44///
45/// **Unix fallbacks:**
46/// 2. `~/.npm-global/bin/claude`
47/// 3. `/usr/local/bin/claude`
48/// 4. `~/.local/bin/claude`
49/// 5. `node_modules/.bin/claude` (relative to CWD)
50/// 6. `~/.yarn/bin/claude`
51/// 7. `~/.claude/local/claude`
52///
53/// **Windows fallbacks:**
54/// 2. `%USERPROFILE%/AppData/Roaming/npm/claude.exe`
55/// 3. `node_modules/.bin/claude.exe` (relative to CWD)
56/// 4. `%USERPROFILE%/scoop/shims/claude.exe`
57/// 5. `%USERPROFILE%/.claude/local/claude.exe`
58///
59/// # Errors
60///
61/// Returns [`Error::CliNotFound`] if no binary is found at any location.
62pub fn find_cli() -> Result<PathBuf> {
63    // 1. which — respects $PATH, handles .exe / PATHEXT on Windows.
64    if let Ok(path) = which::which(CLI_NAME) {
65        return Ok(path);
66    }
67
68    // Platform-specific fallback paths.
69    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
110/// Run `claude --version` and parse the semver string from the output.
111///
112/// The CLI typically prints a line like `claude v1.2.3` or just `1.2.3`.
113/// This function extracts the first semver-like substring (digits and dots).
114///
115/// If `timeout` is `Some`, the command is killed after the deadline expires.
116///
117/// # Errors
118///
119/// - [`Error::Timeout`] if the deadline expires.
120/// - [`Error::SpawnFailed`] if the process cannot be launched.
121/// - [`Error::ProcessExited`] if the process exits with a non-zero code.
122/// - [`Error::ControlProtocol`] if the output cannot be parsed as a version.
123pub 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/// Compare two semver-like version strings (major.minor.patch).
157///
158/// Returns `true` if `version >= minimum`.
159#[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
171// ── Internals ────────────────────────────────────────────────────────────────
172
173/// Extract a semver-like version string from CLI output.
174///
175/// Handles formats like `claude v1.2.3`, `1.2.3`, `v1.2.3-beta.1`.
176fn parse_version(output: &str) -> Option<String> {
177    // Look for a pattern like digits.digits.digits (possibly with pre-release suffix).
178    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            // Take up to the first character that isn't digit, dot, or hyphen/alpha (for pre-release).
182            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
194/// Get the user's home directory.
195///
196/// - **Unix**: reads `$HOME`.
197/// - **Windows**: reads `$USERPROFILE`, falling back to `$HOMEDRIVE` + `$HOMEPATH`.
198fn 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// ── Tests ────────────────────────────────────────────────────────────────────
216
217#[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        // This test is environment-dependent. We just verify the function doesn't panic.
278        let result = find_cli();
279        match result {
280            Ok(path) => assert!(path.is_file()),
281            Err(Error::CliNotFound) => {} // Expected when CLI not installed
282            Err(e) => panic!("unexpected error: {e}"),
283        }
284    }
285
286    #[tokio::test]
287    async fn check_cli_version_with_timeout() {
288        // Create a script/batch file that ignores --version and blocks.
289        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        // With None timeout, a fast command completes normally.
317        // We need a binary that exits 0 but outputs no parseable version.
318        // Note: /usr/bin/true on GNU coreutils responds to --version with
319        // version info, so we create a custom no-op script instead.
320        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}