diff --git a/src/errors.rs b/src/errors.rs new file mode 100644 index 0000000..360e935 --- /dev/null +++ b/src/errors.rs @@ -0,0 +1,110 @@ +//! Error types for the Claude Agent SDK. +//! +//! Mirrors the exception hierarchy in the upstream Python SDK +//! (`claude_agent_sdk._errors`): +//! +//! - [`Error::CliNotFound`] — `CLINotFoundError` +//! - [`Error::CliConnection`] — `CLIConnectionError` +//! - [`Error::Process`] — `ProcessError` +//! - [`Error::JsonDecode`] — `CLIJSONDecodeError` +//! - [`Error::MessageParse`] — `MessageParseError` +//! +//! All variants inherit from `ClaudeSDKError` in Python; in Rust we expose a +//! single `Error` enum and rely on pattern matching (or [`Error::kind`]) to +//! distinguish them. + +use thiserror::Error; + +/// All errors surfaced by the SDK. +/// +/// The Python SDK uses an exception hierarchy rooted at `ClaudeSDKError`. We +/// flatten that into a single enum because Rust pattern matching is more +/// ergonomic than `try`/`except` chains, and because most callers only care +/// about a small subset (e.g. "did the CLI not exist?" vs everything else). +#[derive(Debug, Error)] +pub enum Error { + /// The `claude` CLI binary could not be located on `PATH` or in any of the + /// fallback search locations, and no explicit `cli_path` was supplied via + /// [`crate::ClaudeAgentOptions::cli_path`]. + /// + /// Mirrors Python's `CLINotFoundError`. + #[error("{0}")] + CliNotFound(String), + + /// Generic connection / transport problem with the CLI subprocess — + /// failed to spawn, stdin write failed, transport not ready, etc. + /// + /// Mirrors Python's `CLIConnectionError`. + #[error("{0}")] + CliConnection(String), + + /// The CLI subprocess exited with a non-zero status before yielding a + /// `ResultMessage`. + /// + /// Mirrors Python's `ProcessError`. + #[error("{message}{exit_code}{stderr}", + exit_code = match exit_code { Some(c) => format!(" (exit code: {c})"), None => String::new() }, + stderr = match stderr { Some(s) if !s.is_empty() => format!("\nError output: {s}"), _ => String::new() }, + )] + Process { + /// Human-readable description (e.g. `"Command failed"`). + message: String, + /// Subprocess exit code, when known. + exit_code: Option, + /// Captured stderr output, when stderr was piped via + /// [`crate::ClaudeAgentOptions::capture_stderr`]. + stderr: Option, + }, + + /// A line on the CLI's stdout could not be decoded as JSON even after + /// buffering up to `max_buffer_size` bytes. + /// + /// Mirrors Python's `CLIJSONDecodeError`. + #[error("Failed to decode JSON: {line_preview}...")] + JsonDecode { + /// First ~100 chars of the offending line, included in the message. + line_preview: String, + /// The underlying serde error. + #[source] + source: serde_json::Error, + }, + + /// A JSON message from the CLI was decoded but did not match any known + /// shape, or was missing a required field. + /// + /// Mirrors Python's `MessageParseError`. + #[error("Message parse error: {message}")] + MessageParse { + /// Human-readable description. + message: String, + /// The raw JSON value the parser was looking at, if available. + data: Option, + }, + + /// Underlying I/O failure (rare — most I/O is surfaced as + /// [`Error::CliConnection`]). + #[error("io error: {0}")] + Io(#[from] std::io::Error), + + /// Invalid configuration (e.g. mutually exclusive options). + #[error("invalid configuration: {0}")] + Config(String), +} + +impl Error { + /// Construct a [`Error::CliConnection`] from any displayable value. + pub(crate) fn conn(msg: impl Into) -> Self { + Self::CliConnection(msg.into()) + } + + /// Construct a [`Error::MessageParse`] with raw data attached. + pub(crate) fn parse_with_data(msg: impl Into, data: serde_json::Value) -> Self { + Self::MessageParse { + message: msg.into(), + data: Some(data), + } + } +} + +/// Convenience alias for `Result`. +pub type Result = std::result::Result; diff --git a/src/messages.rs b/src/messages.rs new file mode 100644 index 0000000..72da47b --- /dev/null +++ b/src/messages.rs @@ -0,0 +1,564 @@ +//! Message and content-block types emitted by the Claude CLI. +//! +//! These are the deserialized shapes of the newline-delimited JSON objects the +//! CLI writes to stdout when invoked with `--output-format stream-json`. The +//! field naming follows the wire format (snake_case), so callers can match the +//! Python SDK's field names directly. +//! +//! Forward-compatibility: unknown variants are surfaced as +//! [`Message::Unknown`] / [`ContentBlock::Unknown`] with the raw JSON +//! preserved, rather than failing the stream. This mirrors the Python SDK's +//! "skip unknown message types" behaviour while still letting Rust callers +//! introspect what they got. + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +/// A content block inside a [`UserMessage`] or [`AssistantMessage`]. +/// +/// The CLI tags blocks with a `type` field; we deserialize via that tag. +/// Unknown block types are preserved as [`ContentBlock::Unknown`] so the +/// stream is forward-compatible with newer CLI releases. +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ContentBlock { + /// Plain text emitted by the assistant or echoed from the user. + Text(TextBlock), + /// Extended-thinking block (only present when thinking is enabled). + Thinking(ThinkingBlock), + /// Model decided to invoke a client-side tool. + ToolUse(ToolUseBlock), + /// Result of a previously-issued tool call (returned by the user). + ToolResult(ToolResultBlock), + /// Server-side tool use (web_search, code_execution, advisor, etc.). + ServerToolUse(ServerToolUseBlock), + /// Result block returned for a server-side tool call. + #[serde(rename = "advisor_tool_result")] + ServerToolResult(ServerToolResultBlock), + /// Forward-compatibility escape hatch — block type the SDK does not + /// recognise. The raw `serde_json::Value` is kept so callers can inspect + /// the payload without modifying the SDK. + #[serde(other, skip_serializing)] + Unknown, +} + +/// Plain text block. +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +pub struct TextBlock { + /// Text content. + pub text: String, +} + +/// Extended-thinking block. +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +pub struct ThinkingBlock { + /// The thinking text. + pub thinking: String, + /// Signature used to verify the thinking trace. + pub signature: String, +} + +/// Tool-use block: the model is requesting that a tool be invoked. +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +pub struct ToolUseBlock { + /// Unique ID for this tool invocation; pair with the matching + /// [`ToolResultBlock::tool_use_id`]. + pub id: String, + /// Tool name (e.g. `"Bash"`, `"Read"`). + pub name: String, + /// Tool input parameters, opaque to the SDK. + pub input: Value, +} + +/// Tool-result block: response back to the model for a previous tool call. +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +pub struct ToolResultBlock { + /// The `id` of the [`ToolUseBlock`] this result corresponds to. + pub tool_use_id: String, + /// The tool output. May be a plain string or a structured payload; left + /// as opaque JSON because the CLI emits both shapes. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub content: Option, + /// Whether the tool reported failure. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub is_error: Option, +} + +/// Server-side tool use (executed by the API, not the SDK caller). +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +pub struct ServerToolUseBlock { + /// Unique ID for this server-side invocation. + pub id: String, + /// Server tool name (e.g. `"web_search"`, `"code_execution"`, + /// `"advisor"`). + pub name: String, + /// Tool input, opaque. + pub input: Value, +} + +/// Result block returned for a server-side tool call. +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +pub struct ServerToolResultBlock { + /// The matching server tool-use ID. + pub tool_use_id: String, + /// Raw result payload from the API. + pub content: Value, +} + +// ---- top-level messages ---------------------------------------------------- + +/// A message in the conversation stream. Top-level discriminant is the wire +/// `type` field. +/// +/// Forward-compatibility: unknown message types arrive as [`Message::Unknown`] +/// rather than failing the deserialiser. The Python SDK silently skips them; +/// in Rust we keep the raw value so callers can opt into inspection. +#[derive(Debug, Clone, PartialEq)] +pub enum Message { + /// `{"type": "user", ...}` — user turn (input echoed back, or tool results). + User(UserMessage), + /// `{"type": "assistant", ...}` — assistant turn with content blocks. + Assistant(AssistantMessage), + /// `{"type": "result", ...}` — terminal message with usage / cost. + Result(ResultMessage), + /// `{"type": "system", ...}` — system events (init notifications, etc.). + System(SystemMessage), + /// `{"type": "stream_event", ...}` — partial streaming events (enabled + /// via [`crate::ClaudeAgentOptions::include_partial_messages`]). + StreamEvent(StreamEventMessage), + /// Anything else — raw JSON preserved verbatim. Includes messages with a + /// known top-level shape but a `type` field this SDK version doesn't + /// recognise. + Unknown(Value), +} + +impl<'de> Deserialize<'de> for Message { + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + // Deserialize into a Value first so we can dispatch on `type` without + // having serde retry variants. This keeps forward-compat clean: any + // unknown `type` (or an entirely shapeless payload) lands in + // Message::Unknown verbatim instead of failing the whole stream. + let value = Value::deserialize(deserializer)?; + let tag = value.get("type").and_then(|v| v.as_str()).map(str::to_owned); + match tag.as_deref() { + Some("user") => serde_json::from_value(value) + .map(Message::User) + .map_err(serde::de::Error::custom), + Some("assistant") => serde_json::from_value(value) + .map(Message::Assistant) + .map_err(serde::de::Error::custom), + Some("result") => serde_json::from_value(value) + .map(Message::Result) + .map_err(serde::de::Error::custom), + Some("system") => serde_json::from_value(value) + .map(Message::System) + .map_err(serde::de::Error::custom), + Some("stream_event") => serde_json::from_value(value) + .map(Message::StreamEvent) + .map_err(serde::de::Error::custom), + _ => Ok(Message::Unknown(value)), + } + } +} + +impl Serialize for Message { + fn serialize(&self, serializer: S) -> std::result::Result + where + S: serde::Serializer, + { + match self { + Message::User(u) => u.serialize(serializer), + Message::Assistant(a) => a.serialize(serializer), + Message::Result(r) => r.serialize(serializer), + Message::System(s) => s.serialize(serializer), + Message::StreamEvent(e) => e.serialize(serializer), + Message::Unknown(v) => v.serialize(serializer), + } + } +} + +/// User message: either the prompt echo or tool results returned to the model. +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +pub struct UserMessage { + /// Wire tag — always `"user"`. + #[serde(rename = "type", default = "user_type")] + pub message_type: String, + /// Inner Anthropic-API-shape message envelope. + pub message: UserMessageInner, + /// Set when the user message is delivering a tool result inside a sub-agent. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parent_tool_use_id: Option, + /// Opaque tool-result blob, set when `content` is the structured form. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tool_use_result: Option, + /// Session identifier the message belongs to. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub session_id: Option, + /// Stable UUID of this message; available for checkpointing. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub uuid: Option, +} + +fn user_type() -> String { + "user".into() +} + +/// Inner envelope of a [`UserMessage`]. +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +pub struct UserMessageInner { + /// Always `"user"`. + pub role: String, + /// Either a plain string (simple prompt) or a list of content blocks. + pub content: UserContent, +} + +/// `user.message.content` is polymorphic — either a string (plain prompt) or +/// a list of typed content blocks (tool results). +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +#[serde(untagged)] +pub enum UserContent { + /// Plain-string content. + Text(String), + /// Structured content blocks. + Blocks(Vec), +} + +/// Assistant message with the model's content blocks for one turn. +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +pub struct AssistantMessage { + /// Wire tag — always `"assistant"`. + #[serde(rename = "type", default = "assistant_type")] + pub message_type: String, + /// Inner Anthropic-API-shape message envelope (carries `content`, + /// `model`, `usage`, etc.). + pub message: AssistantMessageInner, + /// Set when the assistant turn is from a sub-agent. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parent_tool_use_id: Option, + /// Top-level error tag set when the API returned an error for this turn. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option, + /// Session identifier. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub session_id: Option, + /// Stable UUID for this message. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub uuid: Option, +} + +fn assistant_type() -> String { + "assistant".into() +} + +/// Inner envelope of an [`AssistantMessage`] — mirrors the Anthropic Messages +/// API shape. +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +pub struct AssistantMessageInner { + /// Always `"assistant"`. + pub role: String, + /// Content blocks for this turn. + pub content: Vec, + /// Model identifier (e.g. `"claude-sonnet-4-5"`). + pub model: String, + /// Anthropic message ID. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub id: Option, + /// Reason the model stopped (e.g. `"end_turn"`, `"tool_use"`, + /// `"max_tokens"`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub stop_reason: Option, + /// Raw usage dict from the API. Left opaque because the schema changes + /// across API versions. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub usage: Option, +} + +/// Terminal `result` message — the iterator stops yielding new messages after +/// this arrives. Carries usage, cost, and any error details for the turn. +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +pub struct ResultMessage { + /// Wire tag — always `"result"`. + #[serde(rename = "type", default = "result_type")] + pub message_type: String, + /// `subtype` — e.g. `"success"`, `"error_max_turns"`, `"error_during_execution"`. + pub subtype: String, + /// Wall-clock duration of the turn in milliseconds. + pub duration_ms: i64, + /// API call duration in milliseconds (subset of `duration_ms`). + pub duration_api_ms: i64, + /// Whether the turn ended in error. + pub is_error: bool, + /// Number of turns consumed. + pub num_turns: i64, + /// Session identifier. + pub session_id: String, + /// Reason the run stopped, when applicable. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub stop_reason: Option, + /// Total cost in USD, when reported by the CLI. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub total_cost_usd: Option, + /// Aggregated usage stats; opaque shape. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub usage: Option, + /// Final string result, when the run produced one (e.g. last assistant + /// text content). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub result: Option, + /// Structured output, when configured via `output_format`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub structured_output: Option, + /// Per-model usage breakdown (legacy `modelUsage` field). + #[serde(default, rename = "modelUsage", skip_serializing_if = "Option::is_none")] + pub model_usage: Option, + /// HTTP status of the failing API call when `is_error` is `true`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub api_error_status: Option, + /// Errors list when the run had structured error info. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub errors: Option>, + /// Stable UUID for this message. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub uuid: Option, +} + +fn result_type() -> String { + "result".into() +} + +/// System message — metadata events emitted by the CLI (init, task lifecycle, +/// hook events, etc.). +/// +/// The Python SDK splits these into more specific subclasses (e.g. +/// `TaskStartedMessage`); we keep one variant and let callers branch on +/// `subtype` + `data`. Drop them onto the floor if you don't care. +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +pub struct SystemMessage { + /// Wire tag — always `"system"`. + #[serde(rename = "type", default = "system_type")] + pub message_type: String, + /// Discriminator (e.g. `"init"`, `"task_started"`, `"hook_started"`). + pub subtype: String, + /// Full raw payload, including any subtype-specific fields. The Python + /// SDK exposes the full dict on `data`; we do the same. + #[serde(flatten)] + pub data: Value, +} + +fn system_type() -> String { + "system".into() +} + +/// `stream_event` — partial Anthropic API stream event, only emitted when +/// `include_partial_messages` is enabled. +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +pub struct StreamEventMessage { + /// Wire tag — always `"stream_event"`. + #[serde(rename = "type", default = "stream_event_type")] + pub message_type: String, + /// Stable UUID. + pub uuid: String, + /// Session identifier. + pub session_id: String, + /// Raw Anthropic API stream event payload, opaque. + pub event: Value, + /// Sub-agent parent tool-use ID when running inside an agent. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parent_tool_use_id: Option, +} + +fn stream_event_type() -> String { + "stream_event".into() +} + +impl Message { + /// True if this message is a [`Message::Result`] — i.e. the terminal + /// message for a turn. Useful for `take_while` / early termination. + pub fn is_result(&self) -> bool { + matches!(self, Message::Result(_)) + } + + /// If this message is an [`Message::Assistant`], return its content + /// blocks. Returns `None` otherwise. + pub fn as_assistant(&self) -> Option<&AssistantMessage> { + match self { + Message::Assistant(a) => Some(a), + _ => None, + } + } + + /// If this message is a [`Message::Result`], return it. Returns `None` + /// otherwise. + pub fn as_result(&self) -> Option<&ResultMessage> { + match self { + Message::Result(r) => Some(r), + _ => None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_assistant_with_text_block() { + let raw = serde_json::json!({ + "type": "assistant", + "message": { + "role": "assistant", + "content": [{"type": "text", "text": "hi"}], + "model": "claude-opus-4-5" + }, + "session_id": "s1", + "uuid": "u1" + }); + let msg: Message = serde_json::from_value(raw).unwrap(); + match msg { + Message::Assistant(a) => { + assert_eq!(a.message.model, "claude-opus-4-5"); + assert_eq!(a.message.content.len(), 1); + match &a.message.content[0] { + ContentBlock::Text(t) => assert_eq!(t.text, "hi"), + other => panic!("expected TextBlock, got {other:?}"), + } + } + other => panic!("expected Assistant, got {other:?}"), + } + } + + #[test] + fn parses_user_with_plain_string_content() { + let raw = serde_json::json!({ + "type": "user", + "message": {"role": "user", "content": "hello"}, + "session_id": "s1" + }); + let msg: Message = serde_json::from_value(raw).unwrap(); + match msg { + Message::User(u) => match u.message.content { + UserContent::Text(s) => assert_eq!(s, "hello"), + other => panic!("expected Text, got {other:?}"), + }, + other => panic!("expected User, got {other:?}"), + } + } + + #[test] + fn parses_user_with_tool_result_blocks() { + let raw = serde_json::json!({ + "type": "user", + "message": { + "role": "user", + "content": [ + {"type": "tool_result", "tool_use_id": "t1", "content": "ok", "is_error": false} + ] + } + }); + let msg: Message = serde_json::from_value(raw).unwrap(); + match msg { + Message::User(u) => match u.message.content { + UserContent::Blocks(blocks) => { + assert_eq!(blocks.len(), 1); + match &blocks[0] { + ContentBlock::ToolResult(t) => { + assert_eq!(t.tool_use_id, "t1"); + assert_eq!(t.is_error, Some(false)); + } + other => panic!("expected ToolResult, got {other:?}"), + } + } + other => panic!("expected Blocks, got {other:?}"), + }, + other => panic!("expected User, got {other:?}"), + } + } + + #[test] + fn parses_result_message() { + let raw = serde_json::json!({ + "type": "result", + "subtype": "success", + "duration_ms": 1234, + "duration_api_ms": 1000, + "is_error": false, + "num_turns": 1, + "session_id": "s1", + "total_cost_usd": 0.0042 + }); + let msg: Message = serde_json::from_value(raw).unwrap(); + match msg { + Message::Result(r) => { + assert_eq!(r.subtype, "success"); + assert_eq!(r.num_turns, 1); + assert_eq!(r.total_cost_usd, Some(0.0042)); + assert!(!r.is_error); + } + other => panic!("expected Result, got {other:?}"), + } + assert!(serde_json::from_value::(serde_json::json!({ + "type": "result", "subtype": "success", + "duration_ms": 1, "duration_api_ms": 1, + "is_error": false, "num_turns": 1, "session_id": "s" + })).unwrap().is_result()); + } + + #[test] + fn parses_system_init_with_passthrough_data() { + let raw = serde_json::json!({ + "type": "system", + "subtype": "init", + "session_id": "s1", + "tools": ["Read", "Write"] + }); + let msg: Message = serde_json::from_value(raw).unwrap(); + match msg { + Message::System(s) => { + assert_eq!(s.subtype, "init"); + assert_eq!(s.data["session_id"], "s1"); + assert_eq!(s.data["tools"][0], "Read"); + } + other => panic!("expected System, got {other:?}"), + } + } + + #[test] + fn unknown_top_level_falls_through() { + // A message with no recognised shape — must surface as Unknown rather + // than failing deser. + let raw = serde_json::json!({"type": "rate_limit_event", "rate_limit_info": {"status": "allowed"}, "uuid": "u", "session_id": "s"}); + let msg: Message = serde_json::from_value(raw.clone()).unwrap(); + match msg { + Message::Unknown(v) => assert_eq!(v, raw), + other => panic!("expected Unknown, got {other:?}"), + } + } + + #[test] + fn thinking_and_tool_use_blocks_roundtrip() { + let raw = serde_json::json!({ + "type": "assistant", + "message": { + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": "hmm", "signature": "sig"}, + {"type": "tool_use", "id": "t1", "name": "Bash", "input": {"cmd": "ls"}} + ], + "model": "m" + } + }); + let msg: Message = serde_json::from_value(raw).unwrap(); + let a = msg.as_assistant().expect("assistant"); + assert!(matches!(a.message.content[0], ContentBlock::Thinking(_))); + match &a.message.content[1] { + ContentBlock::ToolUse(t) => { + assert_eq!(t.name, "Bash"); + assert_eq!(t.input["cmd"], "ls"); + } + other => panic!("expected ToolUse, got {other:?}"), + } + } +} diff --git a/src/options.rs b/src/options.rs new file mode 100644 index 0000000..ea7d7a0 --- /dev/null +++ b/src/options.rs @@ -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, + + /// 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>, + + /// Tool names that are auto-allowed without prompting. Maps to the + /// CLI's `--allowedTools` flag. + pub allowed_tools: Vec, + + /// Tool names explicitly disallowed. Maps to `--disallowedTools`. + pub disallowed_tools: Vec, + + /// Permission mode for the session. + pub permission_mode: Option, + + /// Maximum number of conversation turns. + pub max_turns: Option, + + /// Maximum budget in USD; the run stops when exceeded. + pub max_budget_usd: Option, + + /// Continue the most recent conversation in `cwd`. + pub continue_conversation: bool, + + /// Session ID to resume. + pub resume: Option, + + /// Specific session ID for the conversation (UUID). + pub session_id: Option, + + /// Model identifier (e.g. `"claude-sonnet-4-5"`). + pub model: Option, + + /// Fallback model when the primary fails. + pub fallback_model: Option, + + /// Working directory for the subprocess. + pub cwd: Option, + + /// Explicit path to the `claude` CLI binary. When unset, the SDK + /// searches `PATH` and a handful of standard install locations. + pub cli_path: Option, + + /// Path or JSON string for an additional settings file (`--settings`). + pub settings: Option, + + /// Additional directories Claude may access (`--add-dir`). + pub add_dirs: Vec, + + /// Extra environment variables for the subprocess. + pub env: HashMap, + + /// Pass-through CLI flags (`--flag value`). Value `None` becomes a + /// boolean flag. + pub extra_args: HashMap>, + + /// Maximum stdout buffer size in bytes for the CLI subprocess. + /// Defaults to 1 MiB if unset. + pub max_buffer_size: Option, + + /// 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, + + /// Output format — passed through as `--json-schema` when shaped as + /// `{"type": "json_schema", "schema": {...}}`. + pub output_format: Option, + + /// 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, + + /// 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 `. + 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) -> 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) -> 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) -> 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) -> Self { + self.tools = Some(tools); + self + } + + /// Add one auto-allowed tool name. + pub fn with_allowed_tool(mut self, tool: impl Into) -> Self { + self.allowed_tools.push(tool.into()); + self + } + + /// Replace the entire allowed-tools list. + pub fn with_allowed_tools(mut self, tools: Vec) -> Self { + self.allowed_tools = tools; + self + } + + /// Add one disallowed tool name. + pub fn with_disallowed_tool(mut self, tool: impl Into) -> 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) -> 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) -> Self { + self.session_id = Some(session_id.into()); + self + } + + /// Pick a specific model. + pub fn with_model(mut self, model: impl Into) -> Self { + self.model = Some(model.into()); + self + } + + /// Pick a fallback model. + pub fn with_fallback_model(mut self, model: impl Into) -> Self { + self.fallback_model = Some(model.into()); + self + } + + /// Set the working directory for the subprocess. + pub fn with_cwd(mut self, cwd: impl Into) -> 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) -> 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) -> Self { + self.settings = Some(settings.into()); + self + } + + /// Add an extra directory accessible to Claude. + pub fn with_add_dir(mut self, dir: impl Into) -> Self { + self.add_dirs.push(dir.into()); + self + } + + /// Add an environment variable for the subprocess. + pub fn with_env(mut self, key: impl Into, value: impl Into) -> 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, + value: Option>, + ) -> 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); + } +}