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