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    Print,
28    /// Interactive mode: user sees the full TUI.
29    /// Batty supervises via PTY pattern matching on ANSI-stripped output.
30    #[default]
31    Interactive,
32}
33
34/// Adapter for Claude Code CLI.
35pub struct ClaudeCodeAdapter {
36    /// Override the claude binary name/path (default: "claude").
37    program: String,
38    /// Which mode to run Claude in.
39    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                // In interactive mode, we pass the task as the last positional
77                // argument so Claude starts working immediately.
78                // The user can still type into the session at any time.
79                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    // --- Backend trait method tests ---
261
262    #[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); // UUID v4 format
317    }
318
319    #[test]
320    fn supports_resume_is_true() {
321        let adapter = ClaudeCodeAdapter::new(None);
322        assert!(adapter.supports_resume());
323    }
324}