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