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//! - OAuth token and API key environment variables for authentication
10//!
11
12#[cfg(feature = "async-client")]
13use crate::error::{Error, Result};
14use log::debug;
15use std::path::PathBuf;
16use std::process::Stdio;
17use uuid::Uuid;
18
19/// Permission mode for Claude CLI
20#[derive(Debug, Clone, Copy)]
21pub enum PermissionMode {
22    AcceptEdits,
23    BypassPermissions,
24    Default,
25    Plan,
26}
27
28impl PermissionMode {
29    fn as_str(&self) -> &'static str {
30        match self {
31            PermissionMode::AcceptEdits => "acceptEdits",
32            PermissionMode::BypassPermissions => "bypassPermissions",
33            PermissionMode::Default => "default",
34            PermissionMode::Plan => "plan",
35        }
36    }
37}
38
39/// Builder for creating Claude CLI commands in JSON streaming mode
40///
41/// This builder automatically configures Claude to use:
42/// - `--print` mode for non-interactive operation
43/// - `--output-format stream-json` for streaming JSON responses
44/// - `--input-format stream-json` for JSON input
45/// - `--replay-user-messages` to echo back user messages
46#[derive(Debug, Clone)]
47pub struct ClaudeCliBuilder {
48    command: PathBuf,
49    prompt: Option<String>,
50    debug: Option<String>,
51    verbose: bool,
52    dangerously_skip_permissions: bool,
53    allowed_tools: Vec<String>,
54    disallowed_tools: Vec<String>,
55    mcp_config: Vec<String>,
56    append_system_prompt: Option<String>,
57    permission_mode: Option<PermissionMode>,
58    continue_conversation: bool,
59    resume: Option<String>,
60    model: Option<String>,
61    fallback_model: Option<String>,
62    settings: Option<String>,
63    add_dir: Vec<PathBuf>,
64    ide: bool,
65    strict_mcp_config: bool,
66    session_id: Option<Uuid>,
67    oauth_token: Option<String>,
68    api_key: Option<String>,
69}
70
71impl Default for ClaudeCliBuilder {
72    fn default() -> Self {
73        Self::new()
74    }
75}
76
77impl ClaudeCliBuilder {
78    /// Create a new Claude CLI builder with JSON streaming mode pre-configured
79    pub fn new() -> Self {
80        Self {
81            command: PathBuf::from("claude"),
82            prompt: None,
83            debug: None,
84            verbose: false,
85            dangerously_skip_permissions: false,
86            allowed_tools: Vec::new(),
87            disallowed_tools: Vec::new(),
88            mcp_config: Vec::new(),
89            append_system_prompt: None,
90            permission_mode: None,
91            continue_conversation: false,
92            resume: None,
93            model: None,
94            fallback_model: None,
95            settings: None,
96            add_dir: Vec::new(),
97            ide: false,
98            strict_mcp_config: false,
99            session_id: None,
100            oauth_token: None,
101            api_key: None,
102        }
103    }
104
105    /// Set custom path to Claude binary
106    pub fn command<P: Into<PathBuf>>(mut self, path: P) -> Self {
107        self.command = path.into();
108        self
109    }
110
111    /// Set the prompt for Claude
112    pub fn prompt<S: Into<String>>(mut self, prompt: S) -> Self {
113        self.prompt = Some(prompt.into());
114        self
115    }
116
117    /// Enable debug mode with optional filter
118    pub fn debug<S: Into<String>>(mut self, filter: Option<S>) -> Self {
119        self.debug = filter.map(|s| s.into());
120        self
121    }
122
123    /// Enable verbose mode
124    pub fn verbose(mut self, verbose: bool) -> Self {
125        self.verbose = verbose;
126        self
127    }
128
129    /// Skip all permission checks (dangerous!)
130    pub fn dangerously_skip_permissions(mut self, skip: bool) -> Self {
131        self.dangerously_skip_permissions = skip;
132        self
133    }
134
135    /// Add allowed tools
136    pub fn allowed_tools<I, S>(mut self, tools: I) -> Self
137    where
138        I: IntoIterator<Item = S>,
139        S: Into<String>,
140    {
141        self.allowed_tools
142            .extend(tools.into_iter().map(|s| s.into()));
143        self
144    }
145
146    /// Add disallowed tools
147    pub fn disallowed_tools<I, S>(mut self, tools: I) -> Self
148    where
149        I: IntoIterator<Item = S>,
150        S: Into<String>,
151    {
152        self.disallowed_tools
153            .extend(tools.into_iter().map(|s| s.into()));
154        self
155    }
156
157    /// Add MCP configuration
158    pub fn mcp_config<I, S>(mut self, configs: I) -> Self
159    where
160        I: IntoIterator<Item = S>,
161        S: Into<String>,
162    {
163        self.mcp_config
164            .extend(configs.into_iter().map(|s| s.into()));
165        self
166    }
167
168    /// Append a system prompt
169    pub fn append_system_prompt<S: Into<String>>(mut self, prompt: S) -> Self {
170        self.append_system_prompt = Some(prompt.into());
171        self
172    }
173
174    /// Set permission mode
175    pub fn permission_mode(mut self, mode: PermissionMode) -> Self {
176        self.permission_mode = Some(mode);
177        self
178    }
179
180    /// Continue the most recent conversation
181    pub fn continue_conversation(mut self, continue_conv: bool) -> Self {
182        self.continue_conversation = continue_conv;
183        self
184    }
185
186    /// Resume a specific conversation
187    pub fn resume<S: Into<String>>(mut self, session_id: Option<S>) -> Self {
188        self.resume = session_id.map(|s| s.into());
189        self
190    }
191
192    /// Set the model to use
193    pub fn model<S: Into<String>>(mut self, model: S) -> Self {
194        self.model = Some(model.into());
195        self
196    }
197
198    /// Set fallback model for overload situations
199    pub fn fallback_model<S: Into<String>>(mut self, model: S) -> Self {
200        self.fallback_model = Some(model.into());
201        self
202    }
203
204    /// Load settings from file or JSON
205    pub fn settings<S: Into<String>>(mut self, settings: S) -> Self {
206        self.settings = Some(settings.into());
207        self
208    }
209
210    /// Add directories for tool access
211    pub fn add_directories<I, P>(mut self, dirs: I) -> Self
212    where
213        I: IntoIterator<Item = P>,
214        P: Into<PathBuf>,
215    {
216        self.add_dir.extend(dirs.into_iter().map(|p| p.into()));
217        self
218    }
219
220    /// Automatically connect to IDE
221    pub fn ide(mut self, ide: bool) -> Self {
222        self.ide = ide;
223        self
224    }
225
226    /// Use only MCP servers from config
227    pub fn strict_mcp_config(mut self, strict: bool) -> Self {
228        self.strict_mcp_config = strict;
229        self
230    }
231
232    /// Set a specific session ID (must be a UUID)
233    pub fn session_id(mut self, id: Uuid) -> Self {
234        self.session_id = Some(id);
235        self
236    }
237
238    /// Set OAuth token for authentication (must start with "sk-ant-oat")
239    pub fn oauth_token<S: Into<String>>(mut self, token: S) -> Self {
240        let token_str = token.into();
241        if !token_str.starts_with("sk-ant-oat") {
242            eprintln!("Warning: OAuth token should start with 'sk-ant-oat'");
243        }
244        self.oauth_token = Some(token_str);
245        self
246    }
247
248    /// Set API key for authentication (must start with "sk-ant-api")
249    pub fn api_key<S: Into<String>>(mut self, key: S) -> Self {
250        let key_str = key.into();
251        if !key_str.starts_with("sk-ant-api") {
252            eprintln!("Warning: API key should start with 'sk-ant-api'");
253        }
254        self.api_key = Some(key_str);
255        self
256    }
257
258    /// Build the command arguments (always includes JSON streaming flags)
259    fn build_args(&self) -> Vec<String> {
260        // Always add JSON streaming mode flags
261        // Note: --print with stream-json requires --verbose
262        let mut args = vec![
263            "--print".to_string(),
264            "--verbose".to_string(),
265            "--output-format".to_string(),
266            "stream-json".to_string(),
267            "--input-format".to_string(),
268            "stream-json".to_string(),
269        ];
270
271        if let Some(ref debug) = self.debug {
272            args.push("--debug".to_string());
273            if !debug.is_empty() {
274                args.push(debug.clone());
275            }
276        }
277
278        if self.dangerously_skip_permissions {
279            args.push("--dangerously-skip-permissions".to_string());
280        }
281
282        if !self.allowed_tools.is_empty() {
283            args.push("--allowed-tools".to_string());
284            args.extend(self.allowed_tools.clone());
285        }
286
287        if !self.disallowed_tools.is_empty() {
288            args.push("--disallowed-tools".to_string());
289            args.extend(self.disallowed_tools.clone());
290        }
291
292        if !self.mcp_config.is_empty() {
293            args.push("--mcp-config".to_string());
294            args.extend(self.mcp_config.clone());
295        }
296
297        if let Some(ref prompt) = self.append_system_prompt {
298            args.push("--append-system-prompt".to_string());
299            args.push(prompt.clone());
300        }
301
302        if let Some(ref mode) = self.permission_mode {
303            args.push("--permission-mode".to_string());
304            args.push(mode.as_str().to_string());
305        }
306
307        if self.continue_conversation {
308            args.push("--continue".to_string());
309        }
310
311        if let Some(ref session) = self.resume {
312            args.push("--resume".to_string());
313            args.push(session.clone());
314        }
315
316        if let Some(ref model) = self.model {
317            args.push("--model".to_string());
318            args.push(model.clone());
319        }
320
321        if let Some(ref model) = self.fallback_model {
322            args.push("--fallback-model".to_string());
323            args.push(model.clone());
324        }
325
326        if let Some(ref settings) = self.settings {
327            args.push("--settings".to_string());
328            args.push(settings.clone());
329        }
330
331        if !self.add_dir.is_empty() {
332            args.push("--add-dir".to_string());
333            for dir in &self.add_dir {
334                args.push(dir.to_string_lossy().to_string());
335            }
336        }
337
338        if self.ide {
339            args.push("--ide".to_string());
340        }
341
342        if self.strict_mcp_config {
343            args.push("--strict-mcp-config".to_string());
344        }
345
346        // Always provide a session ID - use provided one or generate a UUID4
347        args.push("--session-id".to_string());
348        let session_uuid = self.session_id.unwrap_or_else(|| {
349            let uuid = Uuid::new_v4();
350            debug!("[CLI] Generated session UUID: {}", uuid);
351            uuid
352        });
353        args.push(session_uuid.to_string());
354
355        // Add prompt as the last argument if provided
356        if let Some(ref prompt) = self.prompt {
357            args.push(prompt.clone());
358        }
359
360        args
361    }
362
363    /// Spawn the Claude process
364    #[cfg(feature = "async-client")]
365    pub async fn spawn(self) -> Result<tokio::process::Child> {
366        let args = self.build_args();
367
368        // Log the full command being executed
369        debug!(
370            "[CLI] Executing command: {} {}",
371            self.command.display(),
372            args.join(" ")
373        );
374
375        let mut cmd = tokio::process::Command::new(&self.command);
376        cmd.args(&args)
377            .stdin(Stdio::piped())
378            .stdout(Stdio::piped())
379            .stderr(Stdio::piped());
380
381        // Set OAuth token environment variable if provided
382        if let Some(ref token) = self.oauth_token {
383            cmd.env("CLAUDE_CODE_OAUTH_TOKEN", token);
384            debug!("[CLI] Setting CLAUDE_CODE_OAUTH_TOKEN environment variable");
385        }
386
387        // Set API key environment variable if provided
388        if let Some(ref key) = self.api_key {
389            cmd.env("ANTHROPIC_API_KEY", key);
390            debug!("[CLI] Setting ANTHROPIC_API_KEY environment variable");
391        }
392
393        let child = cmd.spawn().map_err(Error::Io)?;
394
395        Ok(child)
396    }
397
398    /// Build a Command without spawning (for testing or manual execution)
399    #[cfg(feature = "async-client")]
400    pub fn build_command(self) -> tokio::process::Command {
401        let args = self.build_args();
402        let mut cmd = tokio::process::Command::new(&self.command);
403        cmd.args(&args)
404            .stdin(Stdio::piped())
405            .stdout(Stdio::piped())
406            .stderr(Stdio::piped());
407
408        // Set OAuth token environment variable if provided
409        if let Some(ref token) = self.oauth_token {
410            cmd.env("CLAUDE_CODE_OAUTH_TOKEN", token);
411        }
412
413        // Set API key environment variable if provided
414        if let Some(ref key) = self.api_key {
415            cmd.env("ANTHROPIC_API_KEY", key);
416        }
417
418        cmd
419    }
420
421    /// Spawn the Claude process using synchronous std::process
422    pub fn spawn_sync(self) -> std::io::Result<std::process::Child> {
423        let args = self.build_args();
424
425        // Log the full command being executed
426        debug!(
427            "[CLI] Executing sync command: {} {}",
428            self.command.display(),
429            args.join(" ")
430        );
431
432        let mut cmd = std::process::Command::new(&self.command);
433        cmd.args(&args)
434            .stdin(Stdio::piped())
435            .stdout(Stdio::piped())
436            .stderr(Stdio::piped());
437
438        // Set OAuth token environment variable if provided
439        if let Some(ref token) = self.oauth_token {
440            cmd.env("CLAUDE_CODE_OAUTH_TOKEN", token);
441            debug!("[CLI] Setting CLAUDE_CODE_OAUTH_TOKEN environment variable");
442        }
443
444        // Set API key environment variable if provided
445        if let Some(ref key) = self.api_key {
446            cmd.env("ANTHROPIC_API_KEY", key);
447            debug!("[CLI] Setting ANTHROPIC_API_KEY environment variable");
448        }
449
450        cmd.spawn()
451    }
452}
453
454#[cfg(test)]
455mod tests {
456    use super::*;
457
458    #[test]
459    fn test_streaming_flags_always_present() {
460        let builder = ClaudeCliBuilder::new();
461        let args = builder.build_args();
462
463        // Verify all streaming flags are present by default
464        assert!(args.contains(&"--print".to_string()));
465        assert!(args.contains(&"--verbose".to_string())); // Required for --print with stream-json
466        assert!(args.contains(&"--output-format".to_string()));
467        assert!(args.contains(&"stream-json".to_string()));
468        assert!(args.contains(&"--input-format".to_string()));
469    }
470
471    #[test]
472    fn test_with_prompt() {
473        let builder = ClaudeCliBuilder::new().prompt("Hello, Claude!");
474        let args = builder.build_args();
475
476        assert_eq!(args.last().unwrap(), "Hello, Claude!");
477    }
478
479    #[test]
480    fn test_with_model() {
481        let builder = ClaudeCliBuilder::new()
482            .model("sonnet")
483            .fallback_model("opus");
484        let args = builder.build_args();
485
486        assert!(args.contains(&"--model".to_string()));
487        assert!(args.contains(&"sonnet".to_string()));
488        assert!(args.contains(&"--fallback-model".to_string()));
489        assert!(args.contains(&"opus".to_string()));
490    }
491
492    #[test]
493    fn test_with_debug() {
494        let builder = ClaudeCliBuilder::new().debug(Some("api"));
495        let args = builder.build_args();
496
497        assert!(args.contains(&"--debug".to_string()));
498        assert!(args.contains(&"api".to_string()));
499    }
500
501    #[test]
502    fn test_with_oauth_token() {
503        let valid_token = "sk-ant-oat-123456789";
504        let builder = ClaudeCliBuilder::new().oauth_token(valid_token);
505
506        // OAuth token is set as env var, not in args
507        let args = builder.clone().build_args();
508        assert!(!args.contains(&valid_token.to_string()));
509
510        // Verify it's stored in the builder
511        assert_eq!(builder.oauth_token, Some(valid_token.to_string()));
512    }
513
514    #[test]
515    fn test_oauth_token_validation() {
516        // Test with invalid prefix (should print warning but still accept)
517        let invalid_token = "invalid-token-123";
518        let builder = ClaudeCliBuilder::new().oauth_token(invalid_token);
519        assert_eq!(builder.oauth_token, Some(invalid_token.to_string()));
520    }
521
522    #[test]
523    fn test_with_api_key() {
524        let valid_key = "sk-ant-api-987654321";
525        let builder = ClaudeCliBuilder::new().api_key(valid_key);
526
527        // API key is set as env var, not in args
528        let args = builder.clone().build_args();
529        assert!(!args.contains(&valid_key.to_string()));
530
531        // Verify it's stored in the builder
532        assert_eq!(builder.api_key, Some(valid_key.to_string()));
533    }
534
535    #[test]
536    fn test_api_key_validation() {
537        // Test with invalid prefix (should print warning but still accept)
538        let invalid_key = "invalid-api-key";
539        let builder = ClaudeCliBuilder::new().api_key(invalid_key);
540        assert_eq!(builder.api_key, Some(invalid_key.to_string()));
541    }
542
543    #[test]
544    fn test_both_auth_methods() {
545        let oauth = "sk-ant-oat-123";
546        let api_key = "sk-ant-api-456";
547        let builder = ClaudeCliBuilder::new().oauth_token(oauth).api_key(api_key);
548
549        assert_eq!(builder.oauth_token, Some(oauth.to_string()));
550        assert_eq!(builder.api_key, Some(api_key.to_string()));
551    }
552}