Skip to main content

claude_code_sdk_rust/internal/
cli_discovery.rs

1use 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}