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//! # Session management
105//!
106//! For multi-turn conversations use [`session::Session`]. It threads the
107//! CLI `session_id` across turns, tracks cumulative cost + history, and
108//! supports streaming. See the [session module docs](session) for the
109//! full API.
110//!
111//! ```no_run
112//! # #[cfg(all(feature = "async", feature = "json"))] {
113//! use std::sync::Arc;
114//! use claude_wrapper::Claude;
115//! use claude_wrapper::session::Session;
116//!
117//! # async fn example() -> claude_wrapper::Result<()> {
118//! let claude = Arc::new(Claude::builder().build()?);
119//! let mut session = Session::new(claude);
120//! let _first = session.send("what's 2 + 2?").await?;
121//! let _second = session.send("and squared?").await?;
122//! println!("cost: ${:.4}", session.total_cost_usd());
123//! # Ok(()) }
124//! # }
125//! ```
126//!
127//! # Budget tracking
128//!
129//! Attach a [`BudgetTracker`] to a session (or share one across several
130//! sessions) to enforce a cumulative USD ceiling. Callbacks fire
131//! exactly once when thresholds are crossed; pre-turn checks
132//! short-circuit with [`Error::BudgetExceeded`]
133//! once the ceiling is hit.
134//!
135//! ```no_run
136//! # #[cfg(all(feature = "async", feature = "json"))] {
137//! use std::sync::Arc;
138//! use claude_wrapper::{BudgetTracker, Claude};
139//! use claude_wrapper::session::Session;
140//!
141//! # async fn example() -> claude_wrapper::Result<()> {
142//! let budget = BudgetTracker::builder()
143//!     .max_usd(5.00)
144//!     .warn_at_usd(4.00)
145//!     .on_warning(|t| eprintln!("warning: ${t:.2}"))
146//!     .on_exceeded(|t| eprintln!("budget hit: ${t:.2}"))
147//!     .build();
148//!
149//! let claude = Arc::new(Claude::builder().build()?);
150//! let mut session = Session::new(claude).with_budget(budget.clone());
151//! session.send("hello").await?;
152//! println!("spent: ${:.4}", budget.total_usd());
153//! # Ok(()) }
154//! # }
155//! ```
156//!
157//! # Tool permissions
158//!
159//! Use [`ToolPattern`] for typed `--allowed-tools` / `--disallowed-tools`
160//! entries. Typed constructors always produce valid patterns; loose
161//! `From<&str>` keeps bare strings working for back-compat.
162//!
163//! ```
164//! use claude_wrapper::{QueryCommand, ToolPattern};
165//!
166//! let cmd = QueryCommand::new("review")
167//!     .allowed_tool(ToolPattern::tool("Read"))
168//!     .allowed_tool(ToolPattern::tool_with_args("Bash", "git log:*"))
169//!     .allowed_tool(ToolPattern::all("Write"))
170//!     .allowed_tool(ToolPattern::mcp("my-server", "*"))
171//!     .disallowed_tool(ToolPattern::tool_with_args("Bash", "rm*"));
172//! ```
173//!
174//! # Streaming
175//!
176//! Process NDJSON events in real time with [`streaming::stream_query`]
177//! (async) or [`streaming::stream_query_sync`] (blocking; non-`Send`
178//! handler supported).
179//!
180//! ```no_run
181//! # #[cfg(all(feature = "async", feature = "json"))] {
182//! use claude_wrapper::{Claude, OutputFormat, QueryCommand};
183//! use claude_wrapper::streaming::{StreamEvent, stream_query};
184//!
185//! # async fn example() -> claude_wrapper::Result<()> {
186//! let claude = Claude::builder().build()?;
187//! let cmd = QueryCommand::new("explain quicksort")
188//!     .output_format(OutputFormat::StreamJson);
189//!
190//! stream_query(&claude, &cmd, |event: StreamEvent| {
191//!     if event.is_result() {
192//!         println!("result: {}", event.result_text().unwrap_or(""));
193//!     }
194//! }).await?;
195//! # Ok(()) }
196//! # }
197//! ```
198//!
199//! # MCP config generation
200//!
201//! Generate `.mcp.json` files for `--mcp-config`:
202//!
203//! ```no_run
204//! # #[cfg(feature = "async")] {
205//! use claude_wrapper::{Claude, ClaudeCommand, McpConfigBuilder, QueryCommand};
206//!
207//! # async fn example() -> claude_wrapper::Result<()> {
208//! McpConfigBuilder::new()
209//!     .http_server("hub", "http://127.0.0.1:9090")
210//!     .stdio_server("tool", "npx", ["my-server"])
211//!     .write_to("/tmp/my-project/.mcp.json")?;
212//!
213//! let claude = Claude::builder().build()?;
214//! QueryCommand::new("list tools")
215//!     .mcp_config("/tmp/my-project/.mcp.json")
216//!     .execute(&claude)
217//!     .await?;
218//! # Ok(()) }
219//! # }
220//! ```
221//!
222//! # Dangerous: bypass mode
223//!
224//! `--permission-mode bypassPermissions` is isolated behind
225//! [`dangerous::DangerousClient`], which requires an env-var
226//! acknowledgement ([`dangerous::ALLOW_ENV`] = `"1"`) at process start.
227//! See the [dangerous module docs](dangerous) for details.
228//!
229//! # Escape hatch
230//!
231//! For subcommands not yet wrapped, use [`RawCommand`]:
232//!
233//! ```no_run
234//! # #[cfg(feature = "async")] {
235//! use claude_wrapper::{Claude, ClaudeCommand, RawCommand};
236//!
237//! # async fn example() -> claude_wrapper::Result<()> {
238//! let claude = Claude::builder().build()?;
239//! let output = RawCommand::new("some-future-command")
240//!     .arg("--new-flag")
241//!     .arg("value")
242//!     .execute(&claude)
243//!     .await?;
244//! # Ok(()) }
245//! # }
246//! ```
247
248pub mod budget;
249pub mod command;
250pub mod dangerous;
251pub mod error;
252pub mod exec;
253pub mod mcp_config;
254pub mod retry;
255#[cfg(all(feature = "json", feature = "async"))]
256pub mod session;
257pub mod streaming;
258pub mod tool_pattern;
259pub mod types;
260pub mod version;
261
262use std::collections::HashMap;
263use std::path::{Path, PathBuf};
264use std::time::Duration;
265
266pub use budget::{BudgetBuilder, BudgetTracker};
267pub use command::ClaudeCommand;
268#[cfg(feature = "sync")]
269pub use command::ClaudeCommandSyncExt;
270pub use command::agents::AgentsCommand;
271pub use command::auth::{
272    AuthLoginCommand, AuthLogoutCommand, AuthStatusCommand, SetupTokenCommand,
273};
274pub use command::auto_mode::{
275    AutoModeConfigCommand, AutoModeCritiqueCommand, AutoModeDefaultsCommand,
276};
277pub use command::doctor::DoctorCommand;
278pub use command::install::InstallCommand;
279pub use command::marketplace::{
280    MarketplaceAddCommand, MarketplaceListCommand, MarketplaceRemoveCommand,
281    MarketplaceUpdateCommand,
282};
283pub use command::mcp::{
284    McpAddCommand, McpAddFromDesktopCommand, McpAddJsonCommand, McpGetCommand, McpListCommand,
285    McpRemoveCommand, McpResetProjectChoicesCommand, McpServeCommand,
286};
287pub use command::plugin::{
288    PluginDisableCommand, PluginEnableCommand, PluginInstallCommand, PluginListCommand,
289    PluginTagCommand, PluginUninstallCommand, PluginUpdateCommand, PluginValidateCommand,
290};
291pub use command::query::QueryCommand;
292pub use command::raw::RawCommand;
293pub use command::update::UpdateCommand;
294pub use command::version::VersionCommand;
295pub use error::{Error, Result};
296pub use exec::CommandOutput;
297#[cfg(feature = "tempfile")]
298pub use mcp_config::TempMcpConfig;
299pub use mcp_config::{McpConfigBuilder, McpServerConfig};
300pub use retry::{BackoffStrategy, RetryPolicy};
301#[cfg(all(feature = "json", feature = "async"))]
302pub use session::Session;
303pub use tool_pattern::{PatternError, ToolPattern};
304pub use types::*;
305pub use version::{CliVersion, VersionParseError};
306
307/// The Claude CLI client. Holds shared configuration applied to all commands.
308///
309/// Create one via [`Claude::builder()`] and reuse it across commands.
310#[derive(Debug, Clone)]
311pub struct Claude {
312    pub(crate) binary: PathBuf,
313    pub(crate) working_dir: Option<PathBuf>,
314    pub(crate) env: HashMap<String, String>,
315    pub(crate) global_args: Vec<String>,
316    pub(crate) timeout: Option<Duration>,
317    pub(crate) retry_policy: Option<RetryPolicy>,
318}
319
320impl Claude {
321    /// Create a new builder for configuring the Claude client.
322    #[must_use]
323    pub fn builder() -> ClaudeBuilder {
324        ClaudeBuilder::default()
325    }
326
327    /// Get the path to the claude binary.
328    #[must_use]
329    pub fn binary(&self) -> &Path {
330        &self.binary
331    }
332
333    /// Get the working directory, if set.
334    #[must_use]
335    pub fn working_dir(&self) -> Option<&Path> {
336        self.working_dir.as_deref()
337    }
338
339    /// Create a clone of this client with a different working directory.
340    #[must_use]
341    pub fn with_working_dir(&self, dir: impl Into<PathBuf>) -> Self {
342        let mut clone = self.clone();
343        clone.working_dir = Some(dir.into());
344        clone
345    }
346
347    /// Query the installed CLI version.
348    ///
349    /// Runs `claude --version` and parses the output into a [`CliVersion`].
350    ///
351    /// # Example
352    ///
353    /// ```no_run
354    /// # async fn example() -> claude_wrapper::Result<()> {
355    /// let claude = claude_wrapper::Claude::builder().build()?;
356    /// let version = claude.cli_version().await?;
357    /// println!("Claude CLI {version}");
358    /// # Ok(())
359    /// # }
360    /// ```
361    #[cfg(feature = "async")]
362    pub async fn cli_version(&self) -> Result<CliVersion> {
363        let output = VersionCommand::new().execute(self).await?;
364        CliVersion::parse_version_output(&output.stdout).map_err(|e| Error::Io {
365            message: format!("failed to parse CLI version: {e}"),
366            source: std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()),
367            working_dir: None,
368        })
369    }
370
371    /// Check that the installed CLI version meets a minimum requirement.
372    ///
373    /// Returns the detected version on success, or an error if the version
374    /// is below the minimum.
375    ///
376    /// # Example
377    ///
378    /// ```no_run
379    /// use claude_wrapper::CliVersion;
380    ///
381    /// # async fn example() -> claude_wrapper::Result<()> {
382    /// let claude = claude_wrapper::Claude::builder().build()?;
383    /// let version = claude.check_version(&CliVersion::new(2, 1, 0)).await?;
384    /// println!("CLI version {version} meets minimum requirement");
385    /// # Ok(())
386    /// # }
387    /// ```
388    #[cfg(feature = "async")]
389    pub async fn check_version(&self, minimum: &CliVersion) -> Result<CliVersion> {
390        let version = self.cli_version().await?;
391        if version.satisfies_minimum(minimum) {
392            Ok(version)
393        } else {
394            Err(Error::VersionMismatch {
395                found: version,
396                minimum: *minimum,
397            })
398        }
399    }
400
401    /// Blocking mirror of [`Claude::cli_version`]. Requires the
402    /// `sync` feature.
403    #[cfg(feature = "sync")]
404    pub fn cli_version_sync(&self) -> Result<CliVersion> {
405        let output = VersionCommand::new().execute_sync(self)?;
406        CliVersion::parse_version_output(&output.stdout).map_err(|e| Error::Io {
407            message: format!("failed to parse CLI version: {e}"),
408            source: std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()),
409            working_dir: None,
410        })
411    }
412
413    /// Blocking mirror of [`Claude::check_version`]. Requires the
414    /// `sync` feature.
415    #[cfg(feature = "sync")]
416    pub fn check_version_sync(&self, minimum: &CliVersion) -> Result<CliVersion> {
417        let version = self.cli_version_sync()?;
418        if version.satisfies_minimum(minimum) {
419            Ok(version)
420        } else {
421            Err(Error::VersionMismatch {
422                found: version,
423                minimum: *minimum,
424            })
425        }
426    }
427}
428
429/// Builder for creating a [`Claude`] client.
430///
431/// # Example
432///
433/// ```no_run
434/// use claude_wrapper::Claude;
435///
436/// # fn example() -> claude_wrapper::Result<()> {
437/// let claude = Claude::builder()
438///     .env("AWS_REGION", "us-west-2")
439///     .timeout_secs(120)
440///     .build()?;
441/// # Ok(())
442/// # }
443/// ```
444#[derive(Debug, Default)]
445pub struct ClaudeBuilder {
446    binary: Option<PathBuf>,
447    working_dir: Option<PathBuf>,
448    env: HashMap<String, String>,
449    global_args: Vec<String>,
450    timeout: Option<Duration>,
451    retry_policy: Option<RetryPolicy>,
452}
453
454impl ClaudeBuilder {
455    /// Set the path to the claude binary.
456    ///
457    /// If not set, the binary is resolved from PATH using `which`.
458    #[must_use]
459    pub fn binary(mut self, path: impl Into<PathBuf>) -> Self {
460        self.binary = Some(path.into());
461        self
462    }
463
464    /// Set the working directory for all commands.
465    ///
466    /// The spawned process will use this as its current directory.
467    #[must_use]
468    pub fn working_dir(mut self, path: impl Into<PathBuf>) -> Self {
469        self.working_dir = Some(path.into());
470        self
471    }
472
473    /// Add an environment variable to pass to all commands.
474    #[must_use]
475    pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
476        self.env.insert(key.into(), value.into());
477        self
478    }
479
480    /// Add multiple environment variables.
481    #[must_use]
482    pub fn envs(
483        mut self,
484        vars: impl IntoIterator<Item = (impl Into<String>, impl Into<String>)>,
485    ) -> Self {
486        for (k, v) in vars {
487            self.env.insert(k.into(), v.into());
488        }
489        self
490    }
491
492    /// Set a default timeout for all commands (in seconds).
493    #[must_use]
494    pub fn timeout_secs(mut self, seconds: u64) -> Self {
495        self.timeout = Some(Duration::from_secs(seconds));
496        self
497    }
498
499    /// Set a default timeout for all commands.
500    #[must_use]
501    pub fn timeout(mut self, duration: Duration) -> Self {
502        self.timeout = Some(duration);
503        self
504    }
505
506    /// Add a global argument applied to all commands.
507    ///
508    /// This is an escape hatch for flags not yet covered by the API.
509    #[must_use]
510    pub fn arg(mut self, arg: impl Into<String>) -> Self {
511        self.global_args.push(arg.into());
512        self
513    }
514
515    /// Enable verbose output for all commands (`--verbose`).
516    #[must_use]
517    pub fn verbose(mut self) -> Self {
518        self.global_args.push("--verbose".into());
519        self
520    }
521
522    /// Enable debug output for all commands (`--debug`).
523    #[must_use]
524    pub fn debug(mut self) -> Self {
525        self.global_args.push("--debug".into());
526        self
527    }
528
529    /// Set a default retry policy for all commands.
530    ///
531    /// Individual commands can override this via their own retry settings.
532    ///
533    /// # Example
534    ///
535    /// ```no_run
536    /// use claude_wrapper::{Claude, RetryPolicy};
537    /// use std::time::Duration;
538    ///
539    /// # fn example() -> claude_wrapper::Result<()> {
540    /// let claude = Claude::builder()
541    ///     .retry(RetryPolicy::new()
542    ///         .max_attempts(3)
543    ///         .initial_backoff(Duration::from_secs(2))
544    ///         .exponential()
545    ///         .retry_on_timeout(true))
546    ///     .build()?;
547    /// # Ok(())
548    /// # }
549    /// ```
550    #[must_use]
551    pub fn retry(mut self, policy: RetryPolicy) -> Self {
552        self.retry_policy = Some(policy);
553        self
554    }
555
556    /// Build the Claude client, resolving the binary path.
557    pub fn build(self) -> Result<Claude> {
558        let binary = match self.binary {
559            Some(path) => path,
560            None => which::which("claude").map_err(|_| Error::NotFound)?,
561        };
562
563        Ok(Claude {
564            binary,
565            working_dir: self.working_dir,
566            env: self.env,
567            global_args: self.global_args,
568            timeout: self.timeout,
569            retry_policy: self.retry_policy,
570        })
571    }
572}
573
574#[cfg(test)]
575mod tests {
576    use super::*;
577
578    #[test]
579    fn test_builder_with_binary() {
580        let claude = Claude::builder()
581            .binary("/usr/local/bin/claude")
582            .env("FOO", "bar")
583            .timeout_secs(60)
584            .build()
585            .unwrap();
586
587        assert_eq!(claude.binary, PathBuf::from("/usr/local/bin/claude"));
588        assert_eq!(claude.env.get("FOO").unwrap(), "bar");
589        assert_eq!(claude.timeout, Some(Duration::from_secs(60)));
590    }
591
592    #[test]
593    fn test_builder_global_args() {
594        let claude = Claude::builder()
595            .binary("/usr/local/bin/claude")
596            .arg("--verbose")
597            .build()
598            .unwrap();
599
600        assert_eq!(claude.global_args, vec!["--verbose"]);
601    }
602
603    #[test]
604    fn test_builder_verbose() {
605        let claude = Claude::builder()
606            .binary("/usr/local/bin/claude")
607            .verbose()
608            .build()
609            .unwrap();
610        assert!(claude.global_args.contains(&"--verbose".to_string()));
611    }
612
613    #[test]
614    fn test_builder_debug() {
615        let claude = Claude::builder()
616            .binary("/usr/local/bin/claude")
617            .debug()
618            .build()
619            .unwrap();
620        assert!(claude.global_args.contains(&"--debug".to_string()));
621    }
622}