Skip to main content

claude_code_cli_acp/compat/
claude_probe.rs

1use std::ffi::OsString;
2use std::path::PathBuf;
3use std::process::Command;
4
5use anyhow::{Context, anyhow};
6use serde::{Deserialize, Serialize};
7
8pub const CLAUDE_CODE_CLI_ENV: &str = "CLAUDE_CODE_CLI";
9
10#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
11pub struct ClaudeCli {
12    pub executable: PathBuf,
13}
14
15#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
16pub struct RequiredFlag {
17    pub name: String,
18    pub source: CapabilitySource,
19    pub present: bool,
20}
21
22#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
23pub struct RemovedFlag {
24    pub name: String,
25    pub replacement: String,
26}
27
28#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
29#[serde(rename_all = "snake_case")]
30pub enum CapabilitySource {
31    LocalHelp,
32    OfficialDocs,
33}
34
35#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
36pub struct ClaudeCapabilityReport {
37    pub executable: PathBuf,
38    pub version: Result<String, String>,
39    pub local_help: Result<String, String>,
40    pub required_flags: Vec<RequiredFlag>,
41    pub removed_flags: Vec<RemovedFlag>,
42    pub required_interactive_commands: Vec<String>,
43    pub launch_uses_print_flag: bool,
44}
45
46impl ClaudeCli {
47    pub fn new(executable: impl Into<PathBuf>) -> Self {
48        Self {
49            executable: executable.into(),
50        }
51    }
52
53    pub fn from_env_or_path() -> Self {
54        if let Some(executable) = std::env::var_os(CLAUDE_CODE_CLI_ENV) {
55            return Self::new(executable);
56        }
57
58        match which::which("claude") {
59            Ok(path) => Self::new(path),
60            Err(_) => Self::new("claude"),
61        }
62    }
63
64    pub fn from_env() -> Self {
65        Self::from_env_or_path()
66    }
67
68    pub fn executable(&self) -> &std::path::Path {
69        &self.executable
70    }
71
72    pub fn version(&self) -> anyhow::Result<String> {
73        self.run_single_arg("--version")
74    }
75
76    pub fn help(&self) -> anyhow::Result<String> {
77        self.run_single_arg("--help")
78    }
79
80    pub fn required_flags() -> Vec<RequiredFlag> {
81        [
82            "--session-id",
83            "--resume",
84            "--continue",
85            "--model",
86            "--permission-mode",
87            "--settings",
88            "--add-dir",
89            "--debug-file",
90            "--version",
91            "--help",
92            "--output-format",
93            "--input-format",
94            "--mcp-config",
95            "--strict-mcp-config",
96        ]
97        .into_iter()
98        .map(|name| RequiredFlag {
99            name: name.to_string(),
100            source: CapabilitySource::OfficialDocs,
101            present: false,
102        })
103        .collect()
104    }
105
106    pub fn removed_flags() -> Vec<RemovedFlag> {
107        vec![RemovedFlag {
108            name: "--enable-auto-mode".to_string(),
109            replacement: "--permission-mode auto".to_string(),
110        }]
111    }
112
113    pub fn required_interactive_commands() -> Vec<String> {
114        ["/exit", "/clear", "/resume"]
115            .into_iter()
116            .map(str::to_string)
117            .collect()
118    }
119
120    pub async fn capability_report(&self) -> ClaudeCapabilityReport {
121        let version = self.version().map_err(|error| error.to_string());
122        let help = self.help().map_err(|error| error.to_string());
123        let help_text = help.as_deref().unwrap_or_default();
124        let required_flags = Self::required_flags()
125            .into_iter()
126            .map(|mut flag| {
127                flag.present = help_contains_flag(help_text, &flag.name);
128                flag.source = CapabilitySource::LocalHelp;
129                flag
130            })
131            .collect();
132
133        ClaudeCapabilityReport {
134            executable: self.executable.clone(),
135            version,
136            local_help: help,
137            required_flags,
138            removed_flags: Self::removed_flags(),
139            required_interactive_commands: Self::required_interactive_commands(),
140            launch_uses_print_flag: false,
141        }
142    }
143
144    fn run_single_arg(&self, arg: &str) -> anyhow::Result<String> {
145        let output = Command::new(&self.executable)
146            .arg(arg)
147            .output()
148            .with_context(|| format!("failed to run {}", self.executable.display()))?;
149
150        if !output.status.success() {
151            return Err(anyhow!(
152                "{} {arg} exited with status {}: {}",
153                self.executable.display(),
154                output.status,
155                String::from_utf8_lossy(&output.stderr).trim()
156            ));
157        }
158
159        let stdout = String::from_utf8(output.stdout).with_context(|| {
160            format!(
161                "{} {arg} emitted non-utf8 stdout",
162                self.executable.display()
163            )
164        })?;
165        Ok(stdout.trim().to_string())
166    }
167
168    pub fn command_argv(&self, arg: impl Into<OsString>) -> Vec<OsString> {
169        vec![self.executable.clone().into_os_string(), arg.into()]
170    }
171}
172
173impl ClaudeCapabilityReport {
174    pub fn local_help_contains(&self, flag: &RequiredFlag) -> bool {
175        self.local_help
176            .as_deref()
177            .map(|help| help_contains_flag(help, &flag.name))
178            .unwrap_or(false)
179    }
180}
181
182impl std::fmt::Display for RequiredFlag {
183    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
184        formatter.write_str(&self.name)
185    }
186}
187
188fn help_contains_flag(help: &str, flag: &str) -> bool {
189    help.split(|c: char| c.is_whitespace() || c == ',' || c == '[' || c == ']')
190        .any(|token| token == flag || token.starts_with(&format!("{flag}=")))
191}