port message, option, and error types

- Errors: single enum mirroring the Python SDK's exception hierarchy
  (CliNotFound, CliConnection, Process, JsonDecode, MessageParse). Each
  Python class becomes a variant; the Process variant carries exit code +
  optional captured stderr.

- Options: ClaudeAgentOptions as a plain struct with a builder. Covers the
  full subprocess-arg surface (system_prompt with String / PresetAppend /
  File forms, tools / allowed / disallowed, permission_mode, model and
  fallback, cwd / cli_path / settings, env, extra_args, effort, output_format,
  mcp_servers, fork_session, include_partial_messages, etc.). Deferred
  fields documented inline.

- Messages: ContentBlock enum (Text / Thinking / ToolUse / ToolResult /
  ServerToolUse / ServerToolResult / Unknown) with serde tag dispatch and
  an Unknown fallthrough for forward compatibility. Top-level Message enum
  uses a hand-written Deserialize that dispatches on the "type" tag so
  unrecognised top-level frames land in Message::Unknown with the raw JSON
  preserved (matches the Python SDK's "skip unknown" behaviour, but lets
  Rust callers introspect).

Unit tests cover roundtrip for the common shapes (assistant text +
thinking + tool_use, user with both string and structured content, result,
system, unknown top-level).
This commit is contained in:
Kayos 2026-05-14 08:03:24 -07:00
parent 6af8273f98
commit 184b0a786a
3 changed files with 1137 additions and 0 deletions

463
src/options.rs Normal file
View file

