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                cwd: opts.cwd.as_deref().unwrap_or("."),
44                max_bytes,
45                cancel: &cancel,
46            },
47            |line| parse::parse_line(line, &mut state, &mut active_tools, emit),
48        )
49        .await?;
50
51        match outcome {
52            crate::adapters::SpawnOutcome::Cancelled => Ok(RunResult {
53                success: false,
54                text: Some("Cancelled.".into()),
55                ..Default::default()
56            }),
57            crate::adapters::SpawnOutcome::Done { exit_code, stderr } => {
58                let success = state.success.unwrap_or(exit_code == 0);
59                // When the agent fails with no text, surface the error from
60                // stderr so consumers always have something to show the user.
61                let text = if !success && state.result_text.is_none() {
62                    crate::adapters::extract_error_message(stderr.as_deref())
63                } else {
64                    state.result_text
65                };
66                Ok(RunResult {
67                    success,
68                    text,
69                    exit_code: Some(exit_code),
70                    stats: state.stats,
71                    session_id: state.session_id,
72                    stderr,
73                    cost_usd: state.cost_usd,
74                })
75            }
76        }
77    }
78}
79
80fn build_args(opts: &RunOptions) -> Vec<String> {
81    let mut args = vec![
82        "-p".into(),
83        opts.task.clone(),
84        "--output-format".into(),
85        "stream-json".into(),
86        "--verbose".into(),
87    ];
88
89    if let Some(model) = &opts.model {
90        args.push("--model".into());
91        args.push(model.clone());
92    }
93
94    if let Some(session_id) = &opts.resume_session_id {
95        args.push("--resume".into());
96        args.push(session_id.clone());
97    }
98
99    let claude_opts = opts.providers.as_ref().and_then(|p| p.claude.as_ref());
100
101    if let Some(co) = claude_opts {
102        if let Some(allowed) = &co.allowed_tools {
103            args.push("--allowedTools".into());
104            args.push(allowed.clone());
105        }
106        if let Some(disallowed) = &co.disallowed_tools {
107            args.push("--disallowedTools".into());
108            args.push(disallowed.clone());
109        }
110        if let Some(tools) = &co.tools {
111            args.push("--tools".into());
112            args.push(tools.clone());
113        }
114        if let Some(append) = &co.append_system_prompt {
115            args.push("--append-system-prompt".into());
116            args.push(append.clone());
117        }
118        if let Some(max_turns) = co.max_turns {
119            args.push("--max-turns".into());
120            args.push(max_turns.to_string());
121        }
122        if let Some(budget) = co.max_budget_usd {
123            args.push("--max-budget-usd".into());
124            args.push(budget.to_string());
125        }
126        if let Some(tokens) = co.max_thinking_tokens {
127            args.push("--max-thinking-tokens".into());
128            args.push(tokens.to_string());
129        }
130        if co.continue_session == Some(true) {
131            args.push("--continue".into());
132        }
133        if co.include_partial_messages == Some(true) {
134            args.push("--include-partial-messages".into());
135        }
136        if let Some(effort) = &co.effort {
137            args.push("--effort".into());
138            args.push(effort.clone());
139        }
140        if let Some(agents) = &co.agents {
141            if let Ok(json) = serde_json::to_string(agents) {
142                args.push("--agents".into());
143                args.push(json);
144            }
145        }
146    }
147
148    if let Some(path) = &opts.system_prompt_file {
149        args.push("--system-prompt-file".into());
150        args.push(path.clone());
151    } else if let Some(system_prompt) = &opts.system_prompt {
152        args.push("--system-prompt".into());
153        args.push(system_prompt.clone());
154    }
155
156    // MCP servers: write inline JSON via --mcp-config (Claude CLI accepts this)
157    if let Some(servers) = opts.mcp_servers.as_ref().filter(|s| !s.is_empty()) {
158        if let Ok(json) = serde_json::to_string(&build_mcp_config(servers)) {
159            args.push("--mcp-config".into());
160            args.push(json);
161        }
162    }
163
164    // Permission bypass for non-interactive use (opt-in)
165    if opts.skip_permissions {
166        args.push("--permission-mode".into());
167        args.push("bypassPermissions".into());
168        args.push("--dangerously-skip-permissions".into());
169    }
170
171    args
172}
173
174fn build_mcp_config(servers: &HashMap<String, crate::types::McpServer>) -> serde_json::Value {
175    let mut map = serde_json::Map::new();
176    for (name, server) in servers {
177        let mut entry = serde_json::Map::new();
178        if let Some(url) = &server.url {
179            entry.insert("url".into(), serde_json::Value::String(url.clone()));
180            let t = match server.transport_type {
181                Some(crate::types::McpTransport::Http) => "http",
182                _ => "sse",
183            };
184            entry.insert("type".into(), serde_json::Value::String(t.into()));
185            if let Some(headers) = &server.headers {
186                entry.insert(
187                    "headers".into(),
188                    serde_json::to_value(headers).unwrap_or_default(),
189                );
190            }
191        } else {
192            entry.insert("type".into(), serde_json::Value::String("stdio".into()));
193            if let Some(cmd) = &server.command {
194                entry.insert("command".into(), serde_json::Value::String(cmd.clone()));
195            }
196            if let Some(a) = &server.args {
197                entry.insert("args".into(), serde_json::to_value(a).unwrap_or_default());
198            }
199            if let Some(e) = &server.env {
200                entry.insert("env".into(), serde_json::to_value(e).unwrap_or_default());
201            }
202        }
203        map.insert(name.clone(), serde_json::Value::Object(entry));
204    }
205    serde_json::Value::Object({
206        let mut root = serde_json::Map::new();
207        root.insert("mcpServers".into(), serde_json::Value::Object(map));
208        root
209    })
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215
216    #[test]
217    fn build_args_claude_options() {
218        let opts = RunOptions {
219            task: "do stuff".into(),
220            providers: Some(crate::types::ProviderOptions {
221                claude: Some(crate::types::ClaudeOptions {
222                    allowed_tools: Some("Bash,Read".into()),
223                    disallowed_tools: Some("Write".into()),
224                    tools: Some("Bash,Read,Write".into()),
225                    max_turns: Some(10),
226                    max_budget_usd: Some(1.5),
227                    max_thinking_tokens: Some(8000),
228                    continue_session: Some(true),
229                    include_partial_messages: Some(true),
230                    effort: Some("low".into()),
231                    agents: Some(serde_json::json!({"reviewer": {"prompt": "review"}})),
232                    ..Default::default()
233                }),
234                ..Default::default()
235            }),
236            ..Default::default()
237        };
238        let args = build_args(&opts);
239        assert!(args.contains(&"--allowedTools".to_string()));
240        assert!(args.contains(&"Bash,Read".to_string()));
241        assert!(args.contains(&"--disallowedTools".to_string()));
242        assert!(args.contains(&"Write".to_string()));
243        assert!(args.contains(&"--tools".to_string()));
244        assert!(args.contains(&"Bash,Read,Write".to_string()));
245        assert!(args.contains(&"--max-turns".to_string()));
246        assert!(args.contains(&"10".to_string()));
247        assert!(args.contains(&"--max-budget-usd".to_string()));
248        assert!(args.contains(&"1.5".to_string()));
249        assert!(args.contains(&"--max-thinking-tokens".to_string()));
250        assert!(args.contains(&"8000".to_string()));
251        assert!(args.contains(&"--continue".to_string()));
252        assert!(args.contains(&"--include-partial-messages".to_string()));
253        assert!(args.contains(&"--effort".to_string()));
254        assert!(args.contains(&"low".to_string()));
255        assert!(args.contains(&"--agents".to_string()));
256    }
257
258    #[test]
259    fn build_args_system_prompt_file_takes_precedence() {
260        let opts = RunOptions {
261            task: "hello".into(),
262            system_prompt: Some("inline prompt".into()),
263            system_prompt_file: Some("/path/to/prompt.md".into()),
264            ..Default::default()
265        };
266        let args = build_args(&opts);
267        assert!(args.contains(&"--system-prompt-file".to_string()));
268        assert!(args.contains(&"/path/to/prompt.md".to_string()));
269        assert!(!args.contains(&"--system-prompt".to_string()));
270    }
271
272    #[test]
273    fn build_args_no_permission_bypass_by_default() {
274        let opts = RunOptions {
275            task: "hello".into(),
276            ..Default::default()
277        };
278        let args = build_args(&opts);
279        assert!(!args.contains(&"--dangerously-skip-permissions".to_string()));
280        assert!(!args.contains(&"bypassPermissions".to_string()));
281    }
282
283    #[test]
284    fn build_args_permission_bypass_when_opted_in() {
285        let opts = RunOptions {
286            task: "hello".into(),
287            skip_permissions: true,
288            ..Default::default()
289        };
290        let args = build_args(&opts);
291        assert!(args.contains(&"--dangerously-skip-permissions".to_string()));
292        assert!(args.contains(&"bypassPermissions".to_string()));
293    }
294}