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 #[allow(dead_code)]
28 Print,
29 #[default]
32 Interactive,
33}
34
35pub struct ClaudeCodeAdapter {
37 program: String,
39 mode: ClaudeMode,
41}
42
43impl ClaudeCodeAdapter {
44 pub fn new(program: Option<String>) -> Self {
45 Self {
46 program: program.unwrap_or_else(|| "claude".to_string()),
47 mode: ClaudeMode::default(),
48 }
49 }
50
51 #[allow(dead_code)]
52 pub fn with_mode(mut self, mode: ClaudeMode) -> Self {
53 self.mode = mode;
54 self
55 }
56
57 #[allow(dead_code)]
58 pub fn mode(&self) -> ClaudeMode {
59 self.mode
60 }
61}
62
63impl AgentAdapter for ClaudeCodeAdapter {
64 fn name(&self) -> &str {
65 "claude-code"
66 }
67
68 fn spawn_config(&self, task_description: &str, work_dir: &Path) -> SpawnConfig {
69 let mut args = Vec::new();
70
71 match self.mode {
72 ClaudeMode::Print => {
73 args.push("-p".to_string());
74 args.push("--output-format".to_string());
75 args.push("stream-json".to_string());
76 args.push(task_description.to_string());
77 }
78 ClaudeMode::Interactive => {
79 args.push(task_description.to_string());
83 }
84 }
85
86 SpawnConfig {
87 program: self.program.clone(),
88 args,
89 work_dir: work_dir.to_string_lossy().to_string(),
90 env: vec![],
91 }
92 }
93
94 fn prompt_patterns(&self) -> PromptPatterns {
95 PromptPatterns::claude_code()
96 }
97
98 fn format_input(&self, response: &str) -> String {
99 format!("{response}\n")
100 }
101
102 fn launch_command(
103 &self,
104 prompt: &str,
105 idle: bool,
106 resume: bool,
107 session_id: Option<&str>,
108 ) -> anyhow::Result<String> {
109 let escaped = prompt.replace('\'', "'\\''");
110 if resume {
111 let sid = session_id.context("missing Claude session ID for resume")?;
112 Ok(format!(
113 "exec claude --dangerously-skip-permissions --resume '{sid}'"
114 ))
115 } else if idle {
116 let session_flag = session_id
117 .map(|id| format!(" --session-id '{id}'"))
118 .unwrap_or_default();
119 Ok(format!(
120 "exec claude --dangerously-skip-permissions{session_flag} --append-system-prompt '{escaped}'"
121 ))
122 } else {
123 let session_flag = session_id
124 .map(|id| format!(" --session-id '{id}'"))
125 .unwrap_or_default();
126 Ok(format!(
127 "exec claude --dangerously-skip-permissions{session_flag} '{escaped}'"
128 ))
129 }
130 }
131
132 fn new_session_id(&self) -> Option<String> {
133 Some(Uuid::new_v4().to_string())
134 }
135
136 fn supports_resume(&self) -> bool {
137 true
138 }
139
140 fn health_check(&self) -> super::BackendHealth {
141 super::check_binary_available(&self.program)
142 }
143}
144
145#[cfg(test)]
146mod tests {
147 use super::*;
148
149 #[test]
150 fn default_program_is_claude() {
151 let adapter = ClaudeCodeAdapter::new(None);
152 let config = adapter.spawn_config("test", Path::new("/tmp"));
153 assert_eq!(config.program, "claude");
154 }
155
156 #[test]
157 fn custom_program_path() {
158 let adapter = ClaudeCodeAdapter::new(Some("/usr/local/bin/claude".to_string()));
159 let config = adapter.spawn_config("test", Path::new("/tmp"));
160 assert_eq!(config.program, "/usr/local/bin/claude");
161 }
162
163 #[test]
164 fn default_mode_is_interactive() {
165 let adapter = ClaudeCodeAdapter::new(None);
166 assert_eq!(adapter.mode(), ClaudeMode::Interactive);
167 }
168
169 #[test]
170 fn print_mode_uses_p_flag_and_stream_json() {
171 let adapter = ClaudeCodeAdapter::new(None).with_mode(ClaudeMode::Print);
172 let config = adapter.spawn_config("Fix the auth bug", Path::new("/work"));
173 assert!(config.args.contains(&"-p".to_string()));
174 assert!(config.args.contains(&"stream-json".to_string()));
175 assert!(config.args.contains(&"Fix the auth bug".to_string()));
176 }
177
178 #[test]
179 fn interactive_mode_passes_prompt_as_positional_arg() {
180 let adapter = ClaudeCodeAdapter::new(None).with_mode(ClaudeMode::Interactive);
181 let config = adapter.spawn_config("Fix the auth bug", Path::new("/work"));
182 assert!(!config.args.contains(&"-p".to_string()));
183 assert!(!config.args.contains(&"--prompt".to_string()));
184 assert_eq!(config.args, vec!["Fix the auth bug"]);
185 }
186
187 #[test]
188 fn spawn_sets_work_dir() {
189 let adapter = ClaudeCodeAdapter::new(None);
190 let config = adapter.spawn_config("task", Path::new("/my/worktree"));
191 assert_eq!(config.work_dir, "/my/worktree");
192 }
193
194 #[test]
195 fn prompt_patterns_detect_permission() {
196 let adapter = ClaudeCodeAdapter::new(None);
197 let patterns = adapter.prompt_patterns();
198 let d = patterns.detect("Allow tool Read on /home/user/file.rs?");
199 assert!(d.is_some());
200 assert!(matches!(
201 d.unwrap().kind,
202 crate::prompt::PromptKind::Permission { .. }
203 ));
204 }
205
206 #[test]
207 fn prompt_patterns_detect_continuation() {
208 let adapter = ClaudeCodeAdapter::new(None);
209 let patterns = adapter.prompt_patterns();
210 let d = patterns.detect("Continue? [y/n]");
211 assert!(d.is_some());
212 assert!(matches!(
213 d.unwrap().kind,
214 crate::prompt::PromptKind::Confirmation { .. }
215 ));
216 }
217
218 #[test]
219 fn prompt_patterns_detect_completion_in_json() {
220 let adapter = ClaudeCodeAdapter::new(None);
221 let patterns = adapter.prompt_patterns();
222 let d = patterns.detect(r#"{"type": "result", "subtype": "success"}"#);
223 assert!(d.is_some());
224 assert_eq!(d.unwrap().kind, crate::prompt::PromptKind::Completion);
225 }
226
227 #[test]
228 fn prompt_patterns_detect_error_in_json() {
229 let adapter = ClaudeCodeAdapter::new(None);
230 let patterns = adapter.prompt_patterns();
231 let d = patterns.detect(r#"{"type": "result", "is_error": true}"#);
232 assert!(d.is_some());
233 assert!(matches!(
234 d.unwrap().kind,
235 crate::prompt::PromptKind::Error { .. }
236 ));
237 }
238
239 #[test]
240 fn prompt_patterns_no_match_on_normal_output() {
241 let adapter = ClaudeCodeAdapter::new(None);
242 let patterns = adapter.prompt_patterns();
243 assert!(
244 patterns
245 .detect("Writing function to parse YAML...")
246 .is_none()
247 );
248 }
249
250 #[test]
251 fn format_input_appends_newline() {
252 let adapter = ClaudeCodeAdapter::new(None);
253 assert_eq!(adapter.format_input("y"), "y\n");
254 assert_eq!(adapter.format_input("yes"), "yes\n");
255 }
256
257 #[test]
258 fn name_is_claude_code() {
259 let adapter = ClaudeCodeAdapter::new(None);
260 assert_eq!(adapter.name(), "claude-code");
261 }
262
263 #[test]
266 fn launch_command_active_includes_prompt() {
267 let adapter = ClaudeCodeAdapter::new(None);
268 let cmd = adapter
269 .launch_command("do the thing", false, false, Some("sess-1"))
270 .unwrap();
271 assert!(cmd.contains("exec claude --dangerously-skip-permissions"));
272 assert!(cmd.contains("--session-id 'sess-1'"));
273 assert!(cmd.contains("'do the thing'"));
274 assert!(!cmd.contains("--append-system-prompt"));
275 }
276
277 #[test]
278 fn launch_command_idle_uses_append_system_prompt() {
279 let adapter = ClaudeCodeAdapter::new(None);
280 let cmd = adapter
281 .launch_command("role prompt", true, false, Some("sess-2"))
282 .unwrap();
283 assert!(cmd.contains("--append-system-prompt"));
284 assert!(cmd.contains("--session-id 'sess-2'"));
285 }
286
287 #[test]
288 fn launch_command_resume_uses_resume_flag() {
289 let adapter = ClaudeCodeAdapter::new(None);
290 let cmd = adapter
291 .launch_command("ignored", false, true, Some("sess-3"))
292 .unwrap();
293 assert!(cmd.contains("--resume 'sess-3'"));
294 assert!(!cmd.contains("--append-system-prompt"));
295 }
296
297 #[test]
298 fn launch_command_resume_without_session_id_errors() {
299 let adapter = ClaudeCodeAdapter::new(None);
300 let result = adapter.launch_command("ignored", false, true, None);
301 assert!(result.is_err());
302 }
303
304 #[test]
305 fn launch_command_escapes_single_quotes() {
306 let adapter = ClaudeCodeAdapter::new(None);
307 let cmd = adapter
308 .launch_command("fix user's bug", false, false, None)
309 .unwrap();
310 assert!(cmd.contains("user'\\''s"));
311 }
312
313 #[test]
314 fn new_session_id_returns_uuid() {
315 let adapter = ClaudeCodeAdapter::new(None);
316 let session_id = adapter.new_session_id();
317 assert!(session_id.is_some());
318 let sid = session_id.unwrap();
319 assert_eq!(sid.len(), 36); }
321
322 #[test]
323 fn supports_resume_is_true() {
324 let adapter = ClaudeCodeAdapter::new(None);
325 assert!(adapter.supports_resume());
326 }
327}