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}