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, or with `--print` the path to a
102    /// file that holds one (CLI 2.1.281+)
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    /// URL of a signed configuration document; the CLI exits if it cannot
115    /// load it or it does not cover the selected model. Setting
116    /// `CLAUDE_CODE_CLIENT_DATA_URL` instead keeps the URL out of the
117    /// process list (CLI 2.1.283+)
118    ClientDataUrl(String),
119    /// Continue the most recent conversation
120    Continue,
121    /// Bypass all permission checks
122    DangerouslySkipPermissions,
123    /// Enable debug mode with optional category filter
124    Debug(Option<String>),
125    /// Write debug logs to a specific file path
126    DebugFile(PathBuf),
127    /// Disable all skills/slash commands
128    DisableSlashCommands,
129    /// Tool names to deny (e.g. "Bash(git:*) Edit")
130    DisallowedTools(Vec<String>),
131    /// Automatic fallback model when default is overloaded
132    FallbackModel(String),
133    /// File resources to download at startup (format: file_id:relative_path)
134    File(Vec<String>),
135    /// Create a new session ID when resuming instead of reusing original
136    ForkSession,
137    /// Resume a session linked to a PR
138    FromPr(Option<String>),
139    /// Include partial message chunks as they arrive
140    IncludePartialMessages,
141    /// Input format (text or stream-json)
142    InputFormat(InputFormat),
143    /// JSON Schema for structured output validation
144    JsonSchema(String),
145    /// Maximum dollar amount for API calls
146    MaxBudgetUsd(f64),
147    /// Maximum number of tokens for extended thinking
148    MaxThinkingTokens(u32),
149    /// Load MCP servers from JSON files or strings
150    McpConfig(Vec<String>),
151    /// Enable MCP debug mode (deprecated, use Debug instead)
152    McpDebug,
153    /// Model for the current session
154    Model(String),
155    /// Disable Claude in Chrome integration
156    NoChrome,
157    /// Disable session persistence
158    NoSessionPersistence,
159    /// Output format (text, json, or stream-json)
160    OutputFormat(OutputFormat),
161    /// Permission mode for the session
162    PermissionMode(PermissionMode),
163    /// Tool for handling permission prompts (e.g., "stdio")
164    PermissionPromptTool(String),
165    /// Load plugins from directories
166    PluginDir(Vec<PathBuf>),
167    /// Print response and exit
168    Print,
169    /// Re-emit user messages from stdin back on stdout
170    ReplayUserMessages,
171    /// Resume a conversation by session ID
172    Resume(Option<String>),
173    /// Use a specific session ID (UUID or tagged ID)
174    SessionId(String),
175    /// Comma-separated list of setting sources (user, project, local)
176    SettingSources(String),
177    /// Path to settings JSON file or JSON string
178    Settings(String),
179    /// Only use MCP servers from --mcp-config
180    StrictMcpConfig,
181    /// System prompt for the session
182    SystemPrompt(String),
183    /// Specify available tools from the built-in set
184    Tools(Vec<String>),
185    /// Override verbose mode setting
186    Verbose,
187}
188
189impl CliFlag {
190    /// Get the CLI flag string (e.g., `"--model"`)
191    pub fn as_flag(&self) -> &'static str {
192        match self {
193            CliFlag::AddDir(_) => "--add-dir",
194            CliFlag::Agent(_) => "--agent",
195            CliFlag::Agents(_) => "--agents",
196            CliFlag::AllowDangerouslySkipPermissions => "--allow-dangerously-skip-permissions",
197            CliFlag::AllowedTools(_) => "--allowed-tools",
198            CliFlag::AppendSystemPrompt(_) => "--append-system-prompt",
199            CliFlag::Betas(_) => "--betas",
200            CliFlag::Chrome => "--chrome",
201            CliFlag::ClientDataUrl(_) => "--client-data-url",
202            CliFlag::Continue => "--continue",
203            CliFlag::DangerouslySkipPermissions => "--dangerously-skip-permissions",
204            CliFlag::Debug(_) => "--debug",
205            CliFlag::DebugFile(_) => "--debug-file",
206            CliFlag::DisableSlashCommands => "--disable-slash-commands",
207            CliFlag::DisallowedTools(_) => "--disallowed-tools",
208            CliFlag::FallbackModel(_) => "--fallback-model",
209            CliFlag::File(_) => "--file",
210            CliFlag::ForkSession => "--fork-session",
211            CliFlag::FromPr(_) => "--from-pr",
212            CliFlag::IncludePartialMessages => "--include-partial-messages",
213            CliFlag::InputFormat(_) => "--input-format",
214            CliFlag::JsonSchema(_) => "--json-schema",
215            CliFlag::MaxBudgetUsd(_) => "--max-budget-usd",
216            CliFlag::MaxThinkingTokens(_) => "--max-thinking-tokens",
217            CliFlag::McpConfig(_) => "--mcp-config",
218            CliFlag::McpDebug => "--mcp-debug",
219            CliFlag::Model(_) => "--model",
220            CliFlag::NoChrome => "--no-chrome",
221            CliFlag::NoSessionPersistence => "--no-session-persistence",
222            CliFlag::OutputFormat(_) => "--output-format",
223            CliFlag::PermissionMode(_) => "--permission-mode",
224            CliFlag::PermissionPromptTool(_) => "--permission-prompt-tool",
225            CliFlag::PluginDir(_) => "--plugin-dir",
226            CliFlag::Print => "--print",
227            CliFlag::ReplayUserMessages => "--replay-user-messages",
228            CliFlag::Resume(_) => "--resume",
229            CliFlag::SessionId(_) => "--session-id",
230            CliFlag::SettingSources(_) => "--setting-sources",
231            CliFlag::Settings(_) => "--settings",
232            CliFlag::StrictMcpConfig => "--strict-mcp-config",
233            CliFlag::SystemPrompt(_) => "--system-prompt",
234            CliFlag::Tools(_) => "--tools",
235            CliFlag::Verbose => "--verbose",
236        }
237    }
238
239    /// Convert this flag into CLI arguments (flag + value)
240    pub fn to_args(&self) -> Vec<String> {
241        let flag = self.as_flag().to_string();
242        match self {
243            // Boolean flags (no value)
244            CliFlag::AllowDangerouslySkipPermissions
245            | CliFlag::Chrome
246            | CliFlag::Continue
247            | CliFlag::DangerouslySkipPermissions
248            | CliFlag::DisableSlashCommands
249            | CliFlag::ForkSession
250            | CliFlag::IncludePartialMessages
251            | CliFlag::McpDebug
252            | CliFlag::NoChrome
253            | CliFlag::NoSessionPersistence
254            | CliFlag::Print
255            | CliFlag::ReplayUserMessages
256            | CliFlag::StrictMcpConfig
257            | CliFlag::Verbose => vec![flag],
258
259            // Optional value flags
260            CliFlag::Debug(filter) => match filter {
261                Some(f) => vec![flag, f.clone()],
262                None => vec![flag],
263            },
264            CliFlag::FromPr(value) | CliFlag::Resume(value) => match value {
265                Some(v) => vec![flag, v.clone()],
266                None => vec![flag],
267            },
268
269            // Single string value flags
270            CliFlag::Agent(v)
271            | CliFlag::Agents(v)
272            | CliFlag::AppendSystemPrompt(v)
273            | CliFlag::ClientDataUrl(v)
274            | CliFlag::FallbackModel(v)
275            | CliFlag::JsonSchema(v)
276            | CliFlag::Model(v)
277            | CliFlag::PermissionPromptTool(v)
278            | CliFlag::SessionId(v)
279            | CliFlag::SettingSources(v)
280            | CliFlag::Settings(v)
281            | CliFlag::SystemPrompt(v) => vec![flag, v.clone()],
282
283            // Format flags
284            CliFlag::InputFormat(f) => vec![flag, f.as_str().to_string()],
285            CliFlag::OutputFormat(f) => vec![flag, f.as_str().to_string()],
286            CliFlag::PermissionMode(m) => vec![flag, m.as_str().to_string()],
287
288            // Numeric flags
289            CliFlag::MaxBudgetUsd(amount) => vec![flag, amount.to_string()],
290            CliFlag::MaxThinkingTokens(tokens) => vec![flag, tokens.to_string()],
291
292            // Path flags
293            CliFlag::DebugFile(p) => vec![flag, p.to_string_lossy().to_string()],
294
295            // Multi-value string flags
296            CliFlag::AllowedTools(items)
297            | CliFlag::Betas(items)
298            | CliFlag::DisallowedTools(items)
299            | CliFlag::File(items)
300            | CliFlag::McpConfig(items)
301            | CliFlag::Tools(items) => {
302                let mut args = vec![flag];
303                args.extend(items.clone());
304                args
305            }
306
307            // Multi-value path flags
308            CliFlag::AddDir(paths) | CliFlag::PluginDir(paths) => {
309                let mut args = vec![flag];
310                args.extend(paths.iter().map(|p| p.to_string_lossy().to_string()));
311                args
312            }
313        }
314    }
315
316    /// Returns all CLI flag names with their flag strings.
317    ///
318    /// Useful for enumerating available options in a UI or for validation.
319    ///
320    /// # Example
321    /// ```
322    /// use claude_codes::CliFlag;
323    ///
324    /// for (name, flag) in CliFlag::all_flags() {
325    ///     println!("{}: {}", name, flag);
326    /// }
327    /// ```
328    pub fn all_flags() -> Vec<(&'static str, &'static str)> {
329        vec![
330            ("AddDir", "--add-dir"),
331            ("Agent", "--agent"),
332            ("Agents", "--agents"),
333            (
334                "AllowDangerouslySkipPermissions",
335                "--allow-dangerously-skip-permissions",
336            ),
337            ("AllowedTools", "--allowed-tools"),
338            ("AppendSystemPrompt", "--append-system-prompt"),
339            ("Betas", "--betas"),
340            ("Chrome", "--chrome"),
341            ("ClientDataUrl", "--client-data-url"),
342            ("Continue", "--continue"),
343            (
344                "DangerouslySkipPermissions",
345                "--dangerously-skip-permissions",
346            ),
347            ("Debug", "--debug"),
348            ("DebugFile", "--debug-file"),
349            ("DisableSlashCommands", "--disable-slash-commands"),
350            ("DisallowedTools", "--disallowed-tools"),
351            ("FallbackModel", "--fallback-model"),
352            ("File", "--file"),
353            ("ForkSession", "--fork-session"),
354            ("FromPr", "--from-pr"),
355            ("IncludePartialMessages", "--include-partial-messages"),
356            ("InputFormat", "--input-format"),
357            ("JsonSchema", "--json-schema"),
358            ("MaxBudgetUsd", "--max-budget-usd"),
359            ("MaxThinkingTokens", "--max-thinking-tokens"),
360            ("McpConfig", "--mcp-config"),
361            ("McpDebug", "--mcp-debug"),
362            ("Model", "--model"),
363            ("NoChrome", "--no-chrome"),
364            ("NoSessionPersistence", "--no-session-persistence"),
365            ("OutputFormat", "--output-format"),
366            ("PermissionMode", "--permission-mode"),
367            ("PermissionPromptTool", "--permission-prompt-tool"),
368            ("PluginDir", "--plugin-dir"),
369            ("Print", "--print"),
370            ("ReplayUserMessages", "--replay-user-messages"),
371            ("Resume", "--resume"),
372            ("SessionId", "--session-id"),
373            ("SettingSources", "--setting-sources"),
374            ("Settings", "--settings"),
375            ("StrictMcpConfig", "--strict-mcp-config"),
376            ("SystemPrompt", "--system-prompt"),
377            ("Tools", "--tools"),
378            ("Verbose", "--verbose"),
379        ]
380    }
381}
382
383/// Builder for creating Claude CLI commands in JSON streaming mode
384///
385/// This builder automatically configures Claude to use:
386/// - `--print` mode for non-interactive operation
387/// - `--output-format stream-json` for streaming JSON responses
388/// - `--input-format stream-json` for JSON input
389/// - `--replay-user-messages` to echo back user messages
390#[derive(Debug, Clone)]
391pub struct ClaudeCliBuilder {
392    command: PathBuf,
393    working_directory: Option<PathBuf>,
394    prompt: Option<String>,
395    debug: Option<String>,
396    verbose: bool,
397    dangerously_skip_permissions: bool,
398    allowed_tools: Vec<String>,
399    disallowed_tools: Vec<String>,
400    mcp_config: Vec<String>,
401    append_system_prompt: Option<String>,
402    permission_mode: Option<PermissionMode>,
403    continue_conversation: bool,
404    resume: Option<String>,
405    fork_session: bool,
406    model: Option<String>,
407    fallback_model: Option<String>,
408    settings: Option<String>,
409    add_dir: Vec<PathBuf>,
410    ide: bool,
411    strict_mcp_config: bool,
412    session_id: Option<Uuid>,
413    oauth_token: Option<String>,
414    api_key: Option<String>,
415    /// Tool for handling permission prompts (e.g., "stdio" for bidirectional control)
416    permission_prompt_tool: Option<String>,
417    /// Allow spawning inside another Claude Code session by unsetting CLAUDECODE env var
418    allow_recursion: bool,
419    /// Maximum number of tokens for extended thinking
420    max_thinking_tokens: Option<u32>,
421}
422
423impl Default for ClaudeCliBuilder {
424    fn default() -> Self {
425        Self::new()
426    }
427}
428
429impl ClaudeCliBuilder {
430    /// Create a new Claude CLI builder with JSON streaming mode pre-configured
431    pub fn new() -> Self {
432        Self {
433            command: PathBuf::from("claude"),
434            working_directory: None,
435            prompt: None,
436            debug: None,
437            verbose: false,
438            dangerously_skip_permissions: false,
439            allowed_tools: Vec::new(),
440            disallowed_tools: Vec::new(),
441            mcp_config: Vec::new(),
442            append_system_prompt: None,
443            permission_mode: None,
444            continue_conversation: false,
445            resume: None,
446            fork_session: false,
447            model: None,
448            fallback_model: None,
449            settings: None,
450            add_dir: Vec::new(),
451            ide: false,
452            strict_mcp_config: false,
453            session_id: None,
454            oauth_token: None,
455            api_key: None,
456            permission_prompt_tool: None,
457            allow_recursion: false,
458            max_thinking_tokens: None,
459        }
460    }
461
462    /// Set custom path to Claude binary
463    pub fn command<P: Into<PathBuf>>(mut self, path: P) -> Self {
464        self.command = path.into();
465        self
466    }
467
468    /// Set the working directory for the Claude process.
469    pub fn working_directory<P: Into<PathBuf>>(mut self, path: P) -> Self {
470        self.working_directory = Some(path.into());
471        self
472    }
473
474    /// Set the prompt for Claude
475    pub fn prompt<S: Into<String>>(mut self, prompt: S) -> Self {
476        self.prompt = Some(prompt.into());
477        self
478    }
479
480    /// Enable debug mode with optional filter
481    pub fn debug<S: Into<String>>(mut self, filter: Option<S>) -> Self {
482        self.debug = filter.map(|s| s.into());
483        self
484    }
485
486    /// Enable verbose mode
487    pub fn verbose(mut self, verbose: bool) -> Self {
488        self.verbose = verbose;
489        self
490    }
491
492    /// Skip all permission checks (dangerous!)
493    pub fn dangerously_skip_permissions(mut self, skip: bool) -> Self {
494        self.dangerously_skip_permissions = skip;
495        self
496    }
497
498    /// Add allowed tools
499    pub fn allowed_tools<I, S>(mut self, tools: I) -> Self
500    where
501        I: IntoIterator<Item = S>,
502        S: Into<String>,
503    {
504        self.allowed_tools
505            .extend(tools.into_iter().map(|s| s.into()));
506        self
507    }
508
509    /// Add disallowed tools
510    pub fn disallowed_tools<I, S>(mut self, tools: I) -> Self
511    where
512        I: IntoIterator<Item = S>,
513        S: Into<String>,
514    {
515        self.disallowed_tools
516            .extend(tools.into_iter().map(|s| s.into()));
517        self
518    }
519
520    /// Add MCP configuration
521    pub fn mcp_config<I, S>(mut self, configs: I) -> Self
522    where
523        I: IntoIterator<Item = S>,
524        S: Into<String>,
525    {
526        self.mcp_config
527            .extend(configs.into_iter().map(|s| s.into()));
528        self
529    }
530
531    /// Append a system prompt
532    pub fn append_system_prompt<S: Into<String>>(mut self, prompt: S) -> Self {
533        self.append_system_prompt = Some(prompt.into());
534        self
535    }
536
537    /// Set permission mode
538    pub fn permission_mode(mut self, mode: PermissionMode) -> Self {
539        self.permission_mode = Some(mode);
540        self
541    }
542
543    /// Continue the most recent conversation
544    pub fn continue_conversation(mut self, continue_conv: bool) -> Self {
545        self.continue_conversation = continue_conv;
546        self
547    }
548
549    /// Resume a specific conversation
550    pub fn resume<S: Into<String>>(mut self, session_id: Option<S>) -> Self {
551        self.resume = session_id.map(|s| s.into());
552        self
553    }
554
555    /// When resuming (or continuing), create a new session ID instead of
556    /// reusing the source session's — i.e. fork. The CLI only accepts
557    /// `--session-id` alongside `--resume`/`--continue` when this is set.
558    ///
559    /// Prefer [`fork_from`](Self::fork_from), which assembles the whole
560    /// combination.
561    pub fn fork_session(mut self, fork: bool) -> Self {
562        self.fork_session = fork;
563        self
564    }
565
566    /// Fork an existing session: resume `source_session_id`'s full history
567    /// into a **new** session, leaving the source untouched.
568    ///
569    /// Assembles `--resume <source> --fork-session --session-id <new>`,
570    /// generating a fresh UUID for the fork (override it by chaining
571    /// [`session_id`](Self::session_id)). The forked session starts with the
572    /// source's entire history — the Claude CLI has no headless
573    /// fork-at-a-point cut; for that semantic see `codex-codes`'
574    /// `thread_fork` with `last_turn_id`.
575    ///
576    /// ```no_run
577    /// # use claude_codes::ClaudeCliBuilder;
578    /// let builder = ClaudeCliBuilder::new()
579    ///     .fork_from("0b8fa762-6c48-4f3b-a3a1-0347f96a52bc")
580    ///     .prompt("Continue, but try the other approach");
581    /// ```
582    pub fn fork_from<S: Into<String>>(mut self, source_session_id: S) -> Self {
583        self.resume = Some(source_session_id.into());
584        self.fork_session = true;
585        self
586    }
587
588    /// Set the model to use
589    pub fn model<S: Into<String>>(mut self, model: S) -> Self {
590        self.model = Some(model.into());
591        self
592    }
593
594    /// Set fallback model for overload situations
595    pub fn fallback_model<S: Into<String>>(mut self, model: S) -> Self {
596        self.fallback_model = Some(model.into());
597        self
598    }
599
600    /// Set maximum number of tokens for extended thinking
601    pub fn max_thinking_tokens(mut self, tokens: u32) -> Self {
602        self.max_thinking_tokens = Some(tokens);
603        self
604    }
605
606    /// Load settings from file or JSON
607    pub fn settings<S: Into<String>>(mut self, settings: S) -> Self {
608        self.settings = Some(settings.into());
609        self
610    }
611
612    /// Add directories for tool access
613    pub fn add_directories<I, P>(mut self, dirs: I) -> Self
614    where
615        I: IntoIterator<Item = P>,
616        P: Into<PathBuf>,
617    {
618        self.add_dir.extend(dirs.into_iter().map(|p| p.into()));
619        self
620    }
621
622    /// Automatically connect to IDE
623    pub fn ide(mut self, ide: bool) -> Self {
624        self.ide = ide;
625        self
626    }
627
628    /// Use only MCP servers from config
629    pub fn strict_mcp_config(mut self, strict: bool) -> Self {
630        self.strict_mcp_config = strict;
631        self
632    }
633
634    /// Set a specific session ID (must be a UUID)
635    pub fn session_id(mut self, id: Uuid) -> Self {
636        self.session_id = Some(id);
637        self
638    }
639
640    /// Set OAuth token for authentication (must start with "sk-ant-oat")
641    pub fn oauth_token<S: Into<String>>(mut self, token: S) -> Self {
642        let token_str = token.into();
643        if !token_str.starts_with("sk-ant-oat") {
644            eprintln!("Warning: OAuth token should start with 'sk-ant-oat'");
645        }
646        self.oauth_token = Some(token_str);
647        self
648    }
649
650    /// Set API key for authentication (must start with "sk-ant-api")
651    pub fn api_key<S: Into<String>>(mut self, key: S) -> Self {
652        let key_str = key.into();
653        if !key_str.starts_with("sk-ant-api") {
654            eprintln!("Warning: API key should start with 'sk-ant-api'");
655        }
656        self.api_key = Some(key_str);
657        self
658    }
659
660    /// Enable bidirectional tool permission protocol via stdio
661    ///
662    /// When enabled, Claude CLI will send permission requests via stdout
663    /// and expect responses via stdin. Use "stdio" for standard I/O based
664    /// permission handling.
665    ///
666    /// # Example
667    /// ```
668    /// use claude_codes::ClaudeCliBuilder;
669    ///
670    /// let builder = ClaudeCliBuilder::new()
671    ///     .permission_prompt_tool("stdio")
672    ///     .model("sonnet");
673    /// ```
674    pub fn permission_prompt_tool<S: Into<String>>(mut self, tool: S) -> Self {
675        self.permission_prompt_tool = Some(tool.into());
676        self
677    }
678
679    /// Allow spawning inside another Claude Code session by unsetting the
680    /// `CLAUDECODE` environment variable in the child process.
681    #[cfg(feature = "integration-tests")]
682    pub fn allow_recursion(mut self) -> Self {
683        self.allow_recursion = true;
684        self
685    }
686
687    /// Resolve the command path, using `which` for non-absolute paths.
688    fn resolve_command(&self) -> Result<PathBuf> {
689        if self.command.is_absolute() {
690            return Ok(self.command.clone());
691        }
692        which::which(&self.command).map_err(|_| Error::BinaryNotFound {
693            name: self.command.display().to_string(),
694        })
695    }
696
697    /// Build the command arguments (always includes JSON streaming flags)
698    fn build_args(&self) -> Vec<String> {
699        // Always add JSON streaming mode flags
700        // Note: --print with stream-json requires --verbose
701        let mut args = vec![
702            "--print".to_string(),
703            "--verbose".to_string(),
704            "--output-format".to_string(),
705            "stream-json".to_string(),
706            "--input-format".to_string(),
707            "stream-json".to_string(),
708        ];
709
710        if let Some(ref debug) = self.debug {
711            args.push("--debug".to_string());
712            if !debug.is_empty() {
713                args.push(debug.clone());
714            }
715        }
716
717        if self.dangerously_skip_permissions {
718            args.push("--dangerously-skip-permissions".to_string());
719        }
720
721        if !self.allowed_tools.is_empty() {
722            args.push("--allowed-tools".to_string());
723            args.extend(self.allowed_tools.clone());
724        }
725
726        if !self.disallowed_tools.is_empty() {
727            args.push("--disallowed-tools".to_string());
728            args.extend(self.disallowed_tools.clone());
729        }
730
731        if !self.mcp_config.is_empty() {
732            args.push("--mcp-config".to_string());
733            args.extend(self.mcp_config.clone());
734        }
735
736        if let Some(ref prompt) = self.append_system_prompt {
737            args.push("--append-system-prompt".to_string());
738            args.push(prompt.clone());
739        }
740
741        if let Some(ref mode) = self.permission_mode {
742            args.push("--permission-mode".to_string());
743            args.push(mode.as_str().to_string());
744        }
745
746        if self.continue_conversation {
747            args.push("--continue".to_string());
748        }
749
750        if let Some(ref session) = self.resume {
751            args.push("--resume".to_string());
752            args.push(session.clone());
753        }
754
755        if self.fork_session {
756            args.push("--fork-session".to_string());
757        }
758
759        if let Some(ref model) = self.model {
760            args.push("--model".to_string());
761            args.push(model.clone());
762        }
763
764        if let Some(ref model) = self.fallback_model {
765            args.push("--fallback-model".to_string());
766            args.push(model.clone());
767        }
768
769        if let Some(tokens) = self.max_thinking_tokens {
770            args.push("--max-thinking-tokens".to_string());
771            args.push(tokens.to_string());
772        }
773
774        if let Some(ref settings) = self.settings {
775            args.push("--settings".to_string());
776            args.push(settings.clone());
777        }
778
779        if !self.add_dir.is_empty() {
780            args.push("--add-dir".to_string());
781            for dir in &self.add_dir {
782                args.push(dir.to_string_lossy().to_string());
783            }
784        }
785
786        if self.ide {
787            args.push("--ide".to_string());
788        }
789
790        if self.strict_mcp_config {
791            args.push("--strict-mcp-config".to_string());
792        }
793
794        if let Some(ref tool) = self.permission_prompt_tool {
795            args.push("--permission-prompt-tool".to_string());
796            args.push(tool.clone());
797        }
798
799        // --session-id is only legal alongside --resume/--continue when
800        // --fork-session is set (it names the fork); otherwise emit it only
801        // for fresh sessions. Either way a UUID is generated when unset so
802        // the session id is known before spawn.
803        if (self.resume.is_none() && !self.continue_conversation) || self.fork_session {
804            args.push("--session-id".to_string());
805            let session_uuid = self.session_id.unwrap_or_else(|| {
806                let uuid = Uuid::new_v4();
807                debug!("[CLI] Generated session UUID: {}", uuid);
808                uuid
809            });
810            args.push(session_uuid.to_string());
811        }
812
813        // Add prompt as the last argument if provided
814        if let Some(ref prompt) = self.prompt {
815            args.push(prompt.clone());
816        }
817
818        args
819    }
820
821    /// Spawn the Claude process
822    #[cfg(feature = "async-client")]
823    pub async fn spawn(self) -> Result<tokio::process::Child> {
824        let resolved = self.resolve_command()?;
825        let args = self.build_args();
826
827        debug!(
828            "[CLI] Executing command: {} {}",
829            resolved.display(),
830            args.join(" ")
831        );
832
833        let mut cmd = tokio::process::Command::new(&resolved);
834        cmd.args(&args)
835            .stdin(Stdio::piped())
836            .stdout(Stdio::piped())
837            .stderr(Stdio::piped());
838
839        if let Some(ref dir) = self.working_directory {
840            cmd.current_dir(dir);
841        }
842
843        if self.allow_recursion {
844            cmd.env_remove("CLAUDECODE");
845        }
846
847        if let Some(ref token) = self.oauth_token {
848            cmd.env("CLAUDE_CODE_OAUTH_TOKEN", token);
849        }
850
851        if let Some(ref key) = self.api_key {
852            cmd.env("ANTHROPIC_API_KEY", key);
853        }
854
855        crate::process::configure_no_window(cmd.as_std_mut());
856        let child = cmd.spawn().map_err(Error::Io)?;
857
858        Ok(child)
859    }
860
861    /// Build a Command without spawning (for testing or manual execution)
862    #[cfg(feature = "async-client")]
863    pub fn build_command(self) -> Result<tokio::process::Command> {
864        let resolved = self.resolve_command()?;
865        let args = self.build_args();
866        let mut cmd = tokio::process::Command::new(&resolved);
867        cmd.args(&args)
868            .stdin(Stdio::piped())
869            .stdout(Stdio::piped())
870            .stderr(Stdio::piped());
871
872        if let Some(ref dir) = self.working_directory {
873            cmd.current_dir(dir);
874        }
875
876        if self.allow_recursion {
877            cmd.env_remove("CLAUDECODE");
878        }
879
880        if let Some(ref token) = self.oauth_token {
881            cmd.env("CLAUDE_CODE_OAUTH_TOKEN", token);
882        }
883
884        if let Some(ref key) = self.api_key {
885            cmd.env("ANTHROPIC_API_KEY", key);
886        }
887
888        crate::process::configure_no_window(cmd.as_std_mut());
889        Ok(cmd)
890    }
891
892    /// Spawn the Claude process using synchronous std::process
893    pub fn spawn_sync(self) -> Result<std::process::Child> {
894        let resolved = self.resolve_command()?;
895        let args = self.build_args();
896
897        debug!(
898            "[CLI] Executing sync command: {} {}",
899            resolved.display(),
900            args.join(" ")
901        );
902
903        let mut cmd = std::process::Command::new(&resolved);
904        cmd.args(&args)
905            .stdin(Stdio::piped())
906            .stdout(Stdio::piped())
907            .stderr(Stdio::piped());
908
909        if let Some(ref dir) = self.working_directory {
910            cmd.current_dir(dir);
911        }
912
913        if self.allow_recursion {
914            cmd.env_remove("CLAUDECODE");
915        }
916
917        if let Some(ref token) = self.oauth_token {
918            cmd.env("CLAUDE_CODE_OAUTH_TOKEN", token);
919        }
920
921        if let Some(ref key) = self.api_key {
922            cmd.env("ANTHROPIC_API_KEY", key);
923        }
924
925        crate::process::configure_no_window(&mut cmd);
926        cmd.spawn().map_err(Error::Io)
927    }
928}
929
930#[cfg(test)]
931mod tests {
932    use super::*;
933
934    #[test]
935    fn test_working_directory() {
936        let builder = ClaudeCliBuilder::new().working_directory("workspace");
937        assert_eq!(builder.working_directory, Some(PathBuf::from("workspace")));
938    }
939
940    #[test]
941    fn test_streaming_flags_always_present() {
942        let builder = ClaudeCliBuilder::new();
943        let args = builder.build_args();
944
945        // Verify all streaming flags are present by default
946        assert!(args.contains(&"--print".to_string()));
947        assert!(args.contains(&"--verbose".to_string())); // Required for --print with stream-json
948        assert!(args.contains(&"--output-format".to_string()));
949        assert!(args.contains(&"stream-json".to_string()));
950        assert!(args.contains(&"--input-format".to_string()));
951    }
952
953    #[test]
954    fn test_with_prompt() {
955        let builder = ClaudeCliBuilder::new().prompt("Hello, Claude!");
956        let args = builder.build_args();
957
958        assert_eq!(args.last().unwrap(), "Hello, Claude!");
959    }
960
961    #[test]
962    fn test_with_model() {
963        let builder = ClaudeCliBuilder::new()
964            .model("sonnet")
965            .fallback_model("opus");
966        let args = builder.build_args();
967
968        assert!(args.contains(&"--model".to_string()));
969        assert!(args.contains(&"sonnet".to_string()));
970        assert!(args.contains(&"--fallback-model".to_string()));
971        assert!(args.contains(&"opus".to_string()));
972    }
973
974    #[test]
975    fn test_with_debug() {
976        let builder = ClaudeCliBuilder::new().debug(Some("api"));
977        let args = builder.build_args();
978
979        assert!(args.contains(&"--debug".to_string()));
980        assert!(args.contains(&"api".to_string()));
981    }
982
983    #[test]
984    fn test_with_oauth_token() {
985        let valid_token = "sk-ant-oat-123456789";
986        let builder = ClaudeCliBuilder::new().oauth_token(valid_token);
987
988        // OAuth token is set as env var, not in args
989        let args = builder.clone().build_args();
990        assert!(!args.contains(&valid_token.to_string()));
991
992        // Verify it's stored in the builder
993        assert_eq!(builder.oauth_token, Some(valid_token.to_string()));
994    }
995
996    #[test]
997    fn test_oauth_token_validation() {
998        // Test with invalid prefix (should print warning but still accept)
999        let invalid_token = "invalid-token-123";
1000        let builder = ClaudeCliBuilder::new().oauth_token(invalid_token);
1001        assert_eq!(builder.oauth_token, Some(invalid_token.to_string()));
1002    }
1003
1004    #[test]
1005    fn test_with_api_key() {
1006        let valid_key = "sk-ant-api-987654321";
1007        let builder = ClaudeCliBuilder::new().api_key(valid_key);
1008
1009        // API key is set as env var, not in args
1010        let args = builder.clone().build_args();
1011        assert!(!args.contains(&valid_key.to_string()));
1012
1013        // Verify it's stored in the builder
1014        assert_eq!(builder.api_key, Some(valid_key.to_string()));
1015    }
1016
1017    #[test]
1018    fn test_api_key_validation() {
1019        // Test with invalid prefix (should print warning but still accept)
1020        let invalid_key = "invalid-api-key";
1021        let builder = ClaudeCliBuilder::new().api_key(invalid_key);
1022        assert_eq!(builder.api_key, Some(invalid_key.to_string()));
1023    }
1024
1025    #[test]
1026    fn test_both_auth_methods() {
1027        let oauth = "sk-ant-oat-123";
1028        let api_key = "sk-ant-api-456";
1029        let builder = ClaudeCliBuilder::new().oauth_token(oauth).api_key(api_key);
1030
1031        assert_eq!(builder.oauth_token, Some(oauth.to_string()));
1032        assert_eq!(builder.api_key, Some(api_key.to_string()));
1033    }
1034
1035    #[test]
1036    fn test_permission_prompt_tool() {
1037        let builder = ClaudeCliBuilder::new().permission_prompt_tool("stdio");
1038        let args = builder.build_args();
1039
1040        assert!(args.contains(&"--permission-prompt-tool".to_string()));
1041        assert!(args.contains(&"stdio".to_string()));
1042    }
1043
1044    #[test]
1045    fn test_permission_prompt_tool_not_present_by_default() {
1046        let builder = ClaudeCliBuilder::new();
1047        let args = builder.build_args();
1048
1049        assert!(!args.contains(&"--permission-prompt-tool".to_string()));
1050    }
1051
1052    #[test]
1053    fn test_session_id_present_for_new_session() {
1054        let builder = ClaudeCliBuilder::new();
1055        let args = builder.build_args();
1056
1057        assert!(
1058            args.contains(&"--session-id".to_string()),
1059            "New sessions should have --session-id"
1060        );
1061    }
1062
1063    #[test]
1064    fn test_session_id_not_present_with_resume() {
1065        // When resuming a session, --session-id should NOT be added
1066        // (Claude CLI rejects --session-id + --resume without --fork-session)
1067        let builder = ClaudeCliBuilder::new().resume(Some("existing-uuid".to_string()));
1068        let args = builder.build_args();
1069
1070        assert!(
1071            args.contains(&"--resume".to_string()),
1072            "Should have --resume flag"
1073        );
1074        assert!(
1075            !args.contains(&"--session-id".to_string()),
1076            "--session-id should NOT be present when resuming"
1077        );
1078    }
1079
1080    #[test]
1081    fn test_session_id_not_present_with_continue() {
1082        // When continuing a session, --session-id should NOT be added
1083        let builder = ClaudeCliBuilder::new().continue_conversation(true);
1084        let args = builder.build_args();
1085
1086        assert!(
1087            args.contains(&"--continue".to_string()),
1088            "Should have --continue flag"
1089        );
1090        assert!(
1091            !args.contains(&"--session-id".to_string()),
1092            "--session-id should NOT be present when continuing"
1093        );
1094    }
1095
1096    #[test]
1097    fn test_fork_from_assembles_resume_fork_and_new_session_id() {
1098        let new_id = Uuid::new_v4();
1099        let args = ClaudeCliBuilder::new()
1100            .fork_from("source-uuid")
1101            .session_id(new_id)
1102            .build_args();
1103
1104        let resume_pos = args.iter().position(|a| a == "--resume").unwrap();
1105        assert_eq!(args[resume_pos + 1], "source-uuid");
1106        assert!(args.contains(&"--fork-session".to_string()));
1107        let sid_pos = args.iter().position(|a| a == "--session-id").unwrap();
1108        assert_eq!(args[sid_pos + 1], new_id.to_string());
1109    }
1110
1111    #[test]
1112    fn test_fork_from_generates_session_id_when_unset() {
1113        let args = ClaudeCliBuilder::new()
1114            .fork_from("source-uuid")
1115            .build_args();
1116
1117        assert!(args.contains(&"--fork-session".to_string()));
1118        let sid_pos = args.iter().position(|a| a == "--session-id").unwrap();
1119        assert!(
1120            Uuid::parse_str(&args[sid_pos + 1]).is_ok(),
1121            "generated fork session id should be a UUID"
1122        );
1123    }
1124
1125    #[test]
1126    fn test_fork_session_with_continue_emits_session_id() {
1127        let args = ClaudeCliBuilder::new()
1128            .continue_conversation(true)
1129            .fork_session(true)
1130            .build_args();
1131
1132        assert!(args.contains(&"--continue".to_string()));
1133        assert!(args.contains(&"--fork-session".to_string()));
1134        assert!(args.contains(&"--session-id".to_string()));
1135    }
1136}