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