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 | via [`Conversation`] ([`TurnResult`] reports the session's running total) | 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};
405#[cfg(feature = "async")]
406pub use error::{DuplexLimit, DuplexOperation};
407pub use error::{Error, OutputStream, Result};
408pub use exec::CommandOutput;
409#[cfg(feature = "tempfile")]
410pub use mcp_config::TempMcpConfig;
411pub use mcp_config::{McpConfigBuilder, McpServerConfig};
412pub use retry::{BackoffStrategy, RetryPolicy};
413#[cfg(all(feature = "json", feature = "async"))]
414pub use session::Session;
415pub use tool_pattern::{PatternError, ToolPattern};
416pub use types::*;
417pub use version::{
418 CliVersion, CliVersionStatus, TESTED_CLI_VERSION_MAX, TESTED_CLI_VERSION_MIN, VersionParseError,
419};
420
421/// What the crate knows about a child the moment it is spawned.
422///
423/// Delivered to the [`ClaudeBuilder::on_spawn`] observer before the run can
424/// produce output, which is the point: a supervisor that records the pid only
425/// after a run *finishes* has nothing to reconcile when it crashes mid-run.
426#[derive(Debug, Clone, Copy, PartialEq, Eq)]
427#[non_exhaustive]
428pub struct SpawnInfo {
429 /// The child's process id.
430 pub pid: u32,
431 /// The child's process group id, when it leads its own group.
432 ///
433 /// `None` when [`ClaudeBuilder::process_group`] is disabled, in which case
434 /// the child shares the parent's group and its pid must never be passed to
435 /// `killpg`: that would signal the caller's own process group.
436 pub pgid: Option<u32>,
437}
438
439/// Called with [`SpawnInfo`] each time the crate spawns a CLI child.
440///
441/// Must not block: it runs inline on the spawning thread, between `spawn` and
442/// the first read of the child's output.
443pub type SpawnObserver = std::sync::Arc<dyn Fn(SpawnInfo) + Send + Sync>;
444
445/// Signal sent before the configured [`ClaudeBuilder::kill_grace`] elapses.
446///
447/// Applies only to awaited shutdown on Unix with an owned process group and
448/// nonzero grace. Dropping a future always uses immediate SIGKILL.
449#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
450pub enum TerminationSignal {
451 /// Send SIGTERM, the default graceful termination signal.
452 #[default]
453 Terminate,
454 /// Send SIGINT so the CLI can cancel tools before exiting.
455 Interrupt,
456}
457
458/// The Claude CLI client. Holds shared configuration applied to all commands.
459///
460/// Create one via [`Claude::builder()`] and reuse it across commands.
461#[derive(Clone)]
462pub struct Claude {
463 pub(crate) binary: PathBuf,
464 pub(crate) working_dir: Option<PathBuf>,
465 // env, timeout, and retry_policy are written by the builder under
466 // every feature combination but read only by the feature-gated
467 // exec paths, so they are "never read" with neither `async` nor
468 // `sync` feature.
469 #[allow(dead_code)]
470 pub(crate) env: HashMap<String, String>,
471 #[allow(dead_code)]
472 pub(crate) clear_env: bool,
473 pub(crate) global_args: Vec<String>,
474 #[allow(dead_code)]
475 pub(crate) timeout: Option<Duration>,
476 #[allow(dead_code)]
477 pub(crate) retry_policy: Option<RetryPolicy>,
478 pub(crate) tested_cli_version_range: Option<(CliVersion, CliVersion)>,
479 // Read only by the feature-gated exec paths, like env/timeout.
480 #[allow(dead_code)]
481 pub(crate) process_group: bool,
482 #[allow(dead_code)]
483 pub(crate) kill_grace: Option<Duration>,
484 #[allow(dead_code)]
485 pub(crate) termination_signal: TerminationSignal,
486 #[allow(dead_code)]
487 pub(crate) output_limit: Option<usize>,
488 #[allow(dead_code)]
489 pub(crate) on_spawn: Option<SpawnObserver>,
490 #[allow(dead_code)]
491 pub(crate) die_with_parent: bool,
492}
493
494impl std::fmt::Debug for Claude {
495 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
496 f.debug_struct("Claude")
497 .field("binary", &self.binary)
498 .field("working_dir", &self.working_dir)
499 .field("clear_env", &self.clear_env)
500 .field("global_args", &self.global_args)
501 .field("timeout", &self.timeout)
502 .field("process_group", &self.process_group)
503 .field("kill_grace", &self.kill_grace)
504 .field("termination_signal", &self.termination_signal)
505 .field("output_limit", &self.output_limit)
506 // A closure cannot be rendered, and env may hold credentials, so
507 // neither is printed: only whether an observer is installed.
508 .field("on_spawn", &self.on_spawn.is_some())
509 .finish_non_exhaustive()
510 }
511}
512
513impl Claude {
514 /// Create a new builder for configuring the Claude client.
515 #[must_use]
516 pub fn builder() -> ClaudeBuilder {
517 ClaudeBuilder::default()
518 }
519
520 /// Get the path to the claude binary.
521 #[must_use]
522 pub fn binary(&self) -> &Path {
523 &self.binary
524 }
525
526 /// Get the working directory, if set.
527 #[must_use]
528 pub fn working_dir(&self) -> Option<&Path> {
529 self.working_dir.as_deref()
530 }
531
532 /// Create a clone of this client with a different working directory.
533 #[must_use]
534 pub fn with_working_dir(&self, dir: impl Into<PathBuf>) -> Self {
535 let mut clone = self.clone();
536 clone.working_dir = Some(dir.into());
537 clone
538 }
539
540 /// Query the installed CLI version.
541 ///
542 /// Runs `claude --version` and parses the output into a [`CliVersion`].
543 ///
544 /// # Example
545 ///
546 /// ```no_run
547 /// # async fn example() -> claude_wrapper::Result<()> {
548 /// let claude = claude_wrapper::Claude::builder().build()?;
549 /// let version = claude.cli_version().await?;
550 /// println!("Claude CLI {version}");
551 /// # Ok(())
552 /// # }
553 /// ```
554 #[cfg(feature = "async")]
555 pub async fn cli_version(&self) -> Result<CliVersion> {
556 let output = VersionCommand::new().execute(self).await?;
557 CliVersion::parse_version_output(&output.stdout).map_err(|e| Error::Io {
558 message: format!("failed to parse CLI version: {e}"),
559 source: std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()),
560 working_dir: None,
561 })
562 }
563
564 /// Check that the installed CLI version meets a minimum requirement.
565 ///
566 /// Returns the detected version on success, or an error if the version
567 /// is below the minimum.
568 ///
569 /// # Example
570 ///
571 /// ```no_run
572 /// use claude_wrapper::CliVersion;
573 ///
574 /// # async fn example() -> claude_wrapper::Result<()> {
575 /// let claude = claude_wrapper::Claude::builder().build()?;
576 /// let version = claude.check_version(&CliVersion::new(2, 1, 0)).await?;
577 /// println!("CLI version {version} meets minimum requirement");
578 /// # Ok(())
579 /// # }
580 /// ```
581 #[cfg(feature = "async")]
582 pub async fn check_version(&self, minimum: &CliVersion) -> Result<CliVersion> {
583 let version = self.cli_version().await?;
584 if version.satisfies_minimum(minimum) {
585 Ok(version)
586 } else {
587 Err(Error::VersionMismatch {
588 found: version,
589 minimum: *minimum,
590 })
591 }
592 }
593
594 /// Blocking mirror of [`Claude::cli_version`]. Requires the
595 /// `sync` feature.
596 #[cfg(feature = "sync")]
597 pub fn cli_version_sync(&self) -> Result<CliVersion> {
598 let output = VersionCommand::new().execute_sync(self)?;
599 CliVersion::parse_version_output(&output.stdout).map_err(|e| Error::Io {
600 message: format!("failed to parse CLI version: {e}"),
601 source: std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()),
602 working_dir: None,
603 })
604 }
605
606 /// Blocking mirror of [`Claude::check_version`]. Requires the
607 /// `sync` feature.
608 #[cfg(feature = "sync")]
609 pub fn check_version_sync(&self, minimum: &CliVersion) -> Result<CliVersion> {
610 let version = self.cli_version_sync()?;
611 if version.satisfies_minimum(minimum) {
612 Ok(version)
613 } else {
614 Err(Error::VersionMismatch {
615 found: version,
616 minimum: *minimum,
617 })
618 }
619 }
620
621 /// The tested-against `[min, max]` range declared at build time
622 /// via [`ClaudeBuilder::tested_cli_version_range`], if any.
623 ///
624 /// `None` means no override was set, in which case version checks
625 /// use the crate's own [`TESTED_CLI_VERSION_MIN`] /
626 /// [`TESTED_CLI_VERSION_MAX`]; see [`Self::effective_tested_range`].
627 #[must_use]
628 pub fn tested_cli_version_range(&self) -> Option<(CliVersion, CliVersion)> {
629 self.tested_cli_version_range
630 }
631
632 /// The range version checks actually use: the caller's override when one
633 /// was set, otherwise the crate's own declared range.
634 #[must_use]
635 pub fn effective_tested_range(&self) -> (CliVersion, CliVersion) {
636 self.tested_cli_version_range
637 .unwrap_or((TESTED_CLI_VERSION_MIN, TESTED_CLI_VERSION_MAX))
638 }
639
640 /// Classify the installed CLI against the tested-against range.
641 /// Logs a `tracing::warn!` when outside the range; returns the
642 /// typed status either way.
643 ///
644 /// The range defaults to the crate's own
645 /// [`TESTED_CLI_VERSION_MIN`] / [`TESTED_CLI_VERSION_MAX`],
646 /// because only the crate knows what it was built and tested
647 /// against. [`ClaudeBuilder::tested_cli_version_range`] overrides
648 /// it for hosts that have verified a different range themselves.
649 ///
650 /// Intended for one-shot use at startup, not on every command.
651 #[cfg(feature = "async")]
652 pub async fn cli_version_status(&self) -> Result<CliVersionStatus> {
653 let (min, max) = self.effective_tested_range();
654 let found = self.cli_version().await?;
655 let status = found.status_within(&min, &max);
656 warn_on_drift(&status);
657 Ok(status)
658 }
659
660 /// Blocking mirror of [`Claude::cli_version_status`]. Requires
661 /// the `sync` feature.
662 #[cfg(feature = "sync")]
663 pub fn cli_version_status_sync(&self) -> Result<CliVersionStatus> {
664 let (min, max) = self.effective_tested_range();
665 let found = self.cli_version_sync()?;
666 let status = found.status_within(&min, &max);
667 warn_on_drift(&status);
668 Ok(status)
669 }
670
671 /// Refuse to proceed against a CLI outside the tested range.
672 ///
673 /// The opt-in hard gate. [`Claude::cli_version_status`] is the
674 /// reporting path and keeps its behavior: it returns a typed
675 /// status and warns. This one turns the same condition into
676 /// [`Error::UntestedCliVersion`], for hosts that would rather fail
677 /// at startup than run on an unverified binary.
678 ///
679 /// Returns the detected version on success. The drift warning
680 /// still fires on the failure path, so a host that logs and a host
681 /// that gates see the same line.
682 ///
683 /// # Why this is a method and not a builder option
684 ///
685 /// [`ClaudeBuilder::build`] is synchronous and never spawns the
686 /// binary. Enforcing a version there would mean running a
687 /// subprocess inside a constructor, so the check lives where the
688 /// caller can await it.
689 ///
690 /// # Example
691 ///
692 /// ```no_run
693 /// # async fn example() -> claude_wrapper::Result<()> {
694 /// let claude = claude_wrapper::Claude::builder().build()?;
695 /// // Run once at startup; returns Err on an untested CLI.
696 /// let version = claude.ensure_tested_cli_version().await?;
697 /// println!("running against {version}");
698 /// # Ok(())
699 /// # }
700 /// ```
701 #[cfg(feature = "async")]
702 pub async fn ensure_tested_cli_version(&self) -> Result<CliVersion> {
703 let (min, max) = self.effective_tested_range();
704 let found = self.cli_version().await?;
705 self.gate_version(found, min, max)
706 }
707
708 /// Blocking mirror of [`Claude::ensure_tested_cli_version`].
709 /// Requires the `sync` feature.
710 #[cfg(feature = "sync")]
711 pub fn ensure_tested_cli_version_sync(&self) -> Result<CliVersion> {
712 let (min, max) = self.effective_tested_range();
713 let found = self.cli_version_sync()?;
714 self.gate_version(found, min, max)
715 }
716
717 /// Shared decision for the async and sync gates, so the two cannot
718 /// disagree about what "outside the range" means.
719 #[cfg(any(feature = "async", feature = "sync"))]
720 fn gate_version(
721 &self,
722 found: CliVersion,
723 min: CliVersion,
724 max: CliVersion,
725 ) -> Result<CliVersion> {
726 let status = found.status_within(&min, &max);
727 warn_on_drift(&status);
728 if status.is_tested() {
729 Ok(found)
730 } else {
731 Err(Error::UntestedCliVersion {
732 found,
733 tested_min: min,
734 tested_max: max,
735 })
736 }
737 }
738}
739
740#[allow(dead_code)] // unused with neither `async` nor `sync` feature
741fn warn_on_drift(status: &CliVersionStatus) {
742 match status {
743 CliVersionStatus::Tested => {}
744 CliVersionStatus::NewerUntested {
745 found, tested_max, ..
746 } => {
747 tracing::warn!(
748 found = %found,
749 tested_max = %tested_max,
750 "claude CLI is newer than the wrapper's tested-against range; \
751 semantics may have drifted -- proceed with caution"
752 );
753 }
754 CliVersionStatus::OlderThanMinimum { found, minimum, .. } => {
755 tracing::warn!(
756 found = %found,
757 minimum = %minimum,
758 "claude CLI is older than the wrapper's declared minimum; \
759 incorrect behavior is likely (missing flags, different shapes)"
760 );
761 }
762 }
763}
764
765/// Builder for creating a [`Claude`] client.
766///
767/// # Example
768///
769/// ```no_run
770/// use claude_wrapper::Claude;
771///
772/// # fn example() -> claude_wrapper::Result<()> {
773/// let claude = Claude::builder()
774/// .env("AWS_REGION", "us-west-2")
775/// .timeout_secs(120)
776/// .build()?;
777/// # Ok(())
778/// # }
779/// ```
780#[derive(Default)]
781pub struct ClaudeBuilder {
782 binary: Option<PathBuf>,
783 working_dir: Option<PathBuf>,
784 env: HashMap<String, String>,
785 clear_env: bool,
786 global_args: Vec<String>,
787 timeout: Option<Duration>,
788 retry_policy: Option<RetryPolicy>,
789 tested_cli_version_range: Option<(CliVersion, CliVersion)>,
790 process_group: Option<bool>,
791 kill_grace: Option<Duration>,
792 termination_signal: TerminationSignal,
793 output_limit: Option<usize>,
794 on_spawn: Option<SpawnObserver>,
795 die_with_parent: bool,
796}
797
798impl std::fmt::Debug for ClaudeBuilder {
799 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
800 // Mirrors Claude's Debug: a closure cannot be rendered and env may
801 // hold credentials, so neither is printed.
802 f.debug_struct("ClaudeBuilder")
803 .field("binary", &self.binary)
804 .field("working_dir", &self.working_dir)
805 .field("clear_env", &self.clear_env)
806 .field("global_args", &self.global_args)
807 .field("timeout", &self.timeout)
808 .field("process_group", &self.process_group)
809 .field("kill_grace", &self.kill_grace)
810 .field("termination_signal", &self.termination_signal)
811 .field("output_limit", &self.output_limit)
812 .field("on_spawn", &self.on_spawn.is_some())
813 .finish_non_exhaustive()
814 }
815}
816
817impl ClaudeBuilder {
818 /// Set the path to the claude binary.
819 ///
820 /// If not set, the binary is resolved from PATH using `which`.
821 #[must_use]
822 pub fn binary(mut self, path: impl Into<PathBuf>) -> Self {
823 self.binary = Some(path.into());
824 self
825 }
826
827 /// Set the working directory for all commands.
828 ///
829 /// The spawned process will use this as its current directory.
830 #[must_use]
831 pub fn working_dir(mut self, path: impl Into<PathBuf>) -> Self {
832 self.working_dir = Some(path.into());
833 self
834 }
835
836 /// Add an environment variable to pass to all commands.
837 #[must_use]
838 pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
839 self.env.insert(key.into(), value.into());
840 self
841 }
842
843 /// Add multiple environment variables.
844 #[must_use]
845 pub fn envs(
846 mut self,
847 vars: impl IntoIterator<Item = (impl Into<String>, impl Into<String>)>,
848 ) -> Self {
849 for (k, v) in vars {
850 self.env.insert(k.into(), v.into());
851 }
852 self
853 }
854
855 /// Clear the inherited environment of every spawned Claude CLI child.
856 ///
857 /// By default, children inherit the parent process environment and
858 /// [`env`](Self::env) / [`envs`](Self::envs) add or replace entries. With
859 /// this option enabled, the inherited environment is cleared first and
860 /// only explicitly configured entries are applied. The order in which
861 /// `clear_env`, `env`, and `envs` are called does not affect the result.
862 ///
863 /// Callers normally need to rebuild a minimal environment including
864 /// `PATH`, locale settings, the Claude config directory, and the intended
865 /// authentication selector.
866 ///
867 /// This controls only the direct child process environment. It is not an
868 /// operating-system sandbox and does not prevent a same-UID child from
869 /// reading accessible files or inspecting other processes where the OS
870 /// permits it.
871 ///
872 /// # Example
873 ///
874 /// ```no_run
875 /// use claude_wrapper::Claude;
876 ///
877 /// # fn example() -> claude_wrapper::Result<()> {
878 /// let claude = Claude::builder()
879 /// .clear_env()
880 /// .env("PATH", "/usr/local/bin:/usr/bin:/bin")
881 /// .env("CLAUDE_CONFIG_DIR", "/srv/claude/config")
882 /// .build()?;
883 /// # Ok(())
884 /// # }
885 /// ```
886 #[must_use]
887 pub fn clear_env(mut self) -> Self {
888 self.clear_env = true;
889 self
890 }
891
892 /// Set a default timeout for all commands (in seconds).
893 ///
894 /// See [`Self::timeout`] for scope.
895 #[must_use]
896 pub fn timeout_secs(mut self, seconds: u64) -> Self {
897 self.timeout = Some(Duration::from_secs(seconds));
898 self
899 }
900
901 /// Set a default timeout for all commands.
902 ///
903 /// Bounds one-shot command runs. It does not apply to duplex sessions,
904 /// which stay open across turns and have no lifetime limit; opt in per
905 /// operation with `DuplexOptions::turn_timeout` and
906 /// `DuplexOptions::control_timeout`.
907 #[must_use]
908 pub fn timeout(mut self, duration: Duration) -> Self {
909 self.timeout = Some(duration);
910 self
911 }
912
913 /// Add a global argument applied to all commands.
914 ///
915 /// This is an escape hatch for flags not yet covered by the API.
916 #[must_use]
917 pub fn arg(mut self, arg: impl Into<String>) -> Self {
918 self.global_args.push(arg.into());
919 self
920 }
921
922 /// Enable verbose output for all commands (`--verbose`).
923 #[must_use]
924 pub fn verbose(mut self) -> Self {
925 self.global_args.push("--verbose".into());
926 self
927 }
928
929 /// Enable debug output for all commands (`--debug`).
930 #[must_use]
931 pub fn debug(mut self) -> Self {
932 self.global_args.push("--debug".into());
933 self
934 }
935
936 /// Set a default retry policy for all commands.
937 ///
938 /// Individual commands can override this via their own retry settings.
939 ///
940 /// # Example
941 ///
942 /// ```no_run
943 /// use claude_wrapper::{Claude, RetryPolicy};
944 /// use std::time::Duration;
945 ///
946 /// # fn example() -> claude_wrapper::Result<()> {
947 /// let claude = Claude::builder()
948 /// .retry(RetryPolicy::new()
949 /// .max_attempts(3)
950 /// .initial_backoff(Duration::from_secs(2))
951 /// .exponential()
952 /// .retry_on_timeout(true))
953 /// .build()?;
954 /// # Ok(())
955 /// # }
956 /// ```
957 #[must_use]
958 pub fn retry(mut self, policy: RetryPolicy) -> Self {
959 self.retry_policy = Some(policy);
960 self
961 }
962
963 /// Declare the inclusive `[min, max]` range of `claude` CLI
964 /// versions this client has been tested against.
965 ///
966 /// The wrapper does not enforce the range -- nothing errors when
967 /// it's set wrong. Use [`Claude::cli_version_status`] (or its
968 /// sync mirror) at startup to classify the actually-installed CLI
969 /// against this declaration; that call returns a typed
970 /// [`CliVersionStatus`] AND emits a `tracing::warn!` when
971 /// outside the range. Hosts (claude-server, application code)
972 /// can additionally surface the status to operators.
973 ///
974 /// # Why this exists
975 ///
976 /// CLI semantics drift across minor / patch releases (e.g.
977 /// `claude agents` was repurposed in 2.1.143). The min floor
978 /// lets us say "we know it's broken below this"; the max ceiling
979 /// lets us say "we haven't verified above this -- proceed but
980 /// expect surprises."
981 ///
982 /// # You usually do not need this
983 ///
984 /// The crate declares its own range in
985 /// [`TESTED_CLI_VERSION_MIN`] / [`TESTED_CLI_VERSION_MAX`], and
986 /// version checks use it by default, because only the crate knows
987 /// what it was built and tested against. Set this only when a host
988 /// has verified a different range itself.
989 ///
990 /// # Example
991 ///
992 /// ```no_run
993 /// use claude_wrapper::{Claude, CliVersion};
994 ///
995 /// # async fn example() -> claude_wrapper::Result<()> {
996 /// let claude = Claude::builder()
997 /// .tested_cli_version_range(CliVersion::new(2, 1, 0), CliVersion::new(2, 1, 999))
998 /// .build()?;
999 /// // Run once at startup to log a warning if the CLI is out of range.
1000 /// let _status = claude.cli_version_status().await?;
1001 /// # Ok(()) }
1002 /// ```
1003 #[must_use]
1004 pub fn tested_cli_version_range(mut self, min: CliVersion, max: CliVersion) -> Self {
1005 self.tested_cli_version_range = Some((min, max));
1006 self
1007 }
1008
1009 /// Control whether spawned `claude` children are placed in their
1010 /// own process group on Unix. Defaults to `true`.
1011 ///
1012 /// Own group (the default): cancellation and timeouts kill the
1013 /// child's owned process group. Descendants that create another group
1014 /// require cooperative cleanup by the CLI. The child no longer shares the
1015 /// host terminal's process group, so terminal-generated signals
1016 /// (Ctrl-C) do not reach it; terminating a run is the wrapper's
1017 /// job via drop, timeout, or explicit kill. This is the right
1018 /// contract for supervisors (daemons, MCP servers, worker queues).
1019 ///
1020 /// Shared group (`false`): the child stays in the host's process
1021 /// group, so a terminal Ctrl-C reaches the whole run directly, but
1022 /// a wrapper-side kill only reaches the direct child and any
1023 /// subprocesses it spawned for tool use survive it. This is the
1024 /// right contract for terminal-attached hosts that shell out
1025 /// synchronously and rely on the terminal as the supervisor.
1026 ///
1027 /// No effect on non-Unix targets.
1028 #[must_use]
1029 pub fn process_group(mut self, enabled: bool) -> Self {
1030 self.process_group = Some(enabled);
1031 self
1032 }
1033
1034 /// Grace period before SIGKILL on timeout, explicit cancellation, or a
1035 /// duplex shutdown overrun (Unix). Default: none; kills are immediate.
1036 ///
1037 /// With nonzero grace, the owned process group first receives
1038 /// [`termination_signal`](Self::termination_signal), SIGTERM by default.
1039 /// Output is drained and discarded while the CLI gets a chance to clean
1040 /// up, then SIGKILL follows after the full grace. Keep the grace short
1041 /// (500ms to 2s). Detached descendants require cooperation from the CLI;
1042 /// group signals cannot guarantee their termination.
1043 ///
1044 /// Dropping a future cannot wait and always uses immediate SIGKILL.
1045 /// With [`process_group(false)`](Self::process_group), grace and the soft
1046 /// signal are skipped and only the direct child is killed.
1047 #[must_use]
1048 pub fn kill_grace(mut self, grace: Duration) -> Self {
1049 self.kill_grace = Some(grace);
1050 self
1051 }
1052
1053 /// Select the first signal for graceful shutdown. Defaults to SIGTERM.
1054 ///
1055 /// Requires nonzero [`kill_grace`](Self::kill_grace) and an owned Unix
1056 /// process group. SIGINT lets a cooperating CLI cancel its detached tools
1057 /// before the final group SIGKILL. Other platforms and drop cleanup are
1058 /// unchanged. Cancellation and timeout still return their original error
1059 /// even when the CLI exits successfully during this grace.
1060 ///
1061 /// ```no_run
1062 /// use claude_wrapper::{Claude, TerminationSignal};
1063 /// use std::time::Duration;
1064 /// # fn example() -> claude_wrapper::Result<()> {
1065 /// let claude = Claude::builder()
1066 /// .termination_signal(TerminationSignal::Interrupt)
1067 /// .kill_grace(Duration::from_secs(2))
1068 /// .build()?;
1069 /// # Ok(()) }
1070 /// ```
1071 #[must_use]
1072 pub fn termination_signal(mut self, signal: TerminationSignal) -> Self {
1073 self.termination_signal = signal;
1074 self
1075 }
1076
1077 /// Ceiling, in bytes, on stdout and stderr captured from a buffered
1078 /// run. Default: none; capture grows to whatever the child prints.
1079 ///
1080 /// The limit applies to each stream independently, so a client with
1081 /// a 1 MiB ceiling holds at most 2 MiB across the pair. Reaching it
1082 /// on either stream terminates the child's process group (honoring
1083 /// [`kill_grace`](Self::kill_grace)), reaps the direct child, and
1084 /// returns [`Error::OutputLimitExceeded`] rather than a truncated
1085 /// success: continuing to read past the ceiling would defeat the
1086 /// bound, and presenting a partial answer as a complete one is worse
1087 /// than failing. Output captured before the ceiling tripped is
1088 /// logged at warn.
1089 ///
1090 /// This bounds the buffered paths (`execute`, `execute_json`, and
1091 /// their cancellable and blocking forms), and the total bytes consumed
1092 /// by one-shot async streaming runs. Blocking streaming is not affected.
1093 /// Duplex sessions are not capped by this setting; opt in per session with
1094 /// `DuplexOptions::max_frame_bytes`, `max_turn_bytes`, and
1095 /// `max_turn_events`.
1096 #[must_use]
1097 pub fn output_limit(mut self, bytes: usize) -> Self {
1098 self.output_limit = Some(bytes);
1099 self
1100 }
1101
1102 /// Observe every child this client spawns, at spawn time.
1103 ///
1104 /// The observer receives a [`SpawnInfo`] before the run produces output,
1105 /// which is what makes it useful to a supervisor: recording the pid only
1106 /// once a run *finishes* leaves nothing to reconcile after a crash
1107 /// mid-run. Fires for one-shot runs, streaming runs, and duplex sessions
1108 /// alike, and on retry it fires once per attempt, since each attempt is a
1109 /// distinct process.
1110 ///
1111 /// The observer runs inline on the spawning thread, so it must not block.
1112 /// Write a pidfile or push to a channel; do not do I/O that can stall.
1113 ///
1114 /// # Why a callback rather than a return value
1115 ///
1116 /// The crash case needs the pid to be durable *before* the run can be
1117 /// orphaned. A pid on the result type arrives too late for exactly the
1118 /// scenario that motivates recording it.
1119 ///
1120 /// # Example
1121 ///
1122 /// ```no_run
1123 /// use std::sync::Arc;
1124 /// use claude_wrapper::Claude;
1125 ///
1126 /// # fn example() -> claude_wrapper::Result<()> {
1127 /// let claude = Claude::builder()
1128 /// .on_spawn(Arc::new(|info| {
1129 /// eprintln!("spawned pid {} (group {:?})", info.pid, info.pgid);
1130 /// }))
1131 /// .build()?;
1132 /// # Ok(())
1133 /// # }
1134 /// ```
1135 #[must_use]
1136 pub fn on_spawn(mut self, observer: SpawnObserver) -> Self {
1137 self.on_spawn = Some(observer);
1138 self
1139 }
1140
1141 /// Ask the kernel to kill spawned children when this process dies.
1142 ///
1143 /// **Linux only.** Check [`die_with_parent_supported`](crate::exec::die_with_parent_supported)
1144 /// rather than assuming; elsewhere this is accepted and does nothing.
1145 ///
1146 /// # The problem it addresses
1147 ///
1148 /// Every other cleanup path in this crate is a destructor: `kill_on_drop`,
1149 /// and the process-group kill on drop, timeout, or stream error. None of
1150 /// them run when *this* process is SIGKILLed. The child is then reparented
1151 /// to init, and because it leads its own process group (see
1152 /// [`process_group`](Self::process_group)) terminal signals cannot reach it
1153 /// either. It keeps running, keeps billing, and keeps appending to the
1154 /// session transcript, so a restarted supervisor that resumes the same
1155 /// session id can find itself interleaving with an orphan still writing.
1156 ///
1157 /// On Linux `PR_SET_PDEATHSIG` closes that: the kernel delivers SIGKILL to
1158 /// the child the moment its parent dies, with no cooperation from either
1159 /// side.
1160 ///
1161 /// # What it does not cover
1162 ///
1163 /// - **Non-Linux targets.** macOS has no equivalent. A supervisor that
1164 /// needs the guarantee there has to poll and kill by pid; recording the
1165 /// pid is what [`on_spawn`](Self::on_spawn) is for.
1166 /// - **Re-parenting.** The signal fires when the *immediate* parent dies.
1167 /// If the crate's caller is itself an intermediate process that exits
1168 /// normally, the child dies then, which is usually what you want but is
1169 /// worth knowing if you daemonize between building the client and
1170 /// spawning.
1171 /// - **The fork/prctl window.** Handled: the hook re-checks `getppid()`
1172 /// after arming and exits if the parent already changed. Without that
1173 /// check a parent dying in that window leaves exactly the orphan this
1174 /// option exists to prevent.
1175 ///
1176 /// Off by default, because killing children on parent exit is the right
1177 /// default for a supervisor and the wrong one for a CLI that deliberately
1178 /// backgrounds work.
1179 #[must_use]
1180 pub fn die_with_parent(mut self, enabled: bool) -> Self {
1181 self.die_with_parent = enabled;
1182 self
1183 }
1184
1185 /// Build the Claude client, resolving the binary path.
1186 pub fn build(self) -> Result<Claude> {
1187 let binary = match self.binary {
1188 Some(path) => path,
1189 None => which::which("claude").map_err(|_| Error::NotFound)?,
1190 };
1191
1192 Ok(Claude {
1193 binary,
1194 working_dir: self.working_dir,
1195 env: self.env,
1196 clear_env: self.clear_env,
1197 global_args: self.global_args,
1198 timeout: self.timeout,
1199 retry_policy: self.retry_policy,
1200 tested_cli_version_range: self.tested_cli_version_range,
1201 process_group: self.process_group.unwrap_or(true),
1202 kill_grace: self.kill_grace,
1203 termination_signal: self.termination_signal,
1204 output_limit: self.output_limit,
1205 on_spawn: self.on_spawn,
1206 die_with_parent: self.die_with_parent,
1207 })
1208 }
1209}
1210
1211#[cfg(test)]
1212mod tests {
1213 use super::*;
1214
1215 #[test]
1216 fn builder_process_group_defaults_on_and_can_opt_out() {
1217 let on = Claude::builder()
1218 .binary("/nonexistent/claude")
1219 .build()
1220 .unwrap();
1221 assert!(on.process_group);
1222
1223 let off = Claude::builder()
1224 .binary("/nonexistent/claude")
1225 .process_group(false)
1226 .build()
1227 .unwrap();
1228 assert!(!off.process_group);
1229 }
1230
1231 #[test]
1232 fn builder_clear_env_defaults_off_and_is_call_order_independent() {
1233 let inherited = Claude::builder()
1234 .binary("/nonexistent/claude")
1235 .build()
1236 .unwrap();
1237 assert!(!inherited.clear_env);
1238
1239 let cleared = Claude::builder()
1240 .binary("/nonexistent/claude")
1241 .env("FIRST", "1")
1242 .clear_env()
1243 .env("SECOND", "2")
1244 .build()
1245 .unwrap();
1246 assert!(cleared.clear_env);
1247 assert_eq!(cleared.env.get("FIRST").map(String::as_str), Some("1"));
1248 assert_eq!(cleared.env.get("SECOND").map(String::as_str), Some("2"));
1249 }
1250
1251 #[test]
1252 fn builder_kill_grace_defaults_off_and_can_be_set() {
1253 let off = Claude::builder()
1254 .binary("/nonexistent/claude")
1255 .build()
1256 .unwrap();
1257 assert!(off.kill_grace.is_none());
1258
1259 let on = Claude::builder()
1260 .binary("/nonexistent/claude")
1261 .kill_grace(Duration::from_millis(750))
1262 .build()
1263 .unwrap();
1264 assert_eq!(on.kill_grace, Some(Duration::from_millis(750)));
1265 }
1266
1267 #[test]
1268 fn builder_termination_signal_defaults_to_term_and_can_select_interrupt() {
1269 let default = Claude::builder().binary("/unused").build().unwrap();
1270 assert_eq!(default.termination_signal, TerminationSignal::Terminate);
1271 let interrupt = Claude::builder()
1272 .binary("/unused")
1273 .termination_signal(TerminationSignal::Interrupt)
1274 .build()
1275 .unwrap();
1276 assert_eq!(interrupt.termination_signal, TerminationSignal::Interrupt);
1277 assert!(
1278 interrupt.kill_grace.is_none(),
1279 "signal alone does not enable grace"
1280 );
1281 }
1282
1283 // -- the version gate ------------------------------------------
1284 //
1285 // `gate_version` is the decision both the async and sync gates
1286 // share, and it takes the version rather than fetching it, so the
1287 // policy is testable without spawning a binary.
1288
1289 #[cfg(any(feature = "async", feature = "sync"))]
1290 fn gate(found: (u32, u32, u32)) -> Result<CliVersion> {
1291 let claude = Claude::builder()
1292 .binary("/nonexistent/claude")
1293 .build()
1294 .unwrap();
1295 let (min, max) = claude.effective_tested_range();
1296 claude.gate_version(CliVersion::new(found.0, found.1, found.2), min, max)
1297 }
1298
1299 #[cfg(any(feature = "async", feature = "sync"))]
1300 #[test]
1301 fn gate_accepts_a_version_inside_the_declared_range() {
1302 let found = gate((
1303 TESTED_CLI_VERSION_MIN.major,
1304 TESTED_CLI_VERSION_MIN.minor,
1305 TESTED_CLI_VERSION_MIN.patch,
1306 ))
1307 .expect("the declared minimum must pass its own gate");
1308 assert_eq!(found, TESTED_CLI_VERSION_MIN);
1309 }
1310
1311 #[cfg(any(feature = "async", feature = "sync"))]
1312 #[test]
1313 fn gate_rejects_older_than_minimum_with_both_bounds() {
1314 let err = gate((1, 0, 0)).expect_err("1.0.0 is below any supported floor");
1315 match err {
1316 Error::UntestedCliVersion {
1317 found,
1318 tested_min,
1319 tested_max,
1320 } => {
1321 assert_eq!(found, CliVersion::new(1, 0, 0));
1322 assert_eq!(tested_min, TESTED_CLI_VERSION_MIN);
1323 assert_eq!(tested_max, TESTED_CLI_VERSION_MAX);
1324 // The message must say which side it fell off, since
1325 // "too old" and "too new" call for opposite fixes.
1326 assert!(err_text(&err).contains("older"), "{}", err_text(&err));
1327 }
1328 other => panic!("expected UntestedCliVersion, got {other:?}"),
1329 }
1330 }
1331
1332 #[cfg(any(feature = "async", feature = "sync"))]
1333 #[test]
1334 fn gate_rejects_newer_than_maximum() {
1335 let err = gate((99, 0, 0)).expect_err("99.0.0 is above the tested ceiling");
1336 assert!(matches!(err, Error::UntestedCliVersion { .. }));
1337 assert!(err_text(&err).contains("newer"), "{}", err_text(&err));
1338 }
1339
1340 #[cfg(any(feature = "async", feature = "sync"))]
1341 #[test]
1342 fn gate_honours_a_caller_supplied_range_over_the_crate_default() {
1343 // A host that has verified a narrower window should be able to
1344 // enforce it, including rejecting versions the crate itself
1345 // considers fine.
1346 let claude = Claude::builder()
1347 .binary("/nonexistent/claude")
1348 .tested_cli_version_range(CliVersion::new(3, 0, 0), CliVersion::new(3, 0, 9))
1349 .build()
1350 .unwrap();
1351 let (min, max) = claude.effective_tested_range();
1352 assert_eq!(min, CliVersion::new(3, 0, 0));
1353 assert!(
1354 claude
1355 .gate_version(CliVersion::new(3, 0, 5), min, max)
1356 .is_ok()
1357 );
1358 assert!(
1359 claude
1360 .gate_version(TESTED_CLI_VERSION_MIN, min, max)
1361 .is_err(),
1362 "the caller's range must win over the crate's"
1363 );
1364 }
1365
1366 #[cfg(any(feature = "async", feature = "sync"))]
1367 fn err_text(e: &Error) -> String {
1368 e.to_string()
1369 }
1370
1371 #[test]
1372 fn test_builder_with_binary() {
1373 let claude = Claude::builder()
1374 .binary("/usr/local/bin/claude")
1375 .env("FOO", "bar")
1376 .timeout_secs(60)
1377 .build()
1378 .unwrap();
1379
1380 assert_eq!(claude.binary, PathBuf::from("/usr/local/bin/claude"));
1381 assert_eq!(claude.env.get("FOO").unwrap(), "bar");
1382 assert_eq!(claude.timeout, Some(Duration::from_secs(60)));
1383 }
1384
1385 #[test]
1386 fn test_builder_global_args() {
1387 let claude = Claude::builder()
1388 .binary("/usr/local/bin/claude")
1389 .arg("--verbose")
1390 .build()
1391 .unwrap();
1392
1393 assert_eq!(claude.global_args, vec!["--verbose"]);
1394 }
1395
1396 #[test]
1397 fn test_builder_verbose() {
1398 let claude = Claude::builder()
1399 .binary("/usr/local/bin/claude")
1400 .verbose()
1401 .build()
1402 .unwrap();
1403 assert!(claude.global_args.contains(&"--verbose".to_string()));
1404 }
1405
1406 #[test]
1407 fn test_builder_debug() {
1408 let claude = Claude::builder()
1409 .binary("/usr/local/bin/claude")
1410 .debug()
1411 .build()
1412 .unwrap();
1413 assert!(claude.global_args.contains(&"--debug".to_string()));
1414 }
1415}