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 ([`QueryCommand::execute_json`], [`streaming::StreamEvent`], [`session::Session`], [`streaming::stream_query`]). |
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.6", 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//! # Dangerous: bypass mode
266//!
267//! `--permission-mode bypassPermissions` is isolated behind
268//! [`dangerous::DangerousClient`], which requires an env-var
269//! acknowledgement ([`dangerous::ALLOW_ENV`] = `"1"`) at process start.
270//! See the [dangerous module docs](dangerous) for details.
271//!
272//! # Escape hatch
273//!
274//! For subcommands not yet wrapped, use [`RawCommand`]:
275//!
276//! ```no_run
277//! # #[cfg(feature = "async")] {
278//! use claude_wrapper::{Claude, ClaudeCommand, RawCommand};
279//!
280//! # async fn example() -> claude_wrapper::Result<()> {
281//! let claude = Claude::builder().build()?;
282//! let output = RawCommand::new("some-future-command")
283//!     .arg("--new-flag")
284//!     .arg("value")
285//!     .execute(&claude)
286//!     .await?;
287//! # Ok(()) }
288//! # }
289//! ```
290
291pub mod artifacts;
292pub mod auth;
293pub mod budget;
294pub mod command;
295pub mod commands;
296#[cfg(all(feature = "json", feature = "async"))]
297pub mod conversation;
298pub mod dangerous;
299#[cfg(all(feature = "json", feature = "async"))]
300pub mod duplex;
301pub mod error;
302pub mod exec;
303#[cfg(feature = "json")]
304pub mod history;
305#[cfg(feature = "json")]
306pub mod jobs;
307pub mod mcp_config;
308pub mod retry;
309#[cfg(all(feature = "json", feature = "async"))]
310pub mod session;
311#[cfg(feature = "json")]
312pub mod settings;
313pub mod skills;
314pub mod slash;
315pub mod streaming;
316pub mod tool_pattern;
317pub mod types;
318pub mod version;
319pub mod worktrees;
320
321use std::collections::HashMap;
322use std::path::{Path, PathBuf};
323use std::time::Duration;
324
325pub use budget::{BudgetBuilder, BudgetTracker};
326pub use command::ClaudeCommand;
327#[cfg(feature = "sync")]
328pub use command::ClaudeCommandSyncExt;
329#[allow(deprecated)]
330pub use command::agents::AgentsCommand;
331pub use command::auth::{
332    AuthLoginCommand, AuthLogoutCommand, AuthStatusCommand, LoginMode, SetupTokenCommand,
333};
334pub use command::auto_mode::{
335    AutoModeConfigCommand, AutoModeCritiqueCommand, AutoModeDefaultsCommand,
336};
337pub use command::doctor::DoctorCommand;
338pub use command::install::InstallCommand;
339pub use command::marketplace::{
340    MarketplaceAddCommand, MarketplaceListCommand, MarketplaceRemoveCommand,
341    MarketplaceUpdateCommand,
342};
343pub use command::mcp::{
344    McpAddCommand, McpAddFromDesktopCommand, McpAddJsonCommand, McpGetCommand, McpListCommand,
345    McpRemoveCommand, McpResetProjectChoicesCommand, McpServeCommand,
346};
347pub use command::plugin::{
348    PluginDetailsCommand, PluginDisableCommand, PluginEnableCommand, PluginInstallCommand,
349    PluginListCommand, PluginPruneCommand, PluginTagCommand, PluginUninstallCommand,
350    PluginUpdateCommand, PluginValidateCommand,
351};
352pub use command::project::ProjectPurgeCommand;
353pub use command::query::QueryCommand;
354pub use command::raw::RawCommand;
355pub use command::update::UpdateCommand;
356pub use command::version::VersionCommand;
357#[cfg(all(feature = "json", feature = "async"))]
358pub use conversation::Conversation;
359#[cfg(all(feature = "json", feature = "async"))]
360pub use duplex::{
361    DuplexOptions, DuplexSession, InboundEvent, PermissionDecision, PermissionHandler,
362    PermissionRequest, TurnResult,
363};
364pub use error::{Error, Result};
365pub use exec::CommandOutput;
366#[cfg(feature = "tempfile")]
367pub use mcp_config::TempMcpConfig;
368pub use mcp_config::{McpConfigBuilder, McpServerConfig};
369pub use retry::{BackoffStrategy, RetryPolicy};
370#[cfg(all(feature = "json", feature = "async"))]
371pub use session::Session;
372pub use tool_pattern::{PatternError, ToolPattern};
373pub use types::*;
374pub use version::{CliVersion, CliVersionStatus, VersionParseError};
375
376/// The Claude CLI client. Holds shared configuration applied to all commands.
377///
378/// Create one via [`Claude::builder()`] and reuse it across commands.
379#[derive(Debug, Clone)]
380pub struct Claude {
381    pub(crate) binary: PathBuf,
382    pub(crate) working_dir: Option<PathBuf>,
383    pub(crate) env: HashMap<String, String>,
384    pub(crate) global_args: Vec<String>,
385    pub(crate) timeout: Option<Duration>,
386    pub(crate) retry_policy: Option<RetryPolicy>,
387    pub(crate) tested_cli_version_range: Option<(CliVersion, CliVersion)>,
388}
389
390impl Claude {
391    /// Create a new builder for configuring the Claude client.
392    #[must_use]
393    pub fn builder() -> ClaudeBuilder {
394        ClaudeBuilder::default()
395    }
396
397    /// Get the path to the claude binary.
398    #[must_use]
399    pub fn binary(&self) -> &Path {
400        &self.binary
401    }
402
403    /// Get the working directory, if set.
404    #[must_use]
405    pub fn working_dir(&self) -> Option<&Path> {
406        self.working_dir.as_deref()
407    }
408
409    /// Create a clone of this client with a different working directory.
410    #[must_use]
411    pub fn with_working_dir(&self, dir: impl Into<PathBuf>) -> Self {
412        let mut clone = self.clone();
413        clone.working_dir = Some(dir.into());
414        clone
415    }
416
417    /// Query the installed CLI version.
418    ///
419    /// Runs `claude --version` and parses the output into a [`CliVersion`].
420    ///
421    /// # Example
422    ///
423    /// ```no_run
424    /// # async fn example() -> claude_wrapper::Result<()> {
425    /// let claude = claude_wrapper::Claude::builder().build()?;
426    /// let version = claude.cli_version().await?;
427    /// println!("Claude CLI {version}");
428    /// # Ok(())
429    /// # }
430    /// ```
431    #[cfg(feature = "async")]
432    pub async fn cli_version(&self) -> Result<CliVersion> {
433        let output = VersionCommand::new().execute(self).await?;
434        CliVersion::parse_version_output(&output.stdout).map_err(|e| Error::Io {
435            message: format!("failed to parse CLI version: {e}"),
436            source: std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()),
437            working_dir: None,
438        })
439    }
440
441    /// Check that the installed CLI version meets a minimum requirement.
442    ///
443    /// Returns the detected version on success, or an error if the version
444    /// is below the minimum.
445    ///
446    /// # Example
447    ///
448    /// ```no_run
449    /// use claude_wrapper::CliVersion;
450    ///
451    /// # async fn example() -> claude_wrapper::Result<()> {
452    /// let claude = claude_wrapper::Claude::builder().build()?;
453    /// let version = claude.check_version(&CliVersion::new(2, 1, 0)).await?;
454    /// println!("CLI version {version} meets minimum requirement");
455    /// # Ok(())
456    /// # }
457    /// ```
458    #[cfg(feature = "async")]
459    pub async fn check_version(&self, minimum: &CliVersion) -> Result<CliVersion> {
460        let version = self.cli_version().await?;
461        if version.satisfies_minimum(minimum) {
462            Ok(version)
463        } else {
464            Err(Error::VersionMismatch {
465                found: version,
466                minimum: *minimum,
467            })
468        }
469    }
470
471    /// Blocking mirror of [`Claude::cli_version`]. Requires the
472    /// `sync` feature.
473    #[cfg(feature = "sync")]
474    pub fn cli_version_sync(&self) -> Result<CliVersion> {
475        let output = VersionCommand::new().execute_sync(self)?;
476        CliVersion::parse_version_output(&output.stdout).map_err(|e| Error::Io {
477            message: format!("failed to parse CLI version: {e}"),
478            source: std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()),
479            working_dir: None,
480        })
481    }
482
483    /// Blocking mirror of [`Claude::check_version`]. Requires the
484    /// `sync` feature.
485    #[cfg(feature = "sync")]
486    pub fn check_version_sync(&self, minimum: &CliVersion) -> Result<CliVersion> {
487        let version = self.cli_version_sync()?;
488        if version.satisfies_minimum(minimum) {
489            Ok(version)
490        } else {
491            Err(Error::VersionMismatch {
492                found: version,
493                minimum: *minimum,
494            })
495        }
496    }
497
498    /// The tested-against `[min, max]` range declared at build time
499    /// via [`ClaudeBuilder::tested_cli_version_range`], if any.
500    #[must_use]
501    pub fn tested_cli_version_range(&self) -> Option<(CliVersion, CliVersion)> {
502        self.tested_cli_version_range
503    }
504
505    /// Classify the installed CLI against the declared
506    /// tested-against range. Logs a `tracing::warn!` when outside
507    /// the range; returns the typed status either way. If no range
508    /// was declared via [`ClaudeBuilder::tested_cli_version_range`],
509    /// returns [`CliVersionStatus::Tested`] -- callers that didn't
510    /// opt in get the silent-success path.
511    ///
512    /// Intended for one-shot use at startup, not on every command.
513    #[cfg(feature = "async")]
514    pub async fn cli_version_status(&self) -> Result<CliVersionStatus> {
515        let Some((min, max)) = self.tested_cli_version_range else {
516            return Ok(CliVersionStatus::Tested);
517        };
518        let found = self.cli_version().await?;
519        let status = found.status_within(&min, &max);
520        warn_on_drift(&status);
521        Ok(status)
522    }
523
524    /// Blocking mirror of [`Claude::cli_version_status`]. Requires
525    /// the `sync` feature.
526    #[cfg(feature = "sync")]
527    pub fn cli_version_status_sync(&self) -> Result<CliVersionStatus> {
528        let Some((min, max)) = self.tested_cli_version_range else {
529            return Ok(CliVersionStatus::Tested);
530        };
531        let found = self.cli_version_sync()?;
532        let status = found.status_within(&min, &max);
533        warn_on_drift(&status);
534        Ok(status)
535    }
536}
537
538#[allow(dead_code)] // unused with neither `async` nor `sync` feature
539fn warn_on_drift(status: &CliVersionStatus) {
540    match status {
541        CliVersionStatus::Tested => {}
542        CliVersionStatus::NewerUntested {
543            found, tested_max, ..
544        } => {
545            tracing::warn!(
546                found = %found,
547                tested_max = %tested_max,
548                "claude CLI is newer than the wrapper's tested-against range; \
549                 semantics may have drifted -- proceed with caution"
550            );
551        }
552        CliVersionStatus::OlderThanMinimum { found, minimum, .. } => {
553            tracing::warn!(
554                found = %found,
555                minimum = %minimum,
556                "claude CLI is older than the wrapper's declared minimum; \
557                 incorrect behavior is likely (missing flags, different shapes)"
558            );
559        }
560    }
561}
562
563/// Builder for creating a [`Claude`] client.
564///
565/// # Example
566///
567/// ```no_run
568/// use claude_wrapper::Claude;
569///
570/// # fn example() -> claude_wrapper::Result<()> {
571/// let claude = Claude::builder()
572///     .env("AWS_REGION", "us-west-2")
573///     .timeout_secs(120)
574///     .build()?;
575/// # Ok(())
576/// # }
577/// ```
578#[derive(Debug, Default)]
579pub struct ClaudeBuilder {
580    binary: Option<PathBuf>,
581    working_dir: Option<PathBuf>,
582    env: HashMap<String, String>,
583    global_args: Vec<String>,
584    timeout: Option<Duration>,
585    retry_policy: Option<RetryPolicy>,
586    tested_cli_version_range: Option<(CliVersion, CliVersion)>,
587}
588
589impl ClaudeBuilder {
590    /// Set the path to the claude binary.
591    ///
592    /// If not set, the binary is resolved from PATH using `which`.
593    #[must_use]
594    pub fn binary(mut self, path: impl Into<PathBuf>) -> Self {
595        self.binary = Some(path.into());
596        self
597    }
598
599    /// Set the working directory for all commands.
600    ///
601    /// The spawned process will use this as its current directory.
602    #[must_use]
603    pub fn working_dir(mut self, path: impl Into<PathBuf>) -> Self {
604        self.working_dir = Some(path.into());
605        self
606    }
607
608    /// Add an environment variable to pass to all commands.
609    #[must_use]
610    pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
611        self.env.insert(key.into(), value.into());
612        self
613    }
614
615    /// Add multiple environment variables.
616    #[must_use]
617    pub fn envs(
618        mut self,
619        vars: impl IntoIterator<Item = (impl Into<String>, impl Into<String>)>,
620    ) -> Self {
621        for (k, v) in vars {
622            self.env.insert(k.into(), v.into());
623        }
624        self
625    }
626
627    /// Set a default timeout for all commands (in seconds).
628    #[must_use]
629    pub fn timeout_secs(mut self, seconds: u64) -> Self {
630        self.timeout = Some(Duration::from_secs(seconds));
631        self
632    }
633
634    /// Set a default timeout for all commands.
635    #[must_use]
636    pub fn timeout(mut self, duration: Duration) -> Self {
637        self.timeout = Some(duration);
638        self
639    }
640
641    /// Add a global argument applied to all commands.
642    ///
643    /// This is an escape hatch for flags not yet covered by the API.
644    #[must_use]
645    pub fn arg(mut self, arg: impl Into<String>) -> Self {
646        self.global_args.push(arg.into());
647        self
648    }
649
650    /// Enable verbose output for all commands (`--verbose`).
651    #[must_use]
652    pub fn verbose(mut self) -> Self {
653        self.global_args.push("--verbose".into());
654        self
655    }
656
657    /// Enable debug output for all commands (`--debug`).
658    #[must_use]
659    pub fn debug(mut self) -> Self {
660        self.global_args.push("--debug".into());
661        self
662    }
663
664    /// Set a default retry policy for all commands.
665    ///
666    /// Individual commands can override this via their own retry settings.
667    ///
668    /// # Example
669    ///
670    /// ```no_run
671    /// use claude_wrapper::{Claude, RetryPolicy};
672    /// use std::time::Duration;
673    ///
674    /// # fn example() -> claude_wrapper::Result<()> {
675    /// let claude = Claude::builder()
676    ///     .retry(RetryPolicy::new()
677    ///         .max_attempts(3)
678    ///         .initial_backoff(Duration::from_secs(2))
679    ///         .exponential()
680    ///         .retry_on_timeout(true))
681    ///     .build()?;
682    /// # Ok(())
683    /// # }
684    /// ```
685    #[must_use]
686    pub fn retry(mut self, policy: RetryPolicy) -> Self {
687        self.retry_policy = Some(policy);
688        self
689    }
690
691    /// Declare the inclusive `[min, max]` range of `claude` CLI
692    /// versions this client has been tested against.
693    ///
694    /// The wrapper does not enforce the range -- nothing errors when
695    /// it's set wrong. Use [`Claude::cli_version_status`] (or its
696    /// sync mirror) at startup to classify the actually-installed CLI
697    /// against this declaration; that call returns a typed
698    /// [`CliVersionStatus`] AND emits a `tracing::warn!` when
699    /// outside the range. Hosts (claude-server, application code)
700    /// can additionally surface the status to operators.
701    ///
702    /// # Why this exists
703    ///
704    /// CLI semantics drift across minor / patch releases (e.g.
705    /// `claude agents` was repurposed in 2.1.143). The min floor
706    /// lets us say "we know it's broken below this"; the max ceiling
707    /// lets us say "we haven't verified above this -- proceed but
708    /// expect surprises."
709    ///
710    /// # Example
711    ///
712    /// ```no_run
713    /// use claude_wrapper::{Claude, CliVersion};
714    ///
715    /// # async fn example() -> claude_wrapper::Result<()> {
716    /// let claude = Claude::builder()
717    ///     .tested_cli_version_range(CliVersion::new(2, 1, 0), CliVersion::new(2, 1, 999))
718    ///     .build()?;
719    /// // Run once at startup to log a warning if the CLI is out of range.
720    /// let _status = claude.cli_version_status().await?;
721    /// # Ok(()) }
722    /// ```
723    #[must_use]
724    pub fn tested_cli_version_range(mut self, min: CliVersion, max: CliVersion) -> Self {
725        self.tested_cli_version_range = Some((min, max));
726        self
727    }
728
729    /// Build the Claude client, resolving the binary path.
730    pub fn build(self) -> Result<Claude> {
731        let binary = match self.binary {
732            Some(path) => path,
733            None => which::which("claude").map_err(|_| Error::NotFound)?,
734        };
735
736        Ok(Claude {
737            binary,
738            working_dir: self.working_dir,
739            env: self.env,
740            global_args: self.global_args,
741            timeout: self.timeout,
742            retry_policy: self.retry_policy,
743            tested_cli_version_range: self.tested_cli_version_range,
744        })
745    }
746}
747
748#[cfg(test)]
749mod tests {
750    use super::*;
751
752    #[test]
753    fn test_builder_with_binary() {
754        let claude = Claude::builder()
755            .binary("/usr/local/bin/claude")
756            .env("FOO", "bar")
757            .timeout_secs(60)
758            .build()
759            .unwrap();
760
761        assert_eq!(claude.binary, PathBuf::from("/usr/local/bin/claude"));
762        assert_eq!(claude.env.get("FOO").unwrap(), "bar");
763        assert_eq!(claude.timeout, Some(Duration::from_secs(60)));
764    }
765
766    #[test]
767    fn test_builder_global_args() {
768        let claude = Claude::builder()
769            .binary("/usr/local/bin/claude")
770            .arg("--verbose")
771            .build()
772            .unwrap();
773
774        assert_eq!(claude.global_args, vec!["--verbose"]);
775    }
776
777    #[test]
778    fn test_builder_verbose() {
779        let claude = Claude::builder()
780            .binary("/usr/local/bin/claude")
781            .verbose()
782            .build()
783            .unwrap();
784        assert!(claude.global_args.contains(&"--verbose".to_string()));
785    }
786
787    #[test]
788    fn test_builder_debug() {
789        let claude = Claude::builder()
790            .binary("/usr/local/bin/claude")
791            .debug()
792            .build()
793            .unwrap();
794        assert!(claude.global_args.contains(&"--debug".to_string()));
795    }
796}