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