@ -0,0 +1,463 @@
//! Configuration for [`crate::query`] and [`crate::Client`].
//!
//! [`ClaudeAgentOptions`] mirrors the Python SDK's dataclass of the same name,
//! with one significant difference: in Rust the struct uses the builder
//! pattern. The default-constructed value is always valid; chain
//! `.with_*` / `.set_*` methods to configure.
//!
//! ```
//! use claude_agent_sdk::ClaudeAgentOptions;
//!
//! let opts = ClaudeAgentOptions::new()
//! .with_system_prompt("You are a helpful assistant.")
//! .with_max_turns(1)
//! .with_allowed_tool("Read")
//! .with_allowed_tool("Write");
//! ```
//!
//! v0.1 ships the subset of fields needed to drive the CLI's `--output-format
//! stream-json` mode end-to-end. The advanced fields (hooks, `can_use_tool`,
//! session_store, plugins, sandbox, agents) are deferred — see the
//! crate-level README for the v0.1 vs v0.2 split.
use std::collections::HashMap;
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
/// Permission mode for tool execution.
///
/// Maps to the CLI's `--permission-mode` flag; values match the Python SDK's
/// `PermissionMode` literal type.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub enum PermissionMode {
/// CLI prompts for dangerous tools (default behaviour).
Default,
/// Auto-accept file edits.
AcceptEdits,
/// Plan-only mode (no tool execution).
Plan,
/// Allow all tools — use with caution.
BypassPermissions,
/// Deny anything not pre-approved by allow rules.
DontAsk,
/// Model classifier approves or denies each tool call.
Auto,
}
impl PermissionMode {
/// CLI-flag form of this mode.
pub fn as_cli_str(self) -> &'static str {
match self {
Self::Default => "default",
Self::AcceptEdits => "acceptEdits",
Self::Plan => "plan",
Self::BypassPermissions => "bypassPermissions",
Self::DontAsk => "dontAsk",
Self::Auto => "auto",
}
}
}
/// Adaptive-thinking effort level.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Effort {
/// Minimal thinking, fastest responses.
Low,
/// Moderate thinking.
Medium,
/// Deep reasoning (default for adaptive-capable models).
High,
/// Extended reasoning depth (Opus 4.7 only; falls back to `High` on
/// other models).
Xhigh,
/// Maximum effort.
Max,
}
impl Effort {
/// CLI-flag form.
pub fn as_cli_str(self) -> &'static str {
match self {
Self::Low => "low",
Self::Medium => "medium",
Self::High => "high",
Self::Xhigh => "xhigh",
Self::Max => "max",
}
}
}
/// System-prompt configuration.
///
/// Mirrors the Python SDK union of `str | SystemPromptPreset | SystemPromptFile`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SystemPrompt {
/// Raw string — passed verbatim via `--system-prompt`.
String(String),
/// Append-only on top of the Claude Code default preset, passed via
/// `--append-system-prompt`.
PresetAppend(String),
/// Load the system prompt from a file via `--system-prompt-file`.
File(PathBuf),
}
/// Options for [`crate::query`] and [`crate::Client`].
///
/// Construct with [`ClaudeAgentOptions::new`] (or `default()`) and configure
/// via the builder methods. The struct is plain data — there is no validation
/// at construction; mutually exclusive option combinations are rejected at
/// `connect()` time with a [`crate::Error::Config`].
///
/// ## Field coverage
///
/// v0.1 supports the fields used by the CLI's stream-json subprocess transport.
/// Mirrors most of `ClaudeAgentOptions` from the Python SDK. Hook callbacks,
/// `can_use_tool`, `session_store`, in-process MCP servers, sandbox settings,
/// and plugins are deferred to v0.2 — see the crate README.
#[derive(Debug, Clone, Default)]
pub struct ClaudeAgentOptions {
/// Custom system prompt. When `None`, the CLI's empty-prompt default
/// is used.
pub system_prompt: Option<SystemPrompt>,
/// Restrict the base set of tools available to the model. When `None`,
/// the CLI default toolset is used. An empty `Vec` disables all built-in
/// tools. Mirrors the Python SDK's `tools` field.
pub tools: Option<Vec<String>>,
/// Tool names that are auto-allowed without prompting. Maps to the
/// CLI's `--allowedTools` flag.
pub allowed_tools: Vec<String>,
/// Tool names explicitly disallowed. Maps to `--disallowedTools`.
pub disallowed_tools: Vec<String>,
/// Permission mode for the session.
pub permission_mode: Option<PermissionMode>,
/// Maximum number of conversation turns.
pub max_turns: Option<i32>,
/// Maximum budget in USD; the run stops when exceeded.
pub max_budget_usd: Option<f64>,
/// Continue the most recent conversation in `cwd`.
pub continue_conversation: bool,
/// Session ID to resume.
pub resume: Option<String>,
/// Specific session ID for the conversation (UUID).
pub session_id: Option<String>,
/// Model identifier (e.g. `"claude-sonnet-4-5"`).
pub model: Option<String>,
/// Fallback model when the primary fails.
pub fallback_model: Option<String>,
/// Working directory for the subprocess.
pub cwd: Option<PathBuf>,
/// Explicit path to the `claude` CLI binary. When unset, the SDK
/// searches `PATH` and a handful of standard install locations.
pub cli_path: Option<PathBuf>,
/// Path or JSON string for an additional settings file (`--settings`).
pub settings: Option<String>,
/// Additional directories Claude may access (`--add-dir`).
pub add_dirs: Vec<PathBuf>,
/// Extra environment variables for the subprocess.
pub env: HashMap<String, String>,
/// Pass-through CLI flags (`--flag value`). Value `None` becomes a
/// boolean flag.
pub extra_args: HashMap<String, Option<String>>,
/// Maximum stdout buffer size in bytes for the CLI subprocess.
/// Defaults to 1 MiB if unset.
pub max_buffer_size: Option<usize>,
/// Capture stderr from the subprocess. When `false` (default), stderr
/// is inherited from the parent process. When `true`, stderr is piped
/// and surfaced on the [`crate::Error::Process`] error variant.
pub capture_stderr: bool,
/// Include partial assistant streaming events in the message stream.
pub include_partial_messages: bool,
/// Include hook lifecycle events in the message stream.
pub include_hook_events: bool,
/// Resumed sessions fork into a new session ID.
pub fork_session: bool,
/// Adaptive-thinking effort level.
pub effort: Option<Effort>,
/// Output format — passed through as `--json-schema` when shaped as
/// `{"type": "json_schema", "schema": {...}}`.
pub output_format: Option<serde_json::Value>,
/// MCP server configurations. Either a `serde_json::Value` (passed
/// verbatim as `--mcp-config` JSON) or a path string. v0.1 does not
/// support in-process SDK MCP servers — use external stdio/sse/http
/// servers configured via JSON.
pub mcp_servers: Option<McpServersConfig>,
/// Skip the CLI version check at connect time.
pub skip_version_check: bool,
}
/// MCP server configuration, passed to the CLI as `--mcp-config`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum McpServersConfig {
/// Inline JSON value with shape `{"mcpServers": {...}}` (or a single
/// server dict). Serialized and passed via `--mcp-config <JSON>`.
Inline(serde_json::Value),
/// Path to an MCP config JSON file.
Path(PathBuf),
}
impl ClaudeAgentOptions {
/// Construct an empty options struct — equivalent to `Default::default()`.
pub fn new() -> Self {
Self::default()
}
/// Set a plain-string system prompt.
pub fn with_system_prompt(mut self, prompt: impl Into<String>) -> Self {
self.system_prompt = Some(SystemPrompt::String(prompt.into()));
self
}
/// Append a custom string to the Claude Code default system prompt.
pub fn with_append_system_prompt(mut self, append: impl Into<String>) -> Self {
self.system_prompt = Some(SystemPrompt::PresetAppend(append.into()));
self
}
/// Read the system prompt from a file at runtime.
pub fn with_system_prompt_file(mut self, path: impl Into<PathBuf>) -> Self {
self.system_prompt = Some(SystemPrompt::File(path.into()));
self
}
/// Restrict the base toolset. Pass `None` for an empty list, or a `Vec`
/// of tool names.
pub fn with_tools(mut self, tools: Vec<String>) -> Self {
self.tools = Some(tools);
self
}
/// Add one auto-allowed tool name.
pub fn with_allowed_tool(mut self, tool: impl Into<String>) -> Self {
self.allowed_tools.push(tool.into());
self
}
/// Replace the entire allowed-tools list.
pub fn with_allowed_tools(mut self, tools: Vec<String>) -> Self {
self.allowed_tools = tools;
self
}
/// Add one disallowed tool name.
pub fn with_disallowed_tool(mut self, tool: impl Into<String>) -> Self {
self.disallowed_tools.push(tool.into());
self
}
/// Set the permission mode.
pub fn with_permission_mode(mut self, mode: PermissionMode) -> Self {
self.permission_mode = Some(mode);
self
}
/// Cap the number of conversation turns.
pub fn with_max_turns(mut self, turns: i32) -> Self {
self.max_turns = Some(turns);
self
}
/// Cap the budget in USD.
pub fn with_max_budget_usd(mut self, usd: f64) -> Self {
self.max_budget_usd = Some(usd);
self
}
/// Continue the most recent conversation in `cwd`.
pub fn with_continue_conversation(mut self, on: bool) -> Self {
self.continue_conversation = on;
self
}
/// Resume a specific session by ID.
pub fn with_resume(mut self, session_id: impl Into<String>) -> Self {
self.resume = Some(session_id.into());
self
}
/// Pin a session ID (must be a valid UUID).
pub fn with_session_id(mut self, session_id: impl Into<String>) -> Self {
self.session_id = Some(session_id.into());
self
}
/// Pick a specific model.
pub fn with_model(mut self, model: impl Into<String>) -> Self {
self.model = Some(model.into());
self
}
/// Pick a fallback model.
pub fn with_fallback_model(mut self, model: impl Into<String>) -> Self {
self.fallback_model = Some(model.into());
self
}
/// Set the working directory for the subprocess.
pub fn with_cwd(mut self, cwd: impl Into<PathBuf>) -> Self {
self.cwd = Some(cwd.into());
self
}
/// Override the path to the `claude` CLI binary.
pub fn with_cli_path(mut self, path: impl Into<PathBuf>) -> Self {
self.cli_path = Some(path.into());
self
}
/// Set the `--settings` argument (JSON string or file path).
pub fn with_settings(mut self, settings: impl Into<String>) -> Self {
self.settings = Some(settings.into());
self
}
/// Add an extra directory accessible to Claude.
pub fn with_add_dir(mut self, dir: impl Into<PathBuf>) -> Self {
self.add_dirs.push(dir.into());
self
}
/// Add an environment variable for the subprocess.
pub fn with_env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.env.insert(key.into(), value.into());
self
}
/// Add a pass-through CLI flag with a value.
pub fn with_extra_arg(
mut self,
flag: impl Into<String>,
value: Option<impl Into<String>>,
) -> Self {
self.extra_args.insert(flag.into(), value.map(Into::into));
self
}
/// Cap stdout buffer size.
pub fn with_max_buffer_size(mut self, size: usize) -> Self {
self.max_buffer_size = Some(size);
self
}
/// Capture stderr in errors (otherwise inherited from parent).
pub fn with_capture_stderr(mut self, on: bool) -> Self {
self.capture_stderr = on;
self
}
/// Include partial streaming events in the message stream.
pub fn with_include_partial_messages(mut self, on: bool) -> Self {
self.include_partial_messages = on;
self
}
/// Include hook lifecycle events in the message stream.
pub fn with_include_hook_events(mut self, on: bool) -> Self {
self.include_hook_events = on;
self
}
/// Set adaptive-thinking effort level.
pub fn with_effort(mut self, effort: Effort) -> Self {
self.effort = Some(effort);
self
}
/// Set MCP-server configuration (inline JSON or file path).
pub fn with_mcp_servers(mut self, cfg: McpServersConfig) -> Self {
self.mcp_servers = Some(cfg);
self
}
/// Skip the `claude --version` check at connect time. Mirrors the
/// `CLAUDE_AGENT_SDK_SKIP_VERSION_CHECK` env var behaviour.
pub fn with_skip_version_check(mut self, on: bool) -> Self {
self.skip_version_check = on;
self
}
/// Set structured-output JSON schema. Pass the full `{"type":
/// "json_schema", "schema": {...}}` shape.
pub fn with_output_format(mut self, format: serde_json::Value) -> Self {
self.output_format = Some(format);
self
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn builder_chains() {
let opts = ClaudeAgentOptions::new()
.with_system_prompt("hi")
.with_max_turns(3)
.with_allowed_tool("Read")
.with_allowed_tool("Bash")
.with_permission_mode(PermissionMode::AcceptEdits)
.with_model("claude-sonnet-4-5")
.with_env("FOO", "bar");
assert!(matches!(opts.system_prompt, Some(SystemPrompt::String(ref s)) if s == "hi"));
assert_eq!(opts.max_turns, Some(3));
assert_eq!(opts.allowed_tools, vec!["Read", "Bash"]);
assert_eq!(opts.permission_mode, Some(PermissionMode::AcceptEdits));
assert_eq!(opts.model.as_deref(), Some("claude-sonnet-4-5"));
assert_eq!(opts.env.get("FOO").map(String::as_str), Some("bar"));
}
#[test]
fn permission_mode_cli_str_roundtrip() {
assert_eq!(PermissionMode::Default.as_cli_str(), "default");
assert_eq!(PermissionMode::AcceptEdits.as_cli_str(), "acceptEdits");
assert_eq!(PermissionMode::BypassPermissions.as_cli_str(), "bypassPermissions");
assert_eq!(PermissionMode::DontAsk.as_cli_str(), "dontAsk");
assert_eq!(PermissionMode::Auto.as_cli_str(), "auto");
assert_eq!(PermissionMode::Plan.as_cli_str(), "plan");
}
#[test]
fn effort_cli_str() {
assert_eq!(Effort::Low.as_cli_str(), "low");
assert_eq!(Effort::Xhigh.as_cli_str(), "xhigh");
assert_eq!(Effort::Max.as_cli_str(), "max");
}
#[test]
fn default_is_empty() {
let opts = ClaudeAgentOptions::default();
assert!(opts.system_prompt.is_none());
assert!(opts.allowed_tools.is_empty());
assert_eq!(opts.continue_conversation, false);
assert_eq!(opts.capture_stderr, false);
}
}