claude-wrapper:Claude Code CLI 的类型安全 Rust 封装库
joshrotenberg/claude-wrapper
claude-wrapper 发布 0.13 版本,为 Claude Code CLI 提供类型安全的 Rust 封装,采用 builder 模式,每个子命令生成类型化输出。它同时支持 async(tokio)与 sync 两套 API,并提供长驻 DuplexSession 与短生命周期 Session 两种会话模型,覆盖 MCP 管理、插件市场、权限模式等子命令。
A type-safe Rust wrapper around the Claude Code CLI.
Overview
claude-wrapper gives you a builder-pattern interface for the claude
CLI. Each subcommand is a typed builder that produces a typed output.
The design follows the same shape as
docker-wrapper and
terraform-wrapper.
Features:
- Full coverage of
claude -pviaQueryCommand - Async (tokio) and blocking (
std::thread+wait-timeout) APIs behind feature flags - Long-lived
DuplexSessionfor hosts (IDE backends, daemons, agent servers, chat UIs): oneclaudesubprocess held open across turns, mid-turn interrupts, mid-turn permission decisions, broadcast event subscribers - Transient
Sessionfor short-lived processes (CLIs, build scripts, batch jobs): subprocess-per-turn with--resumecontinuity, cumulative cost + history, optionalBudgetTrackerhard-stops, streaming Conversationfor host-side history/cost/budget bookkeeping over aDuplexSession- NDJSON streaming events (
stream_query/stream_query_sync) - Typed tool-permission patterns (
ToolPattern) - Read-side introspection of Claude Code's on-disk state without spawning the CLI: session history, agents, skills, custom commands, settings, background jobs, worktrees
- MCP server management: list, get, add, add-json, remove, add-from-desktop, login, logout, serve, reset-project-choices
- Plugin and marketplace subcommands
- Other subcommands:
doctor,install,update,project purge,auto-mode, andultrareview(cloud-hosted multi-agent code review) - Auth:
status,login,logout,setup-token McpConfigBuilderfor programmatic.mcp.jsongeneration (with optional tempfile backing)- Retry policy with fixed or exponential backoff
- Opt-in
DangerousClientfor bypass-permissions mode (env-var gated) RawCommandescape hatch for subcommands not yet wrapped
Installation
cargo add claude-wrapper
Default features: ["async", "json", "tempfile"]. To drop tokio
entirely for a sync-only build:
claude-wrapper = { version = "0.13", default-features = false, features = ["json", "sync"] }
Quick start (async)
use claude_wrapper::{Claude, QueryCommand}; #[tokio::main] async fn main() -> claude_wrapper::Result<()> { let claude = Claude::builder().build()?; let output = QueryCommand::new("explain this error: file not found") .model("sonnet") .execute(&claude) .await?; println!("{}", output.stdout); Ok(()) }
Quick start (sync)
Enable the sync feature and use ClaudeCommandSyncExt:
use claude_wrapper::{Claude, ClaudeCommandSyncExt, QueryCommand}; fn main() -> claude_wrapper::Result<()> { let claude = Claude::builder().build()?; let output = QueryCommand::new("explain this error") .model("sonnet") .execute_sync(&claude)?; println!("{}", output.stdout); Ok(()) }
The sync API uses std::process::Command plus wait-timeout for the
SIGKILL-on-deadline path, with dedicated reader threads for stdout and
stderr so the child never blocks on pipe-buffer backpressure. It matches
the async version's behaviour feature-for-feature (timeouts, retries,
large-output drain, non-Send handlers for streaming).
Two-layer builder
The Claude client holds shared configuration (binary path,
environment, timeout, default retry policy). Command builders hold
per-invocation options and call execute(&claude) (or execute_sync).
let claude = Claude::builder() .env("AWS_REGION", "us-west-2") .timeout_secs(300) .retry(RetryPolicy::new().max_attempts(3).exponential()) .build()?;
Claude options:
binary()-- path to theclaudebinary (auto-detected from PATH by default)working_dir()/with_working_dir()-- working directory for commandsenv()/envs()-- environment variables applied to every commandclear_env()-- clear inherited child environment before applyingenv()/envs()arg()-- global arg applied before every subcommandtimeout_secs()/timeout()-- command timeout (no default)retry()-- defaultRetryPolicyapplied to every commandprocess_group()-- isolate each child in its own Unix process group (enabled by default)kill_grace()-- optional SIGTERM grace before SIGKILL on awaited shutdown pathsoutput_limit()-- byte ceiling on captured stdout and stderr (no default)
Claude also exposes:
cli_version()/cli_version_sync()-- parsedCliVersioncheck_version()/check_version_sync()-- assert a minimum version
Awaited cancellation
QueryCommand::execute_cancellable and execute_json_cancellable accept any
future that resolves to (). When it resolves, the wrapper terminates the
owned process group and reaps the direct child before returning
Error::Cancelled. A configured timeout uses the same settlement path.
Dropping an ordinary execute future still triggers immediate process-group cleanup, but a destructor cannot wait for reaping. Use the cancellable methods when the caller needs terminal settlement before it records a run as stopped.
Bounded output capture
A buffered run reads the child's stdout and stderr to EOF, so peak memory is
whatever the CLI decides to print. output_limit() sets a ceiling:
let claude = Claude::builder() .output_limit(4 * 1024 * 1024) .build()?;
The ceiling applies to each stream independently, so the pair holds at most
twice the configured bytes. Reaching it terminates the child's process group
(honoring kill_grace), reaps the direct child, and returns
Error::OutputLimitExceeded { stream, limit_bytes } naming which stream ran
over. It is not a truncated success: a caller can tell a complete answer from
one that exceeded what it agreed to hold. Output captured before the ceiling
tripped is logged at warn rather than returned.
There is no default ceiling. With output_limit() unset, capture behavior is
unchanged.
This bounds the buffered paths (execute, execute_json, and their
cancellable and blocking forms). Streaming runs deliver events as they arrive
and are unaffected.
Child environment policy
Children inherit the parent process environment by default for compatibility. Hosts that need an explicitly constructed environment can opt into clearing it:
let claude = Claude::builder() .clear_env() .env("PATH", "/usr/local/bin:/usr/bin:/bin") .env("LANG", "C.UTF-8") .env("CLAUDE_CONFIG_DIR", "/srv/claude/config") .build()?;
clear_env() is call-order independent: explicit entries survive whether
env() / envs() appear before or after it. Every buffered, stdin, timeout,
retry, streaming, sync, and duplex child uses the same policy.
Clearing the environment controls only what the direct Claude CLI child
inherits. It is not operating-system or process isolation. A same-UID child
may still read accessible files or inspect other processes where the OS
permits it. Callers are responsible for choosing the minimal PATH, locale,
config-home, credential, proxy, Git, and SSH entries appropriate for their
deployment.
Command builders
| Category | Builders |
|---|---|
| Query | QueryCommand |
| MCP | McpListCommand, McpGetCommand, McpAddCommand, McpAddJsonCommand, McpRemoveCommand, McpAddFromDesktopCommand, McpLoginCommand, McpLogoutCommand, McpServeCommand, McpResetProjectChoicesCommand |
| Plugins | PluginListCommand, PluginInstallCommand, PluginUninstallCommand, PluginEnableCommand, PluginDisableCommand, PluginUpdateCommand, PluginValidateCommand, PluginDetailsCommand, PluginPruneCommand, PluginTagCommand |
| Marketplace | MarketplaceListCommand, MarketplaceAddCommand, MarketplaceRemoveCommand, MarketplaceUpdateCommand |
| Auth | AuthStatusCommand, AuthLoginCommand, AuthLogoutCommand, SetupTokenCommand |
| Auto-mode | AutoModeConfigCommand, AutoModeDefaultsCommand, AutoModeCritiqueCommand |
| Lifecycle | InstallCommand, UpdateCommand, ProjectPurgeCommand |
| Review | UltrareviewCommand |
| Misc | VersionCommand, DoctorCommand, AgentsCommand, RawCommand |
Every builder implements ClaudeCommand (args(), async execute).
With the sync feature, ClaudeCommandSyncExt provides a blanket
execute_sync on every command that returns CommandOutput.
QueryCommand additionally has inherent execute_sync /
execute_json_sync that honour the retry policy;
AuthStatusCommand has execute_json_sync.
QueryCommand
Full coverage of claude -p. The common options:
| Method | CLI flag | Purpose |
|---|---|---|
model() |
--model |
Model alias or full ID |
system_prompt() / append_system_prompt() |
--system-prompt / --append-system-prompt |
Override or extend the system prompt |
output_format() |
--output-format |
text, json, stream-json |
effort() |
--effort |
low, medium, high |
max_turns() |
--max-turns |
Turn cap |
max_budget_usd() |
--max-budget-usd |
Per-call spend cap (CLI-side) |
permission_mode() |
--permission-mode |
default, acceptEdits, dontAsk, plan, auto |
allowed_tool() / allowed_tools() / disallowed_tool() / disallowed_tools() |
tool filter flags | Allow/deny tool patterns |
mcp_config() / strict_mcp_config() |
--mcp-config / --strict-mcp-config |
MCP server config |
json_schema() |
--json-schema |
Structured output validation |
agent() / agents_json() |
--agent / --agents |
Agent or custom agents JSON |
no_session_persistence() |
--no-session-persistence |
Don't save the session to disk |
input_format() / include_partial_messages() |
--input-format / --include-partial-messages |
Input/streaming shape |
settings() / fallback_model() / add_dir() / file() |
various | Misc per-turn knobs |
retry() |
(client-side) | Per-command retry override |
Raw session flags (prefer Session for multi-turn):
| Method | CLI flag |
|---|---|
continue_session() |
--continue |
resume() |
--resume |
session_id() |
--session-id |
fork_session() |
--fork-session |
Bypass-permissions mode is only available via
DangerousClient; see below.
JSON output
let result = QueryCommand::new("generate a user struct") .output_format(OutputFormat::Json) .json_schema(r#"{"type":"object","properties":{"id":{"type":"integer"}}}"#) .execute_json(&claude) .await?; println!("{}", result.result); println!("cost: ${:.4}", result.cost_usd.unwrap_or(0.0));
Tool permissions
Use ToolPattern for first-class, validated patterns:
use claude_wrapper::{QueryCommand, ToolPattern}; let cmd = QueryCommand::new("review src/main.rs") .allowed_tool(ToolPattern::tool("Read")) .allowed_tool(ToolPattern::tool_with_args("Bash", "git log:*")) .allowed_tool(ToolPattern::all("Write")) .allowed_tool(ToolPattern::mcp("my-server", "*")) .disallowed_tool(ToolPattern::tool_with_args("Bash", "rm*"));
Bare strings still work via From<&str>:
let cmd = QueryCommand::new("review").allowed_tools(["Read", "Bash(git log:*)"]);
ToolPattern::parse(s) validates shape (balanced parens, no comma,
non-empty name) and returns a typed PatternError on malformed input.
Multi-turn conversations
Two shapes for multi-turn work, each suited to a different process
model. Pick DuplexSession if you can keep a claude subprocess
open for the lifetime of the conversation (IDE backends, daemons,
agent servers, chat UIs). Pick Session if your process is
short-lived and conversation continuity is the main thing you need
(CLIs, build scripts, batch jobs, lambdas).
DuplexSession |
Session |
|
|---|---|---|
| Process model | one child held open across turns | new subprocess per turn, --resume continuity |
| Mid-turn interrupt | yes (interrupt()) |
no (only child.kill() via SIGKILL) |
| Mid-turn permission prompts | yes (PermissionHandler) |
no |
| Broadcast event subscribers | yes (subscribe()) |
no (per-turn streaming via stream_query) |
| Built-in cost / history tracking | no (TurnResult is per-turn) | yes (total_cost_usd, history, BudgetTracker) |
| Right for | long-running hosts | short-lived processes |
DuplexSession (recommended for long-running hosts)
Holds one claude subprocess open in stream-json duplex mode for
the duration of a conversation. User messages go in over stdin,
NDJSON events come back over stdout. Subscribe to the event stream
for token-by-token UI updates, answer permission prompts via an
async callback, and cancel a turn cleanly with interrupt().
use claude_wrapper::Claude; use claude_wrapper::duplex::{DuplexOptions, DuplexSession, InboundEvent}; let claude = Claude::builder().build()?; let session = DuplexSession::spawn( &claude, DuplexOptions::default().model("haiku"), ).await?; let mut events = session.subscribe(); let turn = session.send("what's 2 + 2?").await?; while let Ok(event) = events.try_recv() { if let InboundEvent::Assistant(_) = event { // partial or complete assistant message } } println!("answer: {}", turn.result_text().unwrap_or("")); session.close().await?;
See the duplex module docs
for interrupt(), respond_to_permission(), and the full surface.
Session (for short-lived processes)
Spawns a transient subprocess per turn and threads session_id
across them via --resume. Tracks history, cumulative cost, and
integrates with BudgetTracker for hard-stop ceilings. The right
fit when each turn can stand on its own and the host process
isn't long-lived.
use std::sync::Arc; use claude_wrapper::{Claude, QueryCommand}; use claude_wrapper::session::Session; let claude = Arc::new(Claude::builder().build()?); let mut session = Session::new(Arc::clone(&claude)); let first = session.send("what's 2 + 2?").await?; let second = session .execute(QueryCommand::new("explain").model("opus")) .await?; println!("cost: ${:.4}", session.total_cost_usd()); println!("turns: {}", session.total_turns());
Resume an existing session:
let mut resumed = Session::resume(claude, "sess-abc123"); let next = resumed.send("pick up where we left off").await?;
Session is Send + Sync, holds Arc<Claude>, and can move
between tasks or sit in long-lived actor state. Streaming turns
use Session::stream / Session::stream_execute and capture the
session id from the first event that carries one -- persisted even
if the stream errors partway through.
Session is currently async-only. Sync callers compose equivalent
state using execute_sync / execute_json_sync and BudgetTracker.
Budget tracking
Attach a BudgetTracker to a session (or share one across several
sessions) to enforce USD ceilings:
use claude_wrapper::BudgetTracker; let budget = BudgetTracker::builder() .max_usd(5.00) .warn_at_usd(4.00) .on_warning(|total| eprintln!("warning: ${total:.2} spent")) .on_exceeded(|total| eprintln!("budget hit: ${total:.2}")) .build(); let mut session = Session::new(claude).with_budget(budget.clone()); session.send("hello").await?; println!("spent: ${:.4}", budget.total_usd()); println!("remaining: ${:.4}", budget.remaining_usd().unwrap());
Callbacks fire exactly once at their thresholds. When max_usd is
set, Session::execute / Session::stream_execute short-circuit with
Error::BudgetExceeded before dispatching any turn that would run
with the ceiling already hit -- a hard stop, not just a callback.
Clone a tracker across several sessions to share the running total.
BudgetTracker is internally Arc<Mutex<_>> so clones are cheap and
the total is coherent.
Streaming NDJSON
use claude_wrapper::{Claude, QueryCommand, OutputFormat}; use claude_wrapper::streaming::{StreamEvent, stream_query}; let claude = Claude::builder().build()?; let cmd = QueryCommand::new("explain quicksort") .output_format(OutputFormat::StreamJson); stream_query(&claude, &cmd, |event: StreamEvent| { if event.is_result() { println!("result: {}", event.result_text().unwrap_or("")); println!("session: {}", event.session_id().unwrap_or("")); } }).await?;
Use stream_query_cancellable when a host has an explicit stop signal. It
returns only after the owned process group has been terminated and the direct
child reaped. Streaming also honors QueryCommand::prompt_via_stdin(true), so
sensitive prompts do not need to appear in process arguments.
Sync: stream_query_sync. The handler runs on the caller's thread, so
it can capture non-Send state (Rc<RefCell<_>>, etc.).
On-disk introspection
Read Claude Code's on-disk state under ~/.claude directly, without
spawning the CLI. Each module exposes a root/loader with list / get
accessors and returns an empty result when the directory is absent.
| Module | Type | Reads |
|---|---|---|
history |
HistoryRoot |
sessions and transcripts (projects/<slug>/<id>.jsonl) |
artifacts |
AgentsRoot |
agent definitions (agents/<name>.md) |
skills |
SkillsRoot |
skill definitions (skills/<name>/SKILL.md) |
commands |
CommandsRoot |
custom slash commands |
settings |
SettingsLoader |
the four settings layers, merged by precedence |
jobs |
JobsRoot |
background-agent / supervisor state |
worktrees |
WorktreeRoot |
git worktrees for --worktree sessions |
use claude_wrapper::history::HistoryRoot; let history = HistoryRoot::home()?; for project in history.list_projects()? { println!("{} ({} sessions)", project.decoded_path.display(), project.session_count); }
The history, jobs, and settings modules require the json
feature (on by default). See the inspect_state example for a tour.
MCP servers
// List McpListCommand::new().execute(&claude).await?; // HTTP McpAddCommand::new("sentry", "https://mcp.sentry.dev/mcp") .transport(Transport::Http) .execute(&claude) .await?; // Stdio McpAddCommand::new("my-tool", "npx") .server_args(["my-mcp-server"]) .env("API_KEY", "secret") .execute(&claude) .await?; // From JSON McpAddJsonCommand::new("config.json").execute(&claude).await?; // Remove McpRemoveCommand::new("old-server").execute(&claude).await?;
Programmatic .mcp.json
use claude_wrapper::McpConfigBuilder; McpConfigBuilder::new() .http_server("hub", "http://127.0.0.1:9090") .stdio_server("tool", "npx", ["my-server"]) .write_to("/tmp/my-project/.mcp.json")?;
With the tempfile feature (default), build_temp() returns a
TempMcpConfig that writes to a temp file and deletes it on drop --
useful for one-shot queries.
Dangerous: bypass mode
--permission-mode bypassPermissions is isolated behind
DangerousClient, which requires an env-var acknowledgement at
process start:
use claude_wrapper::{Claude, QueryCommand}; use claude_wrapper::dangerous::DangerousClient; // Set CLAUDE_WRAPPER_ALLOW_DANGEROUS=1 at process start let dangerous = DangerousClient::new(Claude::builder().build()?)?; let output = dangerous .query_bypass(QueryCommand::new("do the risky thing")) .await?;
Construction fails with Error::DangerousNotAllowed if the env var is
absent. There is no equivalent on plain Claude -- the type-level
isolation is deliberate.
Retry policy
use std::time::Duration; use claude_wrapper::RetryPolicy; let policy = RetryPolicy::new() .max_attempts(3) .initial_backoff(Duration::from_secs(1)) .exponential() .retry_on_timeout(true) .retry_on_exit_codes([1, 2]); let claude = Claude::builder().retry(policy).build()?;
Retry wraps every QueryCommand::execute and execute_sync (other
commands don't retry). A per-command override is available via
QueryCommand::retry(policy).
Error handling
use claude_wrapper::Error; match QueryCommand::new("test").execute(&claude).await { Ok(output) => println!("{}", output.stdout), Err(Error::CommandFailed { stderr, exit_code, .. }) => { eprintln!("Command failed (exit {}): {}", exit_code, stderr); } Err(Error::Timeout { .. }) => eprintln!("Command timed out"), Err(Error::BudgetExceeded { total_usd, max_usd }) => { eprintln!("Budget ${:.2} / ${:.2}", total_usd, max_usd); } Err(Error::VersionMismatch { found, minimum }) => { eprintln!("Claude CLI {} < required {}", found, minimum); } Err(e) => eprintln!("Other error: {}", e), }
Escape hatch: RawCommand
For subcommands not yet wrapped:
let output = RawCommand::new("custom-subcommand") .arg("--unsupported-flag") .arg("value") .execute(&claude) .await?;
Cargo features
| Feature | Default | Purpose |
|---|---|---|
async |
yes | tokio-backed async API. Disabling drops tokio from the runtime dep tree. |
json |
yes | JSON output parsing (execute_json, StreamEvent, Session, stream_query). |
tempfile |
yes | TempMcpConfig for one-shot MCP config files. |
sync |
no | Blocking API: *_sync methods on exec, retry, each command builder, and Claude. Pulls in wait-timeout. |
Sync-only build with no tokio:
claude-wrapper = { version = "0.13", default-features = false, features = ["json", "sync"] }
Examples
cargo run --example oneshot # Single query cargo run --example stream_query # Streaming NDJSON cargo run --example json_output # Structured JSON response cargo run --example mcp_config # MCP config generation cargo run --example health_check # CLI diagnostics cargo run --example agent_worker # Agent worker setup cargo run --example supervised_worker # Restart loop with budget tracking cargo run --example duplex_chat # Interactive multi-turn chat (DuplexSession) cargo run --example duplex_interrupt # Cancel an in-flight turn cargo run --example duplex_http_service # One DuplexSession behind HTTP routes cargo run --example inspect_state --features json # Read on-disk state (no CLI)
Most examples use the async API (--features async, on by default);
inspect_state is read-only and needs just --features json.
Testing
# Unit + doc tests cargo test --lib --all-features cargo test --doc --all-features # Integration tests against the bundled fake-claude.sh (no real CLI needed) cargo test --test fake_claude --all-features cargo test --test fake_claude_sync --all-features # Sync-only build + tests (no tokio) cargo test --test fake_claude_sync \ --no-default-features --features "json,sync" # Integration tests against a real `claude` binary (requires auth) cargo test --test integration -- --ignored
License
MIT OR Apache-2.0
来源:GitHub · github.com