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 dangerous;
169pub mod error;
170pub mod exec;
171pub mod mcp_config;
172pub mod retry;
173#[cfg(feature = "json")]
174pub mod session;
175pub mod streaming;
176pub mod types;
177pub mod version;
178
179use std::collections::HashMap;
180use std::path::{Path, PathBuf};
181use std::time::Duration;
182
183pub use command::ClaudeCommand;
184pub use command::agents::AgentsCommand;
185pub use command::auth::{
186    AuthLoginCommand, AuthLogoutCommand, AuthStatusCommand, SetupTokenCommand,
187};
188pub use command::doctor::DoctorCommand;
189pub use command::marketplace::{
190    MarketplaceAddCommand, MarketplaceListCommand, MarketplaceRemoveCommand,
191    MarketplaceUpdateCommand,
192};
193pub use command::mcp::{
194    McpAddCommand, McpAddFromDesktopCommand, McpAddJsonCommand, McpGetCommand, McpListCommand,
195    McpRemoveCommand, McpResetProjectChoicesCommand, McpServeCommand,
196};
197pub use command::plugin::{
198    PluginDisableCommand, PluginEnableCommand, PluginInstallCommand, PluginListCommand,
199    PluginUninstallCommand, PluginUpdateCommand, PluginValidateCommand,
200};
201pub use command::query::QueryCommand;
202pub use command::raw::RawCommand;
203pub use command::version::VersionCommand;
204pub use error::{Error, Result};
205pub use exec::CommandOutput;
206#[cfg(feature = "tempfile")]
207pub use mcp_config::TempMcpConfig;
208pub use mcp_config::{McpConfigBuilder, McpServerConfig};
209pub use retry::{BackoffStrategy, RetryPolicy};
210#[cfg(feature = "json")]
211pub use session::Session;
212pub use types::*;
213pub use version::{CliVersion, VersionParseError};
214
215/// The Claude CLI client. Holds shared configuration applied to all commands.
216///
217/// Create one via [`Claude::builder()`] and reuse it across commands.
218#[derive(Debug, Clone)]
219pub struct Claude {
220    pub(crate) binary: PathBuf,
221    pub(crate) working_dir: Option<PathBuf>,
222    pub(crate) env: HashMap<String, String>,
223    pub(crate) global_args: Vec<String>,
224    pub(crate) timeout: Option<Duration>,
225    pub(crate) retry_policy: Option<RetryPolicy>,
226}
227
228impl Claude {
229    /// Create a new builder for configuring the Claude client.
230    #[must_use]
231    pub fn builder() -> ClaudeBuilder {
232        ClaudeBuilder::default()
233    }
234
235    /// Get the path to the claude binary.
236    #[must_use]
237    pub fn binary(&self) -> &Path {
238        &self.binary
239    }
240
241    /// Get the working directory, if set.
242    #[must_use]
243    pub fn working_dir(&self) -> Option<&Path> {
244        self.working_dir.as_deref()
245    }
246
247    /// Create a clone of this client with a different working directory.
248    #[must_use]
249    pub fn with_working_dir(&self, dir: impl Into<PathBuf>) -> Self {
250        let mut clone = self.clone();
251        clone.working_dir = Some(dir.into());
252        clone
253    }
254
255    /// Query the installed CLI version.
256    ///
257    /// Runs `claude --version` and parses the output into a [`CliVersion`].
258    ///
259    /// # Example
260    ///
261    /// ```no_run
262    /// # async fn example() -> claude_wrapper::Result<()> {
263    /// let claude = claude_wrapper::Claude::builder().build()?;
264    /// let version = claude.cli_version().await?;
265    /// println!("Claude CLI {version}");
266    /// # Ok(())
267    /// # }
268    /// ```
269    pub async fn cli_version(&self) -> Result<CliVersion> {
270        let output = VersionCommand::new().execute(self).await?;
271        CliVersion::parse_version_output(&output.stdout).map_err(|e| Error::Io {
272            message: format!("failed to parse CLI version: {e}"),
273            source: std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()),
274            working_dir: None,
275        })
276    }
277
278    /// Check that the installed CLI version meets a minimum requirement.
279    ///
280    /// Returns the detected version on success, or an error if the version
281    /// is below the minimum.
282    ///
283    /// # Example
284    ///
285    /// ```no_run
286    /// use claude_wrapper::CliVersion;
287    ///
288    /// # async fn example() -> claude_wrapper::Result<()> {
289    /// let claude = claude_wrapper::Claude::builder().build()?;
290    /// let version = claude.check_version(&CliVersion::new(2, 1, 0)).await?;
291    /// println!("CLI version {version} meets minimum requirement");
292    /// # Ok(())
293    /// # }
294    /// ```
295    pub async fn check_version(&self, minimum: &CliVersion) -> Result<CliVersion> {
296        let version = self.cli_version().await?;
297        if version.satisfies_minimum(minimum) {
298            Ok(version)
299        } else {
300            Err(Error::VersionMismatch {
301                found: version,
302                minimum: *minimum,
303            })
304        }
305    }
306}
307
308/// Builder for creating a [`Claude`] client.
309///
310/// # Example
311///
312/// ```no_run
313/// use claude_wrapper::Claude;
314///
315/// # fn example() -> claude_wrapper::Result<()> {
316/// let claude = Claude::builder()
317///     .env("AWS_REGION", "us-west-2")
318///     .timeout_secs(120)
319///     .build()?;
320/// # Ok(())
321/// # }
322/// ```
323#[derive(Debug, Default)]
324pub struct ClaudeBuilder {
325    binary: Option<PathBuf>,
326    working_dir: Option<PathBuf>,
327    env: HashMap<String, String>,
328    global_args: Vec<String>,
329    timeout: Option<Duration>,
330    retry_policy: Option<RetryPolicy>,
331}
332
333impl ClaudeBuilder {
334    /// Set the path to the claude binary.
335    ///
336    /// If not set, the binary is resolved from PATH using `which`.
337    #[must_use]
338    pub fn binary(mut self, path: impl Into<PathBuf>) -> Self {
339        self.binary = Some(path.into());
340        self
341    }
342
343    /// Set the working directory for all commands.
344    ///
345    /// The spawned process will use this as its current directory.
346    #[must_use]
347    pub fn working_dir(mut self, path: impl Into<PathBuf>) -> Self {
348        self.working_dir = Some(path.into());
349        self
350    }
351
352    /// Add an environment variable to pass to all commands.
353    #[must_use]
354    pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
355        self.env.insert(key.into(), value.into());
356        self
357    }
358
359    /// Add multiple environment variables.
360    #[must_use]
361    pub fn envs(
362        mut self,
363        vars: impl IntoIterator<Item = (impl Into<String>, impl Into<String>)>,
364    ) -> Self {
365        for (k, v) in vars {
366            self.env.insert(k.into(), v.into());
367        }
368        self
369    }
370
371    /// Set a default timeout for all commands (in seconds).
372    #[must_use]
373    pub fn timeout_secs(mut self, seconds: u64) -> Self {
374        self.timeout = Some(Duration::from_secs(seconds));
375        self
376    }
377
378    /// Set a default timeout for all commands.
379    #[must_use]
380    pub fn timeout(mut self, duration: Duration) -> Self {
381        self.timeout = Some(duration);
382        self
383    }
384
385    /// Add a global argument applied to all commands.
386    ///
387    /// This is an escape hatch for flags not yet covered by the API.
388    #[must_use]
389    pub fn arg(mut self, arg: impl Into<String>) -> Self {
390        self.global_args.push(arg.into());
391        self
392    }
393
394    /// Enable verbose output for all commands (`--verbose`).
395    #[must_use]
396    pub fn verbose(mut self) -> Self {
397        self.global_args.push("--verbose".into());
398        self
399    }
400
401    /// Enable debug output for all commands (`--debug`).
402    #[must_use]
403    pub fn debug(mut self) -> Self {
404        self.global_args.push("--debug".into());
405        self
406    }
407
408    /// Set a default retry policy for all commands.
409    ///
410    /// Individual commands can override this via their own retry settings.
411    ///
412    /// # Example
413    ///
414    /// ```no_run
415    /// use claude_wrapper::{Claude, RetryPolicy};
416    /// use std::time::Duration;
417    ///
418    /// # fn example() -> claude_wrapper::Result<()> {
419    /// let claude = Claude::builder()
420    ///     .retry(RetryPolicy::new()
421    ///         .max_attempts(3)
422    ///         .initial_backoff(Duration::from_secs(2))
423    ///         .exponential()
424    ///         .retry_on_timeout(true))
425    ///     .build()?;
426    /// # Ok(())
427    /// # }
428    /// ```
429    #[must_use]
430    pub fn retry(mut self, policy: RetryPolicy) -> Self {
431        self.retry_policy = Some(policy);
432        self
433    }
434
435    /// Build the Claude client, resolving the binary path.
436    pub fn build(self) -> Result<Claude> {
437        let binary = match self.binary {
438            Some(path) => path,
439            None => which::which("claude").map_err(|_| Error::NotFound)?,
440        };
441
442        Ok(Claude {
443            binary,
444            working_dir: self.working_dir,
445            env: self.env,
446            global_args: self.global_args,
447            timeout: self.timeout,
448            retry_policy: self.retry_policy,
449        })
450    }
451}
452
453#[cfg(test)]
454mod tests {
455    use super::*;
456
457    #[test]
458    fn test_builder_with_binary() {
459        let claude = Claude::builder()
460            .binary("/usr/local/bin/claude")
461            .env("FOO", "bar")
462            .timeout_secs(60)
463            .build()
464            .unwrap();
465
466        assert_eq!(claude.binary, PathBuf::from("/usr/local/bin/claude"));
467        assert_eq!(claude.env.get("FOO").unwrap(), "bar");
468        assert_eq!(claude.timeout, Some(Duration::from_secs(60)));
469    }
470
471    #[test]
472    fn test_builder_global_args() {
473        let claude = Claude::builder()
474            .binary("/usr/local/bin/claude")
475            .arg("--verbose")
476            .build()
477            .unwrap();
478
479        assert_eq!(claude.global_args, vec!["--verbose"]);
480    }
481
482    #[test]
483    fn test_builder_verbose() {
484        let claude = Claude::builder()
485            .binary("/usr/local/bin/claude")
486            .verbose()
487            .build()
488            .unwrap();
489        assert!(claude.global_args.contains(&"--verbose".to_string()));
490    }
491
492    #[test]
493    fn test_builder_debug() {
494        let claude = Claude::builder()
495            .binary("/usr/local/bin/claude")
496            .debug()
497            .build()
498            .unwrap();
499        assert!(claude.global_args.contains(&"--debug".to_string()));
500    }
501}