cli_agents/adapters/claude/
mod.rs1mod parse;
2
3use crate::DEFAULT_MAX_OUTPUT_BYTES;
4use crate::adapters::CliAdapter;
5use crate::discovery::discover_binary;
6use crate::error::{Error, Result};
7use crate::events::StreamEvent;
8use crate::types::{CliName, RunOptions, RunResult};
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 {
63 exit_code,
64 signal,
65 stderr,
66 dropped_lines,
67 } => {
68 crate::adapters::warn_dropped_lines(dropped_lines, max_bytes, emit);
69 let success = state.success.unwrap_or(exit_code == Some(0));
70 let text = if !success && state.result_text.is_none() {
75 crate::adapters::extract_error_message(stderr.as_deref())
76 .or_else(|| crate::adapters::describe_signal(signal))
77 } else {
78 state.result_text
79 };
80 Ok(RunResult {
81 success,
82 text,
83 exit_code,
84 signal,
85 stats: state.stats,
86 session_id: state.session_id,
87 stderr,
88 cost_usd: state.cost_usd,
89 })
90 }
91 }
92 }
93}
94
95fn build_args(opts: &RunOptions) -> Vec<String> {
96 let mut args = vec![
97 "-p".into(),
98 opts.task.clone(),
99 "--output-format".into(),
100 "stream-json".into(),
101 "--verbose".into(),
102 ];
103
104 if let Some(model) = &opts.model {
105 args.push("--model".into());
106 args.push(model.clone());
107 }
108
109 if let Some(session_id) = &opts.resume_session_id {
110 args.push("--resume".into());
111 args.push(session_id.clone());
112 }
113
114 let claude_opts = opts.providers.as_ref().and_then(|p| p.claude.as_ref());
115
116 if let Some(co) = claude_opts {
117 if let Some(allowed) = &co.allowed_tools {
118 args.push("--allowedTools".into());
119 args.push(allowed.clone());
120 }
121 if let Some(disallowed) = &co.disallowed_tools {
122 args.push("--disallowedTools".into());
123 args.push(disallowed.clone());
124 }
125 if let Some(tools) = &co.tools {
126 args.push("--tools".into());
127 args.push(tools.clone());
128 }
129 if let Some(append) = &co.append_system_prompt {
130 args.push("--append-system-prompt".into());
131 args.push(append.clone());
132 }
133 if let Some(max_turns) = co.max_turns {
134 args.push("--max-turns".into());
135 args.push(max_turns.to_string());
136 }
137 if let Some(budget) = co.max_budget_usd {
138 args.push("--max-budget-usd".into());
139 args.push(budget.to_string());
140 }
141 if let Some(tokens) = co.max_thinking_tokens {
142 args.push("--max-thinking-tokens".into());
143 args.push(tokens.to_string());
144 }
145 if co.continue_session == Some(true) {
146 args.push("--continue".into());
147 }
148 if co.include_partial_messages == Some(true) {
149 args.push("--include-partial-messages".into());
150 }
151 if let Some(effort) = &co.effort {
152 args.push("--effort".into());
153 args.push(effort.clone());
154 }
155 if let Some(agents) = &co.agents {
156 if let Ok(json) = serde_json::to_string(agents) {
157 args.push("--agents".into());
158 args.push(json);
159 }
160 }
161 if let Some(sources) = &co.setting_sources {
162 args.push("--setting-sources".into());
165 args.push(
166 sources
167 .iter()
168 .map(|s| s.as_str())
169 .collect::<Vec<_>>()
170 .join(","),
171 );
172 }
173 }
174
175 if let Some(path) = &opts.system_prompt_file {
176 args.push("--system-prompt-file".into());
177 args.push(path.clone());
178 } else if let Some(system_prompt) = &opts.system_prompt {
179 args.push("--system-prompt".into());
180 args.push(system_prompt.clone());
181 }
182
183 if let Some(servers) = opts.mcp_servers.as_ref().filter(|s| !s.is_empty()) {
185 if let Ok(json) = serde_json::to_string(&build_mcp_config(servers)) {
186 args.push("--mcp-config".into());
187 args.push(json);
188 }
189 }
190
191 if opts.skip_permissions {
193 args.push("--permission-mode".into());
194 args.push("bypassPermissions".into());
195 args.push("--dangerously-skip-permissions".into());
196 }
197
198 if let Some(extra) = claude_opts.and_then(|c| c.extra_args.as_ref()) {
201 args.extend(extra.iter().cloned());
202 }
203
204 args
205}
206
207fn build_mcp_config(servers: &HashMap<String, crate::types::McpServer>) -> serde_json::Value {
208 let mut map = serde_json::Map::new();
209 for (name, server) in servers {
210 let mut entry = serde_json::Map::new();
211 if let Some(url) = &server.url {
212 entry.insert("url".into(), serde_json::Value::String(url.clone()));
213 let t = match server.transport_type {
214 Some(crate::types::McpTransport::Http) => "http",
215 _ => "sse",
216 };
217 entry.insert("type".into(), serde_json::Value::String(t.into()));
218 if let Some(headers) = &server.headers {
219 entry.insert(
220 "headers".into(),
221 serde_json::to_value(headers).unwrap_or_default(),
222 );
223 }
224 } else {
225 entry.insert("type".into(), serde_json::Value::String("stdio".into()));
226 if let Some(cmd) = &server.command {
227 entry.insert("command".into(), serde_json::Value::String(cmd.clone()));
228 }
229 if let Some(a) = &server.args {
230 entry.insert("args".into(), serde_json::to_value(a).unwrap_or_default());
231 }
232 if let Some(e) = &server.env {
233 entry.insert("env".into(), serde_json::to_value(e).unwrap_or_default());
234 }
235 }
236 map.insert(name.clone(), serde_json::Value::Object(entry));
237 }
238 serde_json::Value::Object({
239 let mut root = serde_json::Map::new();
240 root.insert("mcpServers".into(), serde_json::Value::Object(map));
241 root
242 })
243}
244
245#[cfg(test)]
246mod tests {
247 use super::*;
248
249 #[test]
250 fn build_args_claude_options() {
251 let opts = RunOptions {
252 task: "do stuff".into(),
253 providers: Some(crate::types::ProviderOptions {
254 claude: Some(crate::types::ClaudeOptions {
255 allowed_tools: Some("Bash,Read".into()),
256 disallowed_tools: Some("Write".into()),
257 tools: Some("Bash,Read,Write".into()),
258 max_turns: Some(10),
259 max_budget_usd: Some(1.5),
260 max_thinking_tokens: Some(8000),
261 continue_session: Some(true),
262 include_partial_messages: Some(true),
263 effort: Some("low".into()),
264 agents: Some(serde_json::json!({"reviewer": {"prompt": "review"}})),
265 ..Default::default()
266 }),
267 ..Default::default()
268 }),
269 ..Default::default()
270 };
271 let args = build_args(&opts);
272 assert!(args.contains(&"--allowedTools".to_string()));
273 assert!(args.contains(&"Bash,Read".to_string()));
274 assert!(args.contains(&"--disallowedTools".to_string()));
275 assert!(args.contains(&"Write".to_string()));
276 assert!(args.contains(&"--tools".to_string()));
277 assert!(args.contains(&"Bash,Read,Write".to_string()));
278 assert!(args.contains(&"--max-turns".to_string()));
279 assert!(args.contains(&"10".to_string()));
280 assert!(args.contains(&"--max-budget-usd".to_string()));
281 assert!(args.contains(&"1.5".to_string()));
282 assert!(args.contains(&"--max-thinking-tokens".to_string()));
283 assert!(args.contains(&"8000".to_string()));
284 assert!(args.contains(&"--continue".to_string()));
285 assert!(args.contains(&"--include-partial-messages".to_string()));
286 assert!(args.contains(&"--effort".to_string()));
287 assert!(args.contains(&"low".to_string()));
288 assert!(args.contains(&"--agents".to_string()));
289 }
290
291 #[test]
292 fn build_args_system_prompt_file_takes_precedence() {
293 let opts = RunOptions {
294 task: "hello".into(),
295 system_prompt: Some("inline prompt".into()),
296 system_prompt_file: Some("/path/to/prompt.md".into()),
297 ..Default::default()
298 };
299 let args = build_args(&opts);
300 assert!(args.contains(&"--system-prompt-file".to_string()));
301 assert!(args.contains(&"/path/to/prompt.md".to_string()));
302 assert!(!args.contains(&"--system-prompt".to_string()));
303 }
304
305 #[test]
306 fn build_args_no_permission_bypass_by_default() {
307 let opts = RunOptions {
308 task: "hello".into(),
309 ..Default::default()
310 };
311 let args = build_args(&opts);
312 assert!(!args.contains(&"--dangerously-skip-permissions".to_string()));
313 assert!(!args.contains(&"bypassPermissions".to_string()));
314 }
315
316 #[test]
317 fn build_args_permission_bypass_when_opted_in() {
318 let opts = RunOptions {
319 task: "hello".into(),
320 skip_permissions: true,
321 ..Default::default()
322 };
323 let args = build_args(&opts);
324 assert!(args.contains(&"--dangerously-skip-permissions".to_string()));
325 assert!(args.contains(&"bypassPermissions".to_string()));
326 }
327
328 #[test]
329 fn build_args_setting_sources_omitted_by_default() {
330 let opts = RunOptions {
331 task: "hello".into(),
332 ..Default::default()
333 };
334 let args = build_args(&opts);
335 assert!(!args.contains(&"--setting-sources".to_string()));
336 }
337
338 #[test]
339 fn build_args_setting_sources_empty_loads_none() {
340 let opts = RunOptions {
341 task: "hello".into(),
342 providers: Some(crate::types::ProviderOptions {
343 claude: Some(crate::types::ClaudeOptions {
344 setting_sources: Some(vec![]),
345 ..Default::default()
346 }),
347 ..Default::default()
348 }),
349 ..Default::default()
350 };
351 let args = build_args(&opts);
352 let idx = args
353 .iter()
354 .position(|a| a == "--setting-sources")
355 .expect("flag emitted");
356 assert_eq!(args[idx + 1], "");
357 }
358
359 #[test]
360 fn build_args_setting_sources_subset() {
361 use crate::types::SettingSource;
362 let opts = RunOptions {
363 task: "hello".into(),
364 providers: Some(crate::types::ProviderOptions {
365 claude: Some(crate::types::ClaudeOptions {
366 setting_sources: Some(vec![SettingSource::Project, SettingSource::Local]),
367 ..Default::default()
368 }),
369 ..Default::default()
370 }),
371 ..Default::default()
372 };
373 let args = build_args(&opts);
374 let idx = args
375 .iter()
376 .position(|a| a == "--setting-sources")
377 .expect("flag emitted");
378 assert_eq!(args[idx + 1], "project,local");
379 }
380
381 #[test]
382 fn build_args_extra_args_omitted_by_default() {
383 let opts = RunOptions {
384 task: "hello".into(),
385 ..Default::default()
386 };
387 let args = build_args(&opts);
388 assert!(!args.iter().any(|a| a == "--json-schema"));
390 }
391
392 #[test]
393 fn build_args_extra_args_appended_verbatim() {
394 let opts = RunOptions {
395 task: "hello".into(),
396 providers: Some(crate::types::ProviderOptions {
397 claude: Some(crate::types::ClaudeOptions {
398 extra_args: Some(vec!["--json-schema".into(), "{\"type\":\"object\"}".into()]),
399 ..Default::default()
400 }),
401 ..Default::default()
402 }),
403 ..Default::default()
404 };
405 let args = build_args(&opts);
406 let idx = args
407 .iter()
408 .position(|a| a == "--json-schema")
409 .expect("extra_args flag emitted");
410 assert_eq!(args[idx + 1], "{\"type\":\"object\"}");
412 }
413
414 #[test]
415 fn build_args_extra_args_come_after_crate_defaults() {
416 let opts = RunOptions {
420 task: "hello".into(),
421 skip_permissions: true,
422 providers: Some(crate::types::ProviderOptions {
423 claude: Some(crate::types::ClaudeOptions {
424 extra_args: Some(vec!["--output-format".into(), "text".into()]),
425 ..Default::default()
426 }),
427 ..Default::default()
428 }),
429 ..Default::default()
430 };
431 let args = build_args(&opts);
432 let user_idx = args
436 .iter()
437 .rposition(|a| a == "--output-format")
438 .expect("user --output-format emitted");
439 let bypass_idx = args
440 .iter()
441 .position(|a| a == "--dangerously-skip-permissions")
442 .expect("skip_permissions flag emitted");
443 assert!(
444 user_idx > bypass_idx,
445 "user extra_args must be appended last so they win over crate defaults"
446 );
447 assert_eq!(args[user_idx + 1], "text");
448 }
449}