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