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