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    /// Tool for handling permission prompts (e.g., "stdio" for bidirectional control)
70    permission_prompt_tool: Option<String>,
71}
72
73impl Default for ClaudeCliBuilder {
74    fn default() -> Self {
75        Self::new()
76    }
77}
78
79impl ClaudeCliBuilder {
80    /// Create a new Claude CLI builder with JSON streaming mode pre-configured
81    pub fn new() -> Self {
82        Self {
83            command: PathBuf::from("claude"),
84            prompt: None,
85            debug: None,
86            verbose: false,
87            dangerously_skip_permissions: false,
88            allowed_tools: Vec::new(),
89            disallowed_tools: Vec::new(),
90            mcp_config: Vec::new(),
91            append_system_prompt: None,
92            permission_mode: None,
93            continue_conversation: false,
94            resume: None,
95            model: None,
96            fallback_model: None,
97            settings: None,
98            add_dir: Vec::new(),
99            ide: false,
100            strict_mcp_config: false,
101            session_id: None,
102            oauth_token: None,
103            api_key: None,
104            permission_prompt_tool: None,
105        }
106    }
107
108    /// Set custom path to Claude binary
109    pub fn command<P: Into<PathBuf>>(mut self, path: P) -> Self {
110        self.command = path.into();
111        self
112    }
113
114    /// Set the prompt for Claude
115    pub fn prompt<S: Into<String>>(mut self, prompt: S) -> Self {
116        self.prompt = Some(prompt.into());
117        self
118    }
119
120    /// Enable debug mode with optional filter
121    pub fn debug<S: Into<String>>(mut self, filter: Option<S>) -> Self {
122        self.debug = filter.map(|s| s.into());
123        self
124    }
125
126    /// Enable verbose mode
127    pub fn verbose(mut self, verbose: bool) -> Self {
128        self.verbose = verbose;
129        self
130    }
131
132    /// Skip all permission checks (dangerous!)
133    pub fn dangerously_skip_permissions(mut self, skip: bool) -> Self {
134        self.dangerously_skip_permissions = skip;
135        self
136    }
137
138    /// Add allowed tools
139    pub fn allowed_tools<I, S>(mut self, tools: I) -> Self
140    where
141        I: IntoIterator<Item = S>,
142        S: Into<String>,
143    {
144        self.allowed_tools
145            .extend(tools.into_iter().map(|s| s.into()));
146        self
147    }
148
149    /// Add disallowed tools
150    pub fn disallowed_tools<I, S>(mut self, tools: I) -> Self
151    where
152        I: IntoIterator<Item = S>,
153        S: Into<String>,
154    {
155        self.disallowed_tools
156            .extend(tools.into_iter().map(|s| s.into()));
157        self
158    }
159
160    /// Add MCP configuration
161    pub fn mcp_config<I, S>(mut self, configs: I) -> Self
162    where
163        I: IntoIterator<Item = S>,
164        S: Into<String>,
165    {
166        self.mcp_config
167            .extend(configs.into_iter().map(|s| s.into()));
168        self
169    }
170
171    /// Append a system prompt
172    pub fn append_system_prompt<S: Into<String>>(mut self, prompt: S) -> Self {
173        self.append_system_prompt = Some(prompt.into());
174        self
175    }
176
177    /// Set permission mode
178    pub fn permission_mode(mut self, mode: PermissionMode) -> Self {
179        self.permission_mode = Some(mode);
180        self
181    }
182
183    /// Continue the most recent conversation
184    pub fn continue_conversation(mut self, continue_conv: bool) -> Self {
185        self.continue_conversation = continue_conv;
186        self
187    }
188
189    /// Resume a specific conversation
190    pub fn resume<S: Into<String>>(mut self, session_id: Option<S>) -> Self {
191        self.resume = session_id.map(|s| s.into());
192        self
193    }
194
195    /// Set the model to use
196    pub fn model<S: Into<String>>(mut self, model: S) -> Self {
197        self.model = Some(model.into());
198        self
199    }
200
201    /// Set fallback model for overload situations
202    pub fn fallback_model<S: Into<String>>(mut self, model: S) -> Self {
203        self.fallback_model = Some(model.into());
204        self
205    }
206
207    /// Load settings from file or JSON
208    pub fn settings<S: Into<String>>(mut self, settings: S) -> Self {
209        self.settings = Some(settings.into());
210        self
211    }
212
213    /// Add directories for tool access
214    pub fn add_directories<I, P>(mut self, dirs: I) -> Self
215    where
216        I: IntoIterator<Item = P>,
217        P: Into<PathBuf>,
218    {
219        self.add_dir.extend(dirs.into_iter().map(|p| p.into()));
220        self
221    }
222
223    /// Automatically connect to IDE
224    pub fn ide(mut self, ide: bool) -> Self {
225        self.ide = ide;
226        self
227    }
228
229    /// Use only MCP servers from config
230    pub fn strict_mcp_config(mut self, strict: bool) -> Self {
231        self.strict_mcp_config = strict;
232        self
233    }
234
235    /// Set a specific session ID (must be a UUID)
236    pub fn session_id(mut self, id: Uuid) -> Self {
237        self.session_id = Some(id);
238        self
239    }
240
241    /// Set OAuth token for authentication (must start with "sk-ant-oat")
242    pub fn oauth_token<S: Into<String>>(mut self, token: S) -> Self {
243        let token_str = token.into();
244        if !token_str.starts_with("sk-ant-oat") {
245            eprintln!("Warning: OAuth token should start with 'sk-ant-oat'");
246        }
247        self.oauth_token = Some(token_str);
248        self
249    }
250
251    /// Set API key for authentication (must start with "sk-ant-api")
252    pub fn api_key<S: Into<String>>(mut self, key: S) -> Self {
253        let key_str = key.into();
254        if !key_str.starts_with("sk-ant-api") {
255            eprintln!("Warning: API key should start with 'sk-ant-api'");
256        }
257        self.api_key = Some(key_str);
258        self
259    }
260
261    /// Enable bidirectional tool permission protocol via stdio
262    ///
263    /// When enabled, Claude CLI will send permission requests via stdout
264    /// and expect responses via stdin. Use "stdio" for standard I/O based
265    /// permission handling.
266    ///
267    /// # Example
268    /// ```
269    /// use claude_codes::ClaudeCliBuilder;
270    ///
271    /// let builder = ClaudeCliBuilder::new()
272    ///     .permission_prompt_tool("stdio")
273    ///     .model("sonnet");
274    /// ```
275    pub fn permission_prompt_tool<S: Into<String>>(mut self, tool: S) -> Self {
276        self.permission_prompt_tool = Some(tool.into());
277        self
278    }
279
280    /// Build the command arguments (always includes JSON streaming flags)
281    fn build_args(&self) -> Vec<String> {
282        // Always add JSON streaming mode flags
283        // Note: --print with stream-json requires --verbose
284        let mut args = vec![
285            "--print".to_string(),
286            "--verbose".to_string(),
287            "--output-format".to_string(),
288            "stream-json".to_string(),
289            "--input-format".to_string(),
290            "stream-json".to_string(),
291        ];
292
293        if let Some(ref debug) = self.debug {
294            args.push("--debug".to_string());
295            if !debug.is_empty() {
296                args.push(debug.clone());
297            }
298        }
299
300        if self.dangerously_skip_permissions {
301            args.push("--dangerously-skip-permissions".to_string());
302        }
303
304        if !self.allowed_tools.is_empty() {
305            args.push("--allowed-tools".to_string());
306            args.extend(self.allowed_tools.clone());
307        }
308
309        if !self.disallowed_tools.is_empty() {
310            args.push("--disallowed-tools".to_string());
311            args.extend(self.disallowed_tools.clone());
312        }
313
314        if !self.mcp_config.is_empty() {
315            args.push("--mcp-config".to_string());
316            args.extend(self.mcp_config.clone());
317        }
318
319        if let Some(ref prompt) = self.append_system_prompt {
320            args.push("--append-system-prompt".to_string());
321            args.push(prompt.clone());
322        }
323
324        if let Some(ref mode) = self.permission_mode {
325            args.push("--permission-mode".to_string());
326            args.push(mode.as_str().to_string());
327        }
328
329        if self.continue_conversation {
330            args.push("--continue".to_string());
331        }
332
333        if let Some(ref session) = self.resume {
334            args.push("--resume".to_string());
335            args.push(session.clone());
336        }
337
338        if let Some(ref model) = self.model {
339            args.push("--model".to_string());
340            args.push(model.clone());
341        }
342
343        if let Some(ref model) = self.fallback_model {
344            args.push("--fallback-model".to_string());
345            args.push(model.clone());
346        }
347
348        if let Some(ref settings) = self.settings {
349            args.push("--settings".to_string());
350            args.push(settings.clone());
351        }
352
353        if !self.add_dir.is_empty() {
354            args.push("--add-dir".to_string());
355            for dir in &self.add_dir {
356                args.push(dir.to_string_lossy().to_string());
357            }
358        }
359
360        if self.ide {
361            args.push("--ide".to_string());
362        }
363
364        if self.strict_mcp_config {
365            args.push("--strict-mcp-config".to_string());
366        }
367
368        if let Some(ref tool) = self.permission_prompt_tool {
369            args.push("--permission-prompt-tool".to_string());
370            args.push(tool.clone());
371        }
372
373        // Only add --session-id when NOT resuming/continuing an existing session
374        // (Claude CLI error: --session-id can only be used with --continue or --resume
375        // if --fork-session is also specified)
376        if self.resume.is_none() && !self.continue_conversation {
377            args.push("--session-id".to_string());
378            let session_uuid = self.session_id.unwrap_or_else(|| {
379                let uuid = Uuid::new_v4();
380                debug!("[CLI] Generated session UUID: {}", uuid);
381                uuid
382            });
383            args.push(session_uuid.to_string());
384        }
385
386        // Add prompt as the last argument if provided
387        if let Some(ref prompt) = self.prompt {
388            args.push(prompt.clone());
389        }
390
391        args
392    }
393
394    /// Spawn the Claude process
395    #[cfg(feature = "async-client")]
396    pub async fn spawn(self) -> Result<tokio::process::Child> {
397        let args = self.build_args();
398
399        // Log the full command being executed
400        debug!(
401            "[CLI] Executing command: {} {}",
402            self.command.display(),
403            args.join(" ")
404        );
405
406        let mut cmd = tokio::process::Command::new(&self.command);
407        cmd.args(&args)
408            .stdin(Stdio::piped())
409            .stdout(Stdio::piped())
410            .stderr(Stdio::piped());
411
412        // Set OAuth token environment variable if provided
413        if let Some(ref token) = self.oauth_token {
414            cmd.env("CLAUDE_CODE_OAUTH_TOKEN", token);
415            debug!("[CLI] Setting CLAUDE_CODE_OAUTH_TOKEN environment variable");
416        }
417
418        // Set API key environment variable if provided
419        if let Some(ref key) = self.api_key {
420            cmd.env("ANTHROPIC_API_KEY", key);
421            debug!("[CLI] Setting ANTHROPIC_API_KEY environment variable");
422        }
423
424        let child = cmd.spawn().map_err(Error::Io)?;
425
426        Ok(child)
427    }
428
429    /// Build a Command without spawning (for testing or manual execution)
430    #[cfg(feature = "async-client")]
431    pub fn build_command(self) -> tokio::process::Command {
432        let args = self.build_args();
433        let mut cmd = tokio::process::Command::new(&self.command);
434        cmd.args(&args)
435            .stdin(Stdio::piped())
436            .stdout(Stdio::piped())
437            .stderr(Stdio::piped());
438
439        // Set OAuth token environment variable if provided
440        if let Some(ref token) = self.oauth_token {
441            cmd.env("CLAUDE_CODE_OAUTH_TOKEN", token);
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        }
448
449        cmd
450    }
451
452    /// Spawn the Claude process using synchronous std::process
453    pub fn spawn_sync(self) -> std::io::Result<std::process::Child> {
454        let args = self.build_args();
455
456        // Log the full command being executed
457        debug!(
458            "[CLI] Executing sync command: {} {}",
459            self.command.display(),
460            args.join(" ")
461        );
462
463        let mut cmd = std::process::Command::new(&self.command);
464        cmd.args(&args)
465            .stdin(Stdio::piped())
466            .stdout(Stdio::piped())
467            .stderr(Stdio::piped());
468
469        // Set OAuth token environment variable if provided
470        if let Some(ref token) = self.oauth_token {
471            cmd.env("CLAUDE_CODE_OAUTH_TOKEN", token);
472            debug!("[CLI] Setting CLAUDE_CODE_OAUTH_TOKEN environment variable");
473        }
474
475        // Set API key environment variable if provided
476        if let Some(ref key) = self.api_key {
477            cmd.env("ANTHROPIC_API_KEY", key);
478            debug!("[CLI] Setting ANTHROPIC_API_KEY environment variable");
479        }
480
481        cmd.spawn()
482    }
483}
484
485#[cfg(test)]
486mod tests {
487    use super::*;
488
489    #[test]
490    fn test_streaming_flags_always_present() {
491        let builder = ClaudeCliBuilder::new();
492        let args = builder.build_args();
493
494        // Verify all streaming flags are present by default
495        assert!(args.contains(&"--print".to_string()));
496        assert!(args.contains(&"--verbose".to_string())); // Required for --print with stream-json
497        assert!(args.contains(&"--output-format".to_string()));
498        assert!(args.contains(&"stream-json".to_string()));
499        assert!(args.contains(&"--input-format".to_string()));
500    }
501
502    #[test]
503    fn test_with_prompt() {
504        let builder = ClaudeCliBuilder::new().prompt("Hello, Claude!");
505        let args = builder.build_args();
506
507        assert_eq!(args.last().unwrap(), "Hello, Claude!");
508    }
509
510    #[test]
511    fn test_with_model() {
512        let builder = ClaudeCliBuilder::new()
513            .model("sonnet")
514            .fallback_model("opus");
515        let args = builder.build_args();
516
517        assert!(args.contains(&"--model".to_string()));
518        assert!(args.contains(&"sonnet".to_string()));
519        assert!(args.contains(&"--fallback-model".to_string()));
520        assert!(args.contains(&"opus".to_string()));
521    }
522
523    #[test]
524    fn test_with_debug() {
525        let builder = ClaudeCliBuilder::new().debug(Some("api"));
526        let args = builder.build_args();
527
528        assert!(args.contains(&"--debug".to_string()));
529        assert!(args.contains(&"api".to_string()));
530    }
531
532    #[test]
533    fn test_with_oauth_token() {
534        let valid_token = "sk-ant-oat-123456789";
535        let builder = ClaudeCliBuilder::new().oauth_token(valid_token);
536
537        // OAuth token is set as env var, not in args
538        let args = builder.clone().build_args();
539        assert!(!args.contains(&valid_token.to_string()));
540
541        // Verify it's stored in the builder
542        assert_eq!(builder.oauth_token, Some(valid_token.to_string()));
543    }
544
545    #[test]
546    fn test_oauth_token_validation() {
547        // Test with invalid prefix (should print warning but still accept)
548        let invalid_token = "invalid-token-123";
549        let builder = ClaudeCliBuilder::new().oauth_token(invalid_token);
550        assert_eq!(builder.oauth_token, Some(invalid_token.to_string()));
551    }
552
553    #[test]
554    fn test_with_api_key() {
555        let valid_key = "sk-ant-api-987654321";
556        let builder = ClaudeCliBuilder::new().api_key(valid_key);
557
558        // API key is set as env var, not in args
559        let args = builder.clone().build_args();
560        assert!(!args.contains(&valid_key.to_string()));
561
562        // Verify it's stored in the builder
563        assert_eq!(builder.api_key, Some(valid_key.to_string()));
564    }
565
566    #[test]
567    fn test_api_key_validation() {
568        // Test with invalid prefix (should print warning but still accept)
569        let invalid_key = "invalid-api-key";
570        let builder = ClaudeCliBuilder::new().api_key(invalid_key);
571        assert_eq!(builder.api_key, Some(invalid_key.to_string()));
572    }
573
574    #[test]
575    fn test_both_auth_methods() {
576        let oauth = "sk-ant-oat-123";
577        let api_key = "sk-ant-api-456";
578        let builder = ClaudeCliBuilder::new().oauth_token(oauth).api_key(api_key);
579
580        assert_eq!(builder.oauth_token, Some(oauth.to_string()));
581        assert_eq!(builder.api_key, Some(api_key.to_string()));
582    }
583
584    #[test]
585    fn test_permission_prompt_tool() {
586        let builder = ClaudeCliBuilder::new().permission_prompt_tool("stdio");
587        let args = builder.build_args();
588
589        assert!(args.contains(&"--permission-prompt-tool".to_string()));
590        assert!(args.contains(&"stdio".to_string()));
591    }
592
593    #[test]
594    fn test_permission_prompt_tool_not_present_by_default() {
595        let builder = ClaudeCliBuilder::new();
596        let args = builder.build_args();
597
598        assert!(!args.contains(&"--permission-prompt-tool".to_string()));
599    }
600
601    #[test]
602    fn test_session_id_present_for_new_session() {
603        let builder = ClaudeCliBuilder::new();
604        let args = builder.build_args();
605
606        assert!(
607            args.contains(&"--session-id".to_string()),
608            "New sessions should have --session-id"
609        );
610    }
611
612    #[test]
613    fn test_session_id_not_present_with_resume() {
614        // When resuming a session, --session-id should NOT be added
615        // (Claude CLI rejects --session-id + --resume without --fork-session)
616        let builder = ClaudeCliBuilder::new().resume(Some("existing-uuid".to_string()));
617        let args = builder.build_args();
618
619        assert!(
620            args.contains(&"--resume".to_string()),
621            "Should have --resume flag"
622        );
623        assert!(
624            !args.contains(&"--session-id".to_string()),
625            "--session-id should NOT be present when resuming"
626        );
627    }
628
629    #[test]
630    fn test_session_id_not_present_with_continue() {
631        // When continuing a session, --session-id should NOT be added
632        let builder = ClaudeCliBuilder::new().continue_conversation(true);
633        let args = builder.build_args();
634
635        assert!(
636            args.contains(&"--continue".to_string()),
637            "Should have --continue flag"
638        );
639        assert!(
640            !args.contains(&"--session-id".to_string()),
641            "--session-id should NOT be present when continuing"
642        );
643    }
644}