Skip to main content

claude_wrapper/
lib.rs

1//! A type-safe Claude Code CLI wrapper for Rust.
2//!
3//! `claude-wrapper` provides a builder-pattern interface for invoking the
4//! `claude` CLI programmatically. Each subcommand is a typed builder that
5//! produces typed output. The design follows the same shape as
6//! [`docker-wrapper`](https://crates.io/crates/docker-wrapper) and
7//! [`terraform-wrapper`](https://crates.io/crates/terraform-wrapper).
8//!
9//! # Feature flags
10//!
11//! | Feature | Default | Purpose |
12//! |---|---|---|
13//! | `async` | yes | tokio-backed async API. Disabling drops tokio from the runtime dep tree. |
14//! | `json` | yes | JSON output parsing and the JSON-backed surface ([`QueryCommand::execute_json`], [`streaming`], [`session::Session`], [`duplex`], [`conversation`], and the [`history`] / [`jobs`] / [`settings`] introspection modules). |
15//! | `tempfile` | yes | [`TempMcpConfig`] for one-shot MCP config files. |
16//! | `sync` | no | Blocking API: `*_sync` methods on [`exec`], [`retry`], every command builder, and [`Claude`]. |
17//!
18//! Sync-only (tokio-free) build:
19//!
20//! ```toml
21//! claude-wrapper = { version = "0.13", default-features = false, features = ["json", "sync"] }
22//! ```
23//!
24//! # Quick start (async)
25//!
26//! ```no_run
27//! # #[cfg(feature = "async")] {
28//! use claude_wrapper::{Claude, ClaudeCommand, QueryCommand};
29//!
30//! # async fn example() -> claude_wrapper::Result<()> {
31//! let claude = Claude::builder().build()?;
32//! let output = QueryCommand::new("explain this error: file not found")
33//!     .model("sonnet")
34//!     .execute(&claude)
35//!     .await?;
36//! println!("{}", output.stdout);
37//! # Ok(()) }
38//! # }
39//! ```
40//!
41//! # Quick start (sync)
42//!
43//! Enable the `sync` feature and bring [`ClaudeCommandSyncExt`] into scope:
44//!
45//! ```no_run
46//! # #[cfg(feature = "sync")] {
47//! use claude_wrapper::{Claude, ClaudeCommandSyncExt, QueryCommand};
48//!
49//! # fn example() -> claude_wrapper::Result<()> {
50//! let claude = Claude::builder().build()?;
51//! let output = QueryCommand::new("explain this error")
52//!     .execute_sync(&claude)?;
53//! println!("{}", output.stdout);
54//! # Ok(()) }
55//! # }
56//! ```
57//!
58//! # Two-layer builder
59//!
60//! The [`Claude`] client holds shared config (binary path, env, timeout,
61//! default retry policy). Command builders hold per-invocation options
62//! and call `execute(&claude)` (or `execute_sync`).
63//!
64//! ```no_run
65//! # #[cfg(feature = "async")] {
66//! use claude_wrapper::{Claude, ClaudeCommand, Effort, PermissionMode, QueryCommand};
67//!
68//! # async fn example() -> claude_wrapper::Result<()> {
69//! let claude = Claude::builder()
70//!     .env("AWS_REGION", "us-west-2")
71//!     .timeout_secs(300)
72//!     .build()?;
73//!
74//! let output = QueryCommand::new("review src/main.rs")
75//!     .model("opus")
76//!     .system_prompt("You are a senior Rust developer")
77//!     .permission_mode(PermissionMode::Plan)
78//!     .effort(Effort::High)
79//!     .max_turns(5)
80//!     .no_session_persistence()
81//!     .execute(&claude)
82//!     .await?;
83//! # Ok(()) }
84//! # }
85//! ```
86//!
87//! # JSON output
88//!
89//! ```no_run
90//! # #[cfg(all(feature = "async", feature = "json"))] {
91//! use claude_wrapper::{Claude, QueryCommand};
92//!
93//! # async fn example() -> claude_wrapper::Result<()> {
94//! let claude = Claude::builder().build()?;
95//! let result = QueryCommand::new("what is 2+2?")
96//!     .execute_json(&claude)
97//!     .await?;
98//! println!("answer: {}", result.result);
99//! println!("cost: ${:.4}", result.cost_usd.unwrap_or(0.0));
100//! # Ok(()) }
101//! # }
102//! ```
103//!
104//! # Multi-turn conversations
105//!
106//! Two shapes for multi-turn work, each suited to a different
107//! process model. [`DuplexSession`] is the recommended choice for
108//! long-running hosts; [`Session`] is the right fit for short-lived
109//! processes.
110//!
111//! | | [`DuplexSession`] | [`Session`] |
112//! |---|---|---|
113//! | Process model | one child held open across turns | new subprocess per turn, `--resume` continuity |
114//! | Mid-turn interrupt | yes ([`DuplexSession::interrupt`](duplex::DuplexSession::interrupt)) | no (only `child.kill()` via SIGKILL) |
115//! | Mid-turn permission prompts | yes ([`PermissionHandler`]) | no |
116//! | Broadcast event subscribers | yes ([`DuplexSession::subscribe`](duplex::DuplexSession::subscribe)) | no (per-turn `stream_query`) |
117//! | Built-in cost / history tracking | no ([`TurnResult`] is per-turn) | yes ([`Session::total_cost_usd`], [`Session::history`], [`BudgetTracker`]) |
118//! | Right for | long-running hosts (IDE backends, daemons, agent servers, chat UIs) | short-lived processes (CLIs, build scripts, batch jobs, lambdas) |
119//!
120//! ## `DuplexSession` (recommended for long-running hosts)
121//!
122//! ```no_run
123//! # #[cfg(all(feature = "async", feature = "json"))] {
124//! use claude_wrapper::Claude;
125//! use claude_wrapper::duplex::{DuplexOptions, DuplexSession};
126//!
127//! # async fn example() -> claude_wrapper::Result<()> {
128//! let claude = Claude::builder().build()?;
129//! let session = DuplexSession::spawn(
130//!     &claude,
131//!     DuplexOptions::default().model("haiku"),
132//! ).await?;
133//!
134//! let turn = session.send("what's 2 + 2?").await?;
135//! println!("answer: {}", turn.result_text().unwrap_or(""));
136//!
137//! session.close().await?;
138//! # Ok(()) }
139//! # }
140//! ```
141//!
142//! See the [duplex module docs](duplex) for the full API including
143//! `subscribe`, `interrupt`, and `respond_to_permission`.
144//!
145//! For host-side bookkeeping (history, cumulative cost, optional
146//! [`BudgetTracker`] hard stop) on top of a [`DuplexSession`], wrap
147//! it in a [`Conversation`]. See the
148//! [conversation module docs](conversation).
149//!
150//! ## `Session` (for short-lived processes)
151//!
152//! ```no_run
153//! # #[cfg(all(feature = "async", feature = "json"))] {
154//! use std::sync::Arc;
155//! use claude_wrapper::Claude;
156//! use claude_wrapper::session::Session;
157//!
158//! # async fn example() -> claude_wrapper::Result<()> {
159//! let claude = Arc::new(Claude::builder().build()?);
160//! let mut session = Session::new(claude);
161//! let _first = session.send("what's 2 + 2?").await?;
162//! let _second = session.send("and squared?").await?;
163//! println!("cost: ${:.4}", session.total_cost_usd());
164//! # Ok(()) }
165//! # }
166//! ```
167//!
168//! See the [session module docs](session) for the full API.
169//!
170//! # Budget tracking
171//!
172//! Attach a [`BudgetTracker`] to a session (or share one across several
173//! sessions) to enforce a cumulative USD ceiling. Callbacks fire
174//! exactly once when thresholds are crossed; pre-turn checks
175//! short-circuit with [`Error::BudgetExceeded`]
176//! once the ceiling is hit.
177//!
178//! ```no_run
179//! # #[cfg(all(feature = "async", feature = "json"))] {
180//! use std::sync::Arc;
181//! use claude_wrapper::{BudgetTracker, Claude};
182//! use claude_wrapper::session::Session;
183//!
184//! # async fn example() -> claude_wrapper::Result<()> {
185//! let budget = BudgetTracker::builder()
186//!     .max_usd(5.00)
187//!     .warn_at_usd(4.00)
188//!     .on_warning(|t| eprintln!("warning: ${t:.2}"))
189//!     .on_exceeded(|t| eprintln!("budget hit: ${t:.2}"))
190//!     .build();
191//!
192//! let claude = Arc::new(Claude::builder().build()?);
193//! let mut session = Session::new(claude).with_budget(budget.clone());
194//! session.send("hello").await?;
195//! println!("spent: ${:.4}", budget.total_usd());
196//! # Ok(()) }
197//! # }
198//! ```
199//!
200//! # Tool permissions
201//!
202//! Use [`ToolPattern`] for typed `--allowed-tools` / `--disallowed-tools`
203//! entries. Typed constructors always produce valid patterns; loose
204//! `From<&str>` keeps bare strings working for back-compat.
205//!
206//! ```
207//! use claude_wrapper::{QueryCommand, ToolPattern};
208//!
209//! let cmd = QueryCommand::new("review")
210//!     .allowed_tool(ToolPattern::tool("Read"))
211//!     .allowed_tool(ToolPattern::tool_with_args("Bash", "git log:*"))
212//!     .allowed_tool(ToolPattern::all("Write"))
213//!     .allowed_tool(ToolPattern::mcp("my-server", "*"))
214//!     .disallowed_tool(ToolPattern::tool_with_args("Bash", "rm*"));
215//! ```
216//!
217//! # Streaming
218//!
219//! Process NDJSON events in real time with [`streaming::stream_query`]
220//! (async) or [`streaming::stream_query_sync`] (blocking; non-`Send`
221//! handler supported).
222//! Hosts with an explicit stop signal can use
223//! [`streaming::stream_query_cancellable`], which settles process cleanup
224//! before returning [`Error::Cancelled`].
225//!
226//! ```no_run
227//! # #[cfg(all(feature = "async", feature = "json"))] {
228//! use claude_wrapper::{Claude, OutputFormat, QueryCommand};
229//! use claude_wrapper::streaming::{StreamEvent, stream_query};
230//!
231//! # async fn example() -> claude_wrapper::Result<()> {
232//! let claude = Claude::builder().build()?;
233//! let cmd = QueryCommand::new("explain quicksort")
234//!     .output_format(OutputFormat::StreamJson);
235//!
236//! stream_query(&claude, &cmd, |event: StreamEvent| {
237//!     if event.is_result() {
238//!         println!("result: {}", event.result_text().unwrap_or(""));
239//!     }
240//! }).await?;
241//! # Ok(()) }
242//! # }
243//! ```
244//!
245//! # MCP config generation
246//!
247//! Generate `.mcp.json` files for `--mcp-config`:
248//!
249//! ```no_run
250//! # #[cfg(feature = "async")] {
251//! use claude_wrapper::{Claude, ClaudeCommand, McpConfigBuilder, QueryCommand};
252//!
253//! # async fn example() -> claude_wrapper::Result<()> {
254//! McpConfigBuilder::new()
255//!     .http_server("hub", "http://127.0.0.1:9090")
256//!     .stdio_server("tool", "npx", ["my-server"])
257//!     .write_to("/tmp/my-project/.mcp.json")?;
258//!
259//! let claude = Claude::builder().build()?;
260//! QueryCommand::new("list tools")
261//!     .mcp_config("/tmp/my-project/.mcp.json")
262//!     .execute(&claude)
263//!     .await?;
264//! # Ok(()) }
265//! # }
266//! ```
267//!
268//! # On-disk introspection
269//!
270//! A family of read-only modules parses Claude Code's on-disk state
271//! under `~/.claude` directly, without spawning the CLI. Each exposes a
272//! root/loader with `list` / `get` accessors and degrades to an empty
273//! result when the directory is absent: [`history`] (sessions and
274//! transcripts), [`artifacts`] (agents), [`skills`], [`commands`]
275//! (custom slash commands), [`settings`] (the four merged layers),
276//! [`jobs`] (background-agent state), and [`worktrees`]. See the
277//! `inspect_state` example for an end-to-end tour.
278//!
279//! ```no_run
280//! # #[cfg(feature = "json")] {
281//! use claude_wrapper::history::HistoryRoot;
282//!
283//! # fn example() -> claude_wrapper::Result<()> {
284//! let history = HistoryRoot::home()?;
285//! for project in history.list_projects()? {
286//!     println!(
287//!         "{} ({} sessions)",
288//!         project.decoded_path.display(),
289//!         project.session_count,
290//!     );
291//! }
292//! # Ok(()) }
293//! # }
294//! ```
295//!
296//! # Dangerous: bypass mode
297//!
298//! `--permission-mode bypassPermissions` is isolated behind
299//! [`dangerous::DangerousClient`], which requires an env-var
300//! acknowledgement ([`dangerous::ALLOW_ENV`] = `"1"`) at process start.
301//! See the [dangerous module docs](dangerous) for details.
302//!
303//! # Escape hatch
304//!
305//! For subcommands not yet wrapped, use [`RawCommand`]:
306//!
307//! ```no_run
308//! # #[cfg(feature = "async")] {
309//! use claude_wrapper::{Claude, ClaudeCommand, RawCommand};
310//!
311//! # async fn example() -> claude_wrapper::Result<()> {
312//! let claude = Claude::builder().build()?;
313//! let output = RawCommand::new("some-future-command")
314//!     .arg("--new-flag")
315//!     .arg("value")
316//!     .execute(&claude)
317//!     .await?;
318//! # Ok(()) }
319//! # }
320//! ```
321#![warn(missing_docs)]
322
323pub mod artifacts;
324pub mod auth;
325pub mod budget;
326/// Per-subcommand builders and the [`command::ClaudeCommand`] trait.
327pub mod command;
328pub mod commands;
329#[cfg(all(feature = "json", feature = "async"))]
330pub mod conversation;
331pub mod dangerous;
332#[cfg(all(feature = "json", feature = "async"))]
333pub mod duplex;
334pub mod error;
335pub mod exec;
336#[cfg(feature = "json")]
337pub mod history;
338#[cfg(feature = "json")]
339pub mod jobs;
340pub mod mcp_config;
341pub mod memory;
342pub mod plans;
343pub mod retry;
344#[cfg(all(feature = "json", feature = "async"))]
345pub mod session;
346#[cfg(feature = "json")]
347pub mod sessions;
348#[cfg(feature = "json")]
349pub mod settings;
350pub mod skills;
351pub mod slash;
352pub mod streaming;
353#[cfg(feature = "json")]
354pub mod tasks;
355pub mod tool_pattern;
356pub mod types;
357pub mod version;
358pub mod worktrees;
359
360use std::collections::HashMap;
361use std::path::{Path, PathBuf};
362use std::time::Duration;
363
364pub use budget::{BudgetBuilder, BudgetTracker};
365pub use command::ClaudeCommand;
366#[cfg(feature = "sync")]
367pub use command::ClaudeCommandSyncExt;
368#[allow(deprecated)]
369pub use command::agents::AgentsCommand;
370pub use command::auth::{
371    AuthLoginCommand, AuthLogoutCommand, AuthStatusCommand, LoginMode, SetupTokenCommand,
372};
373pub use command::auto_mode::{
374    AutoModeConfigCommand, AutoModeCritiqueCommand, AutoModeDefaultsCommand,
375};
376pub use command::doctor::DoctorCommand;
377pub use command::install::InstallCommand;
378pub use command::marketplace::{
379    MarketplaceAddCommand, MarketplaceListCommand, MarketplaceRemoveCommand,
380    MarketplaceUpdateCommand,
381};
382pub use command::mcp::{
383    McpAddCommand, McpAddFromDesktopCommand, McpAddJsonCommand, McpGetCommand, McpListCommand,
384    McpLoginCommand, McpLogoutCommand, McpRemoveCommand, McpResetProjectChoicesCommand,
385    McpServeCommand,
386};
387pub use command::plugin::{
388    PluginDetailsCommand, PluginDisableCommand, PluginEnableCommand, PluginInstallCommand,
389    PluginListCommand, PluginPruneCommand, PluginTagCommand, PluginUninstallCommand,
390    PluginUpdateCommand, PluginValidateCommand,
391};
392pub use command::project::ProjectPurgeCommand;
393pub use command::query::QueryCommand;
394pub use command::raw::RawCommand;
395pub use command::ultrareview::UltrareviewCommand;
396pub use command::update::UpdateCommand;
397pub use command::version::VersionCommand;
398#[cfg(all(feature = "json", feature = "async"))]
399pub use conversation::Conversation;
400#[cfg(all(feature = "json", feature = "async"))]
401pub use duplex::{
402    DuplexOptions, DuplexSession, InboundEvent, PermissionDecision, PermissionHandler,
403    PermissionRequest, TurnResult,
404};
405pub use error::{Error, OutputStream, Result};
406pub use exec::CommandOutput;
407#[cfg(feature = "tempfile")]
408pub use mcp_config::TempMcpConfig;
409pub use mcp_config::{McpConfigBuilder, McpServerConfig};
410pub use retry::{BackoffStrategy, RetryPolicy};
411#[cfg(all(feature = "json", feature = "async"))]
412pub use session::Session;
413pub use tool_pattern::{PatternError, ToolPattern};
414pub use types::*;
415pub use version::{
416    CliVersion, CliVersionStatus, TESTED_CLI_VERSION_MAX, TESTED_CLI_VERSION_MIN, VersionParseError,
417};
418
419/// What the crate knows about a child the moment it is spawned.
420///
421/// Delivered to the [`ClaudeBuilder::on_spawn`] observer before the run can
422/// produce output, which is the point: a supervisor that records the pid only
423/// after a run *finishes* has nothing to reconcile when it crashes mid-run.
424#[derive(Debug, Clone, Copy, PartialEq, Eq)]
425#[non_exhaustive]
426pub struct SpawnInfo {
427    /// The child's process id.
428    pub pid: u32,
429    /// The child's process group id, when it leads its own group.
430    ///
431    /// `None` when [`ClaudeBuilder::process_group`] is disabled, in which case
432    /// the child shares the parent's group and its pid must never be passed to
433    /// `killpg`: that would signal the caller's own process group.
434    pub pgid: Option<u32>,
435}
436
437/// Called with [`SpawnInfo`] each time the crate spawns a CLI child.
438///
439/// Must not block: it runs inline on the spawning thread, between `spawn` and
440/// the first read of the child's output.
441pub type SpawnObserver = std::sync::Arc<dyn Fn(SpawnInfo) + Send + Sync>;
442
443/// The Claude CLI client. Holds shared configuration applied to all commands.
444///
445/// Create one via [`Claude::builder()`] and reuse it across commands.
446#[derive(Clone)]
447pub struct Claude {
448    pub(crate) binary: PathBuf,
449    pub(crate) working_dir: Option<PathBuf>,
450    // env, timeout, and retry_policy are written by the builder under
451    // every feature combination but read only by the feature-gated
452    // exec paths, so they are "never read" with neither `async` nor
453    // `sync` feature.
454    #[allow(dead_code)]
455    pub(crate) env: HashMap<String, String>,
456    #[allow(dead_code)]
457    pub(crate) clear_env: bool,
458    pub(crate) global_args: Vec<String>,
459    #[allow(dead_code)]
460    pub(crate) timeout: Option<Duration>,
461    #[allow(dead_code)]
462    pub(crate) retry_policy: Option<RetryPolicy>,
463    pub(crate) tested_cli_version_range: Option<(CliVersion, CliVersion)>,
464    // Read only by the feature-gated exec paths, like env/timeout.
465    #[allow(dead_code)]
466    pub(crate) process_group: bool,
467    #[allow(dead_code)]
468    pub(crate) kill_grace: Option<Duration>,
469    #[allow(dead_code)]
470    pub(crate) output_limit: Option<usize>,
471    #[allow(dead_code)]
472    pub(crate) on_spawn: Option<SpawnObserver>,
473    #[allow(dead_code)]
474    pub(crate) die_with_parent: bool,
475}
476
477impl std::fmt::Debug for Claude {
478    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
479        f.debug_struct("Claude")
480            .field("binary", &self.binary)
481            .field("working_dir", &self.working_dir)
482            .field("clear_env", &self.clear_env)
483            .field("global_args", &self.global_args)
484            .field("timeout", &self.timeout)
485            .field("process_group", &self.process_group)
486            .field("kill_grace", &self.kill_grace)
487            .field("output_limit", &self.output_limit)
488            // A closure cannot be rendered, and env may hold credentials, so
489            // neither is printed: only whether an observer is installed.
490            .field("on_spawn", &self.on_spawn.is_some())
491            .finish_non_exhaustive()
492    }
493}
494
495impl Claude {
496    /// Create a new builder for configuring the Claude client.
497    #[must_use]
498    pub fn builder() -> ClaudeBuilder {
499        ClaudeBuilder::default()
500    }
501
502    /// Get the path to the claude binary.
503    #[must_use]
504    pub fn binary(&self) -> &Path {
505        &self.binary
506    }
507
508    /// Get the working directory, if set.
509    #[must_use]
510    pub fn working_dir(&self) -> Option<&Path> {
511        self.working_dir.as_deref()
512    }
513
514    /// Create a clone of this client with a different working directory.
515    #[must_use]
516    pub fn with_working_dir(&self, dir: impl Into<PathBuf>) -> Self {
517        let mut clone = self.clone();
518        clone.working_dir = Some(dir.into());
519        clone
520    }
521
522    /// Query the installed CLI version.
523    ///
524    /// Runs `claude --version` and parses the output into a [`CliVersion`].
525    ///
526    /// # Example
527    ///
528    /// ```no_run
529    /// # async fn example() -> claude_wrapper::Result<()> {
530    /// let claude = claude_wrapper::Claude::builder().build()?;
531    /// let version = claude.cli_version().await?;
532    /// println!("Claude CLI {version}");
533    /// # Ok(())
534    /// # }
535    /// ```
536    #[cfg(feature = "async")]
537    pub async fn cli_version(&self) -> Result<CliVersion> {
538        let output = VersionCommand::new().execute(self).await?;
539        CliVersion::parse_version_output(&output.stdout).map_err(|e| Error::Io {
540            message: format!("failed to parse CLI version: {e}"),
541            source: std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()),
542            working_dir: None,
543        })
544    }
545
546    /// Check that the installed CLI version meets a minimum requirement.
547    ///
548    /// Returns the detected version on success, or an error if the version
549    /// is below the minimum.
550    ///
551    /// # Example
552    ///
553    /// ```no_run
554    /// use claude_wrapper::CliVersion;
555    ///
556    /// # async fn example() -> claude_wrapper::Result<()> {
557    /// let claude = claude_wrapper::Claude::builder().build()?;
558    /// let version = claude.check_version(&CliVersion::new(2, 1, 0)).await?;
559    /// println!("CLI version {version} meets minimum requirement");
560    /// # Ok(())
561    /// # }
562    /// ```
563    #[cfg(feature = "async")]
564    pub async fn check_version(&self, minimum: &CliVersion) -> Result<CliVersion> {
565        let version = self.cli_version().await?;
566        if version.satisfies_minimum(minimum) {
567            Ok(version)
568        } else {
569            Err(Error::VersionMismatch {
570                found: version,
571                minimum: *minimum,
572            })
573        }
574    }
575
576    /// Blocking mirror of [`Claude::cli_version`]. Requires the
577    /// `sync` feature.
578    #[cfg(feature = "sync")]
579    pub fn cli_version_sync(&self) -> Result<CliVersion> {
580        let output = VersionCommand::new().execute_sync(self)?;
581        CliVersion::parse_version_output(&output.stdout).map_err(|e| Error::Io {
582            message: format!("failed to parse CLI version: {e}"),
583            source: std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()),
584            working_dir: None,
585        })
586    }
587
588    /// Blocking mirror of [`Claude::check_version`]. Requires the
589    /// `sync` feature.
590    #[cfg(feature = "sync")]
591    pub fn check_version_sync(&self, minimum: &CliVersion) -> Result<CliVersion> {
592        let version = self.cli_version_sync()?;
593        if version.satisfies_minimum(minimum) {
594            Ok(version)
595        } else {
596            Err(Error::VersionMismatch {
597                found: version,
598                minimum: *minimum,
599            })
600        }
601    }
602
603    /// The tested-against `[min, max]` range declared at build time
604    /// via [`ClaudeBuilder::tested_cli_version_range`], if any.
605    ///
606    /// `None` means no override was set, in which case version checks
607    /// use the crate's own [`TESTED_CLI_VERSION_MIN`] /
608    /// [`TESTED_CLI_VERSION_MAX`]; see [`Self::effective_tested_range`].
609    #[must_use]
610    pub fn tested_cli_version_range(&self) -> Option<(CliVersion, CliVersion)> {
611        self.tested_cli_version_range
612    }
613
614    /// The range version checks actually use: the caller's override when one
615    /// was set, otherwise the crate's own declared range.
616    #[must_use]
617    pub fn effective_tested_range(&self) -> (CliVersion, CliVersion) {
618        self.tested_cli_version_range
619            .unwrap_or((TESTED_CLI_VERSION_MIN, TESTED_CLI_VERSION_MAX))
620    }
621
622    /// Classify the installed CLI against the tested-against range.
623    /// Logs a `tracing::warn!` when outside the range; returns the
624    /// typed status either way.
625    ///
626    /// The range defaults to the crate's own
627    /// [`TESTED_CLI_VERSION_MIN`] / [`TESTED_CLI_VERSION_MAX`],
628    /// because only the crate knows what it was built and tested
629    /// against. [`ClaudeBuilder::tested_cli_version_range`] overrides
630    /// it for hosts that have verified a different range themselves.
631    ///
632    /// Intended for one-shot use at startup, not on every command.
633    #[cfg(feature = "async")]
634    pub async fn cli_version_status(&self) -> Result<CliVersionStatus> {
635        let (min, max) = self.effective_tested_range();
636        let found = self.cli_version().await?;
637        let status = found.status_within(&min, &max);
638        warn_on_drift(&status);
639        Ok(status)
640    }
641
642    /// Blocking mirror of [`Claude::cli_version_status`]. Requires
643    /// the `sync` feature.
644    #[cfg(feature = "sync")]
645    pub fn cli_version_status_sync(&self) -> Result<CliVersionStatus> {
646        let (min, max) = self.effective_tested_range();
647        let found = self.cli_version_sync()?;
648        let status = found.status_within(&min, &max);
649        warn_on_drift(&status);
650        Ok(status)
651    }
652
653    /// Refuse to proceed against a CLI outside the tested range.
654    ///
655    /// The opt-in hard gate. [`Claude::cli_version_status`] is the
656    /// reporting path and keeps its behavior: it returns a typed
657    /// status and warns. This one turns the same condition into
658    /// [`Error::UntestedCliVersion`], for hosts that would rather fail
659    /// at startup than run on an unverified binary.
660    ///
661    /// Returns the detected version on success. The drift warning
662    /// still fires on the failure path, so a host that logs and a host
663    /// that gates see the same line.
664    ///
665    /// # Why this is a method and not a builder option
666    ///
667    /// [`ClaudeBuilder::build`] is synchronous and never spawns the
668    /// binary. Enforcing a version there would mean running a
669    /// subprocess inside a constructor, so the check lives where the
670    /// caller can await it.
671    ///
672    /// # Example
673    ///
674    /// ```no_run
675    /// # async fn example() -> claude_wrapper::Result<()> {
676    /// let claude = claude_wrapper::Claude::builder().build()?;
677    /// // Run once at startup; returns Err on an untested CLI.
678    /// let version = claude.ensure_tested_cli_version().await?;
679    /// println!("running against {version}");
680    /// # Ok(())
681    /// # }
682    /// ```
683    #[cfg(feature = "async")]
684    pub async fn ensure_tested_cli_version(&self) -> Result<CliVersion> {
685        let (min, max) = self.effective_tested_range();
686        let found = self.cli_version().await?;
687        self.gate_version(found, min, max)
688    }
689
690    /// Blocking mirror of [`Claude::ensure_tested_cli_version`].
691    /// Requires the `sync` feature.
692    #[cfg(feature = "sync")]
693    pub fn ensure_tested_cli_version_sync(&self) -> Result<CliVersion> {
694        let (min, max) = self.effective_tested_range();
695        let found = self.cli_version_sync()?;
696        self.gate_version(found, min, max)
697    }
698
699    /// Shared decision for the async and sync gates, so the two cannot
700    /// disagree about what "outside the range" means.
701    #[cfg(any(feature = "async", feature = "sync"))]
702    fn gate_version(
703        &self,
704        found: CliVersion,
705        min: CliVersion,
706        max: CliVersion,
707    ) -> Result<CliVersion> {
708        let status = found.status_within(&min, &max);
709        warn_on_drift(&status);
710        if status.is_tested() {
711            Ok(found)
712        } else {
713            Err(Error::UntestedCliVersion {
714                found,
715                tested_min: min,
716                tested_max: max,
717            })
718        }
719    }
720}
721
722#[allow(dead_code)] // unused with neither `async` nor `sync` feature
723fn warn_on_drift(status: &CliVersionStatus) {
724    match status {
725        CliVersionStatus::Tested => {}
726        CliVersionStatus::NewerUntested {
727            found, tested_max, ..
728        } => {
729            tracing::warn!(
730                found = %found,
731                tested_max = %tested_max,
732                "claude CLI is newer than the wrapper's tested-against range; \
733                 semantics may have drifted -- proceed with caution"
734            );
735        }
736        CliVersionStatus::OlderThanMinimum { found, minimum, .. } => {
737            tracing::warn!(
738                found = %found,
739                minimum = %minimum,
740                "claude CLI is older than the wrapper's declared minimum; \
741                 incorrect behavior is likely (missing flags, different shapes)"
742            );
743        }
744    }
745}
746
747/// Builder for creating a [`Claude`] client.
748///
749/// # Example
750///
751/// ```no_run
752/// use claude_wrapper::Claude;
753///
754/// # fn example() -> claude_wrapper::Result<()> {
755/// let claude = Claude::builder()
756///     .env("AWS_REGION", "us-west-2")
757///     .timeout_secs(120)
758///     .build()?;
759/// # Ok(())
760/// # }
761/// ```
762#[derive(Default)]
763pub struct ClaudeBuilder {
764    binary: Option<PathBuf>,
765    working_dir: Option<PathBuf>,
766    env: HashMap<String, String>,
767    clear_env: bool,
768    global_args: Vec<String>,
769    timeout: Option<Duration>,
770    retry_policy: Option<RetryPolicy>,
771    tested_cli_version_range: Option<(CliVersion, CliVersion)>,
772    process_group: Option<bool>,
773    kill_grace: Option<Duration>,
774    output_limit: Option<usize>,
775    on_spawn: Option<SpawnObserver>,
776    die_with_parent: bool,
777}
778
779impl std::fmt::Debug for ClaudeBuilder {
780    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
781        // Mirrors Claude's Debug: a closure cannot be rendered and env may
782        // hold credentials, so neither is printed.
783        f.debug_struct("ClaudeBuilder")
784            .field("binary", &self.binary)
785            .field("working_dir", &self.working_dir)
786            .field("clear_env", &self.clear_env)
787            .field("global_args", &self.global_args)
788            .field("timeout", &self.timeout)
789            .field("process_group", &self.process_group)
790            .field("kill_grace", &self.kill_grace)
791            .field("output_limit", &self.output_limit)
792            .field("on_spawn", &self.on_spawn.is_some())
793            .finish_non_exhaustive()
794    }
795}
796
797impl ClaudeBuilder {
798    /// Set the path to the claude binary.
799    ///
800    /// If not set, the binary is resolved from PATH using `which`.
801    #[must_use]
802    pub fn binary(mut self, path: impl Into<PathBuf>) -> Self {
803        self.binary = Some(path.into());
804        self
805    }
806
807    /// Set the working directory for all commands.
808    ///
809    /// The spawned process will use this as its current directory.
810    #[must_use]
811    pub fn working_dir(mut self, path: impl Into<PathBuf>) -> Self {
812        self.working_dir = Some(path.into());
813        self
814    }
815
816    /// Add an environment variable to pass to all commands.
817    #[must_use]
818    pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
819        self.env.insert(key.into(), value.into());
820        self
821    }
822
823    /// Add multiple environment variables.
824    #[must_use]
825    pub fn envs(
826        mut self,
827        vars: impl IntoIterator<Item = (impl Into<String>, impl Into<String>)>,
828    ) -> Self {
829        for (k, v) in vars {
830            self.env.insert(k.into(), v.into());
831        }
832        self
833    }
834
835    /// Clear the inherited environment of every spawned Claude CLI child.
836    ///
837    /// By default, children inherit the parent process environment and
838    /// [`env`](Self::env) / [`envs`](Self::envs) add or replace entries. With
839    /// this option enabled, the inherited environment is cleared first and
840    /// only explicitly configured entries are applied. The order in which
841    /// `clear_env`, `env`, and `envs` are called does not affect the result.
842    ///
843    /// Callers normally need to rebuild a minimal environment including
844    /// `PATH`, locale settings, the Claude config directory, and the intended
845    /// authentication selector.
846    ///
847    /// This controls only the direct child process environment. It is not an
848    /// operating-system sandbox and does not prevent a same-UID child from
849    /// reading accessible files or inspecting other processes where the OS
850    /// permits it.
851    ///
852    /// # Example
853    ///
854    /// ```no_run
855    /// use claude_wrapper::Claude;
856    ///
857    /// # fn example() -> claude_wrapper::Result<()> {
858    /// let claude = Claude::builder()
859    ///     .clear_env()
860    ///     .env("PATH", "/usr/local/bin:/usr/bin:/bin")
861    ///     .env("CLAUDE_CONFIG_DIR", "/srv/claude/config")
862    ///     .build()?;
863    /// # Ok(())
864    /// # }
865    /// ```
866    #[must_use]
867    pub fn clear_env(mut self) -> Self {
868        self.clear_env = true;
869        self
870    }
871
872    /// Set a default timeout for all commands (in seconds).
873    #[must_use]
874    pub fn timeout_secs(mut self, seconds: u64) -> Self {
875        self.timeout = Some(Duration::from_secs(seconds));
876        self
877    }
878
879    /// Set a default timeout for all commands.
880    #[must_use]
881    pub fn timeout(mut self, duration: Duration) -> Self {
882        self.timeout = Some(duration);
883        self
884    }
885
886    /// Add a global argument applied to all commands.
887    ///
888    /// This is an escape hatch for flags not yet covered by the API.
889    #[must_use]
890    pub fn arg(mut self, arg: impl Into<String>) -> Self {
891        self.global_args.push(arg.into());
892        self
893    }
894
895    /// Enable verbose output for all commands (`--verbose`).
896    #[must_use]
897    pub fn verbose(mut self) -> Self {
898        self.global_args.push("--verbose".into());
899        self
900    }
901
902    /// Enable debug output for all commands (`--debug`).
903    #[must_use]
904    pub fn debug(mut self) -> Self {
905        self.global_args.push("--debug".into());
906        self
907    }
908
909    /// Set a default retry policy for all commands.
910    ///
911    /// Individual commands can override this via their own retry settings.
912    ///
913    /// # Example
914    ///
915    /// ```no_run
916    /// use claude_wrapper::{Claude, RetryPolicy};
917    /// use std::time::Duration;
918    ///
919    /// # fn example() -> claude_wrapper::Result<()> {
920    /// let claude = Claude::builder()
921    ///     .retry(RetryPolicy::new()
922    ///         .max_attempts(3)
923    ///         .initial_backoff(Duration::from_secs(2))
924    ///         .exponential()
925    ///         .retry_on_timeout(true))
926    ///     .build()?;
927    /// # Ok(())
928    /// # }
929    /// ```
930    #[must_use]
931    pub fn retry(mut self, policy: RetryPolicy) -> Self {
932        self.retry_policy = Some(policy);
933        self
934    }
935
936    /// Declare the inclusive `[min, max]` range of `claude` CLI
937    /// versions this client has been tested against.
938    ///
939    /// The wrapper does not enforce the range -- nothing errors when
940    /// it's set wrong. Use [`Claude::cli_version_status`] (or its
941    /// sync mirror) at startup to classify the actually-installed CLI
942    /// against this declaration; that call returns a typed
943    /// [`CliVersionStatus`] AND emits a `tracing::warn!` when
944    /// outside the range. Hosts (claude-server, application code)
945    /// can additionally surface the status to operators.
946    ///
947    /// # Why this exists
948    ///
949    /// CLI semantics drift across minor / patch releases (e.g.
950    /// `claude agents` was repurposed in 2.1.143). The min floor
951    /// lets us say "we know it's broken below this"; the max ceiling
952    /// lets us say "we haven't verified above this -- proceed but
953    /// expect surprises."
954    ///
955    /// # You usually do not need this
956    ///
957    /// The crate declares its own range in
958    /// [`TESTED_CLI_VERSION_MIN`] / [`TESTED_CLI_VERSION_MAX`], and
959    /// version checks use it by default, because only the crate knows
960    /// what it was built and tested against. Set this only when a host
961    /// has verified a different range itself.
962    ///
963    /// # Example
964    ///
965    /// ```no_run
966    /// use claude_wrapper::{Claude, CliVersion};
967    ///
968    /// # async fn example() -> claude_wrapper::Result<()> {
969    /// let claude = Claude::builder()
970    ///     .tested_cli_version_range(CliVersion::new(2, 1, 0), CliVersion::new(2, 1, 999))
971    ///     .build()?;
972    /// // Run once at startup to log a warning if the CLI is out of range.
973    /// let _status = claude.cli_version_status().await?;
974    /// # Ok(()) }
975    /// ```
976    #[must_use]
977    pub fn tested_cli_version_range(mut self, min: CliVersion, max: CliVersion) -> Self {
978        self.tested_cli_version_range = Some((min, max));
979        self
980    }
981
982    /// Control whether spawned `claude` children are placed in their
983    /// own process group on Unix. Defaults to `true`.
984    ///
985    /// Own group (the default): cancellation and timeouts kill the
986    /// child's whole process tree, but the child no longer shares the
987    /// host terminal's process group, so terminal-generated signals
988    /// (Ctrl-C) do not reach it; terminating a run is the wrapper's
989    /// job via drop, timeout, or explicit kill. This is the right
990    /// contract for supervisors (daemons, MCP servers, worker queues).
991    ///
992    /// Shared group (`false`): the child stays in the host's process
993    /// group, so a terminal Ctrl-C reaches the whole run directly, but
994    /// a wrapper-side kill only reaches the direct child and any
995    /// subprocesses it spawned for tool use survive it. This is the
996    /// right contract for terminal-attached hosts that shell out
997    /// synchronously and rely on the terminal as the supervisor.
998    ///
999    /// No effect on non-Unix targets.
1000    #[must_use]
1001    pub fn process_group(mut self, enabled: bool) -> Self {
1002        self.process_group = Some(enabled);
1003        self
1004    }
1005
1006    /// Grace period between SIGTERM and SIGKILL when a run is killed
1007    /// by a timeout or a duplex shutdown overrun (Unix). Default:
1008    /// none; kills are immediate SIGKILL.
1009    ///
1010    /// With a grace set, the child's whole process group gets SIGTERM
1011    /// first so `claude` can flush its transcript and session state,
1012    /// and SIGKILL follows once the grace elapses. The full grace is
1013    /// waited before the timeout error returns, so keep it short
1014    /// (500ms to 2s). Dropping a future cannot wait, so drop-path
1015    /// cancellation stays immediate SIGKILL, and the grace applies
1016    /// only while the child is in its own process group (see
1017    /// [`process_group`](Self::process_group)).
1018    #[must_use]
1019    pub fn kill_grace(mut self, grace: Duration) -> Self {
1020        self.kill_grace = Some(grace);
1021        self
1022    }
1023
1024    /// Ceiling, in bytes, on stdout and stderr captured from a buffered
1025    /// run. Default: none; capture grows to whatever the child prints.
1026    ///
1027    /// The limit applies to each stream independently, so a client with
1028    /// a 1 MiB ceiling holds at most 2 MiB across the pair. Reaching it
1029    /// on either stream terminates the child's process group (honoring
1030    /// [`kill_grace`](Self::kill_grace)), reaps the direct child, and
1031    /// returns [`Error::OutputLimitExceeded`] rather than a truncated
1032    /// success: continuing to read past the ceiling would defeat the
1033    /// bound, and presenting a partial answer as a complete one is worse
1034    /// than failing. Output captured before the ceiling tripped is
1035    /// logged at warn.
1036    ///
1037    /// This bounds the buffered paths (`execute`, `execute_json`, and
1038    /// their cancellable and blocking forms). Streaming runs deliver
1039    /// events as they arrive and are not affected.
1040    #[must_use]
1041    pub fn output_limit(mut self, bytes: usize) -> Self {
1042        self.output_limit = Some(bytes);
1043        self
1044    }
1045
1046    /// Observe every child this client spawns, at spawn time.
1047    ///
1048    /// The observer receives a [`SpawnInfo`] before the run produces output,
1049    /// which is what makes it useful to a supervisor: recording the pid only
1050    /// once a run *finishes* leaves nothing to reconcile after a crash
1051    /// mid-run. Fires for one-shot runs, streaming runs, and duplex sessions
1052    /// alike, and on retry it fires once per attempt, since each attempt is a
1053    /// distinct process.
1054    ///
1055    /// The observer runs inline on the spawning thread, so it must not block.
1056    /// Write a pidfile or push to a channel; do not do I/O that can stall.
1057    ///
1058    /// # Why a callback rather than a return value
1059    ///
1060    /// The crash case needs the pid to be durable *before* the run can be
1061    /// orphaned. A pid on the result type arrives too late for exactly the
1062    /// scenario that motivates recording it.
1063    ///
1064    /// # Example
1065    ///
1066    /// ```no_run
1067    /// use std::sync::Arc;
1068    /// use claude_wrapper::Claude;
1069    ///
1070    /// # fn example() -> claude_wrapper::Result<()> {
1071    /// let claude = Claude::builder()
1072    ///     .on_spawn(Arc::new(|info| {
1073    ///         eprintln!("spawned pid {} (group {:?})", info.pid, info.pgid);
1074    ///     }))
1075    ///     .build()?;
1076    /// # Ok(())
1077    /// # }
1078    /// ```
1079    #[must_use]
1080    pub fn on_spawn(mut self, observer: SpawnObserver) -> Self {
1081        self.on_spawn = Some(observer);
1082        self
1083    }
1084
1085    /// Ask the kernel to kill spawned children when this process dies.
1086    ///
1087    /// **Linux only.** Check [`die_with_parent_supported`](crate::exec::die_with_parent_supported)
1088    /// rather than assuming; elsewhere this is accepted and does nothing.
1089    ///
1090    /// # The problem it addresses
1091    ///
1092    /// Every other cleanup path in this crate is a destructor: `kill_on_drop`,
1093    /// and the process-group kill on drop, timeout, or stream error. None of
1094    /// them run when *this* process is SIGKILLed. The child is then reparented
1095    /// to init, and because it leads its own process group (see
1096    /// [`process_group`](Self::process_group)) terminal signals cannot reach it
1097    /// either. It keeps running, keeps billing, and keeps appending to the
1098    /// session transcript, so a restarted supervisor that resumes the same
1099    /// session id can find itself interleaving with an orphan still writing.
1100    ///
1101    /// On Linux `PR_SET_PDEATHSIG` closes that: the kernel delivers SIGKILL to
1102    /// the child the moment its parent dies, with no cooperation from either
1103    /// side.
1104    ///
1105    /// # What it does not cover
1106    ///
1107    /// - **Non-Linux targets.** macOS has no equivalent. A supervisor that
1108    ///   needs the guarantee there has to poll and kill by pid; recording the
1109    ///   pid is what [`on_spawn`](Self::on_spawn) is for.
1110    /// - **Re-parenting.** The signal fires when the *immediate* parent dies.
1111    ///   If the crate's caller is itself an intermediate process that exits
1112    ///   normally, the child dies then, which is usually what you want but is
1113    ///   worth knowing if you daemonize between building the client and
1114    ///   spawning.
1115    /// - **The fork/prctl window.** Handled: the hook re-checks `getppid()`
1116    ///   after arming and exits if the parent already changed. Without that
1117    ///   check a parent dying in that window leaves exactly the orphan this
1118    ///   option exists to prevent.
1119    ///
1120    /// Off by default, because killing children on parent exit is the right
1121    /// default for a supervisor and the wrong one for a CLI that deliberately
1122    /// backgrounds work.
1123    #[must_use]
1124    pub fn die_with_parent(mut self, enabled: bool) -> Self {
1125        self.die_with_parent = enabled;
1126        self
1127    }
1128
1129    /// Build the Claude client, resolving the binary path.
1130    pub fn build(self) -> Result<Claude> {
1131        let binary = match self.binary {
1132            Some(path) => path,
1133            None => which::which("claude").map_err(|_| Error::NotFound)?,
1134        };
1135
1136        Ok(Claude {
1137            binary,
1138            working_dir: self.working_dir,
1139            env: self.env,
1140            clear_env: self.clear_env,
1141            global_args: self.global_args,
1142            timeout: self.timeout,
1143            retry_policy: self.retry_policy,
1144            tested_cli_version_range: self.tested_cli_version_range,
1145            process_group: self.process_group.unwrap_or(true),
1146            kill_grace: self.kill_grace,
1147            output_limit: self.output_limit,
1148            on_spawn: self.on_spawn,
1149            die_with_parent: self.die_with_parent,
1150        })
1151    }
1152}
1153
1154#[cfg(test)]
1155mod tests {
1156    use super::*;
1157
1158    #[test]
1159    fn builder_process_group_defaults_on_and_can_opt_out() {
1160        let on = Claude::builder()
1161            .binary("/nonexistent/claude")
1162            .build()
1163            .unwrap();
1164        assert!(on.process_group);
1165
1166        let off = Claude::builder()
1167            .binary("/nonexistent/claude")
1168            .process_group(false)
1169            .build()
1170            .unwrap();
1171        assert!(!off.process_group);
1172    }
1173
1174    #[test]
1175    fn builder_clear_env_defaults_off_and_is_call_order_independent() {
1176        let inherited = Claude::builder()
1177            .binary("/nonexistent/claude")
1178            .build()
1179            .unwrap();
1180        assert!(!inherited.clear_env);
1181
1182        let cleared = Claude::builder()
1183            .binary("/nonexistent/claude")
1184            .env("FIRST", "1")
1185            .clear_env()
1186            .env("SECOND", "2")
1187            .build()
1188            .unwrap();
1189        assert!(cleared.clear_env);
1190        assert_eq!(cleared.env.get("FIRST").map(String::as_str), Some("1"));
1191        assert_eq!(cleared.env.get("SECOND").map(String::as_str), Some("2"));
1192    }
1193
1194    #[test]
1195    fn builder_kill_grace_defaults_off_and_can_be_set() {
1196        let off = Claude::builder()
1197            .binary("/nonexistent/claude")
1198            .build()
1199            .unwrap();
1200        assert!(off.kill_grace.is_none());
1201
1202        let on = Claude::builder()
1203            .binary("/nonexistent/claude")
1204            .kill_grace(Duration::from_millis(750))
1205            .build()
1206            .unwrap();
1207        assert_eq!(on.kill_grace, Some(Duration::from_millis(750)));
1208    }
1209
1210    // -- the version gate ------------------------------------------
1211    //
1212    // `gate_version` is the decision both the async and sync gates
1213    // share, and it takes the version rather than fetching it, so the
1214    // policy is testable without spawning a binary.
1215
1216    #[cfg(any(feature = "async", feature = "sync"))]
1217    fn gate(found: (u32, u32, u32)) -> Result<CliVersion> {
1218        let claude = Claude::builder()
1219            .binary("/nonexistent/claude")
1220            .build()
1221            .unwrap();
1222        let (min, max) = claude.effective_tested_range();
1223        claude.gate_version(CliVersion::new(found.0, found.1, found.2), min, max)
1224    }
1225
1226    #[cfg(any(feature = "async", feature = "sync"))]
1227    #[test]
1228    fn gate_accepts_a_version_inside_the_declared_range() {
1229        let found = gate((
1230            TESTED_CLI_VERSION_MIN.major,
1231            TESTED_CLI_VERSION_MIN.minor,
1232            TESTED_CLI_VERSION_MIN.patch,
1233        ))
1234        .expect("the declared minimum must pass its own gate");
1235        assert_eq!(found, TESTED_CLI_VERSION_MIN);
1236    }
1237
1238    #[cfg(any(feature = "async", feature = "sync"))]
1239    #[test]
1240    fn gate_rejects_older_than_minimum_with_both_bounds() {
1241        let err = gate((1, 0, 0)).expect_err("1.0.0 is below any supported floor");
1242        match err {
1243            Error::UntestedCliVersion {
1244                found,
1245                tested_min,
1246                tested_max,
1247            } => {
1248                assert_eq!(found, CliVersion::new(1, 0, 0));
1249                assert_eq!(tested_min, TESTED_CLI_VERSION_MIN);
1250                assert_eq!(tested_max, TESTED_CLI_VERSION_MAX);
1251                // The message must say which side it fell off, since
1252                // "too old" and "too new" call for opposite fixes.
1253                assert!(err_text(&err).contains("older"), "{}", err_text(&err));
1254            }
1255            other => panic!("expected UntestedCliVersion, got {other:?}"),
1256        }
1257    }
1258
1259    #[cfg(any(feature = "async", feature = "sync"))]
1260    #[test]
1261    fn gate_rejects_newer_than_maximum() {
1262        let err = gate((99, 0, 0)).expect_err("99.0.0 is above the tested ceiling");
1263        assert!(matches!(err, Error::UntestedCliVersion { .. }));
1264        assert!(err_text(&err).contains("newer"), "{}", err_text(&err));
1265    }
1266
1267    #[cfg(any(feature = "async", feature = "sync"))]
1268    #[test]
1269    fn gate_honours_a_caller_supplied_range_over_the_crate_default() {
1270        // A host that has verified a narrower window should be able to
1271        // enforce it, including rejecting versions the crate itself
1272        // considers fine.
1273        let claude = Claude::builder()
1274            .binary("/nonexistent/claude")
1275            .tested_cli_version_range(CliVersion::new(3, 0, 0), CliVersion::new(3, 0, 9))
1276            .build()
1277            .unwrap();
1278        let (min, max) = claude.effective_tested_range();
1279        assert_eq!(min, CliVersion::new(3, 0, 0));
1280        assert!(
1281            claude
1282                .gate_version(CliVersion::new(3, 0, 5), min, max)
1283                .is_ok()
1284        );
1285        assert!(
1286            claude
1287                .gate_version(TESTED_CLI_VERSION_MIN, min, max)
1288                .is_err(),
1289            "the caller's range must win over the crate's"
1290        );
1291    }
1292
1293    #[cfg(any(feature = "async", feature = "sync"))]
1294    fn err_text(e: &Error) -> String {
1295        e.to_string()
1296    }
1297
1298    #[test]
1299    fn test_builder_with_binary() {
1300        let claude = Claude::builder()
1301            .binary("/usr/local/bin/claude")
1302            .env("FOO", "bar")
1303            .timeout_secs(60)
1304            .build()
1305            .unwrap();
1306
1307        assert_eq!(claude.binary, PathBuf::from("/usr/local/bin/claude"));
1308        assert_eq!(claude.env.get("FOO").unwrap(), "bar");
1309        assert_eq!(claude.timeout, Some(Duration::from_secs(60)));
1310    }
1311
1312    #[test]
1313    fn test_builder_global_args() {
1314        let claude = Claude::builder()
1315            .binary("/usr/local/bin/claude")
1316            .arg("--verbose")
1317            .build()
1318            .unwrap();
1319
1320        assert_eq!(claude.global_args, vec!["--verbose"]);
1321    }
1322
1323    #[test]
1324    fn test_builder_verbose() {
1325        let claude = Claude::builder()
1326            .binary("/usr/local/bin/claude")
1327            .verbose()
1328            .build()
1329            .unwrap();
1330        assert!(claude.global_args.contains(&"--verbose".to_string()));
1331    }
1332
1333    #[test]
1334    fn test_builder_debug() {
1335        let claude = Claude::builder()
1336            .binary("/usr/local/bin/claude")
1337            .debug()
1338            .build()
1339            .unwrap();
1340        assert!(claude.global_args.contains(&"--debug".to_string()));
1341    }
1342}