Skip to main content

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
12use crate::error::{Error, Result};
13use log::debug;
14use std::path::PathBuf;
15use std::process::Stdio;
16use uuid::Uuid;
17
18/// Permission mode for Claude CLI
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum PermissionMode {
21    AcceptEdits,
22    BypassPermissions,
23    Default,
24    Delegate,
25    DontAsk,
26    Plan,
27}
28
29impl PermissionMode {
30    /// Get the CLI string representation
31    pub fn as_str(&self) -> &'static str {
32        match self {
33            PermissionMode::AcceptEdits => "acceptEdits",
34            PermissionMode::BypassPermissions => "bypassPermissions",
35            PermissionMode::Default => "default",
36            PermissionMode::Delegate => "delegate",
37            PermissionMode::DontAsk => "dontAsk",
38            PermissionMode::Plan => "plan",
39        }
40    }
41}
42
43/// Input format for Claude CLI
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum InputFormat {
46    Text,
47    StreamJson,
48}
49
50impl InputFormat {
51    /// Get the CLI string representation
52    pub fn as_str(&self) -> &'static str {
53        match self {
54            InputFormat::Text => "text",
55            InputFormat::StreamJson => "stream-json",
56        }
57    }
58}
59
60/// Output format for Claude CLI
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum OutputFormat {
63    Text,
64    Json,
65    StreamJson,
66}
67
68impl OutputFormat {
69    /// Get the CLI string representation
70    pub fn as_str(&self) -> &'static str {
71        match self {
72            OutputFormat::Text => "text",
73            OutputFormat::Json => "json",
74            OutputFormat::StreamJson => "stream-json",
75        }
76    }
77}
78
79/// Comprehensive enum of all Claude CLI flags.
80///
81/// This enum represents every flag available in the Claude CLI (`claude --help`).
82/// Each variant carries the appropriate data type for its flag value.
83///
84/// Use `as_flag()` to get the CLI flag string (e.g., `"--model"`),
85/// or `to_args()` to get the complete flag + value as CLI arguments.
86///
87/// # Example
88/// ```
89/// use claude_codes::CliFlag;
90///
91/// let flag = CliFlag::Model("sonnet".to_string());
92/// assert_eq!(flag.as_flag(), "--model");
93/// assert_eq!(flag.to_args(), vec!["--model", "sonnet"]);
94/// ```
95#[derive(Debug, Clone)]
96pub enum CliFlag {
97    /// Additional directories to allow tool access to
98    AddDir(Vec<PathBuf>),
99    /// Agent for the current session
100    Agent(String),
101    /// JSON object defining custom agents
102    Agents(String),
103    /// Enable bypassing all permission checks as an option
104    AllowDangerouslySkipPermissions,
105    /// Tool names to allow (e.g. "Bash(git:*) Edit")
106    AllowedTools(Vec<String>),
107    /// Append to the default system prompt
108    AppendSystemPrompt(String),
109    /// Beta headers for API requests (API key users only)
110    Betas(Vec<String>),
111    /// Enable Claude in Chrome integration
112    Chrome,
113    /// Continue the most recent conversation
114    Continue,
115    /// Bypass all permission checks
116    DangerouslySkipPermissions,
117    /// Enable debug mode with optional category filter
118    Debug(Option<String>),
119    /// Write debug logs to a specific file path
120    DebugFile(PathBuf),
121    /// Disable all skills/slash commands
122    DisableSlashCommands,
123    /// Tool names to deny (e.g. "Bash(git:*) Edit")
124    DisallowedTools(Vec<String>),
125    /// Automatic fallback model when default is overloaded
126    FallbackModel(String),
127    /// File resources to download at startup (format: file_id:relative_path)
128    File(Vec<String>),
129    /// Create a new session ID when resuming instead of reusing original
130    ForkSession,
131    /// Resume a session linked to a PR
132    FromPr(Option<String>),
133    /// Include partial message chunks as they arrive
134    IncludePartialMessages,
135    /// Input format (text or stream-json)
136    InputFormat(InputFormat),
137    /// JSON Schema for structured output validation
138    JsonSchema(String),
139    /// Maximum dollar amount for API calls
140    MaxBudgetUsd(f64),
141    /// Load MCP servers from JSON files or strings
142    McpConfig(Vec<String>),
143    /// Enable MCP debug mode (deprecated, use Debug instead)
144    McpDebug,
145    /// Model for the current session
146    Model(String),
147    /// Disable Claude in Chrome integration
148    NoChrome,
149    /// Disable session persistence
150    NoSessionPersistence,
151    /// Output format (text, json, or stream-json)
152    OutputFormat(OutputFormat),
153    /// Permission mode for the session
154    PermissionMode(PermissionMode),
155    /// Tool for handling permission prompts (e.g., "stdio")
156    PermissionPromptTool(String),
157    /// Load plugins from directories
158    PluginDir(Vec<PathBuf>),
159    /// Print response and exit
160    Print,
161    /// Re-emit user messages from stdin back on stdout
162    ReplayUserMessages,
163    /// Resume a conversation by session ID
164    Resume(Option<String>),
165    /// Use a specific session ID (UUID or tagged ID)
166    SessionId(String),
167    /// Comma-separated list of setting sources (user, project, local)
168    SettingSources(String),
169    /// Path to settings JSON file or JSON string
170    Settings(String),
171    /// Only use MCP servers from --mcp-config
172    StrictMcpConfig,
173    /// System prompt for the session
174    SystemPrompt(String),
175    /// Specify available tools from the built-in set
176    Tools(Vec<String>),
177    /// Override verbose mode setting
178    Verbose,
179}
180
181impl CliFlag {
182    /// Get the CLI flag string (e.g., `"--model"`)
183    pub fn as_flag(&self) -> &'static str {
184        match self {
185            CliFlag::AddDir(_) => "--add-dir",
186            CliFlag::Agent(_) => "--agent",
187            CliFlag::Agents(_) => "--agents",
188            CliFlag::AllowDangerouslySkipPermissions => "--allow-dangerously-skip-permissions",
189            CliFlag::AllowedTools(_) => "--allowed-tools",
190            CliFlag::AppendSystemPrompt(_) => "--append-system-prompt",
191            CliFlag::Betas(_) => "--betas",
192            CliFlag::Chrome => "--chrome",
193            CliFlag::Continue => "--continue",
194            CliFlag::DangerouslySkipPermissions => "--dangerously-skip-permissions",
195            CliFlag::Debug(_) => "--debug",
196            CliFlag::DebugFile(_) => "--debug-file",
197            CliFlag::DisableSlashCommands => "--disable-slash-commands",
198            CliFlag::DisallowedTools(_) => "--disallowed-tools",
199            CliFlag::FallbackModel(_) => "--fallback-model",
200            CliFlag::File(_) => "--file",
201            CliFlag::ForkSession => "--fork-session",
202            CliFlag::FromPr(_) => "--from-pr",
203            CliFlag::IncludePartialMessages => "--include-partial-messages",
204            CliFlag::InputFormat(_) => "--input-format",
205            CliFlag::JsonSchema(_) => "--json-schema",
206            CliFlag::MaxBudgetUsd(_) => "--max-budget-usd",
207            CliFlag::McpConfig(_) => "--mcp-config",
208            CliFlag::McpDebug => "--mcp-debug",
209            CliFlag::Model(_) => "--model",
210            CliFlag::NoChrome => "--no-chrome",
211            CliFlag::NoSessionPersistence => "--no-session-persistence",
212            CliFlag::OutputFormat(_) => "--output-format",
213            CliFlag::PermissionMode(_) => "--permission-mode",
214            CliFlag::PermissionPromptTool(_) => "--permission-prompt-tool",
215            CliFlag::PluginDir(_) => "--plugin-dir",
216            CliFlag::Print => "--print",
217            CliFlag::ReplayUserMessages => "--replay-user-messages",
218            CliFlag::Resume(_) => "--resume",
219            CliFlag::SessionId(_) => "--session-id",
220            CliFlag::SettingSources(_) => "--setting-sources",
221            CliFlag::Settings(_) => "--settings",
222            CliFlag::StrictMcpConfig => "--strict-mcp-config",
223            CliFlag::SystemPrompt(_) => "--system-prompt",
224            CliFlag::Tools(_) => "--tools",
225            CliFlag::Verbose => "--verbose",
226        }
227    }
228
229    /// Convert this flag into CLI arguments (flag + value)
230    pub fn to_args(&self) -> Vec<String> {
231        let flag = self.as_flag().to_string();
232        match self {
233            // Boolean flags (no value)
234            CliFlag::AllowDangerouslySkipPermissions
235            | CliFlag::Chrome
236            | CliFlag::Continue
237            | CliFlag::DangerouslySkipPermissions
238            | CliFlag::DisableSlashCommands
239            | CliFlag::ForkSession
240            | CliFlag::IncludePartialMessages
241            | CliFlag::McpDebug
242            | CliFlag::NoChrome
243            | CliFlag::NoSessionPersistence
244            | CliFlag::Print
245            | CliFlag::ReplayUserMessages
246            | CliFlag::StrictMcpConfig
247            | CliFlag::Verbose => vec![flag],
248
249            // Optional value flags
250            CliFlag::Debug(filter) => match filter {
251                Some(f) => vec![flag, f.clone()],
252                None => vec![flag],
253            },
254            CliFlag::FromPr(value) | CliFlag::Resume(value) => match value {
255                Some(v) => vec![flag, v.clone()],
256                None => vec![flag],
257            },
258
259            // Single string value flags
260            CliFlag::Agent(v)
261            | CliFlag::Agents(v)
262            | CliFlag::AppendSystemPrompt(v)
263            | CliFlag::FallbackModel(v)
264            | CliFlag::JsonSchema(v)
265            | CliFlag::Model(v)
266            | CliFlag::PermissionPromptTool(v)
267            | CliFlag::SessionId(v)
268            | CliFlag::SettingSources(v)
269            | CliFlag::Settings(v)
270            | CliFlag::SystemPrompt(v) => vec![flag, v.clone()],
271
272            // Format flags
273            CliFlag::InputFormat(f) => vec![flag, f.as_str().to_string()],
274            CliFlag::OutputFormat(f) => vec![flag, f.as_str().to_string()],
275            CliFlag::PermissionMode(m) => vec![flag, m.as_str().to_string()],
276
277            // Numeric flags
278            CliFlag::MaxBudgetUsd(amount) => vec![flag, amount.to_string()],
279
280            // Path flags
281            CliFlag::DebugFile(p) => vec![flag, p.to_string_lossy().to_string()],
282
283            // Multi-value string flags
284            CliFlag::AllowedTools(items)
285            | CliFlag::Betas(items)
286            | CliFlag::DisallowedTools(items)
287            | CliFlag::File(items)
288            | CliFlag::McpConfig(items)
289            | CliFlag::Tools(items) => {
290                let mut args = vec![flag];
291                args.extend(items.clone());
292                args
293            }
294
295            // Multi-value path flags
296            CliFlag::AddDir(paths) | CliFlag::PluginDir(paths) => {
297                let mut args = vec![flag];
298                args.extend(paths.iter().map(|p| p.to_string_lossy().to_string()));
299                args
300            }
301        }
302    }
303
304    /// Returns all CLI flag names with their flag strings.
305    ///
306    /// Useful for enumerating available options in a UI or for validation.
307    ///
308    /// # Example
309    /// ```
310    /// use claude_codes::CliFlag;
311    ///
312    /// for (name, flag) in CliFlag::all_flags() {
313    ///     println!("{}: {}", name, flag);
314    /// }
315    /// ```
316    pub fn all_flags() -> Vec<(&'static str, &'static str)> {
317        vec![
318            ("AddDir", "--add-dir"),
319            ("Agent", "--agent"),
320            ("Agents", "--agents"),
321            (
322                "AllowDangerouslySkipPermissions",
323                "--allow-dangerously-skip-permissions",
324            ),
325            ("AllowedTools", "--allowed-tools"),
326            ("AppendSystemPrompt", "--append-system-prompt"),
327            ("Betas", "--betas"),
328            ("Chrome", "--chrome"),
329            ("Continue", "--continue"),
330            (
331                "DangerouslySkipPermissions",
332                "--dangerously-skip-permissions",
333            ),
334            ("Debug", "--debug"),
335            ("DebugFile", "--debug-file"),
336            ("DisableSlashCommands", "--disable-slash-commands"),
337            ("DisallowedTools", "--disallowed-tools"),
338            ("FallbackModel", "--fallback-model"),
339            ("File", "--file"),
340            ("ForkSession", "--fork-session"),
341            ("FromPr", "--from-pr"),
342            ("IncludePartialMessages", "--include-partial-messages"),
343            ("InputFormat", "--input-format"),
344            ("JsonSchema", "--json-schema"),
345            ("MaxBudgetUsd", "--max-budget-usd"),
346            ("McpConfig", "--mcp-config"),
347            ("McpDebug", "--mcp-debug"),
348            ("Model", "--model"),
349            ("NoChrome", "--no-chrome"),
350            ("NoSessionPersistence", "--no-session-persistence"),
351            ("OutputFormat", "--output-format"),
352            ("PermissionMode", "--permission-mode"),
353            ("PermissionPromptTool", "--permission-prompt-tool"),
354            ("PluginDir", "--plugin-dir"),
355            ("Print", "--print"),
356            ("ReplayUserMessages", "--replay-user-messages"),
357            ("Resume", "--resume"),
358            ("SessionId", "--session-id"),
359            ("SettingSources", "--setting-sources"),
360            ("Settings", "--settings"),
361            ("StrictMcpConfig", "--strict-mcp-config"),
362            ("SystemPrompt", "--system-prompt"),
363            ("Tools", "--tools"),
364            ("Verbose", "--verbose"),
365        ]
366    }
367}
368
369/// Builder for creating Claude CLI commands in JSON streaming mode
370///
371/// This builder automatically configures Claude to use:
372/// - `--print` mode for non-interactive operation
373/// - `--output-format stream-json` for streaming JSON responses
374/// - `--input-format stream-json` for JSON input
375/// - `--replay-user-messages` to echo back user messages
376#[derive(Debug, Clone)]
377pub struct ClaudeCliBuilder {
378    command: PathBuf,
379    prompt: Option<String>,
380    debug: Option<String>,
381    verbose: bool,
382    dangerously_skip_permissions: bool,
383    allowed_tools: Vec<String>,
384    disallowed_tools: Vec<String>,
385    mcp_config: Vec<String>,
386    append_system_prompt: Option<String>,
387    permission_mode: Option<PermissionMode>,
388    continue_conversation: bool,
389    resume: Option<String>,
390    model: Option<String>,
391    fallback_model: Option<String>,
392    settings: Option<String>,
393    add_dir: Vec<PathBuf>,
394    ide: bool,
395    strict_mcp_config: bool,
396    session_id: Option<Uuid>,
397    oauth_token: Option<String>,
398    api_key: Option<String>,
399    /// Tool for handling permission prompts (e.g., "stdio" for bidirectional control)
400    permission_prompt_tool: Option<String>,
401    /// Allow spawning inside another Claude Code session by unsetting CLAUDECODE env var
402    allow_recursion: bool,
403}
404
405impl Default for ClaudeCliBuilder {
406    fn default() -> Self {
407        Self::new()
408    }
409}
410
411impl ClaudeCliBuilder {
412    /// Create a new Claude CLI builder with JSON streaming mode pre-configured
413    pub fn new() -> Self {
414        Self {
415            command: PathBuf::from("claude"),
416            prompt: None,
417            debug: None,
418            verbose: false,
419            dangerously_skip_permissions: false,
420            allowed_tools: Vec::new(),
421            disallowed_tools: Vec::new(),
422            mcp_config: Vec::new(),
423            append_system_prompt: None,
424            permission_mode: None,
425            continue_conversation: false,
426            resume: None,
427            model: None,
428            fallback_model: None,
429            settings: None,
430            add_dir: Vec::new(),
431            ide: false,
432            strict_mcp_config: false,
433            session_id: None,
434            oauth_token: None,
435            api_key: None,
436            permission_prompt_tool: None,
437            allow_recursion: false,
438        }
439    }
440
441    /// Set custom path to Claude binary
442    pub fn command<P: Into<PathBuf>>(mut self, path: P) -> Self {
443        self.command = path.into();
444        self
445    }
446
447    /// Set the prompt for Claude
448    pub fn prompt<S: Into<String>>(mut self, prompt: S) -> Self {
449        self.prompt = Some(prompt.into());
450        self
451    }
452
453    /// Enable debug mode with optional filter
454    pub fn debug<S: Into<String>>(mut self, filter: Option<S>) -> Self {
455        self.debug = filter.map(|s| s.into());
456        self
457    }
458
459    /// Enable verbose mode
460    pub fn verbose(mut self, verbose: bool) -> Self {
461        self.verbose = verbose;
462        self
463    }
464
465    /// Skip all permission checks (dangerous!)
466    pub fn dangerously_skip_permissions(mut self, skip: bool) -> Self {
467        self.dangerously_skip_permissions = skip;
468        self
469    }
470
471    /// Add allowed tools
472    pub fn allowed_tools<I, S>(mut self, tools: I) -> Self
473    where
474        I: IntoIterator<Item = S>,
475        S: Into<String>,
476    {
477        self.allowed_tools
478            .extend(tools.into_iter().map(|s| s.into()));
479        self
480    }
481
482    /// Add disallowed tools
483    pub fn disallowed_tools<I, S>(mut self, tools: I) -> Self
484    where
485        I: IntoIterator<Item = S>,
486        S: Into<String>,
487    {
488        self.disallowed_tools
489            .extend(tools.into_iter().map(|s| s.into()));
490        self
491    }
492
493    /// Add MCP configuration
494    pub fn mcp_config<I, S>(mut self, configs: I) -> Self
495    where
496        I: IntoIterator<Item = S>,
497        S: Into<String>,
498    {
499        self.mcp_config
500            .extend(configs.into_iter().map(|s| s.into()));
501        self
502    }
503
504    /// Append a system prompt
505    pub fn append_system_prompt<S: Into<String>>(mut self, prompt: S) -> Self {
506        self.append_system_prompt = Some(prompt.into());
507        self
508    }
509
510    /// Set permission mode
511    pub fn permission_mode(mut self, mode: PermissionMode) -> Self {
512        self.permission_mode = Some(mode);
513        self
514    }
515
516    /// Continue the most recent conversation
517    pub fn continue_conversation(mut self, continue_conv: bool) -> Self {
518        self.continue_conversation = continue_conv;
519        self
520    }
521
522    /// Resume a specific conversation
523    pub fn resume<S: Into<String>>(mut self, session_id: Option<S>) -> Self {
524        self.resume = session_id.map(|s| s.into());
525        self
526    }
527
528    /// Set the model to use
529    pub fn model<S: Into<String>>(mut self, model: S) -> Self {
530        self.model = Some(model.into());
531        self
532    }
533
534    /// Set fallback model for overload situations
535    pub fn fallback_model<S: Into<String>>(mut self, model: S) -> Self {
536        self.fallback_model = Some(model.into());
537        self
538    }
539
540    /// Load settings from file or JSON
541    pub fn settings<S: Into<String>>(mut self, settings: S) -> Self {
542        self.settings = Some(settings.into());
543        self
544    }
545
546    /// Add directories for tool access
547    pub fn add_directories<I, P>(mut self, dirs: I) -> Self
548    where
549        I: IntoIterator<Item = P>,
550        P: Into<PathBuf>,
551    {
552        self.add_dir.extend(dirs.into_iter().map(|p| p.into()));
553        self
554    }
555
556    /// Automatically connect to IDE
557    pub fn ide(mut self, ide: bool) -> Self {
558        self.ide = ide;
559        self
560    }
561
562    /// Use only MCP servers from config
563    pub fn strict_mcp_config(mut self, strict: bool) -> Self {
564        self.strict_mcp_config = strict;
565        self
566    }
567
568    /// Set a specific session ID (must be a UUID)
569    pub fn session_id(mut self, id: Uuid) -> Self {
570        self.session_id = Some(id);
571        self
572    }
573
574    /// Set OAuth token for authentication (must start with "sk-ant-oat")
575    pub fn oauth_token<S: Into<String>>(mut self, token: S) -> Self {
576        let token_str = token.into();
577        if !token_str.starts_with("sk-ant-oat") {
578            eprintln!("Warning: OAuth token should start with 'sk-ant-oat'");
579        }
580        self.oauth_token = Some(token_str);
581        self
582    }
583
584    /// Set API key for authentication (must start with "sk-ant-api")
585    pub fn api_key<S: Into<String>>(mut self, key: S) -> Self {
586        let key_str = key.into();
587        if !key_str.starts_with("sk-ant-api") {
588            eprintln!("Warning: API key should start with 'sk-ant-api'");
589        }
590        self.api_key = Some(key_str);
591        self
592    }
593
594    /// Enable bidirectional tool permission protocol via stdio
595    ///
596    /// When enabled, Claude CLI will send permission requests via stdout
597    /// and expect responses via stdin. Use "stdio" for standard I/O based
598    /// permission handling.
599    ///
600    /// # Example
601    /// ```
602    /// use claude_codes::ClaudeCliBuilder;
603    ///
604    /// let builder = ClaudeCliBuilder::new()
605    ///     .permission_prompt_tool("stdio")
606    ///     .model("sonnet");
607    /// ```
608    pub fn permission_prompt_tool<S: Into<String>>(mut self, tool: S) -> Self {
609        self.permission_prompt_tool = Some(tool.into());
610        self
611    }
612
613    /// Allow spawning inside another Claude Code session by unsetting the
614    /// `CLAUDECODE` environment variable in the child process.
615    #[cfg(feature = "integration-tests")]
616    pub fn allow_recursion(mut self) -> Self {
617        self.allow_recursion = true;
618        self
619    }
620
621    /// Resolve the command path, using `which` for non-absolute paths.
622    fn resolve_command(&self) -> Result<PathBuf> {
623        if self.command.is_absolute() {
624            return Ok(self.command.clone());
625        }
626        which::which(&self.command).map_err(|_| Error::BinaryNotFound {
627            name: self.command.display().to_string(),
628        })
629    }
630
631    /// Build the command arguments (always includes JSON streaming flags)
632    fn build_args(&self) -> Vec<String> {
633        // Always add JSON streaming mode flags
634        // Note: --print with stream-json requires --verbose
635        let mut args = vec![
636            "--print".to_string(),
637            "--verbose".to_string(),
638            "--output-format".to_string(),
639            "stream-json".to_string(),
640            "--input-format".to_string(),
641            "stream-json".to_string(),
642        ];
643
644        if let Some(ref debug) = self.debug {
645            args.push("--debug".to_string());
646            if !debug.is_empty() {
647                args.push(debug.clone());
648            }
649        }
650
651        if self.dangerously_skip_permissions {
652            args.push("--dangerously-skip-permissions".to_string());
653        }
654
655        if !self.allowed_tools.is_empty() {
656            args.push("--allowed-tools".to_string());
657            args.extend(self.allowed_tools.clone());
658        }
659
660        if !self.disallowed_tools.is_empty() {
661            args.push("--disallowed-tools".to_string());
662            args.extend(self.disallowed_tools.clone());
663        }
664
665        if !self.mcp_config.is_empty() {
666            args.push("--mcp-config".to_string());
667            args.extend(self.mcp_config.clone());
668        }
669
670        if let Some(ref prompt) = self.append_system_prompt {
671            args.push("--append-system-prompt".to_string());
672            args.push(prompt.clone());
673        }
674
675        if let Some(ref mode) = self.permission_mode {
676            args.push("--permission-mode".to_string());
677            args.push(mode.as_str().to_string());
678        }
679
680        if self.continue_conversation {
681            args.push("--continue".to_string());
682        }
683
684        if let Some(ref session) = self.resume {
685            args.push("--resume".to_string());
686            args.push(session.clone());
687        }
688
689        if let Some(ref model) = self.model {
690            args.push("--model".to_string());
691            args.push(model.clone());
692        }
693
694        if let Some(ref model) = self.fallback_model {
695            args.push("--fallback-model".to_string());
696            args.push(model.clone());
697        }
698
699        if let Some(ref settings) = self.settings {
700            args.push("--settings".to_string());
701            args.push(settings.clone());
702        }
703
704        if !self.add_dir.is_empty() {
705            args.push("--add-dir".to_string());
706            for dir in &self.add_dir {
707                args.push(dir.to_string_lossy().to_string());
708            }
709        }
710
711        if self.ide {
712            args.push("--ide".to_string());
713        }
714
715        if self.strict_mcp_config {
716            args.push("--strict-mcp-config".to_string());
717        }
718
719        if let Some(ref tool) = self.permission_prompt_tool {
720            args.push("--permission-prompt-tool".to_string());
721            args.push(tool.clone());
722        }
723
724        // Only add --session-id when NOT resuming/continuing an existing session
725        // (Claude CLI error: --session-id can only be used with --continue or --resume
726        // if --fork-session is also specified)
727        if self.resume.is_none() && !self.continue_conversation {
728            args.push("--session-id".to_string());
729            let session_uuid = self.session_id.unwrap_or_else(|| {
730                let uuid = Uuid::new_v4();
731                debug!("[CLI] Generated session UUID: {}", uuid);
732                uuid
733            });
734            args.push(session_uuid.to_string());
735        }
736
737        // Add prompt as the last argument if provided
738        if let Some(ref prompt) = self.prompt {
739            args.push(prompt.clone());
740        }
741
742        args
743    }
744
745    /// Spawn the Claude process
746    #[cfg(feature = "async-client")]
747    pub async fn spawn(self) -> Result<tokio::process::Child> {
748        let resolved = self.resolve_command()?;
749        let args = self.build_args();
750
751        debug!(
752            "[CLI] Executing command: {} {}",
753            resolved.display(),
754            args.join(" ")
755        );
756
757        let mut cmd = tokio::process::Command::new(&resolved);
758        cmd.args(&args)
759            .stdin(Stdio::piped())
760            .stdout(Stdio::piped())
761            .stderr(Stdio::piped());
762
763        if self.allow_recursion {
764            cmd.env_remove("CLAUDECODE");
765        }
766
767        if let Some(ref token) = self.oauth_token {
768            cmd.env("CLAUDE_CODE_OAUTH_TOKEN", token);
769        }
770
771        if let Some(ref key) = self.api_key {
772            cmd.env("ANTHROPIC_API_KEY", key);
773        }
774
775        let child = cmd.spawn().map_err(Error::Io)?;
776
777        Ok(child)
778    }
779
780    /// Build a Command without spawning (for testing or manual execution)
781    #[cfg(feature = "async-client")]
782    pub fn build_command(self) -> Result<tokio::process::Command> {
783        let resolved = self.resolve_command()?;
784        let args = self.build_args();
785        let mut cmd = tokio::process::Command::new(&resolved);
786        cmd.args(&args)
787            .stdin(Stdio::piped())
788            .stdout(Stdio::piped())
789            .stderr(Stdio::piped());
790
791        if self.allow_recursion {
792            cmd.env_remove("CLAUDECODE");
793        }
794
795        if let Some(ref token) = self.oauth_token {
796            cmd.env("CLAUDE_CODE_OAUTH_TOKEN", token);
797        }
798
799        if let Some(ref key) = self.api_key {
800            cmd.env("ANTHROPIC_API_KEY", key);
801        }
802
803        Ok(cmd)
804    }
805
806    /// Spawn the Claude process using synchronous std::process
807    pub fn spawn_sync(self) -> Result<std::process::Child> {
808        let resolved = self.resolve_command()?;
809        let args = self.build_args();
810
811        debug!(
812            "[CLI] Executing sync command: {} {}",
813            resolved.display(),
814            args.join(" ")
815        );
816
817        let mut cmd = std::process::Command::new(&resolved);
818        cmd.args(&args)
819            .stdin(Stdio::piped())
820            .stdout(Stdio::piped())
821            .stderr(Stdio::piped());
822
823        if self.allow_recursion {
824            cmd.env_remove("CLAUDECODE");
825        }
826
827        if let Some(ref token) = self.oauth_token {
828            cmd.env("CLAUDE_CODE_OAUTH_TOKEN", token);
829        }
830
831        if let Some(ref key) = self.api_key {
832            cmd.env("ANTHROPIC_API_KEY", key);
833        }
834
835        cmd.spawn().map_err(Error::Io)
836    }
837}
838
839#[cfg(test)]
840mod tests {
841    use super::*;
842
843    #[test]
844    fn test_streaming_flags_always_present() {
845        let builder = ClaudeCliBuilder::new();
846        let args = builder.build_args();
847
848        // Verify all streaming flags are present by default
849        assert!(args.contains(&"--print".to_string()));
850        assert!(args.contains(&"--verbose".to_string())); // Required for --print with stream-json
851        assert!(args.contains(&"--output-format".to_string()));
852        assert!(args.contains(&"stream-json".to_string()));
853        assert!(args.contains(&"--input-format".to_string()));
854    }
855
856    #[test]
857    fn test_with_prompt() {
858        let builder = ClaudeCliBuilder::new().prompt("Hello, Claude!");
859        let args = builder.build_args();
860
861        assert_eq!(args.last().unwrap(), "Hello, Claude!");
862    }
863
864    #[test]
865    fn test_with_model() {
866        let builder = ClaudeCliBuilder::new()
867            .model("sonnet")
868            .fallback_model("opus");
869        let args = builder.build_args();
870
871        assert!(args.contains(&"--model".to_string()));
872        assert!(args.contains(&"sonnet".to_string()));
873        assert!(args.contains(&"--fallback-model".to_string()));
874        assert!(args.contains(&"opus".to_string()));
875    }
876
877    #[test]
878    fn test_with_debug() {
879        let builder = ClaudeCliBuilder::new().debug(Some("api"));
880        let args = builder.build_args();
881
882        assert!(args.contains(&"--debug".to_string()));
883        assert!(args.contains(&"api".to_string()));
884    }
885
886    #[test]
887    fn test_with_oauth_token() {
888        let valid_token = "sk-ant-oat-123456789";
889        let builder = ClaudeCliBuilder::new().oauth_token(valid_token);
890
891        // OAuth token is set as env var, not in args
892        let args = builder.clone().build_args();
893        assert!(!args.contains(&valid_token.to_string()));
894
895        // Verify it's stored in the builder
896        assert_eq!(builder.oauth_token, Some(valid_token.to_string()));
897    }
898
899    #[test]
900    fn test_oauth_token_validation() {
901        // Test with invalid prefix (should print warning but still accept)
902        let invalid_token = "invalid-token-123";
903        let builder = ClaudeCliBuilder::new().oauth_token(invalid_token);
904        assert_eq!(builder.oauth_token, Some(invalid_token.to_string()));
905    }
906
907    #[test]
908    fn test_with_api_key() {
909        let valid_key = "sk-ant-api-987654321";
910        let builder = ClaudeCliBuilder::new().api_key(valid_key);
911
912        // API key is set as env var, not in args
913        let args = builder.clone().build_args();
914        assert!(!args.contains(&valid_key.to_string()));
915
916        // Verify it's stored in the builder
917        assert_eq!(builder.api_key, Some(valid_key.to_string()));
918    }
919
920    #[test]
921    fn test_api_key_validation() {
922        // Test with invalid prefix (should print warning but still accept)
923        let invalid_key = "invalid-api-key";
924        let builder = ClaudeCliBuilder::new().api_key(invalid_key);
925        assert_eq!(builder.api_key, Some(invalid_key.to_string()));
926    }
927
928    #[test]
929    fn test_both_auth_methods() {
930        let oauth = "sk-ant-oat-123";
931        let api_key = "sk-ant-api-456";
932        let builder = ClaudeCliBuilder::new().oauth_token(oauth).api_key(api_key);
933
934        assert_eq!(builder.oauth_token, Some(oauth.to_string()));
935        assert_eq!(builder.api_key, Some(api_key.to_string()));
936    }
937
938    #[test]
939    fn test_permission_prompt_tool() {
940        let builder = ClaudeCliBuilder::new().permission_prompt_tool("stdio");
941        let args = builder.build_args();
942
943        assert!(args.contains(&"--permission-prompt-tool".to_string()));
944        assert!(args.contains(&"stdio".to_string()));
945    }
946
947    #[test]
948    fn test_permission_prompt_tool_not_present_by_default() {
949        let builder = ClaudeCliBuilder::new();
950        let args = builder.build_args();
951
952        assert!(!args.contains(&"--permission-prompt-tool".to_string()));
953    }
954
955    #[test]
956    fn test_session_id_present_for_new_session() {
957        let builder = ClaudeCliBuilder::new();
958        let args = builder.build_args();
959
960        assert!(
961            args.contains(&"--session-id".to_string()),
962            "New sessions should have --session-id"
963        );
964    }
965
966    #[test]
967    fn test_session_id_not_present_with_resume() {
968        // When resuming a session, --session-id should NOT be added
969        // (Claude CLI rejects --session-id + --resume without --fork-session)
970        let builder = ClaudeCliBuilder::new().resume(Some("existing-uuid".to_string()));
971        let args = builder.build_args();
972
973        assert!(
974            args.contains(&"--resume".to_string()),
975            "Should have --resume flag"
976        );
977        assert!(
978            !args.contains(&"--session-id".to_string()),
979            "--session-id should NOT be present when resuming"
980        );
981    }
982
983    #[test]
984    fn test_session_id_not_present_with_continue() {
985        // When continuing a session, --session-id should NOT be added
986        let builder = ClaudeCliBuilder::new().continue_conversation(true);
987        let args = builder.build_args();
988
989        assert!(
990            args.contains(&"--continue".to_string()),
991            "Should have --continue flag"
992        );
993        assert!(
994            !args.contains(&"--session-id".to_string()),
995            "--session-id should NOT be present when continuing"
996        );
997    }
998}