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//!
223//! ```no_run
224//! # #[cfg(all(feature = "async", feature = "json"))] {
225//! use claude_wrapper::{Claude, OutputFormat, QueryCommand};
226//! use claude_wrapper::streaming::{StreamEvent, stream_query};
227//!
228//! # async fn example() -> claude_wrapper::Result<()> {
229//! let claude = Claude::builder().build()?;
230//! let cmd = QueryCommand::new("explain quicksort")
231//!     .output_format(OutputFormat::StreamJson);
232//!
233//! stream_query(&claude, &cmd, |event: StreamEvent| {
234//!     if event.is_result() {
235//!         println!("result: {}", event.result_text().unwrap_or(""));
236//!     }
237//! }).await?;
238//! # Ok(()) }
239//! # }
240//! ```
241//!
242//! # MCP config generation
243//!
244//! Generate `.mcp.json` files for `--mcp-config`:
245//!
246//! ```no_run
247//! # #[cfg(feature = "async")] {
248//! use claude_wrapper::{Claude, ClaudeCommand, McpConfigBuilder, QueryCommand};
249//!
250//! # async fn example() -> claude_wrapper::Result<()> {
251//! McpConfigBuilder::new()
252//!     .http_server("hub", "http://127.0.0.1:9090")
253//!     .stdio_server("tool", "npx", ["my-server"])
254//!     .write_to("/tmp/my-project/.mcp.json")?;
255//!
256//! let claude = Claude::builder().build()?;
257//! QueryCommand::new("list tools")
258//!     .mcp_config("/tmp/my-project/.mcp.json")
259//!     .execute(&claude)
260//!     .await?;
261//! # Ok(()) }
262//! # }
263//! ```
264//!
265//! # On-disk introspection
266//!
267//! A family of read-only modules parses Claude Code's on-disk state
268//! under `~/.claude` directly, without spawning the CLI. Each exposes a
269//! root/loader with `list` / `get` accessors and degrades to an empty
270//! result when the directory is absent: [`history`] (sessions and
271//! transcripts), [`artifacts`] (agents), [`skills`], [`commands`]
272//! (custom slash commands), [`settings`] (the four merged layers),
273//! [`jobs`] (background-agent state), and [`worktrees`]. See the
274//! `inspect_state` example for an end-to-end tour.
275//!
276//! ```no_run
277//! # #[cfg(feature = "json")] {
278//! use claude_wrapper::history::HistoryRoot;
279//!
280//! # fn example() -> claude_wrapper::Result<()> {
281//! let history = HistoryRoot::home()?;
282//! for project in history.list_projects()? {
283//!     println!(
284//!         "{} ({} sessions)",
285//!         project.decoded_path.display(),
286//!         project.session_count,
287//!     );
288//! }
289//! # Ok(()) }
290//! # }
291//! ```
292//!
293//! # Dangerous: bypass mode
294//!
295//! `--permission-mode bypassPermissions` is isolated behind
296//! [`dangerous::DangerousClient`], which requires an env-var
297//! acknowledgement ([`dangerous::ALLOW_ENV`] = `"1"`) at process start.
298//! See the [dangerous module docs](dangerous) for details.
299//!
300//! # Escape hatch
301//!
302//! For subcommands not yet wrapped, use [`RawCommand`]:
303//!
304//! ```no_run
305//! # #[cfg(feature = "async")] {
306//! use claude_wrapper::{Claude, ClaudeCommand, RawCommand};
307//!
308//! # async fn example() -> claude_wrapper::Result<()> {
309//! let claude = Claude::builder().build()?;
310//! let output = RawCommand::new("some-future-command")
311//!     .arg("--new-flag")
312//!     .arg("value")
313//!     .execute(&claude)
314//!     .await?;
315//! # Ok(()) }
316//! # }
317//! ```
318#![warn(missing_docs)]
319
320pub mod artifacts;
321pub mod auth;
322pub mod budget;
323/// Per-subcommand builders and the [`command::ClaudeCommand`] trait.
324pub mod command;
325pub mod commands;
326#[cfg(all(feature = "json", feature = "async"))]
327pub mod conversation;
328pub mod dangerous;
329#[cfg(all(feature = "json", feature = "async"))]
330pub mod duplex;
331pub mod error;
332pub mod exec;
333#[cfg(feature = "json")]
334pub mod history;
335#[cfg(feature = "json")]
336pub mod jobs;
337pub mod mcp_config;
338pub mod memory;
339pub mod plans;
340pub mod retry;
341#[cfg(all(feature = "json", feature = "async"))]
342pub mod session;
343#[cfg(feature = "json")]
344pub mod sessions;
345#[cfg(feature = "json")]
346pub mod settings;
347pub mod skills;
348pub mod slash;
349pub mod streaming;
350#[cfg(feature = "json")]
351pub mod tasks;
352pub mod tool_pattern;
353pub mod types;
354pub mod version;
355pub mod worktrees;
356
357use std::collections::HashMap;
358use std::path::{Path, PathBuf};
359use std::time::Duration;
360
361pub use budget::{BudgetBuilder, BudgetTracker};
362pub use command::ClaudeCommand;
363#[cfg(feature = "sync")]
364pub use command::ClaudeCommandSyncExt;
365#[allow(deprecated)]
366pub use command::agents::AgentsCommand;
367pub use command::auth::{
368    AuthLoginCommand, AuthLogoutCommand, AuthStatusCommand, LoginMode, SetupTokenCommand,
369};
370pub use command::auto_mode::{
371    AutoModeConfigCommand, AutoModeCritiqueCommand, AutoModeDefaultsCommand,
372};
373pub use command::doctor::DoctorCommand;
374pub use command::install::InstallCommand;
375pub use command::marketplace::{
376    MarketplaceAddCommand, MarketplaceListCommand, MarketplaceRemoveCommand,
377    MarketplaceUpdateCommand,
378};
379pub use command::mcp::{
380    McpAddCommand, McpAddFromDesktopCommand, McpAddJsonCommand, McpGetCommand, McpListCommand,
381    McpLoginCommand, McpLogoutCommand, McpRemoveCommand, McpResetProjectChoicesCommand,
382    McpServeCommand,
383};
384pub use command::plugin::{
385    PluginDetailsCommand, PluginDisableCommand, PluginEnableCommand, PluginInstallCommand,
386    PluginListCommand, PluginPruneCommand, PluginTagCommand, PluginUninstallCommand,
387    PluginUpdateCommand, PluginValidateCommand,
388};
389pub use command::project::ProjectPurgeCommand;
390pub use command::query::QueryCommand;
391pub use command::raw::RawCommand;
392pub use command::ultrareview::UltrareviewCommand;
393pub use command::update::UpdateCommand;
394pub use command::version::VersionCommand;
395#[cfg(all(feature = "json", feature = "async"))]
396pub use conversation::Conversation;
397#[cfg(all(feature = "json", feature = "async"))]
398pub use duplex::{
399    DuplexOptions, DuplexSession, InboundEvent, PermissionDecision, PermissionHandler,
400    PermissionRequest, TurnResult,
401};
402pub use error::{Error, Result};
403pub use exec::CommandOutput;
404#[cfg(feature = "tempfile")]
405pub use mcp_config::TempMcpConfig;
406pub use mcp_config::{McpConfigBuilder, McpServerConfig};
407pub use retry::{BackoffStrategy, RetryPolicy};
408#[cfg(all(feature = "json", feature = "async"))]
409pub use session::Session;
410pub use tool_pattern::{PatternError, ToolPattern};
411pub use types::*;
412pub use version::{
413    CliVersion, CliVersionStatus, TESTED_CLI_VERSION_MAX, TESTED_CLI_VERSION_MIN, VersionParseError,
414};
415
416/// What the crate knows about a child the moment it is spawned.
417///
418/// Delivered to the [`ClaudeBuilder::on_spawn`] observer before the run can
419/// produce output, which is the point: a supervisor that records the pid only
420/// after a run *finishes* has nothing to reconcile when it crashes mid-run.
421#[derive(Debug, Clone, Copy, PartialEq, Eq)]
422#[non_exhaustive]
423pub struct SpawnInfo {
424    /// The child's process id.
425    pub pid: u32,
426    /// The child's process group id, when it leads its own group.
427    ///
428    /// `None` when [`ClaudeBuilder::process_group`] is disabled, in which case
429    /// the child shares the parent's group and its pid must never be passed to
430    /// `killpg`: that would signal the caller's own process group.
431    pub pgid: Option<u32>,
432}
433
434/// Called with [`SpawnInfo`] each time the crate spawns a CLI child.
435///
436/// Must not block: it runs inline on the spawning thread, between `spawn` and
437/// the first read of the child's output.
438pub type SpawnObserver = std::sync::Arc<dyn Fn(SpawnInfo) + Send + Sync>;
439
440/// The Claude CLI client. Holds shared configuration applied to all commands.
441///
442/// Create one via [`Claude::builder()`] and reuse it across commands.
443#[derive(Clone)]
444pub struct Claude {
445    pub(crate) binary: PathBuf,
446    pub(crate) working_dir: Option<PathBuf>,
447    // env, timeout, and retry_policy are written by the builder under
448    // every feature combination but read only by the feature-gated
449    // exec paths, so they are "never read" with neither `async` nor
450    // `sync` feature.
451    #[allow(dead_code)]
452    pub(crate) env: HashMap<String, String>,
453    pub(crate) global_args: Vec<String>,
454    #[allow(dead_code)]
455    pub(crate) timeout: Option<Duration>,
456    #[allow(dead_code)]
457    pub(crate) retry_policy: Option<RetryPolicy>,
458    pub(crate) tested_cli_version_range: Option<(CliVersion, CliVersion)>,
459    // Read only by the feature-gated exec paths, like env/timeout.
460    #[allow(dead_code)]
461    pub(crate) process_group: bool,
462    #[allow(dead_code)]
463    pub(crate) kill_grace: Option<Duration>,
464    #[allow(dead_code)]
465    pub(crate) on_spawn: Option<SpawnObserver>,
466    #[allow(dead_code)]
467    pub(crate) die_with_parent: bool,
468}
469
470impl std::fmt::Debug for Claude {
471    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
472        f.debug_struct("Claude")
473            .field("binary", &self.binary)
474            .field("working_dir", &self.working_dir)
475            .field("global_args", &self.global_args)
476            .field("timeout", &self.timeout)
477            .field("process_group", &self.process_group)
478            .field("kill_grace", &self.kill_grace)
479            // A closure cannot be rendered, and env may hold credentials, so
480            // neither is printed: only whether an observer is installed.
481            .field("on_spawn", &self.on_spawn.is_some())
482            .finish_non_exhaustive()
483    }
484}
485
486impl Claude {
487    /// Create a new builder for configuring the Claude client.
488    #[must_use]
489    pub fn builder() -> ClaudeBuilder {
490        ClaudeBuilder::default()
491    }
492
493    /// Get the path to the claude binary.
494    #[must_use]
495    pub fn binary(&self) -> &Path {
496        &self.binary
497    }
498
499    /// Get the working directory, if set.
500    #[must_use]
501    pub fn working_dir(&self) -> Option<&Path> {
502        self.working_dir.as_deref()
503    }
504
505    /// Create a clone of this client with a different working directory.
506    #[must_use]
507    pub fn with_working_dir(&self, dir: impl Into<PathBuf>) -> Self {
508        let mut clone = self.clone();
509        clone.working_dir = Some(dir.into());
510        clone
511    }
512
513    /// Query the installed CLI version.
514    ///
515    /// Runs `claude --version` and parses the output into a [`CliVersion`].
516    ///
517    /// # Example
518    ///
519    /// ```no_run
520    /// # async fn example() -> claude_wrapper::Result<()> {
521    /// let claude = claude_wrapper::Claude::builder().build()?;
522    /// let version = claude.cli_version().await?;
523    /// println!("Claude CLI {version}");
524    /// # Ok(())
525    /// # }
526    /// ```
527    #[cfg(feature = "async")]
528    pub async fn cli_version(&self) -> Result<CliVersion> {
529        let output = VersionCommand::new().execute(self).await?;
530        CliVersion::parse_version_output(&output.stdout).map_err(|e| Error::Io {
531            message: format!("failed to parse CLI version: {e}"),
532            source: std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()),
533            working_dir: None,
534        })
535    }
536
537    /// Check that the installed CLI version meets a minimum requirement.
538    ///
539    /// Returns the detected version on success, or an error if the version
540    /// is below the minimum.
541    ///
542    /// # Example
543    ///
544    /// ```no_run
545    /// use claude_wrapper::CliVersion;
546    ///
547    /// # async fn example() -> claude_wrapper::Result<()> {
548    /// let claude = claude_wrapper::Claude::builder().build()?;
549    /// let version = claude.check_version(&CliVersion::new(2, 1, 0)).await?;
550    /// println!("CLI version {version} meets minimum requirement");
551    /// # Ok(())
552    /// # }
553    /// ```
554    #[cfg(feature = "async")]
555    pub async fn check_version(&self, minimum: &CliVersion) -> Result<CliVersion> {
556        let version = self.cli_version().await?;
557        if version.satisfies_minimum(minimum) {
558            Ok(version)
559        } else {
560            Err(Error::VersionMismatch {
561                found: version,
562                minimum: *minimum,
563            })
564        }
565    }
566
567    /// Blocking mirror of [`Claude::cli_version`]. Requires the
568    /// `sync` feature.
569    #[cfg(feature = "sync")]
570    pub fn cli_version_sync(&self) -> Result<CliVersion> {
571        let output = VersionCommand::new().execute_sync(self)?;
572        CliVersion::parse_version_output(&output.stdout).map_err(|e| Error::Io {
573            message: format!("failed to parse CLI version: {e}"),
574            source: std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()),
575            working_dir: None,
576        })
577    }
578
579    /// Blocking mirror of [`Claude::check_version`]. Requires the
580    /// `sync` feature.
581    #[cfg(feature = "sync")]
582    pub fn check_version_sync(&self, minimum: &CliVersion) -> Result<CliVersion> {
583        let version = self.cli_version_sync()?;
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    /// The tested-against `[min, max]` range declared at build time
595    /// via [`ClaudeBuilder::tested_cli_version_range`], if any.
596    ///
597    /// `None` means no override was set, in which case version checks
598    /// use the crate's own [`TESTED_CLI_VERSION_MIN`] /
599    /// [`TESTED_CLI_VERSION_MAX`]; see [`Self::effective_tested_range`].
600    #[must_use]
601    pub fn tested_cli_version_range(&self) -> Option<(CliVersion, CliVersion)> {
602        self.tested_cli_version_range
603    }
604
605    /// The range version checks actually use: the caller's override when one
606    /// was set, otherwise the crate's own declared range.
607    #[must_use]
608    pub fn effective_tested_range(&self) -> (CliVersion, CliVersion) {
609        self.tested_cli_version_range
610            .unwrap_or((TESTED_CLI_VERSION_MIN, TESTED_CLI_VERSION_MAX))
611    }
612
613    /// Classify the installed CLI against the tested-against range.
614    /// Logs a `tracing::warn!` when outside the range; returns the
615    /// typed status either way.
616    ///
617    /// The range defaults to the crate's own
618    /// [`TESTED_CLI_VERSION_MIN`] / [`TESTED_CLI_VERSION_MAX`],
619    /// because only the crate knows what it was built and tested
620    /// against. [`ClaudeBuilder::tested_cli_version_range`] overrides
621    /// it for hosts that have verified a different range themselves.
622    ///
623    /// Intended for one-shot use at startup, not on every command.
624    #[cfg(feature = "async")]
625    pub async fn cli_version_status(&self) -> Result<CliVersionStatus> {
626        let (min, max) = self.effective_tested_range();
627        let found = self.cli_version().await?;
628        let status = found.status_within(&min, &max);
629        warn_on_drift(&status);
630        Ok(status)
631    }
632
633    /// Blocking mirror of [`Claude::cli_version_status`]. Requires
634    /// the `sync` feature.
635    #[cfg(feature = "sync")]
636    pub fn cli_version_status_sync(&self) -> Result<CliVersionStatus> {
637        let (min, max) = self.effective_tested_range();
638        let found = self.cli_version_sync()?;
639        let status = found.status_within(&min, &max);
640        warn_on_drift(&status);
641        Ok(status)
642    }
643
644    /// Refuse to proceed against a CLI outside the tested range.
645    ///
646    /// The opt-in hard gate. [`Claude::cli_version_status`] is the
647    /// reporting path and keeps its behavior: it returns a typed
648    /// status and warns. This one turns the same condition into
649    /// [`Error::UntestedCliVersion`], for hosts that would rather fail
650    /// at startup than run on an unverified binary.
651    ///
652    /// Returns the detected version on success. The drift warning
653    /// still fires on the failure path, so a host that logs and a host
654    /// that gates see the same line.
655    ///
656    /// # Why this is a method and not a builder option
657    ///
658    /// [`ClaudeBuilder::build`] is synchronous and never spawns the
659    /// binary. Enforcing a version there would mean running a
660    /// subprocess inside a constructor, so the check lives where the
661    /// caller can await it.
662    ///
663    /// # Example
664    ///
665    /// ```no_run
666    /// # async fn example() -> claude_wrapper::Result<()> {
667    /// let claude = claude_wrapper::Claude::builder().build()?;
668    /// // Run once at startup; returns Err on an untested CLI.
669    /// let version = claude.ensure_tested_cli_version().await?;
670    /// println!("running against {version}");
671    /// # Ok(())
672    /// # }
673    /// ```
674    #[cfg(feature = "async")]
675    pub async fn ensure_tested_cli_version(&self) -> Result<CliVersion> {
676        let (min, max) = self.effective_tested_range();
677        let found = self.cli_version().await?;
678        self.gate_version(found, min, max)
679    }
680
681    /// Blocking mirror of [`Claude::ensure_tested_cli_version`].
682    /// Requires the `sync` feature.
683    #[cfg(feature = "sync")]
684    pub fn ensure_tested_cli_version_sync(&self) -> Result<CliVersion> {
685        let (min, max) = self.effective_tested_range();
686        let found = self.cli_version_sync()?;
687        self.gate_version(found, min, max)
688    }
689
690    /// Shared decision for the async and sync gates, so the two cannot
691    /// disagree about what "outside the range" means.
692    #[cfg(any(feature = "async", feature = "sync"))]
693    fn gate_version(
694        &self,
695        found: CliVersion,
696        min: CliVersion,
697        max: CliVersion,
698    ) -> Result<CliVersion> {
699        let status = found.status_within(&min, &max);
700        warn_on_drift(&status);
701        if status.is_tested() {
702            Ok(found)
703        } else {
704            Err(Error::UntestedCliVersion {
705                found,
706                tested_min: min,
707                tested_max: max,
708            })
709        }
710    }
711}
712
713#[allow(dead_code)] // unused with neither `async` nor `sync` feature
714fn warn_on_drift(status: &CliVersionStatus) {
715    match status {
716        CliVersionStatus::Tested => {}
717        CliVersionStatus::NewerUntested {
718            found, tested_max, ..
719        } => {
720            tracing::warn!(
721                found = %found,
722                tested_max = %tested_max,
723                "claude CLI is newer than the wrapper's tested-against range; \
724                 semantics may have drifted -- proceed with caution"
725            );
726        }
727        CliVersionStatus::OlderThanMinimum { found, minimum, .. } => {
728            tracing::warn!(
729                found = %found,
730                minimum = %minimum,
731                "claude CLI is older than the wrapper's declared minimum; \
732                 incorrect behavior is likely (missing flags, different shapes)"
733            );
734        }
735    }
736}
737
738/// Builder for creating a [`Claude`] client.
739///
740/// # Example
741///
742/// ```no_run
743/// use claude_wrapper::Claude;
744///
745/// # fn example() -> claude_wrapper::Result<()> {
746/// let claude = Claude::builder()
747///     .env("AWS_REGION", "us-west-2")
748///     .timeout_secs(120)
749///     .build()?;
750/// # Ok(())
751/// # }
752/// ```
753#[derive(Default)]
754pub struct ClaudeBuilder {
755    binary: Option<PathBuf>,
756    working_dir: Option<PathBuf>,
757    env: HashMap<String, String>,
758    global_args: Vec<String>,
759    timeout: Option<Duration>,
760    retry_policy: Option<RetryPolicy>,
761    tested_cli_version_range: Option<(CliVersion, CliVersion)>,
762    process_group: Option<bool>,
763    kill_grace: Option<Duration>,
764    on_spawn: Option<SpawnObserver>,
765    die_with_parent: bool,
766}
767
768impl std::fmt::Debug for ClaudeBuilder {
769    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
770        // Mirrors Claude's Debug: a closure cannot be rendered and env may
771        // hold credentials, so neither is printed.
772        f.debug_struct("ClaudeBuilder")
773            .field("binary", &self.binary)
774            .field("working_dir", &self.working_dir)
775            .field("global_args", &self.global_args)
776            .field("timeout", &self.timeout)
777            .field("process_group", &self.process_group)
778            .field("kill_grace", &self.kill_grace)
779            .field("on_spawn", &self.on_spawn.is_some())
780            .finish_non_exhaustive()
781    }
782}
783
784impl ClaudeBuilder {
785    /// Set the path to the claude binary.
786    ///
787    /// If not set, the binary is resolved from PATH using `which`.
788    #[must_use]
789    pub fn binary(mut self, path: impl Into<PathBuf>) -> Self {
790        self.binary = Some(path.into());
791        self
792    }
793
794    /// Set the working directory for all commands.
795    ///
796    /// The spawned process will use this as its current directory.
797    #[must_use]
798    pub fn working_dir(mut self, path: impl Into<PathBuf>) -> Self {
799        self.working_dir = Some(path.into());
800        self
801    }
802
803    /// Add an environment variable to pass to all commands.
804    #[must_use]
805    pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
806        self.env.insert(key.into(), value.into());
807        self
808    }
809
810    /// Add multiple environment variables.
811    #[must_use]
812    pub fn envs(
813        mut self,
814        vars: impl IntoIterator<Item = (impl Into<String>, impl Into<String>)>,
815    ) -> Self {
816        for (k, v) in vars {
817            self.env.insert(k.into(), v.into());
818        }
819        self
820    }
821
822    /// Set a default timeout for all commands (in seconds).
823    #[must_use]
824    pub fn timeout_secs(mut self, seconds: u64) -> Self {
825        self.timeout = Some(Duration::from_secs(seconds));
826        self
827    }
828
829    /// Set a default timeout for all commands.
830    #[must_use]
831    pub fn timeout(mut self, duration: Duration) -> Self {
832        self.timeout = Some(duration);
833        self
834    }
835
836    /// Add a global argument applied to all commands.
837    ///
838    /// This is an escape hatch for flags not yet covered by the API.
839    #[must_use]
840    pub fn arg(mut self, arg: impl Into<String>) -> Self {
841        self.global_args.push(arg.into());
842        self
843    }
844
845    /// Enable verbose output for all commands (`--verbose`).
846    #[must_use]
847    pub fn verbose(mut self) -> Self {
848        self.global_args.push("--verbose".into());
849        self
850    }
851
852    /// Enable debug output for all commands (`--debug`).
853    #[must_use]
854    pub fn debug(mut self) -> Self {
855        self.global_args.push("--debug".into());
856        self
857    }
858
859    /// Set a default retry policy for all commands.
860    ///
861    /// Individual commands can override this via their own retry settings.
862    ///
863    /// # Example
864    ///
865    /// ```no_run
866    /// use claude_wrapper::{Claude, RetryPolicy};
867    /// use std::time::Duration;
868    ///
869    /// # fn example() -> claude_wrapper::Result<()> {
870    /// let claude = Claude::builder()
871    ///     .retry(RetryPolicy::new()
872    ///         .max_attempts(3)
873    ///         .initial_backoff(Duration::from_secs(2))
874    ///         .exponential()
875    ///         .retry_on_timeout(true))
876    ///     .build()?;
877    /// # Ok(())
878    /// # }
879    /// ```
880    #[must_use]
881    pub fn retry(mut self, policy: RetryPolicy) -> Self {
882        self.retry_policy = Some(policy);
883        self
884    }
885
886    /// Declare the inclusive `[min, max]` range of `claude` CLI
887    /// versions this client has been tested against.
888    ///
889    /// The wrapper does not enforce the range -- nothing errors when
890    /// it's set wrong. Use [`Claude::cli_version_status`] (or its
891    /// sync mirror) at startup to classify the actually-installed CLI
892    /// against this declaration; that call returns a typed
893    /// [`CliVersionStatus`] AND emits a `tracing::warn!` when
894    /// outside the range. Hosts (claude-server, application code)
895    /// can additionally surface the status to operators.
896    ///
897    /// # Why this exists
898    ///
899    /// CLI semantics drift across minor / patch releases (e.g.
900    /// `claude agents` was repurposed in 2.1.143). The min floor
901    /// lets us say "we know it's broken below this"; the max ceiling
902    /// lets us say "we haven't verified above this -- proceed but
903    /// expect surprises."
904    ///
905    /// # You usually do not need this
906    ///
907    /// The crate declares its own range in
908    /// [`TESTED_CLI_VERSION_MIN`] / [`TESTED_CLI_VERSION_MAX`], and
909    /// version checks use it by default, because only the crate knows
910    /// what it was built and tested against. Set this only when a host
911    /// has verified a different range itself.
912    ///
913    /// # Example
914    ///
915    /// ```no_run
916    /// use claude_wrapper::{Claude, CliVersion};
917    ///
918    /// # async fn example() -> claude_wrapper::Result<()> {
919    /// let claude = Claude::builder()
920    ///     .tested_cli_version_range(CliVersion::new(2, 1, 0), CliVersion::new(2, 1, 999))
921    ///     .build()?;
922    /// // Run once at startup to log a warning if the CLI is out of range.
923    /// let _status = claude.cli_version_status().await?;
924    /// # Ok(()) }
925    /// ```
926    #[must_use]
927    pub fn tested_cli_version_range(mut self, min: CliVersion, max: CliVersion) -> Self {
928        self.tested_cli_version_range = Some((min, max));
929        self
930    }
931
932    /// Control whether spawned `claude` children are placed in their
933    /// own process group on Unix. Defaults to `true`.
934    ///
935    /// Own group (the default): cancellation and timeouts kill the
936    /// child's whole process tree, but the child no longer shares the
937    /// host terminal's process group, so terminal-generated signals
938    /// (Ctrl-C) do not reach it; terminating a run is the wrapper's
939    /// job via drop, timeout, or explicit kill. This is the right
940    /// contract for supervisors (daemons, MCP servers, worker queues).
941    ///
942    /// Shared group (`false`): the child stays in the host's process
943    /// group, so a terminal Ctrl-C reaches the whole run directly, but
944    /// a wrapper-side kill only reaches the direct child and any
945    /// subprocesses it spawned for tool use survive it. This is the
946    /// right contract for terminal-attached hosts that shell out
947    /// synchronously and rely on the terminal as the supervisor.
948    ///
949    /// No effect on non-Unix targets.
950    #[must_use]
951    pub fn process_group(mut self, enabled: bool) -> Self {
952        self.process_group = Some(enabled);
953        self
954    }
955
956    /// Grace period between SIGTERM and SIGKILL when a run is killed
957    /// by a timeout or a duplex shutdown overrun (Unix). Default:
958    /// none; kills are immediate SIGKILL.
959    ///
960    /// With a grace set, the child's whole process group gets SIGTERM
961    /// first so `claude` can flush its transcript and session state,
962    /// and SIGKILL follows once the grace elapses. The full grace is
963    /// waited before the timeout error returns, so keep it short
964    /// (500ms to 2s). Dropping a future cannot wait, so drop-path
965    /// cancellation stays immediate SIGKILL, and the grace applies
966    /// only while the child is in its own process group (see
967    /// [`process_group`](Self::process_group)).
968    #[must_use]
969    pub fn kill_grace(mut self, grace: Duration) -> Self {
970        self.kill_grace = Some(grace);
971        self
972    }
973
974    /// Observe every child this client spawns, at spawn time.
975    ///
976    /// The observer receives a [`SpawnInfo`] before the run produces output,
977    /// which is what makes it useful to a supervisor: recording the pid only
978    /// once a run *finishes* leaves nothing to reconcile after a crash
979    /// mid-run. Fires for one-shot runs, streaming runs, and duplex sessions
980    /// alike, and on retry it fires once per attempt, since each attempt is a
981    /// distinct process.
982    ///
983    /// The observer runs inline on the spawning thread, so it must not block.
984    /// Write a pidfile or push to a channel; do not do I/O that can stall.
985    ///
986    /// # Why a callback rather than a return value
987    ///
988    /// The crash case needs the pid to be durable *before* the run can be
989    /// orphaned. A pid on the result type arrives too late for exactly the
990    /// scenario that motivates recording it.
991    ///
992    /// # Example
993    ///
994    /// ```no_run
995    /// use std::sync::Arc;
996    /// use claude_wrapper::Claude;
997    ///
998    /// # fn example() -> claude_wrapper::Result<()> {
999    /// let claude = Claude::builder()
1000    ///     .on_spawn(Arc::new(|info| {
1001    ///         eprintln!("spawned pid {} (group {:?})", info.pid, info.pgid);
1002    ///     }))
1003    ///     .build()?;
1004    /// # Ok(())
1005    /// # }
1006    /// ```
1007    #[must_use]
1008    pub fn on_spawn(mut self, observer: SpawnObserver) -> Self {
1009        self.on_spawn = Some(observer);
1010        self
1011    }
1012
1013    /// Ask the kernel to kill spawned children when this process dies.
1014    ///
1015    /// **Linux only.** Check [`die_with_parent_supported`](crate::exec::die_with_parent_supported)
1016    /// rather than assuming; elsewhere this is accepted and does nothing.
1017    ///
1018    /// # The problem it addresses
1019    ///
1020    /// Every other cleanup path in this crate is a destructor: `kill_on_drop`,
1021    /// and the process-group kill on drop, timeout, or stream error. None of
1022    /// them run when *this* process is SIGKILLed. The child is then reparented
1023    /// to init, and because it leads its own process group (see
1024    /// [`process_group`](Self::process_group)) terminal signals cannot reach it
1025    /// either. It keeps running, keeps billing, and keeps appending to the
1026    /// session transcript, so a restarted supervisor that resumes the same
1027    /// session id can find itself interleaving with an orphan still writing.
1028    ///
1029    /// On Linux `PR_SET_PDEATHSIG` closes that: the kernel delivers SIGKILL to
1030    /// the child the moment its parent dies, with no cooperation from either
1031    /// side.
1032    ///
1033    /// # What it does not cover
1034    ///
1035    /// - **Non-Linux targets.** macOS has no equivalent. A supervisor that
1036    ///   needs the guarantee there has to poll and kill by pid; recording the
1037    ///   pid is what [`on_spawn`](Self::on_spawn) is for.
1038    /// - **Re-parenting.** The signal fires when the *immediate* parent dies.
1039    ///   If the crate's caller is itself an intermediate process that exits
1040    ///   normally, the child dies then, which is usually what you want but is
1041    ///   worth knowing if you daemonize between building the client and
1042    ///   spawning.
1043    /// - **The fork/prctl window.** Handled: the hook re-checks `getppid()`
1044    ///   after arming and exits if the parent already changed. Without that
1045    ///   check a parent dying in that window leaves exactly the orphan this
1046    ///   option exists to prevent.
1047    ///
1048    /// Off by default, because killing children on parent exit is the right
1049    /// default for a supervisor and the wrong one for a CLI that deliberately
1050    /// backgrounds work.
1051    #[must_use]
1052    pub fn die_with_parent(mut self, enabled: bool) -> Self {
1053        self.die_with_parent = enabled;
1054        self
1055    }
1056
1057    /// Build the Claude client, resolving the binary path.
1058    pub fn build(self) -> Result<Claude> {
1059        let binary = match self.binary {
1060            Some(path) => path,
1061            None => which::which("claude").map_err(|_| Error::NotFound)?,
1062        };
1063
1064        Ok(Claude {
1065            binary,
1066            working_dir: self.working_dir,
1067            env: self.env,
1068            global_args: self.global_args,
1069            timeout: self.timeout,
1070            retry_policy: self.retry_policy,
1071            tested_cli_version_range: self.tested_cli_version_range,
1072            process_group: self.process_group.unwrap_or(true),
1073            kill_grace: self.kill_grace,
1074            on_spawn: self.on_spawn,
1075            die_with_parent: self.die_with_parent,
1076        })
1077    }
1078}
1079
1080#[cfg(test)]
1081mod tests {
1082    use super::*;
1083
1084    #[test]
1085    fn builder_process_group_defaults_on_and_can_opt_out() {
1086        let on = Claude::builder()
1087            .binary("/nonexistent/claude")
1088            .build()
1089            .unwrap();
1090        assert!(on.process_group);
1091
1092        let off = Claude::builder()
1093            .binary("/nonexistent/claude")
1094            .process_group(false)
1095            .build()
1096            .unwrap();
1097        assert!(!off.process_group);
1098    }
1099
1100    #[test]
1101    fn builder_kill_grace_defaults_off_and_can_be_set() {
1102        let off = Claude::builder()
1103            .binary("/nonexistent/claude")
1104            .build()
1105            .unwrap();
1106        assert!(off.kill_grace.is_none());
1107
1108        let on = Claude::builder()
1109            .binary("/nonexistent/claude")
1110            .kill_grace(Duration::from_millis(750))
1111            .build()
1112            .unwrap();
1113        assert_eq!(on.kill_grace, Some(Duration::from_millis(750)));
1114    }
1115
1116    // -- the version gate ------------------------------------------
1117    //
1118    // `gate_version` is the decision both the async and sync gates
1119    // share, and it takes the version rather than fetching it, so the
1120    // policy is testable without spawning a binary.
1121
1122    #[cfg(any(feature = "async", feature = "sync"))]
1123    fn gate(found: (u32, u32, u32)) -> Result<CliVersion> {
1124        let claude = Claude::builder()
1125            .binary("/nonexistent/claude")
1126            .build()
1127            .unwrap();
1128        let (min, max) = claude.effective_tested_range();
1129        claude.gate_version(CliVersion::new(found.0, found.1, found.2), min, max)
1130    }
1131
1132    #[cfg(any(feature = "async", feature = "sync"))]
1133    #[test]
1134    fn gate_accepts_a_version_inside_the_declared_range() {
1135        let found = gate((
1136            TESTED_CLI_VERSION_MIN.major,
1137            TESTED_CLI_VERSION_MIN.minor,
1138            TESTED_CLI_VERSION_MIN.patch,
1139        ))
1140        .expect("the declared minimum must pass its own gate");
1141        assert_eq!(found, TESTED_CLI_VERSION_MIN);
1142    }
1143
1144    #[cfg(any(feature = "async", feature = "sync"))]
1145    #[test]
1146    fn gate_rejects_older_than_minimum_with_both_bounds() {
1147        let err = gate((1, 0, 0)).expect_err("1.0.0 is below any supported floor");
1148        match err {
1149            Error::UntestedCliVersion {
1150                found,
1151                tested_min,
1152                tested_max,
1153            } => {
1154                assert_eq!(found, CliVersion::new(1, 0, 0));
1155                assert_eq!(tested_min, TESTED_CLI_VERSION_MIN);
1156                assert_eq!(tested_max, TESTED_CLI_VERSION_MAX);
1157                // The message must say which side it fell off, since
1158                // "too old" and "too new" call for opposite fixes.
1159                assert!(err_text(&err).contains("older"), "{}", err_text(&err));
1160            }
1161            other => panic!("expected UntestedCliVersion, got {other:?}"),
1162        }
1163    }
1164
1165    #[cfg(any(feature = "async", feature = "sync"))]
1166    #[test]
1167    fn gate_rejects_newer_than_maximum() {
1168        let err = gate((99, 0, 0)).expect_err("99.0.0 is above the tested ceiling");
1169        assert!(matches!(err, Error::UntestedCliVersion { .. }));
1170        assert!(err_text(&err).contains("newer"), "{}", err_text(&err));
1171    }
1172
1173    #[cfg(any(feature = "async", feature = "sync"))]
1174    #[test]
1175    fn gate_honours_a_caller_supplied_range_over_the_crate_default() {
1176        // A host that has verified a narrower window should be able to
1177        // enforce it, including rejecting versions the crate itself
1178        // considers fine.
1179        let claude = Claude::builder()
1180            .binary("/nonexistent/claude")
1181            .tested_cli_version_range(CliVersion::new(3, 0, 0), CliVersion::new(3, 0, 9))
1182            .build()
1183            .unwrap();
1184        let (min, max) = claude.effective_tested_range();
1185        assert_eq!(min, CliVersion::new(3, 0, 0));
1186        assert!(
1187            claude
1188                .gate_version(CliVersion::new(3, 0, 5), min, max)
1189                .is_ok()
1190        );
1191        assert!(
1192            claude
1193                .gate_version(TESTED_CLI_VERSION_MIN, min, max)
1194                .is_err(),
1195            "the caller's range must win over the crate's"
1196        );
1197    }
1198
1199    #[cfg(any(feature = "async", feature = "sync"))]
1200    fn err_text(e: &Error) -> String {
1201        e.to_string()
1202    }
1203
1204    #[test]
1205    fn test_builder_with_binary() {
1206        let claude = Claude::builder()
1207            .binary("/usr/local/bin/claude")
1208            .env("FOO", "bar")
1209            .timeout_secs(60)
1210            .build()
1211            .unwrap();
1212
1213        assert_eq!(claude.binary, PathBuf::from("/usr/local/bin/claude"));
1214        assert_eq!(claude.env.get("FOO").unwrap(), "bar");
1215        assert_eq!(claude.timeout, Some(Duration::from_secs(60)));
1216    }
1217
1218    #[test]
1219    fn test_builder_global_args() {
1220        let claude = Claude::builder()
1221            .binary("/usr/local/bin/claude")
1222            .arg("--verbose")
1223            .build()
1224            .unwrap();
1225
1226        assert_eq!(claude.global_args, vec!["--verbose"]);
1227    }
1228
1229    #[test]
1230    fn test_builder_verbose() {
1231        let claude = Claude::builder()
1232            .binary("/usr/local/bin/claude")
1233            .verbose()
1234            .build()
1235            .unwrap();
1236        assert!(claude.global_args.contains(&"--verbose".to_string()));
1237    }
1238
1239    #[test]
1240    fn test_builder_debug() {
1241        let claude = Claude::builder()
1242            .binary("/usr/local/bin/claude")
1243            .debug()
1244            .build()
1245            .unwrap();
1246        assert!(claude.global_args.contains(&"--debug".to_string()));
1247    }
1248}