#![warn(missing_docs)]
pub mod artifacts;
pub mod auth;
pub mod budget;
pub mod command;
pub mod commands;
#[cfg(all(feature = "json", feature = "async"))]
pub mod conversation;
pub mod dangerous;
#[cfg(all(feature = "json", feature = "async"))]
pub mod duplex;
pub mod error;
pub mod exec;
#[cfg(feature = "json")]
pub mod history;
#[cfg(feature = "json")]
pub mod jobs;
pub mod mcp_config;
pub mod memory;
pub mod plans;
pub mod retry;
#[cfg(all(feature = "json", feature = "async"))]
pub mod session;
#[cfg(feature = "json")]
pub mod sessions;
#[cfg(feature = "json")]
pub mod settings;
pub mod skills;
pub mod slash;
pub mod streaming;
#[cfg(feature = "json")]
pub mod tasks;
pub mod tool_pattern;
pub mod types;
pub mod version;
pub mod worktrees;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::time::Duration;
pub use budget::{BudgetBuilder, BudgetTracker};
pub use command::ClaudeCommand;
#[cfg(feature = "sync")]
pub use command::ClaudeCommandSyncExt;
#[allow(deprecated)]
pub use command::agents::AgentsCommand;
pub use command::auth::{
AuthLoginCommand, AuthLogoutCommand, AuthStatusCommand, LoginMode, SetupTokenCommand,
};
pub use command::auto_mode::{
AutoModeConfigCommand, AutoModeCritiqueCommand, AutoModeDefaultsCommand,
};
pub use command::doctor::DoctorCommand;
pub use command::install::InstallCommand;
pub use command::marketplace::{
MarketplaceAddCommand, MarketplaceListCommand, MarketplaceRemoveCommand,
MarketplaceUpdateCommand,
};
pub use command::mcp::{
McpAddCommand, McpAddFromDesktopCommand, McpAddJsonCommand, McpGetCommand, McpListCommand,
McpLoginCommand, McpLogoutCommand, McpRemoveCommand, McpResetProjectChoicesCommand,
McpServeCommand,
};
pub use command::plugin::{
PluginDetailsCommand, PluginDisableCommand, PluginEnableCommand, PluginInstallCommand,
PluginListCommand, PluginPruneCommand, PluginTagCommand, PluginUninstallCommand,
PluginUpdateCommand, PluginValidateCommand,
};
pub use command::project::ProjectPurgeCommand;
pub use command::query::QueryCommand;
pub use command::raw::RawCommand;
pub use command::ultrareview::UltrareviewCommand;
pub use command::update::UpdateCommand;
pub use command::version::VersionCommand;
#[cfg(all(feature = "json", feature = "async"))]
pub use conversation::Conversation;
#[cfg(all(feature = "json", feature = "async"))]
pub use duplex::{
DuplexOptions, DuplexSession, InboundEvent, PermissionDecision, PermissionHandler,
PermissionRequest, TurnResult,
};
pub use error::{Error, Result};
pub use exec::CommandOutput;
#[cfg(feature = "tempfile")]
pub use mcp_config::TempMcpConfig;
pub use mcp_config::{McpConfigBuilder, McpServerConfig};
pub use retry::{BackoffStrategy, RetryPolicy};
#[cfg(all(feature = "json", feature = "async"))]
pub use session::Session;
pub use tool_pattern::{PatternError, ToolPattern};
pub use types::*;
pub use version::{
CliVersion, CliVersionStatus, TESTED_CLI_VERSION_MAX, TESTED_CLI_VERSION_MIN, VersionParseError,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub struct SpawnInfo {
pub pid: u32,
pub pgid: Option<u32>,
}
pub type SpawnObserver = std::sync::Arc<dyn Fn(SpawnInfo) + Send + Sync>;
#[derive(Clone)]
pub struct Claude {
pub(crate) binary: PathBuf,
pub(crate) working_dir: Option<PathBuf>,
#[allow(dead_code)]
pub(crate) env: HashMap<String, String>,
#[allow(dead_code)]
pub(crate) clear_env: bool,
pub(crate) global_args: Vec<String>,
#[allow(dead_code)]
pub(crate) timeout: Option<Duration>,
#[allow(dead_code)]
pub(crate) retry_policy: Option<RetryPolicy>,
pub(crate) tested_cli_version_range: Option<(CliVersion, CliVersion)>,
#[allow(dead_code)]
pub(crate) process_group: bool,
#[allow(dead_code)]
pub(crate) kill_grace: Option<Duration>,
#[allow(dead_code)]
pub(crate) on_spawn: Option<SpawnObserver>,
#[allow(dead_code)]
pub(crate) die_with_parent: bool,
}
impl std::fmt::Debug for Claude {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Claude")
.field("binary", &self.binary)
.field("working_dir", &self.working_dir)
.field("clear_env", &self.clear_env)
.field("global_args", &self.global_args)
.field("timeout", &self.timeout)
.field("process_group", &self.process_group)
.field("kill_grace", &self.kill_grace)
.field("on_spawn", &self.on_spawn.is_some())
.finish_non_exhaustive()
}
}
impl Claude {
#[must_use]
pub fn builder() -> ClaudeBuilder {
ClaudeBuilder::default()
}
#[must_use]
pub fn binary(&self) -> &Path {
&self.binary
}
#[must_use]
pub fn working_dir(&self) -> Option<&Path> {
self.working_dir.as_deref()
}
#[must_use]
pub fn with_working_dir(&self, dir: impl Into<PathBuf>) -> Self {
let mut clone = self.clone();
clone.working_dir = Some(dir.into());
clone
}
#[cfg(feature = "async")]
pub async fn cli_version(&self) -> Result<CliVersion> {
let output = VersionCommand::new().execute(self).await?;
CliVersion::parse_version_output(&output.stdout).map_err(|e| Error::Io {
message: format!("failed to parse CLI version: {e}"),
source: std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()),
working_dir: None,
})
}
#[cfg(feature = "async")]
pub async fn check_version(&self, minimum: &CliVersion) -> Result<CliVersion> {
let version = self.cli_version().await?;
if version.satisfies_minimum(minimum) {
Ok(version)
} else {
Err(Error::VersionMismatch {
found: version,
minimum: *minimum,
})
}
}
#[cfg(feature = "sync")]
pub fn cli_version_sync(&self) -> Result<CliVersion> {
let output = VersionCommand::new().execute_sync(self)?;
CliVersion::parse_version_output(&output.stdout).map_err(|e| Error::Io {
message: format!("failed to parse CLI version: {e}"),
source: std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()),
working_dir: None,
})
}
#[cfg(feature = "sync")]
pub fn check_version_sync(&self, minimum: &CliVersion) -> Result<CliVersion> {
let version = self.cli_version_sync()?;
if version.satisfies_minimum(minimum) {
Ok(version)
} else {
Err(Error::VersionMismatch {
found: version,
minimum: *minimum,
})
}
}
#[must_use]
pub fn tested_cli_version_range(&self) -> Option<(CliVersion, CliVersion)> {
self.tested_cli_version_range
}
#[must_use]
pub fn effective_tested_range(&self) -> (CliVersion, CliVersion) {
self.tested_cli_version_range
.unwrap_or((TESTED_CLI_VERSION_MIN, TESTED_CLI_VERSION_MAX))
}
#[cfg(feature = "async")]
pub async fn cli_version_status(&self) -> Result<CliVersionStatus> {
let (min, max) = self.effective_tested_range();
let found = self.cli_version().await?;
let status = found.status_within(&min, &max);
warn_on_drift(&status);
Ok(status)
}
#[cfg(feature = "sync")]
pub fn cli_version_status_sync(&self) -> Result<CliVersionStatus> {
let (min, max) = self.effective_tested_range();
let found = self.cli_version_sync()?;
let status = found.status_within(&min, &max);
warn_on_drift(&status);
Ok(status)
}
#[cfg(feature = "async")]
pub async fn ensure_tested_cli_version(&self) -> Result<CliVersion> {
let (min, max) = self.effective_tested_range();
let found = self.cli_version().await?;
self.gate_version(found, min, max)
}
#[cfg(feature = "sync")]
pub fn ensure_tested_cli_version_sync(&self) -> Result<CliVersion> {
let (min, max) = self.effective_tested_range();
let found = self.cli_version_sync()?;
self.gate_version(found, min, max)
}
#[cfg(any(feature = "async", feature = "sync"))]
fn gate_version(
&self,
found: CliVersion,
min: CliVersion,
max: CliVersion,
) -> Result<CliVersion> {
let status = found.status_within(&min, &max);
warn_on_drift(&status);
if status.is_tested() {
Ok(found)
} else {
Err(Error::UntestedCliVersion {
found,
tested_min: min,
tested_max: max,
})
}
}
}
#[allow(dead_code)] fn warn_on_drift(status: &CliVersionStatus) {
match status {
CliVersionStatus::Tested => {}
CliVersionStatus::NewerUntested {
found, tested_max, ..
} => {
tracing::warn!(
found = %found,
tested_max = %tested_max,
"claude CLI is newer than the wrapper's tested-against range; \
semantics may have drifted -- proceed with caution"
);
}
CliVersionStatus::OlderThanMinimum { found, minimum, .. } => {
tracing::warn!(
found = %found,
minimum = %minimum,
"claude CLI is older than the wrapper's declared minimum; \
incorrect behavior is likely (missing flags, different shapes)"
);
}
}
}
#[derive(Default)]
pub struct ClaudeBuilder {
binary: Option<PathBuf>,
working_dir: Option<PathBuf>,
env: HashMap<String, String>,
clear_env: bool,
global_args: Vec<String>,
timeout: Option<Duration>,
retry_policy: Option<RetryPolicy>,
tested_cli_version_range: Option<(CliVersion, CliVersion)>,
process_group: Option<bool>,
kill_grace: Option<Duration>,
on_spawn: Option<SpawnObserver>,
die_with_parent: bool,
}
impl std::fmt::Debug for ClaudeBuilder {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ClaudeBuilder")
.field("binary", &self.binary)
.field("working_dir", &self.working_dir)
.field("clear_env", &self.clear_env)
.field("global_args", &self.global_args)
.field("timeout", &self.timeout)
.field("process_group", &self.process_group)
.field("kill_grace", &self.kill_grace)
.field("on_spawn", &self.on_spawn.is_some())
.finish_non_exhaustive()
}
}
impl ClaudeBuilder {
#[must_use]
pub fn binary(mut self, path: impl Into<PathBuf>) -> Self {
self.binary = Some(path.into());
self
}
#[must_use]
pub fn working_dir(mut self, path: impl Into<PathBuf>) -> Self {
self.working_dir = Some(path.into());
self
}
#[must_use]
pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.env.insert(key.into(), value.into());
self
}
#[must_use]
pub fn envs(
mut self,
vars: impl IntoIterator<Item = (impl Into<String>, impl Into<String>)>,
) -> Self {
for (k, v) in vars {
self.env.insert(k.into(), v.into());
}
self
}
#[must_use]
pub fn clear_env(mut self) -> Self {
self.clear_env = true;
self
}
#[must_use]
pub fn timeout_secs(mut self, seconds: u64) -> Self {
self.timeout = Some(Duration::from_secs(seconds));
self
}
#[must_use]
pub fn timeout(mut self, duration: Duration) -> Self {
self.timeout = Some(duration);
self
}
#[must_use]
pub fn arg(mut self, arg: impl Into<String>) -> Self {
self.global_args.push(arg.into());
self
}
#[must_use]
pub fn verbose(mut self) -> Self {
self.global_args.push("--verbose".into());
self
}
#[must_use]
pub fn debug(mut self) -> Self {
self.global_args.push("--debug".into());
self
}
#[must_use]
pub fn retry(mut self, policy: RetryPolicy) -> Self {
self.retry_policy = Some(policy);
self
}
#[must_use]
pub fn tested_cli_version_range(mut self, min: CliVersion, max: CliVersion) -> Self {
self.tested_cli_version_range = Some((min, max));
self
}
#[must_use]
pub fn process_group(mut self, enabled: bool) -> Self {
self.process_group = Some(enabled);
self
}
#[must_use]
pub fn kill_grace(mut self, grace: Duration) -> Self {
self.kill_grace = Some(grace);
self
}
#[must_use]
pub fn on_spawn(mut self, observer: SpawnObserver) -> Self {
self.on_spawn = Some(observer);
self
}
#[must_use]
pub fn die_with_parent(mut self, enabled: bool) -> Self {
self.die_with_parent = enabled;
self
}
pub fn build(self) -> Result<Claude> {
let binary = match self.binary {
Some(path) => path,
None => which::which("claude").map_err(|_| Error::NotFound)?,
};
Ok(Claude {
binary,
working_dir: self.working_dir,
env: self.env,
clear_env: self.clear_env,
global_args: self.global_args,
timeout: self.timeout,
retry_policy: self.retry_policy,
tested_cli_version_range: self.tested_cli_version_range,
process_group: self.process_group.unwrap_or(true),
kill_grace: self.kill_grace,
on_spawn: self.on_spawn,
die_with_parent: self.die_with_parent,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn builder_process_group_defaults_on_and_can_opt_out() {
let on = Claude::builder()
.binary("/nonexistent/claude")
.build()
.unwrap();
assert!(on.process_group);
let off = Claude::builder()
.binary("/nonexistent/claude")
.process_group(false)
.build()
.unwrap();
assert!(!off.process_group);
}
#[test]
fn builder_clear_env_defaults_off_and_is_call_order_independent() {
let inherited = Claude::builder()
.binary("/nonexistent/claude")
.build()
.unwrap();
assert!(!inherited.clear_env);
let cleared = Claude::builder()
.binary("/nonexistent/claude")
.env("FIRST", "1")
.clear_env()
.env("SECOND", "2")
.build()
.unwrap();
assert!(cleared.clear_env);
assert_eq!(cleared.env.get("FIRST").map(String::as_str), Some("1"));
assert_eq!(cleared.env.get("SECOND").map(String::as_str), Some("2"));
}
#[test]
fn builder_kill_grace_defaults_off_and_can_be_set() {
let off = Claude::builder()
.binary("/nonexistent/claude")
.build()
.unwrap();
assert!(off.kill_grace.is_none());
let on = Claude::builder()
.binary("/nonexistent/claude")
.kill_grace(Duration::from_millis(750))
.build()
.unwrap();
assert_eq!(on.kill_grace, Some(Duration::from_millis(750)));
}
#[cfg(any(feature = "async", feature = "sync"))]
fn gate(found: (u32, u32, u32)) -> Result<CliVersion> {
let claude = Claude::builder()
.binary("/nonexistent/claude")
.build()
.unwrap();
let (min, max) = claude.effective_tested_range();
claude.gate_version(CliVersion::new(found.0, found.1, found.2), min, max)
}
#[cfg(any(feature = "async", feature = "sync"))]
#[test]
fn gate_accepts_a_version_inside_the_declared_range() {
let found = gate((
TESTED_CLI_VERSION_MIN.major,
TESTED_CLI_VERSION_MIN.minor,
TESTED_CLI_VERSION_MIN.patch,
))
.expect("the declared minimum must pass its own gate");
assert_eq!(found, TESTED_CLI_VERSION_MIN);
}
#[cfg(any(feature = "async", feature = "sync"))]
#[test]
fn gate_rejects_older_than_minimum_with_both_bounds() {
let err = gate((1, 0, 0)).expect_err("1.0.0 is below any supported floor");
match err {
Error::UntestedCliVersion {
found,
tested_min,
tested_max,
} => {
assert_eq!(found, CliVersion::new(1, 0, 0));
assert_eq!(tested_min, TESTED_CLI_VERSION_MIN);
assert_eq!(tested_max, TESTED_CLI_VERSION_MAX);
assert!(err_text(&err).contains("older"), "{}", err_text(&err));
}
other => panic!("expected UntestedCliVersion, got {other:?}"),
}
}
#[cfg(any(feature = "async", feature = "sync"))]
#[test]
fn gate_rejects_newer_than_maximum() {
let err = gate((99, 0, 0)).expect_err("99.0.0 is above the tested ceiling");
assert!(matches!(err, Error::UntestedCliVersion { .. }));
assert!(err_text(&err).contains("newer"), "{}", err_text(&err));
}
#[cfg(any(feature = "async", feature = "sync"))]
#[test]
fn gate_honours_a_caller_supplied_range_over_the_crate_default() {
let claude = Claude::builder()
.binary("/nonexistent/claude")
.tested_cli_version_range(CliVersion::new(3, 0, 0), CliVersion::new(3, 0, 9))
.build()
.unwrap();
let (min, max) = claude.effective_tested_range();
assert_eq!(min, CliVersion::new(3, 0, 0));
assert!(
claude
.gate_version(CliVersion::new(3, 0, 5), min, max)
.is_ok()
);
assert!(
claude
.gate_version(TESTED_CLI_VERSION_MIN, min, max)
.is_err(),
"the caller's range must win over the crate's"
);
}
#[cfg(any(feature = "async", feature = "sync"))]
fn err_text(e: &Error) -> String {
e.to_string()
}
#[test]
fn test_builder_with_binary() {
let claude = Claude::builder()
.binary("/usr/local/bin/claude")
.env("FOO", "bar")
.timeout_secs(60)
.build()
.unwrap();
assert_eq!(claude.binary, PathBuf::from("/usr/local/bin/claude"));
assert_eq!(claude.env.get("FOO").unwrap(), "bar");
assert_eq!(claude.timeout, Some(Duration::from_secs(60)));
}
#[test]
fn test_builder_global_args() {
let claude = Claude::builder()
.binary("/usr/local/bin/claude")
.arg("--verbose")
.build()
.unwrap();
assert_eq!(claude.global_args, vec!["--verbose"]);
}
#[test]
fn test_builder_verbose() {
let claude = Claude::builder()
.binary("/usr/local/bin/claude")
.verbose()
.build()
.unwrap();
assert!(claude.global_args.contains(&"--verbose".to_string()));
}
#[test]
fn test_builder_debug() {
let claude = Claude::builder()
.binary("/usr/local/bin/claude")
.debug()
.build()
.unwrap();
assert!(claude.global_args.contains(&"--debug".to_string()));
}
}