claude_codes/
cli.rs

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