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