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 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 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 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 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}