Skip to main content

cli_agents/adapters/claude/
mod.rs

1mod 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 Anthropic API auth env vars so the CLI uses its own
44                // subscription credentials (OAuth/keychain). Without this, a
45                // user's shell `ANTHROPIC_API_KEY` would silently bill API
46                // tokens instead of using the Claude Code subscription.
47                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                // When the agent fails with no text, surface the error from
71                // stderr so consumers always have something to show the user —
72                // and when a SIGNAL ended it there is usually no stderr either,
73                // so name the signal rather than leaving the caller a bare code.
74                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            // `--setting-sources <comma-list>` — empty list loads nothing
163            // (skips user/project/local settings and their SessionStart hooks).
164            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    // MCP servers: write inline JSON via --mcp-config (Claude CLI accepts this)
184    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    // Permission bypass for non-interactive use (opt-in)
192    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    // User-supplied flags last, so callers can override earlier defaults
199    // (e.g. force `--output-format text` or pass `--json-schema <schema>`).
200    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        // Nothing about extra_args should appear when the option is unset.
389        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        // Adjacent, in order — `args.extend` preserves both.
411        assert_eq!(args[idx + 1], "{\"type\":\"object\"}");
412    }
413
414    #[test]
415    fn build_args_extra_args_come_after_crate_defaults() {
416        // Callers should be able to override what this crate emits — e.g.
417        // re-pin `--output-format` to `text` even though we default to
418        // stream-json. That only works if user-supplied flags land last.
419        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        // The crate emits `--output-format stream-json` early; the user
433        // override must appear strictly after the permission-bypass block
434        // (the last thing the crate emits).
435        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}