Skip to main content

batty_cli/agent/
claude.rs

1//! Claude Code adapter.
2//!
3//! Supports two modes:
4//! - **Print mode** (`-p --output-format stream-json`): for automated runs
5//!   where structured JSON output enables reliable completion/error detection.
6//! - **Interactive mode** (no `-p`): for supervised runs where the user can
7//!   see and type into Claude's native TUI. Batty supervises on top without
8//!   breaking the interactive experience.
9//!
10//! The supervisor decides which mode to use. The adapter provides spawn
11//! configs and prompt patterns for both.
12#![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/// How to run Claude Code.
23#[derive(Debug, Default, Clone, Copy, PartialEq)]
24pub enum ClaudeMode {
25    /// Print mode: `-p --output-format stream-json`.
26    /// Best for fully automated runs. Structured JSON output.
27    #[allow(dead_code)]
28    Print,
29    /// Interactive mode: user sees the full TUI.
30    /// Batty supervises via PTY pattern matching on ANSI-stripped output.
31    #[default]
32    Interactive,
33}
34
35/// Adapter for Claude Code CLI.
36pub struct ClaudeCodeAdapter {
37    /// Override the claude binary name/path (default: "claude").
38    program: String,
39    /// Which mode to run Claude in.
40    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                // In interactive mode, we pass the task as the last positional
80                // argument so Claude starts working immediately.
81                // The user can still type into the session at any time.
82                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    // --- Backend trait method tests ---
264
265    #[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); // UUID v4 format
320    }
321
322    #[test]
323    fn supports_resume_is_true() {
324        let adapter = ClaudeCodeAdapter::new(None);
325        assert!(adapter.supports_resume());
326    }
327}