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. It follows the same design philosophy as
5//! [`docker-wrapper`](https://crates.io/crates/docker-wrapper) and
6//! [`terraform-wrapper`](https://crates.io/crates/terraform-wrapper):
7//! each CLI subcommand is a builder struct that produces typed output.
8//!
9//! # Quick Start
10//!
11//! ```no_run
12//! use claude_wrapper::{Claude, ClaudeCommand, QueryCommand, OutputFormat};
13//!
14//! # async fn example() -> claude_wrapper::Result<()> {
15//! let claude = Claude::builder().build()?;
16//!
17//! // Simple oneshot query
18//! let output = QueryCommand::new("explain this error: file not found")
19//!     .model("sonnet")
20//!     .output_format(OutputFormat::Json)
21//!     .execute(&claude)
22//!     .await?;
23//!
24//! println!("{}", output.stdout);
25//! # Ok(())
26//! # }
27//! ```
28//!
29//! # Two-Layer Builder
30//!
31//! The [`Claude`] client holds shared config (binary path, env vars, timeout).
32//! Command builders hold per-invocation options and call `execute(&claude)`.
33//!
34//! ```no_run
35//! use claude_wrapper::{Claude, ClaudeCommand, QueryCommand, PermissionMode, Effort};
36//!
37//! # async fn example() -> claude_wrapper::Result<()> {
38//! // Configure once, reuse across commands
39//! let claude = Claude::builder()
40//!     .env("AWS_REGION", "us-west-2")
41//!     .timeout_secs(300)
42//!     .build()?;
43//!
44//! // Each command is a separate builder
45//! let output = QueryCommand::new("review the code in src/main.rs")
46//!     .model("opus")
47//!     .system_prompt("You are a senior Rust developer")
48//!     .permission_mode(PermissionMode::Plan)
49//!     .effort(Effort::High)
50//!     .max_turns(5)
51//!     .no_session_persistence()
52//!     .execute(&claude)
53//!     .await?;
54//! # Ok(())
55//! # }
56//! ```
57//!
58//! # JSON Output Parsing
59//!
60//! Use `execute_json()` to get structured results:
61//!
62//! ```no_run
63//! use claude_wrapper::{Claude, QueryCommand};
64//!
65//! # async fn example() -> claude_wrapper::Result<()> {
66//! let claude = Claude::builder().build()?;
67//! let result = QueryCommand::new("what is 2+2?")
68//!     .execute_json(&claude)
69//!     .await?;
70//!
71//! println!("answer: {}", result.result);
72//! println!("cost: ${:.4}", result.cost_usd.unwrap_or(0.0));
73//! println!("session: {}", result.session_id);
74//! # Ok(())
75//! # }
76//! ```
77//!
78//! # MCP Config Generation
79//!
80//! Generate `.mcp.json` files for use with `--mcp-config`:
81//!
82//! ```no_run
83//! use claude_wrapper::{Claude, ClaudeCommand, McpConfigBuilder, QueryCommand};
84//!
85//! # async fn example() -> claude_wrapper::Result<()> {
86//! // Build a config file with multiple servers
87//! let config_path = McpConfigBuilder::new()
88//!     .http_server("my-hub", "http://127.0.0.1:9090")
89//!     .stdio_server("my-tool", "npx", ["my-mcp-server"])
90//!     .stdio_server_with_env(
91//!         "secure-tool", "node", ["server.js"],
92//!         [("API_KEY", "secret")],
93//!     )
94//!     .write_to("/tmp/my-project/.mcp.json")?;
95//!
96//! // Use it in a query
97//! let claude = Claude::builder().build()?;
98//! let output = QueryCommand::new("list available tools")
99//!     .mcp_config("/tmp/my-project/.mcp.json")
100//!     .execute(&claude)
101//!     .await?;
102//! # Ok(())
103//! # }
104//! ```
105//!
106//! # Working with Multiple Directories
107//!
108//! Clone the client with a different working directory:
109//!
110//! ```no_run
111//! use claude_wrapper::{Claude, ClaudeCommand, QueryCommand};
112//!
113//! # async fn example() -> claude_wrapper::Result<()> {
114//! let claude = Claude::builder().build()?;
115//!
116//! for project in &["/srv/project-a", "/srv/project-b"] {
117//!     let local = claude.with_working_dir(project);
118//!     QueryCommand::new("summarize this project")
119//!         .no_session_persistence()
120//!         .execute(&local)
121//!         .await?;
122//! }
123//! # Ok(())
124//! # }
125//! ```
126//!
127//! # Streaming
128//!
129//! Process NDJSON events in real time:
130//!
131//! ```no_run
132//! use claude_wrapper::{Claude, QueryCommand, OutputFormat};
133//! use claude_wrapper::streaming::{StreamEvent, stream_query};
134//!
135//! # async fn example() -> claude_wrapper::Result<()> {
136//! let claude = Claude::builder().build()?;
137//! let cmd = QueryCommand::new("explain quicksort")
138//!     .output_format(OutputFormat::StreamJson);
139//!
140//! let output = stream_query(&claude, &cmd, |event: StreamEvent| {
141//!     if event.is_result() {
142//!         println!("Result: {}", event.result_text().unwrap_or(""));
143//!     }
144//! }).await?;
145//! # Ok(())
146//! # }
147//! ```
148//!
149//! # Escape Hatch
150//!
151//! For subcommands or flags not yet covered by the typed API:
152//!
153//! ```no_run
154//! use claude_wrapper::{Claude, ClaudeCommand, RawCommand};
155//!
156//! # async fn example() -> claude_wrapper::Result<()> {
157//! let claude = Claude::builder().build()?;
158//! let output = RawCommand::new("some-future-command")
159//!     .arg("--new-flag")
160//!     .arg("value")
161//!     .execute(&claude)
162//!     .await?;
163//! # Ok(())
164//! # }
165//! ```
166
167pub mod command;
168pub mod error;
169pub mod exec;
170pub mod mcp_config;
171pub mod retry;
172#[cfg(feature = "json")]
173pub mod session;
174pub mod streaming;
175pub mod types;
176pub mod version;
177
178use std::collections::HashMap;
179use std::path::{Path, PathBuf};
180use std::time::Duration;
181
182pub use command::ClaudeCommand;
183pub use command::agents::AgentsCommand;
184pub use command::auth::AuthStatusCommand;
185pub use command::doctor::DoctorCommand;
186pub use command::marketplace::{
187    MarketplaceAddCommand, MarketplaceListCommand, MarketplaceRemoveCommand,
188    MarketplaceUpdateCommand,
189};
190pub use command::mcp::{
191    McpAddCommand, McpAddFromDesktopCommand, McpAddJsonCommand, McpGetCommand, McpListCommand,
192    McpRemoveCommand, McpResetProjectChoicesCommand,
193};
194pub use command::plugin::{
195    PluginDisableCommand, PluginEnableCommand, PluginInstallCommand, PluginListCommand,
196    PluginUninstallCommand, PluginUpdateCommand, PluginValidateCommand,
197};
198pub use command::query::QueryCommand;
199pub use command::raw::RawCommand;
200pub use command::version::VersionCommand;
201pub use error::{Error, Result};
202pub use exec::CommandOutput;
203#[cfg(feature = "tempfile")]
204pub use mcp_config::TempMcpConfig;
205pub use mcp_config::{McpConfigBuilder, McpServerConfig};
206pub use retry::{BackoffStrategy, RetryPolicy};
207#[cfg(feature = "json")]
208pub use session::{Session, SessionQuery};
209pub use types::*;
210pub use version::{CliVersion, VersionParseError};
211
212/// The Claude CLI client. Holds shared configuration applied to all commands.
213///
214/// Create one via [`Claude::builder()`] and reuse it across commands.
215#[derive(Debug, Clone)]
216pub struct Claude {
217    pub(crate) binary: PathBuf,
218    pub(crate) working_dir: Option<PathBuf>,
219    pub(crate) env: HashMap<String, String>,
220    pub(crate) global_args: Vec<String>,
221    pub(crate) timeout: Option<Duration>,
222    pub(crate) retry_policy: Option<RetryPolicy>,
223}
224
225impl Claude {
226    /// Create a new builder for configuring the Claude client.
227    #[must_use]
228    pub fn builder() -> ClaudeBuilder {
229        ClaudeBuilder::default()
230    }
231
232    /// Get the path to the claude binary.
233    #[must_use]
234    pub fn binary(&self) -> &Path {
235        &self.binary
236    }
237
238    /// Get the working directory, if set.
239    #[must_use]
240    pub fn working_dir(&self) -> Option<&Path> {
241        self.working_dir.as_deref()
242    }
243
244    /// Create a clone of this client with a different working directory.
245    #[must_use]
246    pub fn with_working_dir(&self, dir: impl Into<PathBuf>) -> Self {
247        let mut clone = self.clone();
248        clone.working_dir = Some(dir.into());
249        clone
250    }
251
252    /// Query the installed CLI version.
253    ///
254    /// Runs `claude --version` and parses the output into a [`CliVersion`].
255    ///
256    /// # Example
257    ///
258    /// ```no_run
259    /// # async fn example() -> claude_wrapper::Result<()> {
260    /// let claude = claude_wrapper::Claude::builder().build()?;
261    /// let version = claude.cli_version().await?;
262    /// println!("Claude CLI {version}");
263    /// # Ok(())
264    /// # }
265    /// ```
266    pub async fn cli_version(&self) -> Result<CliVersion> {
267        let output = VersionCommand::new().execute(self).await?;
268        CliVersion::parse_version_output(&output.stdout).map_err(|e| Error::Io {
269            message: format!("failed to parse CLI version: {e}"),
270            source: std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()),
271            working_dir: None,
272        })
273    }
274
275    /// Check that the installed CLI version meets a minimum requirement.
276    ///
277    /// Returns the detected version on success, or an error if the version
278    /// is below the minimum.
279    ///
280    /// # Example
281    ///
282    /// ```no_run
283    /// use claude_wrapper::CliVersion;
284    ///
285    /// # async fn example() -> claude_wrapper::Result<()> {
286    /// let claude = claude_wrapper::Claude::builder().build()?;
287    /// let version = claude.check_version(&CliVersion::new(2, 1, 0)).await?;
288    /// println!("CLI version {version} meets minimum requirement");
289    /// # Ok(())
290    /// # }
291    /// ```
292    pub async fn check_version(&self, minimum: &CliVersion) -> Result<CliVersion> {
293        let version = self.cli_version().await?;
294        if version.satisfies_minimum(minimum) {
295            Ok(version)
296        } else {
297            Err(Error::VersionMismatch {
298                found: version,
299                minimum: *minimum,
300            })
301        }
302    }
303}
304
305/// Builder for creating a [`Claude`] client.
306///
307/// # Example
308///
309/// ```no_run
310/// use claude_wrapper::Claude;
311///
312/// # fn example() -> claude_wrapper::Result<()> {
313/// let claude = Claude::builder()
314///     .env("AWS_REGION", "us-west-2")
315///     .timeout_secs(120)
316///     .build()?;
317/// # Ok(())
318/// # }
319/// ```
320#[derive(Debug, Default)]
321pub struct ClaudeBuilder {
322    binary: Option<PathBuf>,
323    working_dir: Option<PathBuf>,
324    env: HashMap<String, String>,
325    global_args: Vec<String>,
326    timeout: Option<Duration>,
327    retry_policy: Option<RetryPolicy>,
328}
329
330impl ClaudeBuilder {
331    /// Set the path to the claude binary.
332    ///
333    /// If not set, the binary is resolved from PATH using `which`.
334    #[must_use]
335    pub fn binary(mut self, path: impl Into<PathBuf>) -> Self {
336        self.binary = Some(path.into());
337        self
338    }
339
340    /// Set the working directory for all commands.
341    ///
342    /// The spawned process will use this as its current directory.
343    #[must_use]
344    pub fn working_dir(mut self, path: impl Into<PathBuf>) -> Self {
345        self.working_dir = Some(path.into());
346        self
347    }
348
349    /// Add an environment variable to pass to all commands.
350    #[must_use]
351    pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
352        self.env.insert(key.into(), value.into());
353        self
354    }
355
356    /// Add multiple environment variables.
357    #[must_use]
358    pub fn envs(
359        mut self,
360        vars: impl IntoIterator<Item = (impl Into<String>, impl Into<String>)>,
361    ) -> Self {
362        for (k, v) in vars {
363            self.env.insert(k.into(), v.into());
364        }
365        self
366    }
367
368    /// Set a default timeout for all commands (in seconds).
369    #[must_use]
370    pub fn timeout_secs(mut self, seconds: u64) -> Self {
371        self.timeout = Some(Duration::from_secs(seconds));
372        self
373    }
374
375    /// Set a default timeout for all commands.
376    #[must_use]
377    pub fn timeout(mut self, duration: Duration) -> Self {
378        self.timeout = Some(duration);
379        self
380    }
381
382    /// Add a global argument applied to all commands.
383    ///
384    /// This is an escape hatch for flags not yet covered by the API.
385    #[must_use]
386    pub fn arg(mut self, arg: impl Into<String>) -> Self {
387        self.global_args.push(arg.into());
388        self
389    }
390
391    /// Enable verbose output for all commands (`--verbose`).
392    #[must_use]
393    pub fn verbose(mut self) -> Self {
394        self.global_args.push("--verbose".into());
395        self
396    }
397
398    /// Enable debug output for all commands (`--debug`).
399    #[must_use]
400    pub fn debug(mut self) -> Self {
401        self.global_args.push("--debug".into());
402        self
403    }
404
405    /// Suppress non-essential output for all commands (`--quiet`).
406    #[must_use]
407    pub fn quiet(mut self) -> Self {
408        self.global_args.push("--quiet".into());
409        self
410    }
411
412    /// Control color output for all commands.
413    ///
414    /// Passes `--color` when `enabled` is `true`, `--no-color` when `false`.
415    #[must_use]
416    pub fn color(mut self, enabled: bool) -> Self {
417        if enabled {
418            self.global_args.push("--color".into());
419        } else {
420            self.global_args.push("--no-color".into());
421        }
422        self
423    }
424
425    /// Set a default retry policy for all commands.
426    ///
427    /// Individual commands can override this via their own retry settings.
428    ///
429    /// # Example
430    ///
431    /// ```no_run
432    /// use claude_wrapper::{Claude, RetryPolicy};
433    /// use std::time::Duration;
434    ///
435    /// # fn example() -> claude_wrapper::Result<()> {
436    /// let claude = Claude::builder()
437    ///     .retry(RetryPolicy::new()
438    ///         .max_attempts(3)
439    ///         .initial_backoff(Duration::from_secs(2))
440    ///         .exponential()
441    ///         .retry_on_timeout(true))
442    ///     .build()?;
443    /// # Ok(())
444    /// # }
445    /// ```
446    #[must_use]
447    pub fn retry(mut self, policy: RetryPolicy) -> Self {
448        self.retry_policy = Some(policy);
449        self
450    }
451
452    /// Build the Claude client, resolving the binary path.
453    pub fn build(self) -> Result<Claude> {
454        let binary = match self.binary {
455            Some(path) => path,
456            None => which::which("claude").map_err(|_| Error::NotFound)?,
457        };
458
459        Ok(Claude {
460            binary,
461            working_dir: self.working_dir,
462            env: self.env,
463            global_args: self.global_args,
464            timeout: self.timeout,
465            retry_policy: self.retry_policy,
466        })
467    }
468}
469
470#[cfg(test)]
471mod tests {
472    use super::*;
473
474    #[test]
475    fn test_builder_with_binary() {
476        let claude = Claude::builder()
477            .binary("/usr/local/bin/claude")
478            .env("FOO", "bar")
479            .timeout_secs(60)
480            .build()
481            .unwrap();
482
483        assert_eq!(claude.binary, PathBuf::from("/usr/local/bin/claude"));
484        assert_eq!(claude.env.get("FOO").unwrap(), "bar");
485        assert_eq!(claude.timeout, Some(Duration::from_secs(60)));
486    }
487
488    #[test]
489    fn test_builder_global_args() {
490        let claude = Claude::builder()
491            .binary("/usr/local/bin/claude")
492            .arg("--verbose")
493            .build()
494            .unwrap();
495
496        assert_eq!(claude.global_args, vec!["--verbose"]);
497    }
498
499    #[test]
500    fn test_builder_verbose() {
501        let claude = Claude::builder()
502            .binary("/usr/local/bin/claude")
503            .verbose()
504            .build()
505            .unwrap();
506        assert!(claude.global_args.contains(&"--verbose".to_string()));
507    }
508
509    #[test]
510    fn test_builder_debug() {
511        let claude = Claude::builder()
512            .binary("/usr/local/bin/claude")
513            .debug()
514            .build()
515            .unwrap();
516        assert!(claude.global_args.contains(&"--debug".to_string()));
517    }
518
519    #[test]
520    fn test_builder_quiet() {
521        let claude = Claude::builder()
522            .binary("/usr/local/bin/claude")
523            .quiet()
524            .build()
525            .unwrap();
526        assert!(claude.global_args.contains(&"--quiet".to_string()));
527    }
528
529    #[test]
530    fn test_builder_color_enabled() {
531        let claude = Claude::builder()
532            .binary("/usr/local/bin/claude")
533            .color(true)
534            .build()
535            .unwrap();
536        assert!(claude.global_args.contains(&"--color".to_string()));
537        assert!(!claude.global_args.contains(&"--no-color".to_string()));
538    }
539
540    #[test]
541    fn test_builder_color_disabled() {
542        let claude = Claude::builder()
543            .binary("/usr/local/bin/claude")
544            .color(false)
545            .build()
546            .unwrap();
547        assert!(claude.global_args.contains(&"--no-color".to_string()));
548        assert!(!claude.global_args.contains(&"--color".to_string()));
549    }
550}