claude_runner_core/command/mod.rs
1//! Claude Code Command Builder
2//!
3//! Provides fluent API for constructing and executing Claude Code CLI commands.
4//!
5//! ## Execution Modes
6//!
7//! This module supports two execution modes:
8//!
9//! - **Non-interactive mode** ([`execute`](ClaudeCommand::execute)): Captures stdout/stderr, suitable for programmatic usage
10//! - **Interactive mode** ([`execute_interactive`](ClaudeCommand::execute_interactive)): Allows Claude Code to take over terminal (TTY attached)
11//!
12//! The distinction is critical: `.output()` captures process output which prevents Claude Code from
13//! accessing the terminal for interactive sessions. Interactive mode uses `.status()` to preserve TTY access.
14
15use std::path::PathBuf;
16use error_tools::{ Result, Error };
17
18mod params_core;
19mod params_security;
20mod params_extended;
21
22/// Builder for Claude Code CLI commands
23///
24/// # Example
25///
26/// ```no_run
27/// use claude_runner_core::ClaudeCommand;
28///
29/// let result = ClaudeCommand::new()
30/// .with_working_directory( "/home/user/project" )
31/// .with_max_output_tokens( 200_000 )
32/// .execute()?;
33/// # Ok::<(), Box<dyn std::error::Error>>(())
34/// ```
35#[ allow( clippy::struct_excessive_bools ) ] // four independent flags (continue, skip_permissions, dry_run, unset_claudecode) — enum refactor adds no clarity
36#[derive( Debug )]
37pub struct ClaudeCommand {
38 pub(super) working_directory: Option<PathBuf>,
39 pub(super) max_output_tokens: Option<u32>,
40 pub(super) continue_conversation: bool,
41 pub(super) message: Option<String>,
42 pub(super) args: Vec<String>,
43
44 // Tier 1: Critical parameters with different defaults (fix automation blockers)
45 pub(super) bash_default_timeout_ms: Option<u32>,
46 pub(super) bash_max_timeout_ms: Option<u32>,
47 pub(super) auto_continue: Option<bool>,
48 pub(super) telemetry: Option<bool>,
49
50 // Tier 2: Essential parameters with standard defaults (security-sensitive)
51 pub(super) auto_approve_tools: Option<bool>,
52 pub(super) action_mode: Option<crate::types::ActionMode>,
53 pub(super) log_level: Option<crate::types::LogLevel>,
54 pub(super) temperature: Option<f64>,
55
56 // Safety override
57 pub(super) skip_permissions: bool,
58
59 // Terminal & IDE flags with non-standard builder defaults
60 pub(super) chrome: Option<bool>,
61
62 // Tier 3: Optional parameters with standard defaults
63 pub(super) sandbox_mode: Option<bool>,
64 pub(super) session_dir: Option<PathBuf>,
65 pub(super) top_p: Option<f64>,
66 pub(super) top_k: Option<u32>,
67
68 // Execution control
69 pub(super) dry_run: bool,
70
71 // Isolation
72 pub(super) home_override: Option< PathBuf >,
73
74 // Stdin piping
75 pub(super) stdin_file: Option< PathBuf >,
76
77 // Subprocess environment control
78 pub(super) unset_claudecode: bool,
79}
80
81impl ClaudeCommand {
82 /// Create a new Claude Code command builder
83 ///
84 /// # Example
85 ///
86 /// ```
87 /// use claude_runner_core::ClaudeCommand;
88 ///
89 /// let cmd = ClaudeCommand::new();
90 /// ```
91 #[inline]
92 #[must_use]
93 pub fn new() -> Self {
94 // Fix(issue-token-limit-default): Default token limit changed from 32K to 200K
95 // Root cause: Migration from factory pattern didnt preserve correct default value
96 // Pitfall: Always verify defaults match specification when refactoring APIs
97
98 // Fix(issue-bash-timeout-default): Bash timeouts increased from 2min/10min to 1hr/2hr
99 // Root cause: Standard 2min default causes premature timeout in real automation workflows
100 // Pitfall: Always set explicit timeouts matching actual operation duration needs
101
102 // Fix(issue-auto-continue-default): Auto-continue enabled by default (true vs false)
103 // Root cause: Standard false blocks all automation with manual prompts
104 // Pitfall: Programmatic usage requires automation-friendly defaults
105
106 // Fix(issue-telemetry-default): Telemetry disabled by default (false vs true)
107 // Root cause: Automation contexts shouldnt send usage data without explicit consent
108 // Pitfall: Respect user privacy in programmatic execution
109
110 // Fix(issue-chrome-default): Chrome enabled by default (Some(true) vs None/omit)
111 // Root cause: Browser context is essential for web-aware automation; omitting --chrome
112 // relies on the user's global claude config being set to on, which is not guaranteed
113 // Pitfall: Store as field, not push to args in new() — must remain overridable by with_chrome()
114
115 Self {
116 working_directory: None,
117 max_output_tokens: Some( 200_000 ),
118 continue_conversation: false,
119 message: None,
120 args: Vec::new(),
121
122 // Tier 1: Different defaults (fix automation blockers)
123 bash_default_timeout_ms: Some( 3_600_000 ), // 1 hour (vs 2 min standard)
124 bash_max_timeout_ms: Some( 7_200_000 ), // 2 hours (vs 10 min standard)
125 auto_continue: Some( true ), // Enable automation (vs false standard)
126 telemetry: Some( false ), // Disable telemetry (vs true standard)
127
128 skip_permissions: false,
129 chrome: Some( true ), // Enable browser context by default (vs off in raw claude binary)
130
131 // Tier 2 & 3: Standard defaults (security-sensitive, opt-in only)
132 auto_approve_tools: None, // Inherits standard: false
133 action_mode: None, // Inherits standard: Ask
134 log_level: None, // Inherits standard: Info
135 temperature: None, // Inherits standard: 1.0
136 sandbox_mode: None, // Inherits standard: true
137 session_dir: None, // Inherits standard: auto-detect
138 top_p: None, // Inherits standard: None
139 top_k: None, // Inherits standard: None
140
141 dry_run: false,
142
143 home_override: None,
144
145 stdin_file: None,
146 unset_claudecode: true,
147 }
148 }
149
150 /// Describe only the invocation line (no `cd` prefix)
151 ///
152 /// Unlike `describe()`, this always returns a single line (without the leading `cd /dir` line).
153 /// When `unset_claudecode` is active (the default), the line starts with `env -u CLAUDECODE claude ...`;
154 /// when disabled via `with_unset_claudecode(false)`, it starts with `claude ...`.
155 ///
156 /// # Critical: Implementation Must Use `describe().lines().last()`
157 ///
158 /// Do NOT reconstruct the command from parts — that would diverge from the
159 /// actual command built by `build_command()`. The only correct implementation
160 /// is to delegate to `describe()` and extract the last line.
161 ///
162 /// # Example
163 ///
164 /// ```
165 /// use claude_runner_core::ClaudeCommand;
166 ///
167 /// // Default: CLAUDECODE is removed — invocation line starts with "env -u CLAUDECODE"
168 /// let compact = ClaudeCommand::new()
169 /// .with_working_directory( "/tmp" )
170 /// .with_skip_permissions( true )
171 /// .describe_compact();
172 ///
173 /// assert!( compact.starts_with( "env -u CLAUDECODE" ) );
174 /// assert!( !compact.contains( "cd " ) );
175 /// ```
176 #[inline]
177 #[must_use]
178 pub fn describe_compact( &self ) -> String {
179 // Fix(issue-describe-compact-double-cd): Always extract last line from describe()
180 // Root cause: describe() emits "cd /dir\nclaude ..." when working_directory is set
181 // Pitfall: Rebuilding from parts diverges from build_command(); always delegate to describe()
182 self.describe()
183 .lines()
184 .last()
185 .unwrap_or( "claude" )
186 .to_string()
187 }
188
189 /// Describe the command line that would be executed
190 ///
191 /// Returns a human-readable representation of the command. If a working
192 /// directory is set, the first line is `cd /path/to/dir`. The last line
193 /// is the `claude` invocation with all flags and arguments.
194 ///
195 /// # Output Flag Order
196 ///
197 /// The command-line flag order in the output is fixed by the implementation,
198 /// **not** by the order in which `with_*` builder methods are called. The order is:
199 ///
200 /// 0. `env -u CLAUDECODE` prefix (if `unset_claudecode` is true, the default)
201 /// 1. `--dangerously-skip-permissions` (if `skip_permissions` is true)
202 /// 2. `--chrome` or `--no-chrome` (from `chrome` field; default `Some(true)` = `--chrome`)
203 /// 3. custom args (in insertion order via `with_arg`)
204 /// 4. `-c` (if `continue_conversation` is true)
205 /// 5. `"<message>"` (if message is set)
206 ///
207 /// This matters when writing tests that assert the exact output string (e.g. `assert_eq!`).
208 /// Use `contains` assertions for individual flags when order is not the subject of the test.
209 ///
210 /// # Critical: Must Mirror `build_command()`
211 ///
212 /// `describe()` reconstructs the command string independently of `build_command()`. Every CLI
213 /// flag that `build_command()` emits from a **typed field** (not from `self.args`) MUST also
214 /// appear in `describe()` at the corresponding position.
215 ///
216 /// Typed-field flags (currently `skip_permissions`, `chrome`, `continue_conversation`) are
217 /// emitted directly in `build_command()` — NOT via `self.args`. Updating `build_command()`
218 /// without updating `describe()` causes dry-run output to diverge from the actual command.
219 ///
220 /// Pitfall: always update both methods when adding a new typed-field CLI parameter.
221 ///
222 /// # Example
223 ///
224 /// ```
225 /// use claude_runner_core::ClaudeCommand;
226 ///
227 /// let desc = ClaudeCommand::new()
228 /// .with_working_directory( "/tmp" )
229 /// .with_skip_permissions( true )
230 /// .with_message( "hello" )
231 /// .describe();
232 ///
233 /// assert!( desc.contains( "cd /tmp" ) );
234 /// assert!( desc.contains( "--dangerously-skip-permissions" ) );
235 /// ```
236 #[inline]
237 #[must_use]
238 pub fn describe( &self ) -> String {
239 let mut lines = Vec::new();
240
241 if let Some( ref dir ) = self.working_directory {
242 lines.push( format!( "cd {}", dir.display() ) );
243 }
244
245 // Fix(BUG-246): prefix `env -u CLAUDECODE` when unset_claudecode is active so
246 // trace/dry-run output is WYSIWYG — what you see is what subprocess actually runs.
247 // Root cause: describe() started with "claude" unconditionally; env_remove("CLAUDECODE")
248 // in build_command() is an OS-level call invisible in the displayed command string.
249 // Pitfall: both describe() and build_command() must be kept in sync — every env
250 // manipulation in build_command() must appear in describe() output at the same position.
251 let mut parts = if self.unset_claudecode
252 {
253 vec![ "env".to_string(), "-u".to_string(), "CLAUDECODE".to_string(), "claude".to_string() ]
254 }
255 else
256 {
257 vec![ "claude".to_string() ]
258 };
259
260 if self.skip_permissions {
261 parts.push( "--dangerously-skip-permissions".to_string() );
262 }
263
264 // Emit chrome flag from typed field (default: Some(true) → --chrome)
265 match self.chrome {
266 Some( true ) => parts.push( "--chrome".to_string() ),
267 Some( false ) => parts.push( "--no-chrome".to_string() ),
268 None => {}
269 }
270
271 for arg in &self.args {
272 parts.push( arg.clone() );
273 }
274
275 if self.continue_conversation {
276 parts.push( "-c".to_string() );
277 }
278
279 if let Some( ref msg ) = self.message {
280 // Fix(issue-describe-backslash-escape): Escape `\` before `"` to prevent malformed shell output
281 // Root cause: Only `"` was escaped, not `\`. Messages containing `\"` produced `\\"` in output
282 // which shell parses as a closing double-quote, breaking the command representation.
283 // Pitfall: Always escape `\` first, then `"`, when quoting for double-quoted shell strings.
284 let escaped = msg.replace( '\\', "\\\\" ).replace( '"', "\\\"" );
285 parts.push( format!( "\"{escaped}\"" ) );
286 }
287
288 // stdin redirect notation appended to the invocation line (P1: must mirror build_command)
289 if let Some( ref path ) = self.stdin_file
290 {
291 parts.push( format!( "< {}", path.display() ) );
292 }
293
294 lines.push( parts.join( " " ) );
295 lines.join( "\n" )
296 }
297
298 /// Describe environment variables that would be set
299 ///
300 /// Returns one `NAME=VALUE` line per configured environment variable.
301 /// Only includes variables that have been explicitly set (via defaults
302 /// or builder methods). Omits `None` values.
303 ///
304 /// # Example
305 ///
306 /// ```
307 /// use claude_runner_core::ClaudeCommand;
308 ///
309 /// let env = ClaudeCommand::new().describe_env();
310 ///
311 /// assert!( env.contains( "CLAUDE_CODE_MAX_OUTPUT_TOKENS=200000" ) );
312 /// assert!( env.contains( "CLAUDE_CODE_BASH_TIMEOUT=3600000" ) );
313 /// ```
314 #[inline]
315 #[must_use]
316 pub fn describe_env( &self ) -> String {
317 let mut lines = Vec::new();
318
319 if let Some( tokens ) = self.max_output_tokens {
320 lines.push( format!( "CLAUDE_CODE_MAX_OUTPUT_TOKENS={tokens}" ) );
321 }
322 if let Some( timeout ) = self.bash_default_timeout_ms {
323 lines.push( format!( "CLAUDE_CODE_BASH_TIMEOUT={timeout}" ) );
324 }
325 if let Some( max_timeout ) = self.bash_max_timeout_ms {
326 lines.push( format!( "CLAUDE_CODE_BASH_MAX_TIMEOUT={max_timeout}" ) );
327 }
328 if let Some( auto_continue ) = self.auto_continue {
329 lines.push( format!( "CLAUDE_CODE_AUTO_CONTINUE={auto_continue}" ) );
330 }
331 if let Some( telemetry ) = self.telemetry {
332 lines.push( format!( "CLAUDE_CODE_TELEMETRY={telemetry}" ) );
333 }
334 if let Some( approve ) = self.auto_approve_tools {
335 lines.push( format!( "CLAUDE_CODE_AUTO_APPROVE_TOOLS={approve}" ) );
336 }
337 if let Some( mode ) = self.action_mode {
338 lines.push( format!( "CLAUDE_CODE_ACTION_MODE={}", mode.as_str() ) );
339 }
340 if let Some( level ) = self.log_level {
341 lines.push( format!( "CLAUDE_CODE_LOG_LEVEL={}", level.as_str() ) );
342 }
343 if let Some( temp ) = self.temperature {
344 lines.push( format!( "CLAUDE_CODE_TEMPERATURE={temp}" ) );
345 }
346 if let Some( sandbox ) = self.sandbox_mode {
347 lines.push( format!( "CLAUDE_CODE_SANDBOX_MODE={sandbox}" ) );
348 }
349 if let Some( ref dir ) = self.session_dir {
350 lines.push( format!( "CLAUDE_CODE_SESSION_DIR={}", dir.display() ) );
351 }
352 if let Some( top_p ) = self.top_p {
353 lines.push( format!( "CLAUDE_CODE_TOP_P={top_p}" ) );
354 }
355 if let Some( top_k ) = self.top_k {
356 lines.push( format!( "CLAUDE_CODE_TOP_K={top_k}" ) );
357 }
358 if let Some( ref home ) = self.home_override {
359 lines.push( format!( "HOME={}", home.display() ) );
360 }
361
362 lines.join( "\n" )
363 }
364
365 /// Execute the Claude Code command and capture output (non-interactive mode)
366 ///
367 /// This is the SINGLE execution point for non-interactive Claude Code process invocations.
368 /// For interactive sessions, use [`execute_interactive`](Self::execute_interactive).
369 ///
370 /// Returns [`ExecutionOutput`](crate::ExecutionOutput) with stdout, stderr, and exit code.
371 ///
372 /// # Errors
373 ///
374 /// Returns error if Claude Code binary not found in PATH or process fails to spawn.
375 ///
376 /// # Example
377 ///
378 /// ```no_run
379 /// use claude_runner_core::ClaudeCommand;
380 ///
381 /// let result = ClaudeCommand::new()
382 /// .with_max_output_tokens( 200_000 )
383 /// .execute()?;
384 /// println!( "{}", result.stdout );
385 /// # Ok::<(), Box<dyn std::error::Error>>(())
386 /// ```
387 #[inline]
388 pub fn execute( &self ) -> Result< crate::types::ExecutionOutput > {
389 if self.dry_run {
390 return Ok( crate::types::ExecutionOutput {
391 stdout: self.describe_compact(),
392 stderr: String::new(),
393 exit_code: 0,
394 } );
395 }
396
397 let mut cmd = self.build_command();
398
399 if let Some( ref path ) = self.stdin_file
400 {
401 let file = std::fs::File::open( path )
402 .map_err( | e | Error::msg( format!( "cannot open stdin file '{}': {e}", path.display() ) ) )?;
403 cmd.stdin( std::process::Stdio::from( file ) );
404 }
405
406 // Fix(BUG-241): map NotFound to an actionable install hint.
407 // Root cause: Command::output() on a missing binary returns io::ErrorKind::NotFound
408 // with a raw OS error string ("No such file or directory (os error 2)") — giving
409 // the caller no actionable information about what to install or where.
410 // Pitfall: always check e.kind() before formatting the error; NotFound is a distinct
411 // user-fixable condition and must produce a separate message from other spawn failures.
412 let output = cmd.output()
413 .map_err( |e|
414 {
415 if e.kind() == std::io::ErrorKind::NotFound
416 {
417 Error::msg( "claude binary not found in PATH — install with: npm i -g @anthropic-ai/claude-code" )
418 }
419 else
420 {
421 Error::msg( format!( "Failed to execute Claude Code: {e}" ) )
422 }
423 } )?;
424
425 let stdout = String::from_utf8_lossy( &output.stdout ).to_string();
426 let stderr = String::from_utf8_lossy( &output.stderr ).to_string();
427 // Fix(BUG-242): use signal_exit_code() so SIGTERM (→143) and SIGKILL (→137) are
428 // preserved instead of collapsed to -1.
429 // Root cause: unwrap_or(-1) returns -1 for any signal-killed subprocess on Unix;
430 // callers cannot distinguish a signal kill from any other non-exit condition.
431 // Pitfall: code() returns None only for signal kills on Unix — never for a normal exit;
432 // the #[cfg(unix)] branch in signal_exit_code fires exactly in those cases.
433 let exit_code = crate::signal_exit_code( &output.status );
434
435 Ok( crate::types::ExecutionOutput { stdout, stderr, exit_code } )
436 }
437
438 /// Execute the Claude Code command in interactive mode (TTY attached)
439 ///
440 /// This method allows Claude Code to take over the terminal for interactive sessions.
441 /// Unlike [`execute`](Self::execute), this doesnt capture output and instead lets
442 /// Claude Code directly interact with the user's terminal.
443 ///
444 /// # Errors
445 ///
446 /// Returns error if Claude Code binary not found in PATH or process fails to spawn.
447 ///
448 /// # Example
449 ///
450 /// ```no_run
451 /// use claude_runner_core::ClaudeCommand;
452 ///
453 /// let exit_status = ClaudeCommand::new()
454 /// .with_max_output_tokens( 200_000 )
455 /// .execute_interactive()?;
456 /// # Ok::<(), Box<dyn std::error::Error>>(())
457 /// ```
458 #[inline]
459 pub fn execute_interactive( &self ) -> Result< std::process::ExitStatus > {
460 if self.dry_run {
461 #[cfg( unix )]
462 {
463 use std::os::unix::process::ExitStatusExt;
464 return Ok( std::process::ExitStatus::from_raw( 0 ) );
465 }
466 #[cfg( not( unix ) )]
467 {
468 // On non-Unix, run a no-op command to obtain a success ExitStatus
469 let status = std::process::Command::new( "cmd" )
470 .args( [ "/C", "exit", "0" ] )
471 .status()
472 .map_err( |e| Error::msg( format!( "Failed to create dry-run status: {e}" ) ) )?;
473 return Ok( status );
474 }
475 }
476
477 let mut cmd = self.build_command();
478
479 if let Some( ref path ) = self.stdin_file
480 {
481 let file = std::fs::File::open( path )
482 .map_err( | e | Error::msg( format!( "cannot open stdin file '{}': {e}", path.display() ) ) )?;
483 cmd.stdin( std::process::Stdio::from( file ) );
484 }
485
486 // Fix(BUG-241): map NotFound to install hint (mirrors the fix in execute()).
487 // Root cause: same as execute() — Command::status() on a missing binary emits
488 // a raw OS error string with no install guidance.
489 // Pitfall: both execute() and execute_interactive() must carry this fix; missing
490 // one leaves interactive mode with an unhelpful message.
491 let status = cmd.status()
492 .map_err( |e|
493 {
494 if e.kind() == std::io::ErrorKind::NotFound
495 {
496 Error::msg( "claude binary not found in PATH — install with: npm i -g @anthropic-ai/claude-code" )
497 }
498 else
499 {
500 Error::msg( format!( "Failed to execute Claude Code: {e}" ) )
501 }
502 } )?;
503
504 Ok( status )
505 }
506
507 /// Build the Command instance with all configured parameters
508 ///
509 /// SINGLE EXECUTION POINT: This is the ONLY location where `Command::new("claude")` appears
510 ///
511 /// Pitfall: any typed-field CLI flag OR env manipulation added here MUST also
512 /// appear in `describe()` at the same relative position — otherwise dry-run/trace diverges.
513 /// Typed-field flags: `skip_permissions`, `chrome`, `continue_conversation` (see `describe()`).
514 /// Env manipulations: `unset_claudecode` → `cmd.env_remove("CLAUDECODE")` → shown as `env -u CLAUDECODE` prefix.
515 /// Flags pushed via `self.args` are automatically mirrored; only typed fields need manual sync.
516 #[inline]
517 fn build_command( &self ) -> std::process::Command {
518 use std::process::Command;
519
520 // SINGLE EXECUTION POINT: This is the ONLY location where `Command::new("claude")` appears
521 let mut cmd = Command::new( "claude" );
522
523 // Set working directory if provided
524 if let Some( ref dir ) = self.working_directory {
525 cmd.current_dir( dir );
526 }
527
528 // Set max output tokens (fixes token limit bug: 32K → 200K)
529 if let Some( tokens ) = self.max_output_tokens {
530 cmd.env( "CLAUDE_CODE_MAX_OUTPUT_TOKENS", tokens.to_string() );
531 }
532
533 // Tier 1: Critical parameters with different defaults
534 if let Some( timeout ) = self.bash_default_timeout_ms {
535 cmd.env( "CLAUDE_CODE_BASH_TIMEOUT", timeout.to_string() );
536 }
537
538 if let Some( max_timeout ) = self.bash_max_timeout_ms {
539 cmd.env( "CLAUDE_CODE_BASH_MAX_TIMEOUT", max_timeout.to_string() );
540 }
541
542 if let Some( auto_continue ) = self.auto_continue {
543 cmd.env( "CLAUDE_CODE_AUTO_CONTINUE", auto_continue.to_string() );
544 }
545
546 if let Some( telemetry ) = self.telemetry {
547 cmd.env( "CLAUDE_CODE_TELEMETRY", telemetry.to_string() );
548 }
549
550 // Tier 2: Essential parameters (security-sensitive)
551 if let Some( approve ) = self.auto_approve_tools {
552 cmd.env( "CLAUDE_CODE_AUTO_APPROVE_TOOLS", approve.to_string() );
553 }
554
555 if let Some( mode ) = self.action_mode {
556 cmd.env( "CLAUDE_CODE_ACTION_MODE", mode.as_str() );
557 }
558
559 if let Some( level ) = self.log_level {
560 cmd.env( "CLAUDE_CODE_LOG_LEVEL", level.as_str() );
561 }
562
563 if let Some( temp ) = self.temperature {
564 cmd.env( "CLAUDE_CODE_TEMPERATURE", temp.to_string() );
565 }
566
567 // Tier 3: Optional parameters
568 if let Some( sandbox ) = self.sandbox_mode {
569 cmd.env( "CLAUDE_CODE_SANDBOX_MODE", sandbox.to_string() );
570 }
571
572 if let Some( ref dir ) = self.session_dir {
573 cmd.env( "CLAUDE_CODE_SESSION_DIR", dir.to_string_lossy().as_ref() );
574 }
575
576 if let Some( top_p ) = self.top_p {
577 cmd.env( "CLAUDE_CODE_TOP_P", top_p.to_string() );
578 }
579
580 if let Some( top_k ) = self.top_k {
581 cmd.env( "CLAUDE_CODE_TOP_K", top_k.to_string() );
582 }
583
584 if let Some( ref home ) = self.home_override {
585 cmd.env( "HOME", home );
586 }
587
588 if self.unset_claudecode
589 {
590 cmd.env_remove( "CLAUDECODE" );
591 }
592
593 // Add skip-permissions flag before custom args
594 if self.skip_permissions {
595 cmd.arg( "--dangerously-skip-permissions" );
596 }
597
598 // Emit chrome flag from typed field (default: Some(true) → --chrome)
599 match self.chrome {
600 Some( true ) => { cmd.arg( "--chrome" ); }
601 Some( false ) => { cmd.arg( "--no-chrome" ); }
602 None => {}
603 }
604
605 // Add custom arguments
606 for arg in &self.args {
607 cmd.arg( arg );
608 }
609
610 // Add continuation flag if requested
611 if self.continue_conversation {
612 cmd.arg( "-c" );
613 }
614
615 // Add message last if provided
616 if let Some( ref msg ) = self.message {
617 cmd.arg( msg );
618 }
619
620 cmd
621 }
622}
623
624/// Query installed Claude Code version.
625///
626/// Runs `claude --version` and returns trimmed stdout.
627/// Returns `None` if binary not found or produces no output.
628///
629/// # Examples
630///
631/// ```no_run
632/// if let Some( version ) = claude_runner_core::claude_version()
633/// {
634/// println!( "Claude Code version: {version}" );
635/// }
636/// ```
637#[ inline ]
638#[ must_use ]
639pub fn claude_version() -> Option< String >
640{
641 // Route through ClaudeCommand::execute() → build_command() to preserve the
642 // single-execution-point invariant (Command::new("claude") must appear exactly once).
643 // Fix(issue-claude-version-chrome): with_chrome(None) omits the --chrome flag;
644 // Root cause: ClaudeCommand::new() defaults chrome=Some(true) for automation use,
645 // but version queries must not pass browser-context flags.
646 // Pitfall: Always override automation defaults for system-query functions.
647 let output = ClaudeCommand::new()
648 .with_chrome( None )
649 .with_args( [ "--version" ] )
650 .execute()
651 .ok()?;
652 let s = output.stdout.trim().to_string();
653 if s.is_empty() { None } else { Some( s ) }
654}
655
656impl Default for ClaudeCommand {
657 #[inline]
658 fn default() -> Self {
659 Self::new()
660 }
661}
662
663// ============================================================================
664// Subprocess spawning
665// ============================================================================
666
667impl ClaudeCommand {
668 /// Spawn the Claude Code process with piped stdout/stderr and return the `Child` handle.
669 ///
670 /// Unlike [`execute`](Self::execute), this method does **not** wait for the subprocess
671 /// to finish. The caller owns the `Child` and is responsible for calling
672 /// [`Child::wait`](std::process::Child::wait) or
673 /// [`Child::wait_with_output`](std::process::Child::wait_with_output).
674 ///
675 /// Used by `run_isolated()` to enable timeout-with-kill-and-partial-output handling.
676 ///
677 /// # Errors
678 ///
679 /// Returns `io::Error` on spawn failure. Check `e.kind() == ErrorKind::NotFound` to
680 /// detect a missing `claude` binary.
681 ///
682 /// # Example
683 ///
684 /// ```no_run
685 /// use claude_runner_core::ClaudeCommand;
686 ///
687 /// let mut child = ClaudeCommand::new()
688 /// .with_message( "hello" )
689 /// .spawn_piped()?;
690 /// let output = child.wait_with_output()?;
691 /// # Ok::<(), std::io::Error>(())
692 /// ```
693 #[ inline ]
694 pub fn spawn_piped( &self ) -> std::io::Result< std::process::Child >
695 {
696 use std::process::Stdio;
697 let mut cmd = self.build_command();
698 if let Some( ref path ) = self.stdin_file
699 {
700 let file = std::fs::File::open( path )?;
701 cmd.stdin( Stdio::from( file ) );
702 }
703 else
704 {
705 cmd.stdin( Stdio::null() );
706 }
707 cmd.stdout( Stdio::piped() );
708 cmd.stderr( Stdio::piped() );
709 cmd.spawn()
710 }
711
712 /// Spawn the Claude Code process with inherited TTY stdio and return the `Child` handle.
713 ///
714 /// Unlike [`spawn_piped`](Self::spawn_piped), stdout and stderr are inherited from the
715 /// parent process so Claude can use the terminal for interactive output. stdin is
716 /// either the provided `--file` content or inherited from the parent TTY.
717 ///
718 /// The caller owns the `Child` and is responsible for calling
719 /// [`Child::wait`](std::process::Child::wait) after killing or waiting for the process.
720 ///
721 /// Used by `run_interactive` in `claude_runner` when `--timeout > 0` to enable
722 /// watchdog-kill while preserving the full TTY experience.
723 ///
724 /// # Errors
725 ///
726 /// Returns `io::Error` on spawn failure. Check `e.kind() == ErrorKind::NotFound` to
727 /// detect a missing `claude` binary.
728 #[ inline ]
729 pub fn spawn_tty( &self ) -> std::io::Result< std::process::Child >
730 {
731 use std::process::Stdio;
732 let mut cmd = self.build_command();
733 if let Some( ref path ) = self.stdin_file
734 {
735 let file = std::fs::File::open( path )?;
736 cmd.stdin( Stdio::from( file ) );
737 }
738 // stdout and stderr inherit from parent (TTY passthrough) — no redirection needed.
739 cmd.spawn()
740 }
741}
742
743// ============================================================================
744// Testing Support
745// ============================================================================
746//
747// Note: Uses #[doc(hidden)] instead of #[cfg(test)] because integration tests
748// in tests/ directory need access to this method. Integration tests compile
749// against the public API and cannot see #[cfg(test)] items from the library.
750
751impl ClaudeCommand {
752 /// Test helper: Expose built Command for inspection
753 ///
754 /// **FOR TESTING ONLY** - This method allows integration tests to inspect
755 /// the constructed Command without executing it.
756 ///
757 /// # Why Public?
758 ///
759 /// Integration tests (in `tests/` directory) need this to verify command
760 /// construction. Cannot use `#[cfg(test)]` because integration tests compile
761 /// against the public API.
762 ///
763 /// # Do Not Use in Production
764 ///
765 /// This method is marked `#[doc(hidden)]` to prevent it from appearing in
766 /// public documentation. It should only be used by tests in this crate.
767 #[ doc( hidden ) ]
768 #[ inline ]
769 #[ must_use ]
770 pub fn build_command_for_test( &self ) -> std::process::Command {
771 self.build_command()
772 }
773}