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
142impl ClaudeCodeAdapter {
143    /// Build the launch command for SDK (stream-json) mode.
144    ///
145    /// Returns a shell command that starts Claude Code in non-interactive mode
146    /// with structured NDJSON I/O on stdin/stdout.
147    ///
148    /// `system_prompt` is the role prompt injected via `--append-system-prompt`.
149    /// Pass `None` to omit it (e.g., for `batty chat` interactive mode).
150    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    // --- Backend trait method tests ---
290
291    #[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); // UUID v4 format
346    }
347
348    #[test]
349    fn supports_resume_is_true() {
350        let adapter = ClaudeCodeAdapter::new(None);
351        assert!(adapter.supports_resume());
352    }
353
354    // --- SDK launch command tests ---
355
356    #[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}