cli_agents/adapters/claude/
mod.rs1mod parse;
2
3use crate::adapters::CliAdapter;
4use crate::discovery::discover_binary;
5use crate::error::{Error, Result};
6use crate::events::StreamEvent;
7use crate::types::{CliName, RunOptions, RunResult};
8use crate::DEFAULT_MAX_OUTPUT_BYTES;
9use std::collections::HashMap;
10use tokio_util::sync::CancellationToken;
11
12pub struct ClaudeAdapter;
13
14impl CliAdapter for ClaudeAdapter {
15 fn name(&self) -> CliName {
16 CliName::Claude
17 }
18
19 async fn run(
20 &self,
21 opts: &RunOptions,
22 emit: &(dyn Fn(StreamEvent) + Send + Sync),
23 cancel: CancellationToken,
24 ) -> Result<RunResult> {
25 let binary = match &opts.executable_path {
26 Some(p) => p.clone(),
27 None => discover_binary(CliName::Claude).await.ok_or(Error::NoCli)?,
28 };
29
30 let args = build_args(opts);
31 let extra_env = opts.env.clone().unwrap_or_default();
32 let max_bytes = opts.max_output_bytes.unwrap_or(DEFAULT_MAX_OUTPUT_BYTES);
33
34 let mut state = parse::ParseState::default();
35 let mut active_tools: HashMap<String, String> = HashMap::new();
36
37 let outcome = crate::adapters::spawn_and_stream(
38 crate::adapters::SpawnParams {
39 cli_label: "claude",
40 binary: &binary,
41 args: &args,
42 extra_env: &extra_env,
43 strip_env: &["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"],
48 cwd: opts.cwd.as_deref().unwrap_or("."),
49 max_bytes,
50 cancel: &cancel,
51 },
52 |line| parse::parse_line(line, &mut state, &mut active_tools, emit),
53 )
54 .await?;
55
56 match outcome {
57 crate::adapters::SpawnOutcome::Cancelled => Ok(RunResult {
58 success: false,
59 text: Some("Cancelled.".into()),
60 ..Default::default()
61 }),
62 crate::adapters::SpawnOutcome::Done { exit_code, stderr } => {
63 let success = state.success.unwrap_or(exit_code == 0);
64 let text = if !success && state.result_text.is_none() {
67 crate::adapters::extract_error_message(stderr.as_deref())
68 } else {
69 state.result_text
70 };
71 Ok(RunResult {
72 success,
73 text,
74 exit_code: Some(exit_code),
75 stats: state.stats,
76 session_id: state.session_id,
77 stderr,
78 cost_usd: state.cost_usd,
79 })
80 }
81 }
82 }
83}
84
85fn build_args(opts: &RunOptions) -> Vec<String> {
86 let mut args = vec![
87 "-p".into(),
88 opts.task.clone(),
89 "--output-format".into(),
90 "stream-json".into(),
91 "--verbose".into(),
92 ];
93
94 if let Some(model) = &opts.model {
95 args.push("--model".into());
96 args.push(model.clone());
97 }
98
99 if let Some(session_id) = &opts.resume_session_id {
100 args.push("--resume".into());
101 args.push(session_id.clone());
102 }
103
104 let claude_opts = opts.providers.as_ref().and_then(|p| p.claude.as_ref());
105
106 if let Some(co) = claude_opts {
107 if let Some(allowed) = &co.allowed_tools {
108 args.push("--allowedTools".into());
109 args.push(allowed.clone());
110 }
111 if let Some(disallowed) = &co.disallowed_tools {
112 args.push("--disallowedTools".into());
113 args.push(disallowed.clone());
114 }
115 if let Some(tools) = &co.tools {
116 args.push("--tools".into());
117 args.push(tools.clone());
118 }
119 if let Some(append) = &co.append_system_prompt {
120 args.push("--append-system-prompt".into());
121 args.push(append.clone());
122 }
123 if let Some(max_turns) = co.max_turns {
124 args.push("--max-turns".into());
125 args.push(max_turns.to_string());
126 }
127 if let Some(budget) = co.max_budget_usd {
128 args.push("--max-budget-usd".into());
129 args.push(budget.to_string());
130 }
131 if let Some(tokens) = co.max_thinking_tokens {
132 args.push("--max-thinking-tokens".into());
133 args.push(tokens.to_string());
134 }
135 if co.continue_session == Some(true) {
136 args.push("--continue".into());
137 }
138 if co.include_partial_messages == Some(true) {
139 args.push("--include-partial-messages".into());
140 }
141 if let Some(effort) = &co.effort {
142 args.push("--effort".into());
143 args.push(effort.clone());
144 }
145 if let Some(agents) = &co.agents {
146 if let Ok(json) = serde_json::to_string(agents) {
147 args.push("--agents".into());
148 args.push(json);
149 }
150 }
151 if let Some(sources) = &co.setting_sources {
152 args.push("--setting-sources".into());
155 args.push(
156 sources
157 .iter()
158 .map(|s| s.as_str())
159 .collect::<Vec<_>>()
160 .join(","),
161 );
162 }
163 }
164
165 if let Some(path) = &opts.system_prompt_file {
166 args.push("--system-prompt-file".into());
167 args.push(path.clone());
168 } else if let Some(system_prompt) = &opts.system_prompt {
169 args.push("--system-prompt".into());
170 args.push(system_prompt.clone());
171 }
172
173 if let Some(servers) = opts.mcp_servers.as_ref().filter(|s| !s.is_empty()) {
175 if let Ok(json) = serde_json::to_string(&build_mcp_config(servers)) {
176 args.push("--mcp-config".into());
177 args.push(json);
178 }
179 }
180
181 if opts.skip_permissions {
183 args.push("--permission-mode".into());
184 args.push("bypassPermissions".into());
185 args.push("--dangerously-skip-permissions".into());
186 }
187
188 args
189}
190
191fn build_mcp_config(servers: &HashMap<String, crate::types::McpServer>) -> serde_json::Value {
192 let mut map = serde_json::Map::new();
193 for (name, server) in servers {
194 let mut entry = serde_json::Map::new();
195 if let Some(url) = &server.url {
196 entry.insert("url".into(), serde_json::Value::String(url.clone()));
197 let t = match server.transport_type {
198 Some(crate::types::McpTransport::Http) => "http",
199 _ => "sse",
200 };
201 entry.insert("type".into(), serde_json::Value::String(t.into()));
202 if let Some(headers) = &server.headers {
203 entry.insert(
204 "headers".into(),
205 serde_json::to_value(headers).unwrap_or_default(),
206 );
207 }
208 } else {
209 entry.insert("type".into(), serde_json::Value::String("stdio".into()));
210 if let Some(cmd) = &server.command {
211 entry.insert("command".into(), serde_json::Value::String(cmd.clone()));
212 }
213 if let Some(a) = &server.args {
214 entry.insert("args".into(), serde_json::to_value(a).unwrap_or_default());
215 }
216 if let Some(e) = &server.env {
217 entry.insert("env".into(), serde_json::to_value(e).unwrap_or_default());
218 }
219 }
220 map.insert(name.clone(), serde_json::Value::Object(entry));
221 }
222 serde_json::Value::Object({
223 let mut root = serde_json::Map::new();
224 root.insert("mcpServers".into(), serde_json::Value::Object(map));
225 root
226 })
227}
228
229#[cfg(test)]
230mod tests {
231 use super::*;
232
233 #[test]
234 fn build_args_claude_options() {
235 let opts = RunOptions {
236 task: "do stuff".into(),
237 providers: Some(crate::types::ProviderOptions {
238 claude: Some(crate::types::ClaudeOptions {
239 allowed_tools: Some("Bash,Read".into()),
240 disallowed_tools: Some("Write".into()),
241 tools: Some("Bash,Read,Write".into()),
242 max_turns: Some(10),
243 max_budget_usd: Some(1.5),
244 max_thinking_tokens: Some(8000),
245 continue_session: Some(true),
246 include_partial_messages: Some(true),
247 effort: Some("low".into()),
248 agents: Some(serde_json::json!({"reviewer": {"prompt": "review"}})),
249 ..Default::default()
250 }),
251 ..Default::default()
252 }),
253 ..Default::default()
254 };
255 let args = build_args(&opts);
256 assert!(args.contains(&"--allowedTools".to_string()));
257 assert!(args.contains(&"Bash,Read".to_string()));
258 assert!(args.contains(&"--disallowedTools".to_string()));
259 assert!(args.contains(&"Write".to_string()));
260 assert!(args.contains(&"--tools".to_string()));
261 assert!(args.contains(&"Bash,Read,Write".to_string()));
262 assert!(args.contains(&"--max-turns".to_string()));
263 assert!(args.contains(&"10".to_string()));
264 assert!(args.contains(&"--max-budget-usd".to_string()));
265 assert!(args.contains(&"1.5".to_string()));
266 assert!(args.contains(&"--max-thinking-tokens".to_string()));
267 assert!(args.contains(&"8000".to_string()));
268 assert!(args.contains(&"--continue".to_string()));
269 assert!(args.contains(&"--include-partial-messages".to_string()));
270 assert!(args.contains(&"--effort".to_string()));
271 assert!(args.contains(&"low".to_string()));
272 assert!(args.contains(&"--agents".to_string()));
273 }
274
275 #[test]
276 fn build_args_system_prompt_file_takes_precedence() {
277 let opts = RunOptions {
278 task: "hello".into(),
279 system_prompt: Some("inline prompt".into()),
280 system_prompt_file: Some("/path/to/prompt.md".into()),
281 ..Default::default()
282 };
283 let args = build_args(&opts);
284 assert!(args.contains(&"--system-prompt-file".to_string()));
285 assert!(args.contains(&"/path/to/prompt.md".to_string()));
286 assert!(!args.contains(&"--system-prompt".to_string()));
287 }
288
289 #[test]
290 fn build_args_no_permission_bypass_by_default() {
291 let opts = RunOptions {
292 task: "hello".into(),
293 ..Default::default()
294 };
295 let args = build_args(&opts);
296 assert!(!args.contains(&"--dangerously-skip-permissions".to_string()));
297 assert!(!args.contains(&"bypassPermissions".to_string()));
298 }
299
300 #[test]
301 fn build_args_permission_bypass_when_opted_in() {
302 let opts = RunOptions {
303 task: "hello".into(),
304 skip_permissions: true,
305 ..Default::default()
306 };
307 let args = build_args(&opts);
308 assert!(args.contains(&"--dangerously-skip-permissions".to_string()));
309 assert!(args.contains(&"bypassPermissions".to_string()));
310 }
311
312 #[test]
313 fn build_args_setting_sources_omitted_by_default() {
314 let opts = RunOptions {
315 task: "hello".into(),
316 ..Default::default()
317 };
318 let args = build_args(&opts);
319 assert!(!args.contains(&"--setting-sources".to_string()));
320 }
321
322 #[test]
323 fn build_args_setting_sources_empty_loads_none() {
324 let opts = RunOptions {
325 task: "hello".into(),
326 providers: Some(crate::types::ProviderOptions {
327 claude: Some(crate::types::ClaudeOptions {
328 setting_sources: Some(vec![]),
329 ..Default::default()
330 }),
331 ..Default::default()
332 }),
333 ..Default::default()
334 };
335 let args = build_args(&opts);
336 let idx = args
337 .iter()
338 .position(|a| a == "--setting-sources")
339 .expect("flag emitted");
340 assert_eq!(args[idx + 1], "");
341 }
342
343 #[test]
344 fn build_args_setting_sources_subset() {
345 use crate::types::SettingSource;
346 let opts = RunOptions {
347 task: "hello".into(),
348 providers: Some(crate::types::ProviderOptions {
349 claude: Some(crate::types::ClaudeOptions {
350 setting_sources: Some(vec![SettingSource::Project, SettingSource::Local]),
351 ..Default::default()
352 }),
353 ..Default::default()
354 }),
355 ..Default::default()
356 };
357 let args = build_args(&opts);
358 let idx = args
359 .iter()
360 .position(|a| a == "--setting-sources")
361 .expect("flag emitted");
362 assert_eq!(args[idx + 1], "project,local");
363 }
364}