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 budget;
292pub mod command;
293#[cfg(all(feature = "json", feature = "async"))]
294pub mod conversation;
295pub mod dangerous;
296#[cfg(all(feature = "json", feature = "async"))]
297pub mod duplex;
298pub mod error;
299pub mod exec;
300pub mod mcp_config;
301pub mod retry;
302#[cfg(all(feature = "json", feature = "async"))]
303pub mod session;
304pub mod streaming;
305pub mod tool_pattern;
306pub mod types;
307pub mod version;
308
309use std::collections::HashMap;
310use std::path::{Path, PathBuf};
311use std::time::Duration;
312
313pub use budget::{BudgetBuilder, BudgetTracker};
314pub use command::ClaudeCommand;
315#[cfg(feature = "sync")]
316pub use command::ClaudeCommandSyncExt;
317pub use command::agents::AgentsCommand;
318pub use command::auth::{
319 AuthLoginCommand, AuthLogoutCommand, AuthStatusCommand, SetupTokenCommand,
320};
321pub use command::auto_mode::{
322 AutoModeConfigCommand, AutoModeCritiqueCommand, AutoModeDefaultsCommand,
323};
324pub use command::doctor::DoctorCommand;
325pub use command::install::InstallCommand;
326pub use command::marketplace::{
327 MarketplaceAddCommand, MarketplaceListCommand, MarketplaceRemoveCommand,
328 MarketplaceUpdateCommand,
329};
330pub use command::mcp::{
331 McpAddCommand, McpAddFromDesktopCommand, McpAddJsonCommand, McpGetCommand, McpListCommand,
332 McpRemoveCommand, McpResetProjectChoicesCommand, McpServeCommand,
333};
334pub use command::plugin::{
335 PluginDisableCommand, PluginEnableCommand, PluginInstallCommand, PluginListCommand,
336 PluginTagCommand, PluginUninstallCommand, PluginUpdateCommand, PluginValidateCommand,
337};
338pub use command::query::QueryCommand;
339pub use command::raw::RawCommand;
340pub use command::update::UpdateCommand;
341pub use command::version::VersionCommand;
342#[cfg(all(feature = "json", feature = "async"))]
343pub use conversation::Conversation;
344#[cfg(all(feature = "json", feature = "async"))]
345pub use duplex::{
346 DuplexOptions, DuplexSession, InboundEvent, PermissionDecision, PermissionHandler,
347 PermissionRequest, TurnResult,
348};
349pub use error::{Error, Result};
350pub use exec::CommandOutput;
351#[cfg(feature = "tempfile")]
352pub use mcp_config::TempMcpConfig;
353pub use mcp_config::{McpConfigBuilder, McpServerConfig};
354pub use retry::{BackoffStrategy, RetryPolicy};
355#[cfg(all(feature = "json", feature = "async"))]
356pub use session::Session;
357pub use tool_pattern::{PatternError, ToolPattern};
358pub use types::*;
359pub use version::{CliVersion, VersionParseError};
360
361/// The Claude CLI client. Holds shared configuration applied to all commands.
362///
363/// Create one via [`Claude::builder()`] and reuse it across commands.
364#[derive(Debug, Clone)]
365pub struct Claude {
366 pub(crate) binary: PathBuf,
367 pub(crate) working_dir: Option<PathBuf>,
368 pub(crate) env: HashMap<String, String>,
369 pub(crate) global_args: Vec<String>,
370 pub(crate) timeout: Option<Duration>,
371 pub(crate) retry_policy: Option<RetryPolicy>,
372}
373
374impl Claude {
375 /// Create a new builder for configuring the Claude client.
376 #[must_use]
377 pub fn builder() -> ClaudeBuilder {
378 ClaudeBuilder::default()
379 }
380
381 /// Get the path to the claude binary.
382 #[must_use]
383 pub fn binary(&self) -> &Path {
384 &self.binary
385 }
386
387 /// Get the working directory, if set.
388 #[must_use]
389 pub fn working_dir(&self) -> Option<&Path> {
390 self.working_dir.as_deref()
391 }
392
393 /// Create a clone of this client with a different working directory.
394 #[must_use]
395 pub fn with_working_dir(&self, dir: impl Into<PathBuf>) -> Self {
396 let mut clone = self.clone();
397 clone.working_dir = Some(dir.into());
398 clone
399 }
400
401 /// Query the installed CLI version.
402 ///
403 /// Runs `claude --version` and parses the output into a [`CliVersion`].
404 ///
405 /// # Example
406 ///
407 /// ```no_run
408 /// # async fn example() -> claude_wrapper::Result<()> {
409 /// let claude = claude_wrapper::Claude::builder().build()?;
410 /// let version = claude.cli_version().await?;
411 /// println!("Claude CLI {version}");
412 /// # Ok(())
413 /// # }
414 /// ```
415 #[cfg(feature = "async")]
416 pub async fn cli_version(&self) -> Result<CliVersion> {
417 let output = VersionCommand::new().execute(self).await?;
418 CliVersion::parse_version_output(&output.stdout).map_err(|e| Error::Io {
419 message: format!("failed to parse CLI version: {e}"),
420 source: std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()),
421 working_dir: None,
422 })
423 }
424
425 /// Check that the installed CLI version meets a minimum requirement.
426 ///
427 /// Returns the detected version on success, or an error if the version
428 /// is below the minimum.
429 ///
430 /// # Example
431 ///
432 /// ```no_run
433 /// use claude_wrapper::CliVersion;
434 ///
435 /// # async fn example() -> claude_wrapper::Result<()> {
436 /// let claude = claude_wrapper::Claude::builder().build()?;
437 /// let version = claude.check_version(&CliVersion::new(2, 1, 0)).await?;
438 /// println!("CLI version {version} meets minimum requirement");
439 /// # Ok(())
440 /// # }
441 /// ```
442 #[cfg(feature = "async")]
443 pub async fn check_version(&self, minimum: &CliVersion) -> Result<CliVersion> {
444 let version = self.cli_version().await?;
445 if version.satisfies_minimum(minimum) {
446 Ok(version)
447 } else {
448 Err(Error::VersionMismatch {
449 found: version,
450 minimum: *minimum,
451 })
452 }
453 }
454
455 /// Blocking mirror of [`Claude::cli_version`]. Requires the
456 /// `sync` feature.
457 #[cfg(feature = "sync")]
458 pub fn cli_version_sync(&self) -> Result<CliVersion> {
459 let output = VersionCommand::new().execute_sync(self)?;
460 CliVersion::parse_version_output(&output.stdout).map_err(|e| Error::Io {
461 message: format!("failed to parse CLI version: {e}"),
462 source: std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()),
463 working_dir: None,
464 })
465 }
466
467 /// Blocking mirror of [`Claude::check_version`]. Requires the
468 /// `sync` feature.
469 #[cfg(feature = "sync")]
470 pub fn check_version_sync(&self, minimum: &CliVersion) -> Result<CliVersion> {
471 let version = self.cli_version_sync()?;
472 if version.satisfies_minimum(minimum) {
473 Ok(version)
474 } else {
475 Err(Error::VersionMismatch {
476 found: version,
477 minimum: *minimum,
478 })
479 }
480 }
481}
482
483/// Builder for creating a [`Claude`] client.
484///
485/// # Example
486///
487/// ```no_run
488/// use claude_wrapper::Claude;
489///
490/// # fn example() -> claude_wrapper::Result<()> {
491/// let claude = Claude::builder()
492/// .env("AWS_REGION", "us-west-2")
493/// .timeout_secs(120)
494/// .build()?;
495/// # Ok(())
496/// # }
497/// ```
498#[derive(Debug, Default)]
499pub struct ClaudeBuilder {
500 binary: Option<PathBuf>,
501 working_dir: Option<PathBuf>,
502 env: HashMap<String, String>,
503 global_args: Vec<String>,
504 timeout: Option<Duration>,
505 retry_policy: Option<RetryPolicy>,
506}
507
508impl ClaudeBuilder {
509 /// Set the path to the claude binary.
510 ///
511 /// If not set, the binary is resolved from PATH using `which`.
512 #[must_use]
513 pub fn binary(mut self, path: impl Into<PathBuf>) -> Self {
514 self.binary = Some(path.into());
515 self
516 }
517
518 /// Set the working directory for all commands.
519 ///
520 /// The spawned process will use this as its current directory.
521 #[must_use]
522 pub fn working_dir(mut self, path: impl Into<PathBuf>) -> Self {
523 self.working_dir = Some(path.into());
524 self
525 }
526
527 /// Add an environment variable to pass to all commands.
528 #[must_use]
529 pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
530 self.env.insert(key.into(), value.into());
531 self
532 }
533
534 /// Add multiple environment variables.
535 #[must_use]
536 pub fn envs(
537 mut self,
538 vars: impl IntoIterator<Item = (impl Into<String>, impl Into<String>)>,
539 ) -> Self {
540 for (k, v) in vars {
541 self.env.insert(k.into(), v.into());
542 }
543 self
544 }
545
546 /// Set a default timeout for all commands (in seconds).
547 #[must_use]
548 pub fn timeout_secs(mut self, seconds: u64) -> Self {
549 self.timeout = Some(Duration::from_secs(seconds));
550 self
551 }
552
553 /// Set a default timeout for all commands.
554 #[must_use]
555 pub fn timeout(mut self, duration: Duration) -> Self {
556 self.timeout = Some(duration);
557 self
558 }
559
560 /// Add a global argument applied to all commands.
561 ///
562 /// This is an escape hatch for flags not yet covered by the API.
563 #[must_use]
564 pub fn arg(mut self, arg: impl Into<String>) -> Self {
565 self.global_args.push(arg.into());
566 self
567 }
568
569 /// Enable verbose output for all commands (`--verbose`).
570 #[must_use]
571 pub fn verbose(mut self) -> Self {
572 self.global_args.push("--verbose".into());
573 self
574 }
575
576 /// Enable debug output for all commands (`--debug`).
577 #[must_use]
578 pub fn debug(mut self) -> Self {
579 self.global_args.push("--debug".into());
580 self
581 }
582
583 /// Set a default retry policy for all commands.
584 ///
585 /// Individual commands can override this via their own retry settings.
586 ///
587 /// # Example
588 ///
589 /// ```no_run
590 /// use claude_wrapper::{Claude, RetryPolicy};
591 /// use std::time::Duration;
592 ///
593 /// # fn example() -> claude_wrapper::Result<()> {
594 /// let claude = Claude::builder()
595 /// .retry(RetryPolicy::new()
596 /// .max_attempts(3)
597 /// .initial_backoff(Duration::from_secs(2))
598 /// .exponential()
599 /// .retry_on_timeout(true))
600 /// .build()?;
601 /// # Ok(())
602 /// # }
603 /// ```
604 #[must_use]
605 pub fn retry(mut self, policy: RetryPolicy) -> Self {
606 self.retry_policy = Some(policy);
607 self
608 }
609
610 /// Build the Claude client, resolving the binary path.
611 pub fn build(self) -> Result<Claude> {
612 let binary = match self.binary {
613 Some(path) => path,
614 None => which::which("claude").map_err(|_| Error::NotFound)?,
615 };
616
617 Ok(Claude {
618 binary,
619 working_dir: self.working_dir,
620 env: self.env,
621 global_args: self.global_args,
622 timeout: self.timeout,
623 retry_policy: self.retry_policy,
624 })
625 }
626}
627
628#[cfg(test)]
629mod tests {
630 use super::*;
631
632 #[test]
633 fn test_builder_with_binary() {
634 let claude = Claude::builder()
635 .binary("/usr/local/bin/claude")
636 .env("FOO", "bar")
637 .timeout_secs(60)
638 .build()
639 .unwrap();
640
641 assert_eq!(claude.binary, PathBuf::from("/usr/local/bin/claude"));
642 assert_eq!(claude.env.get("FOO").unwrap(), "bar");
643 assert_eq!(claude.timeout, Some(Duration::from_secs(60)));
644 }
645
646 #[test]
647 fn test_builder_global_args() {
648 let claude = Claude::builder()
649 .binary("/usr/local/bin/claude")
650 .arg("--verbose")
651 .build()
652 .unwrap();
653
654 assert_eq!(claude.global_args, vec!["--verbose"]);
655 }
656
657 #[test]
658 fn test_builder_verbose() {
659 let claude = Claude::builder()
660 .binary("/usr/local/bin/claude")
661 .verbose()
662 .build()
663 .unwrap();
664 assert!(claude.global_args.contains(&"--verbose".to_string()));
665 }
666
667 #[test]
668 fn test_builder_debug() {
669 let claude = Claude::builder()
670 .binary("/usr/local/bin/claude")
671 .debug()
672 .build()
673 .unwrap();
674 assert!(claude.global_args.contains(&"--debug".to_string()));
675 }
676}