Skip to main content

cli_agents/adapters/claude/
mod.rs

1mod 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 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 { exit_code, stderr } => {
63                let success = state.success.unwrap_or(exit_code == 0);
64                // When the agent fails with no text, surface the error from
65                // stderr so consumers always have something to show the user.
66                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            // `--setting-sources <comma-list>` — empty list loads nothing
153            // (skips user/project/local settings and their SessionStart hooks).
154            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    // MCP servers: write inline JSON via --mcp-config (Claude CLI accepts this)
174    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    // Permission bypass for non-interactive use (opt-in)
182    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}