batty_cli/agent/
claude.rs1#![cfg_attr(not(test), allow(dead_code))]
13
14use std::path::Path;
15
16use anyhow::Context;
17use uuid::Uuid;
18
19use crate::agent::{AgentAdapter, SpawnConfig};
20use crate::prompt::PromptPatterns;
21
22#[derive(Debug, Default, Clone, Copy, PartialEq)]
24pub enum ClaudeMode {
25 Print,
28 #[default]
31 Interactive,
32}
33
34pub struct ClaudeCodeAdapter {
36 program: String,
38 mode: ClaudeMode,
40}
41
42impl ClaudeCodeAdapter {
43 pub fn new(program: Option<String>) -> Self {
44 Self {
45 program: program.unwrap_or_else(|| "claude".to_string()),
46 mode: ClaudeMode::default(),
47 }
48 }
49
50 pub fn with_mode(mut self, mode: ClaudeMode) -> Self {
51 self.mode = mode;
52 self
53 }
54
55 pub fn mode(&self) -> ClaudeMode {
56 self.mode
57 }
58}
59
60impl AgentAdapter for ClaudeCodeAdapter {
61 fn name(&self) -> &str {
62 "claude-code"
63 }
64
65 fn spawn_config(&self, task_description: &str, work_dir: &Path) -> SpawnConfig {
66 let mut args = Vec::new();
67
68 match self.mode {
69 ClaudeMode::Print => {
70 args.push("-p".to_string());
71 args.push("--output-format".to_string());
72 args.push("stream-json".to_string());
73 args.push(task_description.to_string());
74 }
75 ClaudeMode::Interactive => {
76 args.push(task_description.to_string());
80 }
81 }
82
83 SpawnConfig {
84 program: self.program.clone(),
85 args,
86 work_dir: work_dir.to_string_lossy().to_string(),
87 env: vec![],
88 }
89 }
90
91 fn prompt_patterns(&self) -> PromptPatterns {
92 PromptPatterns::claude_code()
93 }
94
95 fn format_input(&self, response: &str) -> String {
96 format!("{response}\n")
97 }
98
99 fn launch_command(
100 &self,
101 prompt: &str,
102 idle: bool,
103 resume: bool,
104 session_id: Option<&str>,
105 ) -> anyhow::Result<String> {
106 let escaped = prompt.replace('\'', "'\\''");
107 if resume {
108 let sid = session_id.context("missing Claude session ID for resume")?;
109 Ok(format!(
110 "exec claude --dangerously-skip-permissions --resume '{sid}'"
111 ))
112 } else if idle {
113 let session_flag = session_id
114 .map(|id| format!(" --session-id '{id}'"))
115 .unwrap_or_default();
116 Ok(format!(
117 "exec claude --dangerously-skip-permissions{session_flag} --append-system-prompt '{escaped}'"
118 ))
119 } else {
120 let session_flag = session_id
121 .map(|id| format!(" --session-id '{id}'"))
122 .unwrap_or_default();
123 Ok(format!(
124 "exec claude --dangerously-skip-permissions{session_flag} '{escaped}'"
125 ))
126 }
127 }
128
129 fn new_session_id(&self) -> Option<String> {
130 Some(Uuid::new_v4().to_string())
131 }
132
133 fn supports_resume(&self) -> bool {
134 true
135 }
136
137 fn health_check(&self) -> super::BackendHealth {
138 super::check_binary_available(&self.program)
139 }
140}
141
142impl ClaudeCodeAdapter {
143 pub fn sdk_launch_command(
151 &self,
152 session_id: Option<&str>,
153 system_prompt: Option<&str>,
154 ) -> String {
155 let mut cmd = format!(
156 "exec {} -p --verbose --input-format=stream-json --output-format=stream-json --permission-mode=bypassPermissions",
157 self.program,
158 );
159 if let Some(sid) = session_id {
160 let escaped = sid.replace('\'', "'\\''");
161 cmd.push_str(&format!(" --session-id '{escaped}'"));
162 }
163 if let Some(prompt) = system_prompt {
164 let escaped = prompt.replace('\'', "'\\''");
165 cmd.push_str(&format!(" --append-system-prompt '{escaped}'"));
166 }
167 cmd
168 }
169}
170
171#[cfg(test)]
172mod tests {
173 use super::*;
174
175 #[test]
176 fn default_program_is_claude() {
177 let adapter = ClaudeCodeAdapter::new(None);
178 let config = adapter.spawn_config("test", Path::new("/tmp"));
179 assert_eq!(config.program, "claude");
180 }
181
182 #[test]
183 fn custom_program_path() {
184 let adapter = ClaudeCodeAdapter::new(Some("/usr/local/bin/claude".to_string()));
185 let config = adapter.spawn_config("test", Path::new("/tmp"));
186 assert_eq!(config.program, "/usr/local/bin/claude");
187 }
188
189 #[test]
190 fn default_mode_is_interactive() {
191 let adapter = ClaudeCodeAdapter::new(None);
192 assert_eq!(adapter.mode(), ClaudeMode::Interactive);
193 }
194
195 #[test]
196 fn print_mode_uses_p_flag_and_stream_json() {
197 let adapter = ClaudeCodeAdapter::new(None).with_mode(ClaudeMode::Print);
198 let config = adapter.spawn_config("Fix the auth bug", Path::new("/work"));
199 assert!(config.args.contains(&"-p".to_string()));
200 assert!(config.args.contains(&"stream-json".to_string()));
201 assert!(config.args.contains(&"Fix the auth bug".to_string()));
202 }
203
204 #[test]
205 fn interactive_mode_passes_prompt_as_positional_arg() {
206 let adapter = ClaudeCodeAdapter::new(None).with_mode(ClaudeMode::Interactive);
207 let config = adapter.spawn_config("Fix the auth bug", Path::new("/work"));
208 assert!(!config.args.contains(&"-p".to_string()));
209 assert!(!config.args.contains(&"--prompt".to_string()));
210 assert_eq!(config.args, vec!["Fix the auth bug"]);
211 }
212
213 #[test]
214 fn spawn_sets_work_dir() {
215 let adapter = ClaudeCodeAdapter::new(None);
216 let config = adapter.spawn_config("task", Path::new("/my/worktree"));
217 assert_eq!(config.work_dir, "/my/worktree");
218 }
219
220 #[test]
221 fn prompt_patterns_detect_permission() {
222 let adapter = ClaudeCodeAdapter::new(None);
223 let patterns = adapter.prompt_patterns();
224 let d = patterns.detect("Allow tool Read on /home/user/file.rs?");
225 assert!(d.is_some());
226 assert!(matches!(
227 d.unwrap().kind,
228 crate::prompt::PromptKind::Permission { .. }
229 ));
230 }
231
232 #[test]
233 fn prompt_patterns_detect_continuation() {
234 let adapter = ClaudeCodeAdapter::new(None);
235 let patterns = adapter.prompt_patterns();
236 let d = patterns.detect("Continue? [y/n]");
237 assert!(d.is_some());
238 assert!(matches!(
239 d.unwrap().kind,
240 crate::prompt::PromptKind::Confirmation { .. }
241 ));
242 }
243
244 #[test]
245 fn prompt_patterns_detect_completion_in_json() {
246 let adapter = ClaudeCodeAdapter::new(None);
247 let patterns = adapter.prompt_patterns();
248 let d = patterns.detect(r#"{"type": "result", "subtype": "success"}"#);
249 assert!(d.is_some());
250 assert_eq!(d.unwrap().kind, crate::prompt::PromptKind::Completion);
251 }
252
253 #[test]
254 fn prompt_patterns_detect_error_in_json() {
255 let adapter = ClaudeCodeAdapter::new(None);
256 let patterns = adapter.prompt_patterns();
257 let d = patterns.detect(r#"{"type": "result", "is_error": true}"#);
258 assert!(d.is_some());
259 assert!(matches!(
260 d.unwrap().kind,
261 crate::prompt::PromptKind::Error { .. }
262 ));
263 }
264
265 #[test]
266 fn prompt_patterns_no_match_on_normal_output() {
267 let adapter = ClaudeCodeAdapter::new(None);
268 let patterns = adapter.prompt_patterns();
269 assert!(
270 patterns
271 .detect("Writing function to parse YAML...")
272 .is_none()
273 );
274 }
275
276 #[test]
277 fn format_input_appends_newline() {
278 let adapter = ClaudeCodeAdapter::new(None);
279 assert_eq!(adapter.format_input("y"), "y\n");
280 assert_eq!(adapter.format_input("yes"), "yes\n");
281 }
282
283 #[test]
284 fn name_is_claude_code() {
285 let adapter = ClaudeCodeAdapter::new(None);
286 assert_eq!(adapter.name(), "claude-code");
287 }
288
289 #[test]
292 fn launch_command_active_includes_prompt() {
293 let adapter = ClaudeCodeAdapter::new(None);
294 let cmd = adapter
295 .launch_command("do the thing", false, false, Some("sess-1"))
296 .unwrap();
297 assert!(cmd.contains("exec claude --dangerously-skip-permissions"));
298 assert!(cmd.contains("--session-id 'sess-1'"));
299 assert!(cmd.contains("'do the thing'"));
300 assert!(!cmd.contains("--append-system-prompt"));
301 }
302
303 #[test]
304 fn launch_command_idle_uses_append_system_prompt() {
305 let adapter = ClaudeCodeAdapter::new(None);
306 let cmd = adapter
307 .launch_command("role prompt", true, false, Some("sess-2"))
308 .unwrap();
309 assert!(cmd.contains("--append-system-prompt"));
310 assert!(cmd.contains("--session-id 'sess-2'"));
311 }
312
313 #[test]
314 fn launch_command_resume_uses_resume_flag() {
315 let adapter = ClaudeCodeAdapter::new(None);
316 let cmd = adapter
317 .launch_command("ignored", false, true, Some("sess-3"))
318 .unwrap();
319 assert!(cmd.contains("--resume 'sess-3'"));
320 assert!(!cmd.contains("--append-system-prompt"));
321 }
322
323 #[test]
324 fn launch_command_resume_without_session_id_errors() {
325 let adapter = ClaudeCodeAdapter::new(None);
326 let result = adapter.launch_command("ignored", false, true, None);
327 assert!(result.is_err());
328 }
329
330 #[test]
331 fn launch_command_escapes_single_quotes() {
332 let adapter = ClaudeCodeAdapter::new(None);
333 let cmd = adapter
334 .launch_command("fix user's bug", false, false, None)
335 .unwrap();
336 assert!(cmd.contains("user'\\''s"));
337 }
338
339 #[test]
340 fn new_session_id_returns_uuid() {
341 let adapter = ClaudeCodeAdapter::new(None);
342 let session_id = adapter.new_session_id();
343 assert!(session_id.is_some());
344 let sid = session_id.unwrap();
345 assert_eq!(sid.len(), 36); }
347
348 #[test]
349 fn supports_resume_is_true() {
350 let adapter = ClaudeCodeAdapter::new(None);
351 assert!(adapter.supports_resume());
352 }
353
354 #[test]
357 fn sdk_launch_command_includes_stream_json_flags() {
358 let adapter = ClaudeCodeAdapter::new(None);
359 let cmd = adapter.sdk_launch_command(None, None);
360 assert!(cmd.contains("exec claude"));
361 assert!(cmd.contains("-p"));
362 assert!(cmd.contains("--verbose"));
363 assert!(cmd.contains("--input-format=stream-json"));
364 assert!(cmd.contains("--output-format=stream-json"));
365 assert!(cmd.contains("--permission-mode=bypassPermissions"));
366 assert!(!cmd.contains("--session-id"));
367 assert!(!cmd.contains("--append-system-prompt"));
368 }
369
370 #[test]
371 fn sdk_launch_command_with_session_id() {
372 let adapter = ClaudeCodeAdapter::new(None);
373 let cmd = adapter.sdk_launch_command(Some("sess-abc-123"), None);
374 assert!(cmd.contains("--session-id 'sess-abc-123'"));
375 }
376
377 #[test]
378 fn sdk_launch_command_with_system_prompt() {
379 let adapter = ClaudeCodeAdapter::new(None);
380 let cmd = adapter.sdk_launch_command(None, Some("You are an engineer."));
381 assert!(cmd.contains("--append-system-prompt 'You are an engineer.'"));
382 }
383
384 #[test]
385 fn sdk_launch_command_escapes_prompt_quotes() {
386 let adapter = ClaudeCodeAdapter::new(None);
387 let cmd = adapter.sdk_launch_command(None, Some("Fix user's bug"));
388 assert!(cmd.contains("user'\\''s"));
389 }
390
391 #[test]
392 fn sdk_launch_command_custom_binary() {
393 let adapter = ClaudeCodeAdapter::new(Some("/opt/claude".to_string()));
394 let cmd = adapter.sdk_launch_command(None, None);
395 assert!(cmd.contains("exec /opt/claude -p"));
396 }
397}