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
142#[cfg(test)]
143mod tests {
144 use super::*;
145
146 #[test]
147 fn default_program_is_claude() {
148 let adapter = ClaudeCodeAdapter::new(None);
149 let config = adapter.spawn_config("test", Path::new("/tmp"));
150 assert_eq!(config.program, "claude");
151 }
152
153 #[test]
154 fn custom_program_path() {
155 let adapter = ClaudeCodeAdapter::new(Some("/usr/local/bin/claude".to_string()));
156 let config = adapter.spawn_config("test", Path::new("/tmp"));
157 assert_eq!(config.program, "/usr/local/bin/claude");
158 }
159
160 #[test]
161 fn default_mode_is_interactive() {
162 let adapter = ClaudeCodeAdapter::new(None);
163 assert_eq!(adapter.mode(), ClaudeMode::Interactive);
164 }
165
166 #[test]
167 fn print_mode_uses_p_flag_and_stream_json() {
168 let adapter = ClaudeCodeAdapter::new(None).with_mode(ClaudeMode::Print);
169 let config = adapter.spawn_config("Fix the auth bug", Path::new("/work"));
170 assert!(config.args.contains(&"-p".to_string()));
171 assert!(config.args.contains(&"stream-json".to_string()));
172 assert!(config.args.contains(&"Fix the auth bug".to_string()));
173 }
174
175 #[test]
176 fn interactive_mode_passes_prompt_as_positional_arg() {
177 let adapter = ClaudeCodeAdapter::new(None).with_mode(ClaudeMode::Interactive);
178 let config = adapter.spawn_config("Fix the auth bug", Path::new("/work"));
179 assert!(!config.args.contains(&"-p".to_string()));
180 assert!(!config.args.contains(&"--prompt".to_string()));
181 assert_eq!(config.args, vec!["Fix the auth bug"]);
182 }
183
184 #[test]
185 fn spawn_sets_work_dir() {
186 let adapter = ClaudeCodeAdapter::new(None);
187 let config = adapter.spawn_config("task", Path::new("/my/worktree"));
188 assert_eq!(config.work_dir, "/my/worktree");
189 }
190
191 #[test]
192 fn prompt_patterns_detect_permission() {
193 let adapter = ClaudeCodeAdapter::new(None);
194 let patterns = adapter.prompt_patterns();
195 let d = patterns.detect("Allow tool Read on /home/user/file.rs?");
196 assert!(d.is_some());
197 assert!(matches!(
198 d.unwrap().kind,
199 crate::prompt::PromptKind::Permission { .. }
200 ));
201 }
202
203 #[test]
204 fn prompt_patterns_detect_continuation() {
205 let adapter = ClaudeCodeAdapter::new(None);
206 let patterns = adapter.prompt_patterns();
207 let d = patterns.detect("Continue? [y/n]");
208 assert!(d.is_some());
209 assert!(matches!(
210 d.unwrap().kind,
211 crate::prompt::PromptKind::Confirmation { .. }
212 ));
213 }
214
215 #[test]
216 fn prompt_patterns_detect_completion_in_json() {
217 let adapter = ClaudeCodeAdapter::new(None);
218 let patterns = adapter.prompt_patterns();
219 let d = patterns.detect(r#"{"type": "result", "subtype": "success"}"#);
220 assert!(d.is_some());
221 assert_eq!(d.unwrap().kind, crate::prompt::PromptKind::Completion);
222 }
223
224 #[test]
225 fn prompt_patterns_detect_error_in_json() {
226 let adapter = ClaudeCodeAdapter::new(None);
227 let patterns = adapter.prompt_patterns();
228 let d = patterns.detect(r#"{"type": "result", "is_error": true}"#);
229 assert!(d.is_some());
230 assert!(matches!(
231 d.unwrap().kind,
232 crate::prompt::PromptKind::Error { .. }
233 ));
234 }
235
236 #[test]
237 fn prompt_patterns_no_match_on_normal_output() {
238 let adapter = ClaudeCodeAdapter::new(None);
239 let patterns = adapter.prompt_patterns();
240 assert!(
241 patterns
242 .detect("Writing function to parse YAML...")
243 .is_none()
244 );
245 }
246
247 #[test]
248 fn format_input_appends_newline() {
249 let adapter = ClaudeCodeAdapter::new(None);
250 assert_eq!(adapter.format_input("y"), "y\n");
251 assert_eq!(adapter.format_input("yes"), "yes\n");
252 }
253
254 #[test]
255 fn name_is_claude_code() {
256 let adapter = ClaudeCodeAdapter::new(None);
257 assert_eq!(adapter.name(), "claude-code");
258 }
259
260 #[test]
263 fn launch_command_active_includes_prompt() {
264 let adapter = ClaudeCodeAdapter::new(None);
265 let cmd = adapter
266 .launch_command("do the thing", false, false, Some("sess-1"))
267 .unwrap();
268 assert!(cmd.contains("exec claude --dangerously-skip-permissions"));
269 assert!(cmd.contains("--session-id 'sess-1'"));
270 assert!(cmd.contains("'do the thing'"));
271 assert!(!cmd.contains("--append-system-prompt"));
272 }
273
274 #[test]
275 fn launch_command_idle_uses_append_system_prompt() {
276 let adapter = ClaudeCodeAdapter::new(None);
277 let cmd = adapter
278 .launch_command("role prompt", true, false, Some("sess-2"))
279 .unwrap();
280 assert!(cmd.contains("--append-system-prompt"));
281 assert!(cmd.contains("--session-id 'sess-2'"));
282 }
283
284 #[test]
285 fn launch_command_resume_uses_resume_flag() {
286 let adapter = ClaudeCodeAdapter::new(None);
287 let cmd = adapter
288 .launch_command("ignored", false, true, Some("sess-3"))
289 .unwrap();
290 assert!(cmd.contains("--resume 'sess-3'"));
291 assert!(!cmd.contains("--append-system-prompt"));
292 }
293
294 #[test]
295 fn launch_command_resume_without_session_id_errors() {
296 let adapter = ClaudeCodeAdapter::new(None);
297 let result = adapter.launch_command("ignored", false, true, None);
298 assert!(result.is_err());
299 }
300
301 #[test]
302 fn launch_command_escapes_single_quotes() {
303 let adapter = ClaudeCodeAdapter::new(None);
304 let cmd = adapter
305 .launch_command("fix user's bug", false, false, None)
306 .unwrap();
307 assert!(cmd.contains("user'\\''s"));
308 }
309
310 #[test]
311 fn new_session_id_returns_uuid() {
312 let adapter = ClaudeCodeAdapter::new(None);
313 let session_id = adapter.new_session_id();
314 assert!(session_id.is_some());
315 let sid = session_id.unwrap();
316 assert_eq!(sid.len(), 36); }
318
319 #[test]
320 fn supports_resume_is_true() {
321 let adapter = ClaudeCodeAdapter::new(None);
322 assert!(adapter.supports_resume());
323 }
324}