claude_code_cli_acp/compat/
claude_probe.rs1use 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}