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