claude_codes/
cli.rs

1//! Builder pattern for configuring and launching the Claude CLI process.
2//!
3//! This module provides [`ClaudeCliBuilder`] for constructing Claude CLI commands
4//! with the correct flags for JSON streaming mode. The builder automatically configures:
5//!
6//! - JSON streaming input/output formats
7//! - Non-interactive print mode
8//! - Verbose output for proper streaming
9//!
10//! # Example
11//!
12//! ```no_run
13//! use claude_codes::ClaudeCliBuilder;
14//!
15//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
16//! // Build and spawn an async Claude process
17//! let child = ClaudeCliBuilder::new()
18//!     .model("sonnet")
19//!     .session_id("my-session")
20//!     .spawn().await?;
21//!     
22//! // Or for synchronous usage
23//! let child = ClaudeCliBuilder::new()
24//!     .model("opus")
25//!     .spawn_sync()?;
26//! # Ok(())
27//! # }
28//! ```
29
30use crate::error::{Error, Result};
31use std::path::PathBuf;
32use std::process::Stdio;
33use tokio::process::{Child, Command};
34use tracing::debug;
35use uuid::Uuid;
36
37/// Permission mode for Claude CLI
38#[derive(Debug, Clone, Copy)]
39pub enum PermissionMode {
40    AcceptEdits,
41    BypassPermissions,
42    Default,
43    Plan,
44}
45
46impl PermissionMode {
47    fn as_str(&self) -> &'static str {
48        match self {
49            PermissionMode::AcceptEdits => "acceptEdits",
50            PermissionMode::BypassPermissions => "bypassPermissions",
51            PermissionMode::Default => "default",
52            PermissionMode::Plan => "plan",
53        }
54    }
55}
56
57/// Builder for creating Claude CLI commands in JSON streaming mode
58///
59/// This builder automatically configures Claude to use:
60/// - `--print` mode for non-interactive operation
61/// - `--output-format stream-json` for streaming JSON responses
62/// - `--input-format stream-json` for JSON input
63/// - `--replay-user-messages` to echo back user messages
64#[derive(Debug, Clone)]
65pub struct ClaudeCliBuilder {
66    command: PathBuf,
67    prompt: Option<String>,
68    debug: Option<String>,
69    verbose: bool,
70    dangerously_skip_permissions: bool,
71    allowed_tools: Vec<String>,
72    disallowed_tools: Vec<String>,
73    mcp_config: Vec<String>,
74    append_system_prompt: Option<String>,
75    permission_mode: Option<PermissionMode>,
76    continue_conversation: bool,
77    resume: Option<String>,
78    model: Option<String>,
79    fallback_model: Option<String>,
80    settings: Option<String>,
81    add_dir: Vec<PathBuf>,
82    ide: bool,
83    strict_mcp_config: bool,
84    session_id: Option<String>,
85}
86
87impl Default for ClaudeCliBuilder {
88    fn default() -> Self {
89        Self::new()
90    }
91}
92
93impl ClaudeCliBuilder {
94    /// Create a new Claude CLI builder with JSON streaming mode pre-configured
95    pub fn new() -> Self {
96        Self {
97            command: PathBuf::from("claude"),
98            prompt: None,
99            debug: None,
100            verbose: false,
101            dangerously_skip_permissions: false,
102            allowed_tools: Vec::new(),
103            disallowed_tools: Vec::new(),
104            mcp_config: Vec::new(),
105            append_system_prompt: None,
106            permission_mode: None,
107            continue_conversation: false,
108            resume: None,
109            model: None,
110            fallback_model: None,
111            settings: None,
112            add_dir: Vec::new(),
113            ide: false,
114            strict_mcp_config: false,
115            session_id: None,
116        }
117    }
118
119    /// Set custom path to Claude binary
120    pub fn command<P: Into<PathBuf>>(mut self, path: P) -> Self {
121        self.command = path.into();
122        self
123    }
124
125    /// Set the prompt for Claude
126    pub fn prompt<S: Into<String>>(mut self, prompt: S) -> Self {
127        self.prompt = Some(prompt.into());
128        self
129    }
130
131    /// Enable debug mode with optional filter
132    pub fn debug<S: Into<String>>(mut self, filter: Option<S>) -> Self {
133        self.debug = filter.map(|s| s.into());
134        self
135    }
136
137    /// Enable verbose mode
138    pub fn verbose(mut self, verbose: bool) -> Self {
139        self.verbose = verbose;
140        self
141    }
142
143    /// Skip all permission checks (dangerous!)
144    pub fn dangerously_skip_permissions(mut self, skip: bool) -> Self {
145        self.dangerously_skip_permissions = skip;
146        self
147    }
148
149    /// Add allowed tools
150    pub fn allowed_tools<I, S>(mut self, tools: I) -> Self
151    where
152        I: IntoIterator<Item = S>,
153        S: Into<String>,
154    {
155        self.allowed_tools
156            .extend(tools.into_iter().map(|s| s.into()));
157        self
158    }
159
160    /// Add disallowed tools
161    pub fn disallowed_tools<I, S>(mut self, tools: I) -> Self
162    where
163        I: IntoIterator<Item = S>,
164        S: Into<String>,
165    {
166        self.disallowed_tools
167            .extend(tools.into_iter().map(|s| s.into()));
168        self
169    }
170
171    /// Add MCP configuration
172    pub fn mcp_config<I, S>(mut self, configs: I) -> Self
173    where
174        I: IntoIterator<Item = S>,
175        S: Into<String>,
176    {
177        self.mcp_config
178            .extend(configs.into_iter().map(|s| s.into()));
179        self
180    }
181
182    /// Append a system prompt
183    pub fn append_system_prompt<S: Into<String>>(mut self, prompt: S) -> Self {
184        self.append_system_prompt = Some(prompt.into());
185        self
186    }
187
188    /// Set permission mode
189    pub fn permission_mode(mut self, mode: PermissionMode) -> Self {
190        self.permission_mode = Some(mode);
191        self
192    }
193
194    /// Continue the most recent conversation
195    pub fn continue_conversation(mut self, continue_conv: bool) -> Self {
196        self.continue_conversation = continue_conv;
197        self
198    }
199
200    /// Resume a specific conversation
201    pub fn resume<S: Into<String>>(mut self, session_id: Option<S>) -> Self {
202        self.resume = session_id.map(|s| s.into());
203        self
204    }
205
206    /// Set the model to use
207    pub fn model<S: Into<String>>(mut self, model: S) -> Self {
208        self.model = Some(model.into());
209        self
210    }
211
212    /// Set fallback model for overload situations
213    pub fn fallback_model<S: Into<String>>(mut self, model: S) -> Self {
214        self.fallback_model = Some(model.into());
215        self
216    }
217
218    /// Load settings from file or JSON
219    pub fn settings<S: Into<String>>(mut self, settings: S) -> Self {
220        self.settings = Some(settings.into());
221        self
222    }
223
224    /// Add directories for tool access
225    pub fn add_directories<I, P>(mut self, dirs: I) -> Self
226    where
227        I: IntoIterator<Item = P>,
228        P: Into<PathBuf>,
229    {
230        self.add_dir.extend(dirs.into_iter().map(|p| p.into()));
231        self
232    }
233
234    /// Automatically connect to IDE
235    pub fn ide(mut self, ide: bool) -> Self {
236        self.ide = ide;
237        self
238    }
239
240    /// Use only MCP servers from config
241    pub fn strict_mcp_config(mut self, strict: bool) -> Self {
242        self.strict_mcp_config = strict;
243        self
244    }
245
246    /// Set a specific session ID
247    pub fn session_id<S: Into<String>>(mut self, id: S) -> Self {
248        self.session_id = Some(id.into());
249        self
250    }
251
252    /// Build the command arguments (always includes JSON streaming flags)
253    fn build_args(&self) -> Vec<String> {
254        // Always add JSON streaming mode flags
255        // Note: --print with stream-json requires --verbose
256        let mut args = vec![
257            "--print".to_string(),
258            "--verbose".to_string(),
259            "--output-format".to_string(),
260            "stream-json".to_string(),
261            "--input-format".to_string(),
262            "stream-json".to_string(),
263        ];
264
265        if let Some(ref debug) = self.debug {
266            args.push("--debug".to_string());
267            if !debug.is_empty() {
268                args.push(debug.clone());
269            }
270        }
271
272        if self.dangerously_skip_permissions {
273            args.push("--dangerously-skip-permissions".to_string());
274        }
275
276        if !self.allowed_tools.is_empty() {
277            args.push("--allowed-tools".to_string());
278            args.extend(self.allowed_tools.clone());
279        }
280
281        if !self.disallowed_tools.is_empty() {
282            args.push("--disallowed-tools".to_string());
283            args.extend(self.disallowed_tools.clone());
284        }
285
286        if !self.mcp_config.is_empty() {
287            args.push("--mcp-config".to_string());
288            args.extend(self.mcp_config.clone());
289        }
290
291        if let Some(ref prompt) = self.append_system_prompt {
292            args.push("--append-system-prompt".to_string());
293            args.push(prompt.clone());
294        }
295
296        if let Some(ref mode) = self.permission_mode {
297            args.push("--permission-mode".to_string());
298            args.push(mode.as_str().to_string());
299        }
300
301        if self.continue_conversation {
302            args.push("--continue".to_string());
303        }
304
305        if let Some(ref session) = self.resume {
306            args.push("--resume".to_string());
307            args.push(session.clone());
308        }
309
310        if let Some(ref model) = self.model {
311            args.push("--model".to_string());
312            args.push(model.clone());
313        }
314
315        if let Some(ref model) = self.fallback_model {
316            args.push("--fallback-model".to_string());
317            args.push(model.clone());
318        }
319
320        if let Some(ref settings) = self.settings {
321            args.push("--settings".to_string());
322            args.push(settings.clone());
323        }
324
325        if !self.add_dir.is_empty() {
326            args.push("--add-dir".to_string());
327            for dir in &self.add_dir {
328                args.push(dir.to_string_lossy().to_string());
329            }
330        }
331
332        if self.ide {
333            args.push("--ide".to_string());
334        }
335
336        if self.strict_mcp_config {
337            args.push("--strict-mcp-config".to_string());
338        }
339
340        // Always provide a session ID - use provided one or generate a UUID4
341        args.push("--session-id".to_string());
342        if let Some(ref id) = self.session_id {
343            args.push(id.clone());
344        } else {
345            // Generate a UUID4 if no session ID was provided
346            let uuid = Uuid::new_v4();
347            debug!("[CLI] Generated session UUID: {}", uuid);
348            args.push(uuid.to_string());
349        }
350
351        // Add prompt as the last argument if provided
352        if let Some(ref prompt) = self.prompt {
353            args.push(prompt.clone());
354        }
355
356        args
357    }
358
359    /// Spawn the Claude process
360    pub async fn spawn(self) -> Result<Child> {
361        let args = self.build_args();
362
363        // Log the full command being executed
364        debug!(
365            "[CLI] Executing command: {} {}",
366            self.command.display(),
367            args.join(" ")
368        );
369        eprintln!("Executing: {} {}", self.command.display(), args.join(" "));
370
371        let child = Command::new(&self.command)
372            .args(&args)
373            .stdin(Stdio::piped())
374            .stdout(Stdio::piped())
375            .stderr(Stdio::piped())
376            .spawn()
377            .map_err(Error::Io)?;
378
379        Ok(child)
380    }
381
382    /// Build a Command without spawning (for testing or manual execution)
383    pub fn build_command(self) -> Command {
384        let args = self.build_args();
385        let mut cmd = Command::new(&self.command);
386        cmd.args(&args)
387            .stdin(Stdio::piped())
388            .stdout(Stdio::piped())
389            .stderr(Stdio::piped());
390        cmd
391    }
392
393    /// Spawn the Claude process using synchronous std::process
394    pub fn spawn_sync(self) -> std::io::Result<std::process::Child> {
395        let args = self.build_args();
396
397        // Log the full command being executed
398        debug!(
399            "[CLI] Executing sync command: {} {}",
400            self.command.display(),
401            args.join(" ")
402        );
403        eprintln!("Executing: {} {}", self.command.display(), args.join(" "));
404
405        std::process::Command::new(&self.command)
406            .args(&args)
407            .stdin(Stdio::piped())
408            .stdout(Stdio::piped())
409            .stderr(Stdio::piped())
410            .spawn()
411    }
412}
413
414#[cfg(test)]
415mod tests {
416    use super::*;
417
418    #[test]
419    fn test_streaming_flags_always_present() {
420        let builder = ClaudeCliBuilder::new();
421        let args = builder.build_args();
422
423        // Verify all streaming flags are present by default
424        assert!(args.contains(&"--print".to_string()));
425        assert!(args.contains(&"--verbose".to_string())); // Required for --print with stream-json
426        assert!(args.contains(&"--output-format".to_string()));
427        assert!(args.contains(&"stream-json".to_string()));
428        assert!(args.contains(&"--input-format".to_string()));
429    }
430
431    #[test]
432    fn test_with_prompt() {
433        let builder = ClaudeCliBuilder::new().prompt("Hello, Claude!");
434        let args = builder.build_args();
435
436        assert_eq!(args.last().unwrap(), "Hello, Claude!");
437    }
438
439    #[test]
440    fn test_with_model() {
441        let builder = ClaudeCliBuilder::new()
442            .model("sonnet")
443            .fallback_model("opus");
444        let args = builder.build_args();
445
446        assert!(args.contains(&"--model".to_string()));
447        assert!(args.contains(&"sonnet".to_string()));
448        assert!(args.contains(&"--fallback-model".to_string()));
449        assert!(args.contains(&"opus".to_string()));
450    }
451
452    #[test]
453    fn test_with_debug() {
454        let builder = ClaudeCliBuilder::new().debug(Some("api"));
455        let args = builder.build_args();
456
457        assert!(args.contains(&"--debug".to_string()));
458        assert!(args.contains(&"api".to_string()));
459    }
460}