From 5afe04375ed9a878b4023b65855f6a0c8db96809 Mon Sep 17 00:00:00 2001 From: Thomas Carr <9591402+htcarr3@users.noreply.github.com> Date: Sun, 15 Mar 2026 18:29:37 -0400 Subject: [PATCH] feat: add Stripe-style configuration, DSL builders, and decomposed docs Introduces a convenience layer on top of the existing Options-based API: - Configuration: module-level config via Forwardable (ClaudeAgent.model=), block-based configure, to_options merge with per-request overrides - Entry points: ClaudeAgent.ask (one-shot) and ClaudeAgent.chat (multi-turn) - PermissionPolicy: declarative allow/deny/matching DSL compiling to can_use_tool - HookRegistry: Ruby method names mapping to 22 CLI events, additive merge - Message module: text_content, pattern matching, prepended into all types - EventHandler.define: block-based DSL for handler definitions - MCP Server block DSL and symbol type shortcuts in Tool schema - Session resource: retrieve (raises), rename/tag/fork/reload/resume methods - NotFoundError added to error hierarchy - README trimmed to 120-line primer, full docs decomposed into 14 guides in docs/ - CLAUDE.md and conventions.md updated with new patterns --- .claude/rules/conventions.md | 82 +- CLAUDE.md | 28 +- README.md | 1581 +---------------- docs/architecture.md | 339 ++++ docs/client.md | 526 ++++++ docs/configuration.md | 571 ++++++ docs/conversations.md | 461 +++++ docs/errors.md | 127 ++ docs/events.md | 225 +++ docs/getting-started.md | 310 ++++ docs/hooks.md | 380 ++++ docs/logging.md | 96 + docs/mcp.md | 308 ++++ docs/messages.md | 871 +++++++++ docs/permissions.md | 611 +++++++ docs/queries.md | 227 +++ docs/sessions.md | 335 ++++ lib/claude_agent.rb | 175 ++ lib/claude_agent/configuration.rb | 129 ++ lib/claude_agent/conversation.rb | 31 +- lib/claude_agent/errors.rb | 3 + lib/claude_agent/event_handler.rb | 14 + lib/claude_agent/hook_registry.rb | 110 ++ lib/claude_agent/mcp/server.rb | 22 + lib/claude_agent/mcp/tool.rb | 27 +- lib/claude_agent/message.rb | 93 + lib/claude_agent/options.rb | 10 + lib/claude_agent/permission_policy.rb | 174 ++ lib/claude_agent/session.rb | 111 +- lib/claude_agent/session_paths.rb | 7 +- test/claude_agent/test_configuration.rb | 266 +++ test/claude_agent/test_global_defaults.rb | 168 ++ test/claude_agent/test_hook_registry.rb | 169 ++ test/claude_agent/test_message_module.rb | 164 ++ test/claude_agent/test_permission_policy.rb | 185 ++ test/claude_agent/test_session.rb | 113 +- .../test_conversation_scenarios.rb | 46 + test/integration/test_query_scenarios.rb | 49 + test/smoke/test_basic.rb | 8 + 39 files changed, 7580 insertions(+), 1572 deletions(-) create mode 100644 docs/architecture.md create mode 100644 docs/client.md create mode 100644 docs/configuration.md create mode 100644 docs/conversations.md create mode 100644 docs/errors.md create mode 100644 docs/events.md create mode 100644 docs/getting-started.md create mode 100644 docs/hooks.md create mode 100644 docs/logging.md create mode 100644 docs/mcp.md create mode 100644 docs/messages.md create mode 100644 docs/permissions.md create mode 100644 docs/queries.md create mode 100644 docs/sessions.md create mode 100644 lib/claude_agent/configuration.rb create mode 100644 lib/claude_agent/hook_registry.rb create mode 100644 lib/claude_agent/message.rb create mode 100644 lib/claude_agent/permission_policy.rb create mode 100644 test/claude_agent/test_configuration.rb create mode 100644 test/claude_agent/test_global_defaults.rb create mode 100644 test/claude_agent/test_hook_registry.rb create mode 100644 test/claude_agent/test_message_module.rb create mode 100644 test/claude_agent/test_permission_policy.rb diff --git a/.claude/rules/conventions.md b/.claude/rules/conventions.md index 1eaa3ad..af57635 100644 --- a/.claude/rules/conventions.md +++ b/.claude/rules/conventions.md @@ -587,36 +587,86 @@ validate! \ ## Advanced Patterns -### Global Coordinator (Use Sparingly) +### Stripe-Style Global Configuration -For CLI applications that need shared state: +Module-level config with `Forwardable` delegators for common fields. A `Configuration` +object holds defaults; `to_options(**overrides)` merges per-request overrides and returns +the internal `Options` type. ```ruby -# lib/gem_name/cli.rb -COORDINATOR = Coordinator.new +module GemName + require "forwardable" + + @config = Configuration.setup + + class << self + extend Forwardable + attr_reader :config + + def_delegators :@config, :model, :model=, + :max_turns, :max_turns= -class Cli::Base - def initialize(*) - super - initialize_coordinator unless COORDINATOR.configured? + def configure = yield(@config) + def reset_config! = @config = Configuration.setup end end + +# Usage: +GemName.model = "opus" +GemName.configure { |c| c.max_turns = 10 } ``` -### Factory Methods in Coordinator +### DSL Builder Pattern + +Declarative builders that compile to the format consumed by internal plumbing. +The builder accumulates rules/matchers, then `to_*` produces the final artifact. ```ruby -class Coordinator - def app(role: nil, host: nil) - Commands::App.new(config, role: role, host: host) +class PermissionPolicy + def initialize(&block) + @rules = [] + yield self if block_given? end - def builder - @builder ||= Commands::Builder.new(config) + def allow(*names) + names.each { |n| @rules << { name: n, action: :allow } } + self end - def configured? - @config.present? + def to_can_use_tool + rules = @rules.dup.freeze + ->(tool_name, input, ctx) { ... } end end ``` + +Key conventions: +- Constructor takes `&block` and yields `self` +- Methods return `self` for chaining +- `to_*` method compiles to the internal format +- `empty?` predicate for skipping when unconfigured + +### Prepending Modules into Data.define Types + +To add shared behavior (like `deconstruct_keys` overrides) to `Data.define` classes, +use `prepend` not `include`. Data.define generates methods on the class itself, so +`include` would be shadowed. When overriding `deconstruct_keys`, filter virtual keys +out before calling `super` — Data's implementation stops early on unknown member keys. + +```ruby +module Message + def deconstruct_keys(keys) + if keys.nil? + { type: type }.merge(super) + elsif keys.include?(:type) + member_keys = keys - [ :type ] + base = member_keys.empty? ? {} : super(member_keys) + { type: type }.merge(base) + else + super + end + end +end + +MESSAGE_TYPES.each { |klass| klass.prepend(Message) } +``` diff --git a/CLAUDE.md b/CLAUDE.md index c8c2e46..90f9877 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,23 @@ # ClaudeAgent Ruby SDK -Ruby SDK for building autonomous AI agents that interact with Claude Code CLI. See [README.md](README.md) for API usage, message types, configuration, and examples. +Ruby SDK for building autonomous AI agents that interact with Claude Code CLI. See [README.md](README.md) for a quick start, and the [docs/](docs/) folder for full guides: + +| Guide | What's in it | +|--------------------------------------------|------------------------------------------------------| +| [Getting Started](docs/getting-started.md) | Install, first queries, multi-turn basics | +| [Configuration](docs/configuration.md) | Global config, Options, sandbox, agents | +| [Conversations](docs/conversations.md) | Multi-turn API, TurnResult, callbacks, tool tracking | +| [Queries](docs/queries.md) | One-shot: ask, query_turn, query | +| [Permissions](docs/permissions.md) | PermissionPolicy DSL, can_use_tool, queue | +| [Hooks](docs/hooks.md) | HookRegistry DSL, hook events, input types | +| [MCP Tools](docs/mcp.md) | In-process tools, servers, schemas | +| [Events](docs/events.md) | EventHandler, typed callbacks | +| [Messages](docs/messages.md) | 22 message types, 8 content blocks, pattern matching | +| [Sessions](docs/sessions.md) | Session discovery, mutations, forking | +| [Client](docs/client.md) | Low-level bidirectional API | +| [Errors](docs/errors.md) | Error hierarchy | +| [Logging](docs/logging.md) | Debug logging, log levels | +| [Architecture](docs/architecture.md) | Internal design, data flow | ## Stack @@ -36,11 +53,14 @@ bin/release VERSION # Release gem (e.g., bin/release 1.2.0) ## Conventions -- **Immutable data types**: All messages and options use `Data.define` +- **Immutable data types**: All messages and content blocks use `Data.define`, frozen at construction - **Frozen string literals**: Every file starts with `# frozen_string_literal: true` -- **Message polymorphism**: Use `case` statements or `is_a?()` for content block types +- **Message module**: All message/block types include `ClaudeAgent::Message` (text_content, pattern matching) +- **Stripe-style config**: `ClaudeAgent.model = "opus"`, `ClaudeAgent.configure { |c| ... }`, `Configuration#to_options` +- **Convenience entry points**: `ClaudeAgent.ask(prompt)` (one-shot), `ClaudeAgent.chat { |c| ... }` (multi-turn) +- **DSL builders**: `PermissionPolicy` and `HookRegistry` compile to lambdas/hashes consumed by Options - **Error hierarchy**: All errors inherit from `ClaudeAgent::Error` with context (exit code, stderr, etc.) -- **Protocol flow**: Transport → ControlProtocol → MessageParser → typed message objects +- **Protocol flow**: Configuration → Options → Transport → ControlProtocol → MessageParser → typed messages ## Testing Notes diff --git a/README.md b/README.md index ed17759..69590db 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # ClaudeAgent -Ruby gem for building AI-powered applications with the [Claude Agent SDK](https://platform.claude.com/docs/en/agent-sdk/overview). This library essentially wraps the Claude Code CLI, providing both simple one-shot queries and interactive bidirectional sessions. +Ruby SDK for building AI-powered applications with the [Claude Agent SDK](https://platform.claude.com/docs/en/agent-sdk/overview). Wraps the Claude Code CLI with idiomatic Ruby interfaces for one-shot queries, multi-turn conversations, permissions, hooks, and MCP tools. ## Requirements @@ -9,1587 +9,110 @@ Ruby gem for building AI-powered applications with the [Claude Agent SDK](https: ## Installation -Add to your Gemfile: - ```ruby gem "claude_agent" ``` -Then run: - -```bash -bundle install -``` - -Or install directly: - -```bash -gem install claude_agent -``` - ## Quick Start -### Conversation (Recommended) - -The simplest way to have multi-turn conversations: - -```ruby -require "claude_agent" - -ClaudeAgent::Conversation.open( - permission_mode: "acceptEdits", - on_stream: ->(text) { print text } -) do |c| - c.say("Fix the bug in auth.rb") - c.say("Now add tests for the fix") - puts "\nTotal cost: $#{c.total_cost}" -end -``` - ### One-Shot Query -For single questions: - -```ruby -require "claude_agent" - -ClaudeAgent.query(prompt: "What is the capital of France?").each do |message| - case message - when ClaudeAgent::AssistantMessage - puts message.text - when ClaudeAgent::ResultMessage - puts "Cost: $#{message.total_cost_usd}" - end -end -``` - -### One-Shot with TurnResult - -Get a structured result without writing `case` statements: - ```ruby require "claude_agent" -turn = ClaudeAgent.query_turn(prompt: "What is the capital of France?") +turn = ClaudeAgent.ask("What is the capital of France?") puts turn.text puts "Cost: $#{turn.cost}" -puts "Model: #{turn.model}" -``` - -### Interactive Client - -For fine-grained control over the conversation: - -```ruby -require "claude_agent" - -ClaudeAgent::Client.open do |client| - client.on_text { |text| print text } - - turn = client.send_and_receive("Remember the number 42") - turn = client.send_and_receive("What number did I ask you to remember?") - - puts "\nAnswer: #{turn.text}" -end -``` - -## Conversation API - -The `Conversation` class manages the full lifecycle: auto-connects on first message, tracks multi-turn history, accumulates usage, and builds a unified tool activity timeline. - -### Basic Usage - -```ruby -conversation = ClaudeAgent.conversation( - model: "claude-sonnet-4-5-20250514", - permission_mode: "acceptEdits", - on_text: ->(text) { print text } -) - -turn = conversation.say("Help me refactor this module") -puts turn.tool_uses.map(&:display_label) # ["Read lib/foo.rb", "Edit lib/foo.rb"] - -turn = conversation.say("Now update the tests") -puts "Session cost: $#{conversation.total_cost}" - -conversation.close ``` -### Block Form - -Automatically cleans up when the block exits: - -```ruby -ClaudeAgent::Conversation.open( - max_turns: 10, - on_tool_use: ->(tool) { puts " Using: #{tool.display_label}" } -) do |c| - c.say("Implement the feature described in SPEC.md") - c.say("Run the tests and fix any failures") - - puts "Tools used: #{c.tool_activity.size}" - puts "Total cost: $#{c.total_cost}" -end -``` - -### Resume a Previous Session +### Multi-Turn Conversation ```ruby -conversation = ClaudeAgent.resume_conversation("session-abc-123") -turn = conversation.say("Continue where we left off") -conversation.close -``` - -### Callbacks - -Register callbacks for real-time event handling. Any `on_*` keyword is accepted — see [Event Handlers](#event-handlers) for the full list. - -```ruby -conversation = ClaudeAgent.conversation( - on_text: ->(text) { print text }, - on_stream: ->(text) { print text }, # Alias for on_text - on_thinking: ->(thought) { puts "Thinking: #{thought}" }, - on_tool_use: ->(tool) { puts "Tool: #{tool.display_label}" }, - on_tool_result: ->(result) { puts "Result: #{result.content&.slice(0, 80)}" }, - on_result: ->(result) { puts "Done! Cost: $#{result.total_cost_usd}" }, - on_message: ->(msg) { log(msg) }, # Catch-all - on_stream_event: ->(evt) { handle_stream(evt) }, # Type-based - on_status: ->(status) { show_status(status) }, - on_tool_progress: ->(prog) { update_spinner(prog) } -) -``` - -### Tool Activity Timeline - -Track all tool executions across turns with timing: - -```ruby -ClaudeAgent::Conversation.open(permission_mode: "acceptEdits") do |c| - c.say("Refactor the auth module") - - c.tool_activity.each do |activity| - puts "#{activity.display_label} (turn #{activity.turn_index})" - puts " Duration: #{activity.duration&.round(2)}s" if activity.duration - puts " Error!" if activity.error? - end -end -``` - -### Live Tool Tracking - -Track tool status in real time for live UIs. Unlike `tool_activity` (built after a turn), `LiveToolActivity` updates as tools run: - -```ruby -# Conversation level — opt in with track_tools: true -ClaudeAgent::Conversation.open( - permission_mode: "acceptEdits", - track_tools: true -) do |c| - c.tool_tracker.on_start { |entry| puts "▸ #{entry.display_label}" } - c.tool_tracker.on_progress { |entry| puts " #{entry.elapsed&.round(1)}s..." } - c.tool_tracker.on_complete { |entry| puts "✓ #{entry.display_label}" } - +ClaudeAgent.chat do |c| c.say("Fix the bug in auth.rb") - # Tracker resets between turns automatically -end - -# Client level — attach directly -tracker = ClaudeAgent::ToolActivityTracker.attach(client) -tracker.on_start { |entry| show_spinner(entry) } -tracker.on_complete { |entry| hide_spinner(entry) } - -# Standalone — attach to any EventHandler -tracker = ClaudeAgent::ToolActivityTracker.attach(event_handler) - -# Catch-all callback (receives event symbol + entry) -tracker.on_change { |event, entry| log(event, entry.id) } - -# Query running/completed tools at any point -tracker.running # => [LiveToolActivity, ...] -tracker.done # => [LiveToolActivity, ...] -tracker.errored # => [LiveToolActivity, ...] -tracker["toolu_01ABC"] # => LiveToolActivity (O(1) lookup by tool use ID) -``` - -### Conversation Accessors - -```ruby -conversation.turns # Array of TurnResult objects -conversation.messages # All messages across all turns -conversation.tool_activity # Array of ToolActivity objects -conversation.tool_tracker # ToolActivityTracker (when track_tools: true) -conversation.total_cost # Total cost in USD -conversation.session_id # Session ID from most recent turn -conversation.usage # CumulativeUsage stats -conversation.open? # Whether conversation is connected -conversation.closed? # Whether conversation has been closed -``` - -## Configuration - -Use `ClaudeAgent::Options` to customize behavior: - -```ruby -options = ClaudeAgent::Options.new( - # Model selection - model: "claude-sonnet-4-5-20250514", - fallback_model: "claude-haiku-3-5-20241022", - - # Conversation limits - max_turns: 10, - max_budget_usd: 1.0, - - # Extended thinking - thinking: { type: "enabled", budgetTokens: 10000 }, # or "adaptive" or "disabled" - # max_thinking_tokens: 10000, # Shorthand (when thinking not set) - - # Response effort - effort: "high", # "low", "medium", "high", "max" - - # System prompt - system_prompt: "You are a helpful coding assistant.", - append_system_prompt: "Always be concise.", - - # Tool configuration - tools: ["Read", "Write", "Bash"], - allowed_tools: ["Read"], - disallowed_tools: ["Write"], - - # Permission modes: "default", "acceptEdits", "plan", "dontAsk", "bypassPermissions" - permission_mode: "acceptEdits", - permission_queue: true, # Enable queue-based permissions (see Permissions section) - - # Working directory for file operations - cwd: "/path/to/project", - add_dirs: ["/additional/path"], - - # Agent configuration - agent: "my-agent", # Agent name for main thread - - # Session management - resume: "session-id", - session_id: "custom-uuid", # Custom conversation UUID - continue_conversation: true, - fork_session: true, - persist_session: true, # Default: true - - # Structured output - output_format: { - type: "object", - properties: { answer: { type: "string" } } - }, - - # Prompt suggestions - prompt_suggestions: true, - - # Debug logging (CLI-level) - debug: true, - debug_file: "/path/to/debug.log" -) - -ClaudeAgent.query(prompt: "Help me refactor this code", options: options) -``` - -### Tools Preset - -Use a preset tool configuration: - -```ruby -# Using ToolsPreset class -options = ClaudeAgent::Options.new( - tools: ClaudeAgent::ToolsPreset.new(preset: "claude_code") -) - -# Or as a Hash -options = ClaudeAgent::Options.new( - tools: { type: "preset", preset: "claude_code" } -) -``` - -### Sandbox Settings - -Configure sandboxed command execution: - -```ruby -sandbox = ClaudeAgent::SandboxSettings.new( - enabled: true, - auto_allow_bash_if_sandboxed: true, - excluded_commands: ["docker", "kubectl"], - network: ClaudeAgent::SandboxNetworkConfig.new( - allowed_domains: ["api.example.com"], - allow_local_binding: true, - allow_managed_domains_only: false - ), - filesystem: ClaudeAgent::SandboxFilesystemConfig.new( - allow_write: ["/tmp/*"], - deny_write: ["/etc/*"], - deny_read: ["/secrets/*"] - ), - ripgrep: ClaudeAgent::SandboxRipgrepConfig.new( - command: "/usr/local/bin/rg" - ) -) - -options = ClaudeAgent::Options.new(sandbox: sandbox) -``` - -### Custom Agents - -Define custom subagents: - -```ruby -agents = { - "test-runner" => ClaudeAgent::AgentDefinition.new( - description: "Runs tests and reports results", - prompt: "You are a test runner. Execute tests and report failures clearly.", - tools: ["Read", "Bash"], - model: "haiku", - max_turns: 5, # Max agentic turns before stopping - skills: ["testing", "debug"] # Skills to preload into agent context - ) -} - -options = ClaudeAgent::Options.new(agents: agents) -``` - -## TurnResult - -A `TurnResult` accumulates all messages from sending a prompt to receiving the final `ResultMessage`. It eliminates the need for `case` statements over raw message types. - -### Getting a TurnResult - -```ruby -# Via Conversation -turn = conversation.say("Fix the bug") - -# Via Client -turn = client.send_and_receive("Fix the bug") - -# Via one-shot query -turn = ClaudeAgent.query_turn(prompt: "Fix the bug") -``` - -### Accessors - -```ruby -# Text and thinking -turn.text # All text content concatenated -turn.thinking # All thinking content concatenated - -# Tool usage -turn.tool_uses # Array of ToolUseBlock / ServerToolUseBlock -turn.tool_results # Array of ToolResultBlock / ServerToolResultBlock -turn.tool_executions # Array of { tool_use:, tool_result: } pairs - -# Result data -turn.cost # Total cost in USD -turn.duration_ms # Wall-clock duration -turn.session_id # Session ID for resumption -turn.model # Model name -turn.stop_reason # Why the model stopped ("end_turn", "tool_use", etc.) -turn.usage # Token usage hash -turn.model_usage # Per-model usage breakdown -turn.structured_output # Structured output (if requested) -turn.num_turns # Number of turns in session - -# Status -turn.success? # Whether turn completed successfully -turn.error? # Whether turn ended with error -turn.complete? # Whether a ResultMessage was received -turn.errors # Array of error strings -turn.permission_denials # Array of SDKPermissionDenial - -# Filtered message access -turn.assistant_messages # All AssistantMessages -turn.user_messages # All UserMessages / UserMessageReplays -turn.stream_events # All StreamEvents -turn.content_blocks # All content blocks across assistant messages -``` - -## Event Handlers - -Register typed callbacks instead of writing `case` statements. Works with `Client`, `Conversation`, or standalone. - -Three event layers fire for every message: - -1. **Catch-all** — `:message` fires for every message -2. **Type-based** — `message.type` fires (e.g. `:assistant`, `:stream_event`, `:status`, `:tool_progress`) -3. **Decomposed** — convenience events for rich content (`:text`, `:thinking`, `:tool_use`, `:tool_result`) - -### Via Client - -```ruby -ClaudeAgent::Client.open do |client| - # Decomposed events (extracted content) - client.on_text { |text| print text } - client.on_tool_use { |tool| puts "\nUsing: #{tool.display_label}" } - client.on_tool_result { |result, tool_use| puts "Done: #{tool_use&.name}" } - client.on_result { |result| puts "\nCost: $#{result.total_cost_usd}" } - - # Type-based events (full message object) - client.on_stream_event { |evt| handle_stream(evt) } - client.on_status { |status| show_status(status) } - client.on_tool_progress { |prog| update_spinner(prog) } - - client.send_and_receive("Fix the bug in auth.rb") -end -``` - -### Via One-Shot Query - -```ruby -events = ClaudeAgent::EventHandler.new - .on_text { |text| print text } - .on_result { |r| puts "\nDone!" } - -ClaudeAgent.query_turn(prompt: "Explain this code", events: events) -``` - -### Standalone - -```ruby -handler = ClaudeAgent::EventHandler.new -handler.on(:text) { |text| print text } -handler.on(:thinking) { |thought| puts "Thinking: #{thought}" } -handler.on(:tool_use) { |tool| puts "Tool: #{tool.display_label}" } -handler.on(:tool_result) { |result, tool_use| puts "Result for #{tool_use&.name}" } -handler.on(:result) { |result| puts "Cost: $#{result.total_cost_usd}" } -handler.on(:message) { |msg| log(msg) } # Catch-all - -# Type-based events work with on() too -handler.on(:stream_event) { |evt| handle_stream(evt) } -handler.on(:status) { |status| show_status(status) } - -# Dispatch manually -client.receive_response.each { |msg| handler.handle(msg) } -handler.reset! # Clear turn state between turns -``` - -## Message Types - -The SDK provides strongly-typed message classes for all protocol messages. - -### AssistantMessage - -Claude's responses: - -```ruby -message.text # Combined text content -message.thinking # Extended thinking content (if enabled) -message.model # Model that generated the response -message.uuid # Message UUID -message.session_id # Session identifier -message.tool_uses # Array of ToolUseBlock if Claude wants to use tools -message.has_tool_use? # Check if tools are being used -``` - -### ResultMessage - -Final message with usage statistics: - -```ruby -result.uuid # Message UUID -result.session_id # Session identifier -result.num_turns # Number of conversation turns -result.duration_ms # Total duration in milliseconds -result.total_cost_usd # API cost in USD -result.usage # Token usage breakdown -result.model_usage # Per-model usage breakdown -result.is_error # Whether the session ended in error -result.success? # Convenience method -result.error? # Convenience method -result.errors # Array of error messages (if any) -result.permission_denials # Array of SDKPermissionDenial (if any) -result.stop_reason # Why the model stopped generating (e.g. "end_turn", "tool_use") -result.fast_mode_state # Fast mode status (if applicable) -``` - -### UserMessageReplay - -Replayed user messages when resuming a session with existing history: - -```ruby -replay.content # Message content -replay.uuid # Message UUID -replay.session_id # Session identifier -replay.parent_tool_use_id # Parent tool use ID (if tool result) -replay.replay? # true if this is a replayed message -replay.synthetic? # true if this is a synthetic message -``` - -### SystemMessage - -Internal system events: - -```ruby -system_msg.subtype # e.g., "init" -system_msg.data # Event-specific data -``` - -### StreamEvent - -Real-time streaming events: - -```ruby -event.uuid # Event UUID -event.session_id # Session identifier -event.event_type # Type of stream event -event.event # Raw event data -``` - -### CompactBoundaryMessage - -Conversation compaction marker: - -```ruby -boundary.uuid # Message UUID -boundary.session_id # Session identifier -boundary.trigger # "manual" or "auto" -boundary.pre_tokens # Token count before compaction -``` - -### StatusMessage - -Session status updates: - -```ruby -status.uuid # Message UUID -status.session_id # Session identifier -status.status # e.g., "compacting" -``` - -### ToolProgressMessage - -Long-running tool progress: - -```ruby -progress.tool_use_id # Tool use ID -progress.tool_name # Tool name -progress.elapsed_time_seconds # Time elapsed -``` - -### HookResponseMessage - -Hook execution output: - -```ruby -hook_response.hook_id # Hook identifier -hook_response.hook_name # Hook name -hook_response.hook_event # Hook event type -hook_response.stdout # Hook stdout -hook_response.stderr # Hook stderr -hook_response.output # Combined output -hook_response.exit_code # Exit code -hook_response.outcome # "success", "error", or "cancelled" -hook_response.success? # Convenience predicate -hook_response.error? # Convenience predicate -hook_response.cancelled? # Convenience predicate -``` - -### HookStartedMessage - -Hook execution start notification: - -```ruby -hook_started.hook_id # Hook identifier -hook_started.hook_name # Hook name -hook_started.hook_event # Hook event type -``` - -### HookProgressMessage - -Progress during hook execution: - -```ruby -hook_progress.hook_id # Hook identifier -hook_progress.hook_name # Hook name -hook_progress.hook_event # Hook event type -hook_progress.stdout # Hook stdout so far -hook_progress.stderr # Hook stderr so far -hook_progress.output # Combined output -``` - -### ToolUseSummaryMessage - -Summary of tool use for collapsed display: - -```ruby -summary.summary # Human-readable summary text -summary.preceding_tool_use_ids # Tool use IDs this summarizes -``` - -### FilesPersistedEvent - -Files persisted to storage during a session: - -```ruby -persisted.files # Array of successfully persisted file paths -persisted.failed # Array of files that failed to persist -persisted.processed_at # Timestamp of persistence -``` - -### AuthStatusMessage - -Authentication status during login: - -```ruby -auth.is_authenticating # Whether auth is in progress -auth.output # Auth output messages -auth.error # Error message (if any) -``` - -### TaskNotificationMessage - -Background task completion notifications: - -```ruby -notification.task_id # Background task ID -notification.status # "completed", "failed", or "stopped" -notification.output_file # Path to task output file -notification.summary # Task summary -notification.completed? # Convenience predicate -notification.failed? # Convenience predicate -notification.stopped? # Convenience predicate -``` - -### TaskStartedMessage - -Background task (subagent) start notification: - -```ruby -task_started.task_id # Task ID -task_started.tool_use_id # Associated tool use ID (optional) -task_started.description # Task description (optional) -task_started.task_type # Task type (optional) -``` - -### TaskProgressMessage - -Background task progress reporting: - -```ruby -task_progress.task_id # Task ID -task_progress.description # What the task is doing -task_progress.usage # Token usage so far (optional) -task_progress.last_tool_name # Most recent tool used (optional) -``` - -### RateLimitEvent - -Rate limit status and utilization: - -```ruby -rate_limit.rate_limit_info # Full rate limit info hash -rate_limit.status # Rate limit status (e.g. "allowed_warning") -``` - -### PromptSuggestionMessage - -Suggested follow-up prompts (requires `prompt_suggestions: true`): - -```ruby -suggestion.suggestion # The suggested prompt text -``` - -### ElicitationCompleteMessage - -MCP elicitation completion: - -```ruby -elicitation.uuid # Message UUID -elicitation.session_id # Session identifier -elicitation.mcp_server_name # MCP server that requested elicitation -elicitation.elicitation_id # Elicitation identifier -``` - -### LocalCommandOutputMessage - -Local command output: - -```ruby -output.uuid # Message UUID -output.session_id # Session identifier -output.content # Command output content -``` - -### GenericMessage - -Wraps unknown/future message types instead of raising errors: - -```ruby -msg.type # Message type as symbol (e.g. :fancy_new) -msg[:data] # Dynamic field access via [] -msg.data # Dynamic field access via method_missing -msg.to_h # Raw data hash -``` - -## Content Blocks - -Assistant messages contain content blocks: - -```ruby -message.content.each do |block| - case block - when ClaudeAgent::TextBlock - puts block.text - when ClaudeAgent::ThinkingBlock - puts "Thinking: #{block.thinking}" - when ClaudeAgent::ToolUseBlock - puts "Tool: #{block.display_label}" - puts " File: #{block.file_path}" if block.file_path - puts " Summary: #{block.summary}" - when ClaudeAgent::ToolResultBlock - puts "Result for #{block.tool_use_id}: #{block.content}" - when ClaudeAgent::ServerToolUseBlock - puts "MCP Tool: #{block.display_label}" # "server_name/tool_name" - when ClaudeAgent::ServerToolResultBlock - puts "MCP Result from #{block.server_name}" - when ClaudeAgent::ImageContentBlock - puts "Image: #{block.media_type}, source: #{block.source_type}" - when ClaudeAgent::GenericBlock - puts "Unknown block: #{block.type}, data: #{block.to_h}" - end + c.say("Now add tests for the fix") + puts "Total cost: $#{c.total_cost}" end ``` -### ToolUseBlock Introspection - -Tool use blocks provide human-readable labels and summaries: - -```ruby -block.name # "Read", "Write", "Bash", etc. -block.input # Tool input parameters (symbol-keyed Hash) -block.file_path # File path for Read/Write/Edit/NotebookEdit (nil otherwise) -block.display_label # One-line label: "Read lib/foo.rb", "Bash: git status", "Grep: pattern" -block.summary # Detailed: "Write: /path.rb (3 lines)", "Edit: /path.rb replacing 5 line(s)" -block.summary(max: 100) # Custom max length -``` - -`ServerToolUseBlock` provides the same interface with server context in labels (e.g. `"calculator/add"`). - -### GenericBlock - -Unknown content block types are wrapped instead of returning raw Hashes: +### Streaming ```ruby -block.type # Block type as symbol -block[:field] # Dynamic field access via [] -block.field # Dynamic field access via method_missing -block.to_h # Raw data hash +ClaudeAgent.ask("Explain Ruby blocks") { |msg| print msg.text_content } ``` -## MCP Tools - -Create in-process MCP tools that Claude can use: +### Global Configuration ```ruby -# Define a tool -calculator = ClaudeAgent::MCP::Tool.new( - name: "add", - description: "Add two numbers together", - schema: { a: Float, b: Float } -) do |args| - args[:a] + args[:b] +ClaudeAgent.configure do |c| + c.model = "claude-sonnet-4-5-20250514" + c.permission_mode = "acceptEdits" + c.max_turns = 10 end -# Create a server with tools -server = ClaudeAgent::MCP::Server.new( - name: "calculator", - tools: [calculator] -) - -# Use with options (SDK MCP servers) -options = ClaudeAgent::Options.new( - mcp_servers: { - "calculator" => { type: "sdk", instance: server } - } -) - -ClaudeAgent.query( - prompt: "What is 25 + 17?", - options: options -) -``` - -> **Note:** MCP tool handlers receive **symbol-keyed** argument hashes (e.g. `args[:a]` not `args["a"]`). - -### External MCP Servers - -Configure external MCP servers: - -```ruby -options = ClaudeAgent::Options.new( - mcp_servers: { - "filesystem" => { - type: "stdio", - command: "npx", - args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"] - } - } -) -``` - -### Tool Schema - -Define schemas using Ruby types or JSON Schema: - -```ruby -# Ruby types (converted automatically) -schema: { - name: String, - age: Integer, - score: Float, - active: TrueClass, # boolean - tags: Array, - metadata: Hash -} - -# Or use JSON Schema directly -schema: { - type: "object", - properties: { - name: { type: "string", description: "User's name" }, - age: { type: "integer", minimum: 0 } - }, - required: ["name"] -} -``` - -### Tool Annotations - -Annotate tools with hints about their behavior: - -```ruby -tool = ClaudeAgent::MCP::Tool.new( - name: "search", - description: "Search the web", - schema: { query: String }, - annotations: { - readOnlyHint: true, # Tool only reads data, no side effects - destructiveHint: false, # Tool does not destroy/delete data - idempotentHint: true, # Repeated calls with same args have same effect - openWorldHint: true, # Tool interacts with external systems - title: "Web Search" # Human-readable display name - } -) { |args| "Results for #{args[:query]}" } - -# Or with the convenience method -tool = ClaudeAgent::MCP.tool( - "search", "Search the web", { query: String }, - annotations: { readOnlyHint: true, openWorldHint: true } -) { |args| "Results" } +# Or set individual fields +ClaudeAgent.model = "claude-sonnet-4-5-20250514" ``` -All annotation fields are optional hints — omit any that don't apply. - -### Tool Return Values - -Tools can return various formats: +### Permissions ```ruby -# Simple string -ClaudeAgent::MCP::Tool.new(name: "greet", description: "Greet") do |args| - "Hello, #{args[:name]}!" -end - -# Number (converted to string) -ClaudeAgent::MCP::Tool.new(name: "add", description: "Add") do |args| - args[:a] + args[:b] -end - -# Custom MCP content -ClaudeAgent::MCP::Tool.new(name: "fancy", description: "Fancy") do |args| - { content: [{ type: "text", text: "Custom response" }] } +ClaudeAgent.permissions do |p| + p.allow "Read", "Grep", "Glob" + p.deny "Bash", message: "Shell access disabled" + p.deny_all end ``` -## Hooks - -Intercept tool usage and other events with hooks: +### Hooks ```ruby -options = ClaudeAgent::Options.new( - hooks: { - "PreToolUse" => [ - ClaudeAgent::HookMatcher.new( - matcher: "Bash|Write", # Match specific tools - callbacks: [ - ->(input, context) { - puts "Tool: #{input.tool_name}" - puts "Input: #{input.tool_input}" - puts "Tool Use ID: #{input.tool_use_id}" - { continue_: true } # Allow the tool to proceed - } - ] - ) - ], - "PostToolUse" => [ - ClaudeAgent::HookMatcher.new( - matcher: /^mcp__/, # Regex matching - callbacks: [ - ->(input, context) { - puts "Result: #{input.tool_response}" - { continue_: true } - } - ] - ) - ] - } -) -``` - -> **Note:** Hook callbacks receive **symbol-keyed** input hashes (e.g. `input.tool_input[:file_path]`). - -### Hook Events - -All available hook events: - -- `PreToolUse` - Before tool execution -- `PostToolUse` - After successful tool execution -- `PostToolUseFailure` - After tool execution failure -- `Notification` - System notifications -- `UserPromptSubmit` - When user submits a prompt -- `SessionStart` - When session starts -- `SessionEnd` - When session ends -- `Stop` - When agent stops -- `SubagentStart` - When subagent starts -- `SubagentStop` - When subagent stops -- `PreCompact` - Before conversation compaction -- `PermissionRequest` - When permission is requested -- `Setup` - Initial setup or maintenance (trigger: "init" or "maintenance") -- `TeammateIdle` - When a teammate agent becomes idle -- `TaskCompleted` - When a background task completes -- `ConfigChange` - When configuration changes -- `WorktreeCreate` - When a git worktree is created -- `WorktreeRemove` - When a git worktree is removed - -### Hook Input Types - -| Event | Input Type | Key Fields | -|--------------------|---------------------------|---------------------------------------------------------| -| PreToolUse | `PreToolUseInput` | tool_name, tool_input, tool_use_id | -| PostToolUse | `PostToolUseInput` | tool_name, tool_input, tool_response, tool_use_id | -| PostToolUseFailure | `PostToolUseFailureInput` | tool_name, tool_input, error, tool_use_id, is_interrupt | -| Notification | `NotificationInput` | message, title, notification_type | -| UserPromptSubmit | `UserPromptSubmitInput` | prompt | -| SessionStart | `SessionStartInput` | source, agent_type, model | -| SessionEnd | `SessionEndInput` | reason | -| Stop | `StopInput` | stop_hook_active | -| SubagentStart | `SubagentStartInput` | agent_id, agent_type | -| SubagentStop | `SubagentStopInput` | stop_hook_active, agent_id, agent_transcript_path | -| PreCompact | `PreCompactInput` | trigger, custom_instructions | -| PermissionRequest | `PermissionRequestInput` | tool_name, tool_input, permission_suggestions | -| Setup | `SetupInput` | trigger (init/maintenance) | -| TeammateIdle | `TeammateIdleInput` | teammate_name, team_name | -| TaskCompleted | `TaskCompletedInput` | task_id, task_subject, task_description, teammate_name | -| ConfigChange | `ConfigChangeInput` | source, file_path | -| WorktreeCreate | `WorktreeCreateInput` | name | -| WorktreeRemove | `WorktreeRemoveInput` | worktree_path | - -All hook inputs inherit from `BaseHookInput` with: `hook_event_name`, `session_id`, `transcript_path`, `cwd`, `permission_mode`. - -## Permissions - -Control tool permissions programmatically: - -```ruby -options = ClaudeAgent::Options.new( - can_use_tool: ->(tool_name, tool_input, context) { - # Context includes: permission_suggestions, blocked_path, decision_reason, - # tool_use_id, agent_id, description - - # Allow all read operations - if tool_name == "Read" - ClaudeAgent::PermissionResultAllow.new - # Deny writes to sensitive paths - elsif tool_name == "Write" && tool_input[:file_path]&.include?(".env") - ClaudeAgent::PermissionResultDeny.new( - message: "Cannot modify .env files", - interrupt: true - ) - else - ClaudeAgent::PermissionResultAllow.new - end - } -) -``` - -> **Note:** `can_use_tool` callbacks receive **symbol-keyed** `tool_input` hashes. - -### Permission Results - -```ruby -# Allow with optional modifications -ClaudeAgent::PermissionResultAllow.new( - updated_input: { file_path: "/safe/path" }, # Modify tool input - updated_permissions: [...] # Update permission rules -) - -# Deny -ClaudeAgent::PermissionResultDeny.new( - message: "Operation not allowed", - interrupt: true # Stop the agent -) -``` - -### Permission Queue - -For UI-driven permission handling (e.g. TUI's, desktop apps, web UIs), use queue-based permissions instead of synchronous callbacks: - -```ruby -# Enable via Options -options = ClaudeAgent::Options.new(permission_queue: true) - -# Or via Conversation (queue mode is the default) -conversation = ClaudeAgent.conversation(permission_mode: "default") -``` - -Resolve permissions from any thread: - -```ruby -# Non-blocking poll -if request = client.pending_permission - puts "Tool: #{request.tool_name}" - puts "Input: #{request.input}" - request.allow! # or request.deny!(message: "Not allowed") +ClaudeAgent.hooks do |h| + h.before_tool_use(/Bash/) { |input, ctx| { continue_: true } } + h.after_tool_use { |input, ctx| { continue_: true } } end - -# Check if any are waiting -client.pending_permissions? - -# Blocking wait with timeout -request = client.permission_queue.pop(timeout: 30) -request&.allow! - -# Drain all pending (e.g. during shutdown) -client.permission_queue.drain!(reason: "Session ended") ``` -### Hybrid Mode - -Combine synchronous callbacks with deferred queue resolution: +### MCP Tools ```ruby -options = ClaudeAgent::Options.new( - can_use_tool: ->(tool_name, tool_input, context) { - if tool_name == "Read" - ClaudeAgent::PermissionResultAllow.new # Auto-allow reads - else - context.request.defer! # Defer everything else to the queue - end - } -) - -client = ClaudeAgent::Client.new(options: options) -client.connect - -# In another thread: resolve deferred permissions -Thread.new do - loop do - request = client.permission_queue.pop - break unless request - request.allow! # Or show UI dialog +server = ClaudeAgent::MCP::Server.new(name: "calc") do |s| + s.tool("add", "Add two numbers", { a: :number, b: :number }) do |args| + args[:a] + args[:b] end end -``` -### Permission Updates - -```ruby -update = ClaudeAgent::PermissionUpdate.new( - type: "addRules", # addRules, replaceRules, removeRules, setMode, addDirectories, removeDirectories - rules: [ - ClaudeAgent::PermissionRuleValue.new(tool_name: "Read", rule_content: "/**") - ], - behavior: "allow", - destination: "session" # userSettings, projectSettings, localSettings, session, cliArg -) -``` - -## MCP Elicitation - -Handle MCP server elicitation requests (e.g. OAuth flows, form input): - -```ruby -options = ClaudeAgent::Options.new( - on_elicitation: ->(request, signal:) { - # request contains: server_name, message, mode, url, elicitation_id, requested_schema - case request[:mode] - when "oauth" - # Handle OAuth flow - { action: "accept", content: { token: "..." } } - else - { action: "decline" } - end - } -) -``` - -Without `on_elicitation`, all elicitation requests are declined by default. - -## Error Handling - -The SDK provides specific error types: - -```ruby -begin - ClaudeAgent.query(prompt: "Hello") -rescue ClaudeAgent::CLINotFoundError - puts "Claude Code CLI not installed" -rescue ClaudeAgent::CLIVersionError => e - puts "CLI version too old: #{e.message}" - puts "Required: #{e.required_version}, Actual: #{e.actual_version}" -rescue ClaudeAgent::CLIConnectionError => e - puts "Connection failed: #{e.message}" -rescue ClaudeAgent::ProcessError => e - puts "Process error: #{e.message}, exit code: #{e.exit_code}" -rescue ClaudeAgent::TimeoutError => e - puts "Timeout: #{e.message}" -rescue ClaudeAgent::JSONDecodeError => e - puts "Invalid JSON response" -rescue ClaudeAgent::MessageParseError => e - puts "Could not parse message" -rescue ClaudeAgent::AbortError => e - puts "Operation aborted" -end +ClaudeAgent.register_mcp_server(server) ``` -## Cumulative Usage +## Documentation -The `Client` automatically tracks cumulative usage across turns: - -```ruby -ClaudeAgent::Client.open do |client| - client.send_and_receive("Hello") - client.send_and_receive("Follow up") - - usage = client.cumulative_usage - puts "Tokens: #{usage.input_tokens} in / #{usage.output_tokens} out" - puts "Cache: #{usage.cache_read_input_tokens} read / #{usage.cache_creation_input_tokens} created" - puts "Cost: $#{usage.total_cost_usd}" - puts "Turns: #{usage.num_turns}" - puts "Duration: #{usage.duration_ms}ms" -end -``` - -Also available via `Conversation#usage`: - -```ruby -ClaudeAgent::Conversation.open do |c| - c.say("Hello") - puts c.usage.to_h # => { input_tokens: 100, output_tokens: 50, ... } -end -``` - -## Client API - -For fine-grained control: - -```ruby -client = ClaudeAgent::Client.new(options: options) - -# Connect to CLI -client.connect - -# Send queries and receive TurnResults -turn = client.send_and_receive("First question") -puts turn.text - -turn = client.send_and_receive("Follow-up question") -puts turn.text - -# Or use lower-level send/receive -client.send_message("Question") -client.receive_response.each { |msg| process(msg) } - -# Or receive as TurnResult without sending -client.send_message("Question") -turn = client.receive_turn - -# Event handlers (persist across turns) -client.on_text { |text| print text } -client.on_tool_use { |tool| puts tool.display_label } -client.on_tool_result { |result, tool_use| puts "Done: #{tool_use&.name}" } -client.on_thinking { |thought| puts thought } -client.on_result { |result| puts "Cost: $#{result.total_cost_usd}" } -client.on_message { |msg| log(msg) } -# Type-based events for all message types -client.on_assistant { |msg| handle_assistant(msg) } -client.on_stream_event { |evt| handle_stream(evt) } -client.on_status { |status| show_status(status) } -client.on_tool_progress { |prog| update_spinner(prog) } - -# Control methods -client.interrupt # Cancel current operation -client.set_model("claude-opus-4-5-20251101") # Change model -client.set_permission_mode("acceptEdits") # Change permissions -client.set_max_thinking_tokens(5000) # Change thinking limit -client.stop_task("task-123") # Stop a running background task -client.apply_flag_settings({ "model" => "..." }) # Merge settings into flag layer - -# File checkpointing (requires enable_file_checkpointing: true) -result = client.rewind_files("user-message-uuid", dry_run: true) -puts "Can rewind: #{result.can_rewind}" -puts "Files changed: #{result.files_changed}" - -# Dynamic MCP server management -result = client.set_mcp_servers({ - "my-server" => { type: "stdio", command: "node", args: ["server.js"] } -}) -puts "Added: #{result.added}, Removed: #{result.removed}" - -# MCP server lifecycle -client.mcp_reconnect("my-server") # Reconnect a disconnected server -client.mcp_toggle("my-server", enabled: false) # Disable a server -client.mcp_toggle("my-server", enabled: true) # Re-enable a server -client.mcp_authenticate("my-remote-server") # OAuth authentication -client.mcp_clear_auth("my-remote-server") # Clear stored credentials - -# Permission queue access -client.pending_permission # Non-blocking poll for next request -client.pending_permissions? # Check if any requests waiting -client.permission_queue # Direct access to PermissionQueue - -# Cumulative usage tracking -client.cumulative_usage # CumulativeUsage with totals across all turns - -# Query capabilities -client.supported_commands.each { |cmd| puts "#{cmd.name}: #{cmd.description}" } -client.supported_models.each { |model| puts "#{model.value}: #{model.display_name}" } -client.supported_agents.each { |agent| puts "#{agent.name}: #{agent.description}" } -client.mcp_server_status.each { |s| puts "#{s.name}: #{s.status}" } -puts client.account_info.email - -# Disconnect -client.disconnect -``` - -## Session Discovery - -Find and inspect past Claude Code sessions from disk without spawning a CLI subprocess. - -### Session.find / Session.all - -```ruby -# Find a specific session by ID -session = ClaudeAgent::Session.find("abc-123-def") -session = ClaudeAgent::Session.find("abc-123-def", dir: "/my/project") - -# List all sessions (most recent first) -sessions = ClaudeAgent::Session.all - -# Filter by directory, limit results -sessions = ClaudeAgent::Session.where(dir: "/path/to/project", limit: 10) - -sessions.each do |s| - puts "#{s.summary} (#{s.git_branch || 'no branch'})" - puts " Session: #{s.session_id}" - puts " Modified: #{Time.at(s.last_modified / 1000)}" - puts " Prompt: #{s.first_prompt}" if s.first_prompt -end -``` - -### Reading Messages - -`Session#messages` returns a chainable, `Enumerable` relation: - -```ruby -session = ClaudeAgent::Session.find("abc-123-def") - -# All messages -session.messages.each { |m| puts "#{m.type}: #{m.uuid}" } - -# Paginated -session.messages.where(limit: 10).map(&:uuid) -session.messages.where(offset: 5, limit: 10).to_a - -# Enumerable methods work -session.messages.first -session.messages.count -session.messages.select { |m| m.type == "assistant" } -``` - -### Session Fields - -| Field | Type | Description | -|------------------|-----------------|--------------------------------------------------| -| `session_id` | `String` | UUID of the session | -| `summary` | `String` | Custom title, last summary, or first prompt | -| `last_modified` | `Integer` | Epoch milliseconds of last modification | -| `file_size` | `Integer` | Session file size in bytes | -| `custom_title` | `String\|nil` | User-set title, if any | -| `first_prompt` | `String\|nil` | First meaningful user prompt | -| `git_branch` | `String\|nil` | Git branch the session was on | -| `cwd` | `String\|nil` | Working directory of the session | - -### Functional API - -The lower-level functional API is also available: - -```ruby -# List sessions (returns SessionInfo objects) -infos = ClaudeAgent.list_sessions(dir: "/path/to/project", limit: 10) - -# Read messages directly -messages = ClaudeAgent.get_session_messages("abc-123-def", limit: 10, offset: 5) -``` - -### Resume a Past Session - -Use with `Conversation.resume` to pick up where you left off: - -```ruby -session = ClaudeAgent::Session.where(dir: Dir.pwd, limit: 5).first - -conversation = ClaudeAgent.resume_conversation(session.session_id) -turn = conversation.say("Continue where we left off") -conversation.close -``` - -## V2 Session API (Unstable) - -> **Warning**: This API is unstable and may change without notice. - -The V2 Session API provides a simpler interface for multi-turn conversations, matching the TypeScript SDK's `SDKSession` interface. - -### Create a Session - -```ruby -# Create a new session -session = ClaudeAgent.unstable_v2_create_session( - model: "claude-sonnet-4-5-20250514", - permission_mode: "acceptEdits" -) - -# Send a message -session.send("Hello, Claude!") - -# Stream responses -session.stream.each do |msg| - case msg - when ClaudeAgent::AssistantMessage - puts msg.text - when ClaudeAgent::ResultMessage - puts "Done! Cost: $#{msg.total_cost_usd}" - end -end - -# Continue the conversation -session.send("Tell me more") -session.stream.each { |msg| puts msg.text if msg.is_a?(ClaudeAgent::AssistantMessage) } - -# Close when done -session.close -``` - -### Resume a Session - -```ruby -# Resume an existing session by ID -session = ClaudeAgent.unstable_v2_resume_session( - "session-abc123", - model: "claude-sonnet-4-5-20250514" -) - -session.send("What were we discussing?") -session.stream.each { |msg| puts msg.text if msg.is_a?(ClaudeAgent::AssistantMessage) } -session.close -``` - -### One-Shot Prompt - -```ruby -# Simple one-shot prompt (auto-closes session) -result = ClaudeAgent.unstable_v2_prompt( - "What is 2 + 2?", - model: "claude-sonnet-4-5-20250514" -) - -puts "Success: #{result.success?}" -puts "Cost: $#{result.total_cost_usd}" -``` - -### SessionOptions - -The V2 API uses a simplified options type: - -```ruby -options = ClaudeAgent::SessionOptions.new( - model: "claude-sonnet-4-5-20250514", # Required - permission_mode: "acceptEdits", # Optional - allowed_tools: ["Read", "Grep"], # Optional - disallowed_tools: ["Write"], # Optional - can_use_tool: ->(name, input, ctx) { ... }, # Optional - hooks: { "PreToolUse" => [...] }, # Optional - env: { "MY_VAR" => "value" }, # Optional - path_to_claude_code_executable: "/custom/path" # Optional -) - -session = ClaudeAgent.unstable_v2_create_session(options) -``` - -## Types Reference - -### Return Types - -| Type | Purpose | -|--------------------------|----------------------------------------------------------------------------------| -| `TurnResult` | Complete agent turn with text, tools, usage, and status accessors | -| `ToolActivity` | Tool use/result pair with turn index and timing (immutable, post-turn) | -| `LiveToolActivity` | Mutable real-time tool status (running/done/error) with elapsed time | -| `ToolActivityTracker` | Enumerable collection of `LiveToolActivity` with auto-wiring and `on_change` | -| `CumulativeUsage` | Running totals of tokens, cost, turns, and duration | -| `PermissionRequest` | Deferred permission promise resolvable from any thread | -| `PermissionQueue` | Thread-safe queue of pending permission requests | -| `EventHandler` | Typed event callback registry | -| `SlashCommand` | Available slash commands (name, description, argument_hint) | -| `ModelInfo` | Available models (value, display_name, description) | -| `AgentInfo` | Available agents (name, description, model) | -| `McpServerStatus` | MCP server status (name, status, server_info) | -| `AccountInfo` | Account information (email, organization, subscription_type) | -| `ModelUsage` | Per-model usage stats (input_tokens, output_tokens, cost_usd) | -| `McpSetServersResult` | Result of set_mcp_servers (added, removed, errors) | -| `RewindFilesResult` | Result of rewind_files (can_rewind, error, files_changed, insertions, deletions) | -| `Session` | Session finder with `.find`, `.all`, `#messages` (wraps SessionInfo) | -| `SessionMessageRelation` | Chainable, Enumerable query object for session messages | -| `SessionInfo` | Session metadata from `list_sessions` (session_id, summary, git_branch, cwd) | -| `SessionMessage` | Message from a session transcript (type, uuid, session_id, message) | -| `SDKPermissionDenial` | Permission denial info (tool_name, tool_use_id, tool_input) | - -## Logging - -The SDK includes optional logging with zero overhead when disabled. All log output is silent by default. - -### Quick Debug - -```ruby -# Enable debug logging to stderr -ClaudeAgent.debug! - -# Or to a file -ClaudeAgent.debug!(output: File.open("claude_agent.log", "a")) -``` - -### Custom Logger - -Set any `Logger`-compatible instance at the module level: - -```ruby -ClaudeAgent.logger = Logger.new($stderr, level: :info) -``` - -### Per-Query Logger - -Override the module-level logger for a specific query or client: - -```ruby -my_logger = Logger.new("query.log", level: :debug) - -ClaudeAgent.query(prompt: "Hello", options: ClaudeAgent::Options.new(logger: my_logger)) - -# Or with Client -client = ClaudeAgent::Client.new(options: ClaudeAgent::Options.new(logger: my_logger)) -``` - -### Log Output - -When enabled, the SDK logs events across transport, protocol, parsing, MCP, and query layers: - -``` -[ClaudeAgent] [12:00:00.123] INFO -- transport: Process spawned (pid=12345) -[ClaudeAgent] [12:00:00.456] DEBUG -- protocol: Sending control request: initialize (req_1_abc) -[ClaudeAgent] [12:00:01.789] INFO -- protocol: Permission decision for Bash: allow -[ClaudeAgent] [12:00:02.100] INFO -- query: Query complete (3.45s, cost=$0.012) -``` - -### Log Levels - -| Level | What's Logged | -|-------|----------------------------------------------------------------------------------------------------------------| -| ERROR | Control request failures, unknown message types | -| WARN | Force kills, JSON parse errors during buffering, unknown MCP tools | -| INFO | Process spawn/close, protocol start/stop, permission decisions, tool calls, query start/completion with timing | -| DEBUG | Full commands, message types received, control request/response routing, reader thread lifecycle | - -## Environment Variables - -The SDK sets these automatically: - -- `CLAUDE_CODE_ENTRYPOINT=sdk-rb` -- `CLAUDE_AGENT_SDK_VERSION=` - -Enable debug logging via environment variable: - -```bash -export CLAUDE_AGENT_DEBUG=1 -``` - -Skip version checking (for development): - -```bash -export CLAUDE_AGENT_SDK_SKIP_VERSION_CHECK=true -``` +| Guide | Description | +|--------------------------------------------|----------------------------------------------------------| +| [Getting Started](docs/getting-started.md) | Installation, first queries, multi-turn basics | +| [Configuration](docs/configuration.md) | Global config, Options, sandbox, agents, env vars | +| [Conversations](docs/conversations.md) | Multi-turn API, TurnResult, callbacks, tool tracking | +| [Queries](docs/queries.md) | One-shot interfaces: ask, query_turn, query | +| [Permissions](docs/permissions.md) | PermissionPolicy DSL, can_use_tool, queue mode | +| [Hooks](docs/hooks.md) | HookRegistry DSL, hook events, input types | +| [MCP Tools](docs/mcp.md) | In-process tools, servers, schemas, elicitation | +| [Events](docs/events.md) | EventHandler, typed callbacks, event layers | +| [Messages](docs/messages.md) | All 22 message types, 8 content blocks, pattern matching | +| [Sessions](docs/sessions.md) | Session discovery, mutations, forking, resume | +| [Client](docs/client.md) | Low-level bidirectional API (advanced) | +| [Errors](docs/errors.md) | Error hierarchy and handling patterns | +| [Logging](docs/logging.md) | Debug logging, custom loggers, log levels | +| [Architecture](docs/architecture.md) | Internal design, data flow, module map | ## Development ```bash -# Install dependencies -bin/setup - -# Run unit tests -bundle exec rake test - -# Run integration tests (requires Claude Code CLI v2.0.0+) -bundle exec rake test_integration - -# Run all tests -bundle exec rake test_all - -# Validate RBS type signatures -bundle exec rake rbs - -# Linting -bundle exec rubocop - -# Interactive console -bin/console - -# Binstubs for convenience -bin/test # Unit tests only -bin/test-integration # Integration tests -bin/test-all # All tests -bin/rbs-validate # Validate RBS signatures -bin/release 1.2.0 # Release a new version -``` - -## Architecture - -``` -ClaudeAgent.conversation() / ClaudeAgent::Conversation - │ - │ Manages lifecycle, callbacks, turn history, tool activity - │ - ▼ -ClaudeAgent::Client - │ - │ Event handlers, cumulative usage, permission queue - │ - ▼ -┌──────────────────────────┐ -│ Control Protocol │ Request/response routing -│ - Hooks │ Permission callbacks -│ - MCP bridging │ Tool interception -└──────────┬───────────────┘ - │ - ▼ -┌──────────────────────────┐ -│ Subprocess Transport │ JSON Lines protocol -│ - stdin/stdout │ Process management -│ - stderr handling │ -└──────────┬───────────────┘ - │ - ▼ - Claude Code CLI +bin/setup # Install dependencies +bundle exec rake # Tests + RBS + RuboCop +bundle exec rake test # Unit tests +bundle exec rake test_integration # Integration tests (requires CLI v2.0.0+) +bundle exec rubocop # Lint +bin/console # IRB with gem loaded ``` ## License diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..92803dd --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,339 @@ +# Architecture + +Internal architecture of the ClaudeAgent Ruby SDK. + +--- + +## Layer Diagram + +``` + ClaudeAgent.ask / .chat / .query + | + Configuration (Stripe-style global defaults, Forwardable delegators) + | + Options (validation, CLI arg serialization, env vars) + | + Conversation / Client (lifecycle, turns, event dispatch) + | + EventHandler / TurnResult / CumulativeUsage + | + ControlProtocol (request/response routing, hooks, MCP, permissions) + Primitives | Lifecycle | Messaging | Commands | RequestHandling + | + Transport::Subprocess (JSON Lines framing, stdin/stdout, process mgmt) + | + Claude Code CLI (spawned as subprocess) +``` + +--- + +## Module Responsibilities + +### Entry Points + +| Module | Role | +|--------------------------|-------------------------------------------------------------------------------------------------------| +| `ClaudeAgent.ask` | One-shot query, returns `TurnResult`. Merges global config, builds `EventHandler` from `on_*` kwargs. | +| `ClaudeAgent.chat` | Multi-turn conversation. Block form auto-cleans; no block returns `Conversation`. | +| `ClaudeAgent.query` | Low-level streaming enumerator. Returns `Enumerator`. | +| `ClaudeAgent.query_turn` | Like `query` but accumulates into `TurnResult` with optional `EventHandler`. | + +### Configuration Layer + +| Module | Role | +|-----------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `Configuration` | Stripe-style global defaults. Holds all configurable fields (Tier 1/2/3), global `PermissionPolicy`, `HookRegistry`, and MCP server registrations. `to_options(**overrides)` merges config + per-request kwargs into an `Options` instance. | +| `Options` | All configurable attributes with validation and CLI arg serialization. Includes `Serializer` mixin for `to_cli_args` and `to_env`. Auto-compiles `PermissionPolicy` to lambda, `HookRegistry` to hash. Auto-sets `permission_prompt_tool_name = "stdio"` when `can_use_tool` or `permission_queue` is present. | + +### Conversation Layer + +| Module | Role | +|----------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `Conversation` | High-level lifecycle manager. Wraps `Client` with auto-connect on first `say`, multi-turn history, tool activity timeline with timestamps, and cumulative cost tracking. Partitions kwargs into callbacks / conversation keys / options keys. Supports `open` (block), `resume` (session ID), and permission mode mapping (`:queue`, `:accept_edits`, policy, callable). | +| `Client` | Bidirectional connection to CLI. Composes `Transport`, `ControlProtocol`, `EventHandler`, `CumulativeUsage`, and `PermissionQueue`. Provides `send_message`, `receive_turn`, `send_and_receive`, `stream_input`, `interrupt`, and `abort!`. Includes `Commands` mixin for control operations (permission mode, model changes, file rewind, MCP server management). | + +### Event & Accumulation Layer + +| Module | Role | +|-------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `EventHandler` | Three-layer event dispatch: (1) `:message` catch-all, (2) type-based (`:assistant`, `:stream_event`, `:status`, etc.), (3) decomposed (`:text`, `:thinking`, `:tool_use`, `:tool_result`). Pairs tool results with their originating tool use blocks. Supports `EventHandler.define` DSL and method chaining. | +| `TurnResult` | Message accumulator for a single agent turn. Convenience accessors: `text`, `thinking`, `tool_uses`, `tool_results`, `tool_executions`, `cost`, `session_id`, `usage`, `model`, `structured_output`, `permission_denials`. Accumulates streaming text deltas as fallback for aborted turns. | +| `CumulativeUsage` | Thread-safe (Mutex) token and cost tracker across turns. Sums `input_tokens`, `output_tokens`, cache tokens. Takes session-cumulative `total_cost_usd` and `num_turns` from the most recent `ResultMessage`. | + +### Protocol Layer + +| Module | Role | +|-------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `ControlProtocol` | Core protocol handler. Composed of five submodules (below). Manages shared state: transport, parser, request counter, pending requests/results, threading primitives (Mutex, ConditionVariable, Queue), abort signal. | +| `Primitives` | Low-level read/write helpers. `write_message` serializes and sends JSON. Request/response ID generation. Stateless except for shared counters and pending-request maps. | +| `Lifecycle` | Connection lifecycle: `start` (connect transport, spawn reader thread, send initialize), `stop` (end input, join reader, close transport), `abort!` (cancel pending requests, drain permission queue, terminate transport). Background `reader_loop` routes `control_request`, `control_response`, and SDK messages to appropriate handlers or the message queue. | +| `Messaging` | Consumer-facing message delivery: `each_message`, `receive_response`, `send_user_message`, `stream_input`, `stream_conversation`. Reads from the internal `Queue`, parses via `MessageParser`, checks abort signal. | +| `Commands` | Control commands sent to CLI: `change_permission_mode`, `change_model`, `rewind_files`, `mcp_server_status`, `set_mcp_servers`, `interrupt`. Each sends a `control_request` and waits for the response. | +| `RequestHandling` | Handles incoming control requests from CLI: `can_use_tool` (three modes: synchronous callback, queue-based, default allow), `hook_callback`, `mcp_message` (routes to SDK MCP server instances), `elicitation`. Normalizes Ruby field names to CLI camelCase keys. | + +### Transport Layer + +| Module | Role | +|-------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `Transport::Base` | Abstract base class defining the transport interface. | +| `Transport::Subprocess` | Spawns Claude Code CLI via `Open3.popen3` or custom spawn function. Manages stdin/stdout/stderr streams. JSON Lines framing with partial-JSON buffering. Version checking against minimum CLI version. Supports graceful termination (SIGTERM) and force kill (SIGKILL). Custom spawn support via `SpawnOptions` / `SpawnedProcess` for non-standard process management. | + +### Parsing Layer + +| Module | Role | +|-----------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `MessageParser` | Registry-based router. Maps raw JSON hashes (string keys, camelCase) to typed message objects. `deep_transform_keys` normalizes to snake_case symbols. Dispatches by `type` (top-level) or `type:subtype` (system messages). Unknown types wrapped in `GenericMessage`. | + +### Permission System + +| Module | Role | +|---------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `PermissionPolicy` | Declarative DSL for permission rules: `allow`, `deny`, `allow_matching`, `deny_matching`, `allow_all`, `deny_all`, `ask` (custom fallback). Compiles to a `can_use_tool` lambda. Rules evaluated in order; first match wins. | +| `PermissionQueue` | Thread-safe `Queue` wrapper for deferred permission requests. Non-blocking `poll`, blocking `pop(timeout:)`, and `drain!` for abort cleanup. | +| `PermissionRequest` | Deferred permission request resolved from any thread. `allow!` / `deny!` unblock the reader thread via Mutex + ConditionVariable. Supports hybrid mode: callback can call `context.request.defer!` to enqueue instead of returning synchronously. | + +### Hook System + +| Module | Role | +|----------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `HookRegistry` | Ruby-friendly DSL mapping idiomatic method names (`before_tool_use`, `after_tool_use`, `on_session_start`, etc.) to CLI event names (`PreToolUse`, `PostToolUse`, `SessionStart`, etc.). Compiles to the `Hash{String => Array}` format consumed by `Options#hooks`. Supports regex/string tool matchers and additive merge. | + +### MCP Layer + +| Module | Role | +|---------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `MCP::Server` | In-process MCP server. Handles JSON-RPC messages: `initialize`, `tools/list`, `tools/call`. Registered via `Options#mcp_servers` with `type: "sdk"`. Block DSL for inline tool definition. | +| `MCP::Tool` | Single tool definition with name, description, JSON Schema (auto-normalized from Ruby types/symbols), optional annotations, and handler block. Formats results as MCP content blocks. | + +### Session Layer + +| Module | Role | +|--------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `Session` | Rails-like finder with Stripe-style resource methods. `find` / `retrieve` / `all` / `where` class methods. Instance methods: `messages` (returns `SessionMessageRelation`), `rename`, `tag_session`, `fork`, `reload`, `resume`. | +| `SessionMessageRelation` | Chainable Enumerable query object. Lazy evaluation with `where(limit:, offset:)`. Wraps `GetSessionMessages`. | +| `ListSessions` | Reads session metadata from disk without spawning CLI. Returns `SessionInfo` sorted by last modified. Supports directory scoping and git worktree inclusion. | +| `GetSessionMessages` | Reads JSONL session transcript, reconstructs main conversation thread, returns `SessionMessage` array with pagination. | +| `GetSessionInfo` | Targeted single-session lookup by UUID. | +| `ForkSession` | Creates a new session file with remapped UUIDs, optional truncation point. | +| `SessionMutations` | Appends `custom-title` and `tag` entries to session files. | +| `SessionPaths` | Shared infrastructure for resolving session file paths across projects and worktrees. | + +### Abort & Signal + +| Module | Role | +|-------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `AbortController` | JavaScript-style abort controller. Owns an `AbortSignal`. `abort(reason)` triggers the signal; `reset!` clears it for reuse. | +| `AbortSignal` | Thread-safe (Mutex + ConditionVariable) signal. `aborted?`, `reason`, `on_abort` callbacks, `wait(timeout:)`, `check!` (raises `AbortError`). Used by `ControlProtocol` (reader loop check, queue push), `Conversation` (auto-reset per turn). | + +### Tool Activity Tracking + +| Module | Role | +|-----------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `ToolActivity` | Immutable (`Data.define`) record of a completed tool execution. Pairs `ToolUseBlock` + `ToolResultBlock` with turn index and wall-clock timestamps. | +| `LiveToolActivity` | Mutable wrapper for real-time status tracking. States: `:running`, `:done`, `:error`. Updated by progress messages. Suitable for live UIs. | +| `ToolActivityTracker` | Enumerable collection with auto-wiring. Attaches to `EventHandler` or `Client`. Callbacks: `on_start`, `on_complete`, `on_progress`, `on_change`. Query methods: `running`, `done`, `errored`, `find_by_id`. | + +--- + +## Data Flow + +### One-Shot Query (`ask`) + +``` +User calls ClaudeAgent.ask(prompt, **kwargs) + | + +-- extract_callbacks separates on_* from config overrides + +-- Configuration.to_options merges global defaults + overrides --> Options + +-- build_events creates EventHandler from callbacks + | + +-- query_turn(prompt, options, events) + | + +-- ClaudeAgent.query(prompt, options) returns Enumerator + | | + | +-- Transport::Subprocess.new(options) + | +-- ControlProtocol.new(transport, options) + | +-- protocol.start(streaming: true) + | | +-- transport.connect --> spawn CLI subprocess + | | +-- reader_loop starts in background Thread + | | +-- send_initialize --> handshake with CLI + | +-- protocol.send_user_message(prompt) + | | +-- write JSON to stdin + | +-- protocol.each_message yields parsed messages + | +-- reader_loop reads JSON Lines from stdout + | +-- routes control_request to RequestHandling + | +-- routes control_response to pending request + | +-- queues SDK messages for consumer + | +-- consumer pops from Queue + | +-- MessageParser.parse(raw) --> typed message + | +-- yield message to Enumerator + | + +-- TurnResult << message (accumulates) + +-- EventHandler.handle(message) (dispatches events) + +-- yield message to caller block (if given) + +-- return TurnResult +``` + +### Multi-Turn Conversation (`chat`) + +``` +User calls ClaudeAgent.chat(**kwargs) + | + +-- merge_config_into_kwargs applies global defaults + +-- Conversation.new(**merged) + | + +-- partition_kwargs --> callbacks / conversation_kwargs / options_kwargs + +-- build_options (compiles PermissionPolicy, HookRegistry, permission mode) + +-- Client.new(options) + +-- register_callbacks on Client's EventHandler + +-- register_timing_hooks for tool activity timestamps + | + +-- conversation.say(prompt) + | + +-- ensure_connected! (auto-connects on first call) + | +-- Client.connect + | +-- ControlProtocol.start(streaming: true) + +-- Client.send_and_receive(prompt) + | +-- send_message --> protocol.send_user_message + | +-- receive_turn + | +-- TurnResult.new + | +-- receive_response yields messages + | +-- TurnResult << message + | +-- EventHandler.handle(message) + | +-- CumulativeUsage.track(message) + | +-- stops on ResultMessage + +-- build_tool_activities (timestamps from hooks) + +-- return TurnResult +``` + +### Permission Request Flow + +``` +CLI sends control_request { subtype: "can_use_tool" } + | + +-- reader_loop receives raw message + +-- handle_control_request dispatches to handle_can_use_tool + | + +-- Mode 1: Synchronous callback + | +-- options.can_use_tool.call(name, input, context) + | +-- callback returns PermissionResultAllow or PermissionResultDeny + | +-- (or callback calls context.request.defer! to switch to queue mode) + | + +-- Mode 2: Queue-based + | +-- PermissionRequest created with Mutex + ConditionVariable + | +-- pushed to PermissionQueue + | +-- reader thread blocks on perm_request.wait(timeout:) + | +-- main thread polls client.pending_permission + | +-- main thread calls request.allow! or request.deny! + | +-- ConditionVariable.broadcast unblocks reader thread + | + +-- Mode 3: Default allow (no callback, no queue) + | + +-- normalize_permission_result --> Hash + +-- send_control_response back to CLI +``` + +--- + +## Immutable Types + +All message types and content blocks use `Data.define`, frozen at construction: + +**Messages**: `UserMessage`, `UserMessageReplay`, `AssistantMessage`, `SystemMessage`, `ResultMessage`, `StreamEvent`, `CompactBoundaryMessage`, `StatusMessage`, `ToolProgressMessage`, `HookResponseMessage`, `AuthStatusMessage`, `TaskNotificationMessage`, `HookStartedMessage`, `HookProgressMessage`, `ToolUseSummaryMessage`, `FilesPersistedEvent`, `TaskStartedMessage`, `TaskProgressMessage`, `RateLimitEvent`, `PromptSuggestionMessage`, `ElicitationCompleteMessage`, `LocalCommandOutputMessage`, `GenericMessage` + +**Content Blocks**: `TextBlock`, `ThinkingBlock`, `ToolUseBlock`, `ToolResultBlock`, `ServerToolUseBlock`, `ServerToolResultBlock`, `ImageContentBlock`, `GenericBlock` + +**Data Types**: `SessionInfo`, `SessionMessage`, `ForkSessionResult`, `ToolActivity`, `TaskUsage`, `SDKPermissionDenial`, `RewindFilesResult`, `ToolsPreset`, `SlashCommand`, `McpServerStatus`, `McpSetServersResult`, `PermissionResultAllow`, `PermissionResultDeny` + +--- + +## Thread Safety + +| Component | Mechanism | Purpose | +|---------------------------------------------|-------------------------------|----------------------------------------------------------------------------------------------------| +| `PermissionRequest` | `Mutex` + `ConditionVariable` | Reader thread blocks on `wait`; main thread resolves via `allow!` / `deny!` | +| `PermissionQueue` | `Queue` (thread-safe stdlib) | Bridges reader thread and consumer thread for permission requests | +| `AbortSignal` | `Mutex` + `ConditionVariable` | Multiple consumers check `aborted?`; `on_abort` callbacks fire once; `wait` blocks until triggered | +| `CumulativeUsage` | `Mutex` | All reads and writes synchronized | +| `ControlProtocol` | `Mutex` + `ConditionVariable` | Shared state for pending requests/results; reader thread signals consumer | +| `Transport::Subprocess` | `Mutex` | Protects stdin writes and stream close operations | +| `ControlProtocol.reader_loop` | Background `Thread` | Reads transport, routes control messages, queues SDK messages | +| `Transport::Subprocess.start_stderr_reader` | Background `Thread` | Drains stderr to prevent pipe buffer fill; forwards to `stderr_callback` | + +--- + +## Types Reference + +### Core Return Types + +| Type | Description | +|-------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `TurnResult` | Accumulator for a single turn. Accessors: `text`, `thinking`, `tool_uses`, `tool_results`, `tool_executions`, `cost`, `session_id`, `usage`, `model`, `stop_reason`, `structured_output`, `permission_denials` | +| `CumulativeUsage` | Cross-turn token/cost tracker. Fields: `input_tokens`, `output_tokens`, `cache_read_input_tokens`, `cache_creation_input_tokens`, `total_cost_usd`, `num_turns`, `duration_ms`, `duration_api_ms` | +| `EventHandler` | Event dispatcher. Events: `message`, `user`, `assistant`, `system`, `result`, `stream_event`, `status`, `tool_progress`, `text`, `thinking`, `tool_use`, `tool_result`, and more | + +### Tool Activity + +| Type | Description | +|-----------------------|-----------------------------------------------------------------------------------------------| +| `ToolActivity` | Immutable. Post-turn record of a tool execution with timestamps and turn index | +| `LiveToolActivity` | Mutable. Real-time status (`:running`, `:done`, `:error`) with elapsed time | +| `ToolActivityTracker` | Enumerable collection with `on_start` / `on_complete` / `on_progress` / `on_change` callbacks | + +### Permissions + +| Type | Description | +|-------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `PermissionRequest` | Deferred request with `allow!` / `deny!` / `defer!`. Thread-safe resolution | +| `PermissionQueue` | Thread-safe queue with `poll` (non-blocking) and `pop` (blocking) | +| `PermissionPolicy` | Declarative DSL. Compiles to `can_use_tool` lambda | +| `PermissionResultAllow` | Allow response with optional `updated_input` and `updated_permissions` | +| `PermissionResultDeny` | Deny response with `message` and `interrupt` flag | +| `ToolPermissionContext` | Context passed to `can_use_tool`: `permission_suggestions`, `blocked_path`, `decision_reason`, `tool_use_id`, `agent_id`, `description`, `signal`, `request` | + +### Sessions + +| Type | Description | +|--------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------| +| `Session` | Rich finder object. Class methods: `find`, `retrieve`, `all`, `where`. Instance: `messages`, `rename`, `fork`, `resume`, `reload` | +| `SessionInfo` | Immutable metadata: `session_id`, `summary`, `last_modified`, `file_size`, `custom_title`, `first_prompt`, `git_branch`, `cwd`, `tag`, `created_at` | +| `SessionMessage` | Immutable transcript entry: `type`, `uuid`, `session_id`, `message`, `parent_tool_use_id` | +| `SessionMessageRelation` | Chainable Enumerable with `where(limit:, offset:)` | +| `ForkSessionResult` | Fork result with new `session_id` | + +### MCP + +| Type | Description | +|-----------------------|-----------------------------------------------------------------------------------------------| +| `MCP::Server` | In-process MCP server hosting `MCP::Tool` instances | +| `MCP::Tool` | Tool definition with schema normalization and handler block | +| `McpServerStatus` | Status of an MCP server: `name`, `status`, `server_info`, `error`, `config`, `scope`, `tools` | +| `McpSetServersResult` | Result of dynamic server management: `added`, `removed`, `errors` | + +### Abort + +| Type | Description | +|-------------------|-------------------------------------------------------------------------------------------| +| `AbortController` | Owns an `AbortSignal`. Methods: `abort(reason)`, `reset!` | +| `AbortSignal` | Thread-safe signal. Methods: `aborted?`, `reason`, `on_abort`, `wait`, `check!`, `reset!` | + +--- + +## Development + +```bash +bin/setup # Install dependencies +bundle exec rake # Unit tests + RBS + RuboCop (default task) +bundle exec rake test # Unit tests only +bundle exec rake test_integration # Integration tests (requires CLI v2.0.0+) +bundle exec rake test_smoke # Smoke tests against local LLM (e.g. Ollama) +bundle exec rake test_all # All tests +bundle exec rake rbs # Validate RBS type signatures +bundle exec rubocop # Lint +bin/console # IRB with gem loaded +``` + +**Binstubs**: `bin/test`, `bin/test-integration`, `bin/test-all`, `bin/test-smoke`, `bin/rbs-validate` + +**Test structure**: Unit tests in `test/claude_agent/` (no CLI required), integration tests in `test/integration/` (require `INTEGRATION=true`), smoke tests in `test/smoke/` (require Ollama + `SMOKE=true`). Support files and mocks in `test/support/`. + +**Single file**: `bundle exec ruby -Itest test/claude_agent/test_foo.rb` diff --git a/docs/client.md b/docs/client.md new file mode 100644 index 0000000..da112e4 --- /dev/null +++ b/docs/client.md @@ -0,0 +1,526 @@ +# Client + +The `Client` class provides fine-grained, bidirectional control over a persistent Claude Code CLI connection. It supports multi-turn conversations, streaming, interrupts, dynamic model and permission changes, file checkpointing, and MCP server management. + +> **Most users should prefer the higher-level APIs.** `ClaudeAgent.ask` handles one-shot queries with global configuration. `ClaudeAgent.chat` and `Conversation` manage multi-turn state, auto-connection, event callbacks, tool activity tracking, and cleanup. Reach for `Client` only when you need direct control over connection lifecycle, split send/receive, or protocol-level commands that `Conversation` does not expose. + +## Overview + +`Client` wraps a `ControlProtocol` over a `Transport::Subprocess`, giving you: + +- Persistent connection with explicit connect/disconnect +- Multiple conversation turns over a single CLI process +- Streaming message delivery via enumerators or blocks +- Typed event handlers that persist across turns +- Control commands: model switching, permission changes, file rewind, MCP management +- Abort/interrupt support with partial result recovery +- Cumulative usage tracking across all turns +- Asynchronous permission queue for UI-driven approval flows + +## Creating and Connecting + +### Constructor + +```ruby +client = ClaudeAgent::Client.new( + options: ClaudeAgent::Options.new(model: "opus", max_turns: 10), + transport: nil # defaults to Transport::Subprocess +) +``` + +Both parameters are optional. When omitted, `options` defaults to a bare `Options.new` and `transport` defaults to a `Transport::Subprocess` built from those options. + +### Connecting + +Call `connect` to start the CLI subprocess and perform the protocol handshake. An optional `prompt` sends an initial message immediately after connection. + +```ruby +client.connect +client.connect(prompt: "You are a helpful coding assistant") +``` + +Raises `CLIConnectionError` if the client is already connected. + +### Block form + +`Client.open` connects, yields the client, and guarantees disconnection: + +```ruby +ClaudeAgent::Client.open( + options: ClaudeAgent::Options.new(model: "opus"), + prompt: "Hello" +) do |client| + client.send_message("Fix the bug") + client.receive_response.each { |msg| puts msg } +end +# client is automatically disconnected here +``` + +## Sending and Receiving + +### send_and_receive + +The primary method for a complete turn. Sends a message and blocks until a `ResultMessage` arrives, accumulating everything into a `TurnResult`. Dispatches registered event handlers as messages flow through. + +```ruby +turn = client.send_and_receive("Fix the bug in auth.rb") +puts turn.text +puts "Cost: $#{turn.cost}" +puts "Tools used: #{turn.tool_uses.map(&:display_label).join(", ")}" +``` + +With a streaming block: + +```ruby +turn = client.send_and_receive("Fix the bug") do |msg| + case msg + when ClaudeAgent::AssistantMessage + print msg.text + end +end +``` + +Parameters: + +| Parameter | Type | Default | Description | +|---------------|-------------------|-------------|--------------------------------------| +| `content` | `String`, `Array` | required | Message content | +| `session_id:` | `String` | `"default"` | Session ID for multi-session support | +| `uuid:` | `String`, `nil` | `nil` | Message UUID for file checkpointing | + +Returns a `TurnResult`. See the [Queries](queries.md) doc for `TurnResult` accessors. + +### Split send/receive + +For finer control, separate the send and receive steps. + +**send_message** queues a message to the CLI without waiting for a response: + +```ruby +client.send_message("Hello") +client.send_message("Follow up", session_id: "session-2", uuid: "msg-uuid-1") +``` + +**query** is an alias for `send_message`: + +```ruby +client.query("Hello") +``` + +**receive_turn** blocks until a `ResultMessage` arrives, returning a `TurnResult`. It dispatches event handlers and resets turn-level handler state afterward. + +```ruby +client.send_message("Fix the bug") +turn = client.receive_turn +puts turn.text +``` + +With a block: + +```ruby +turn = client.receive_turn do |msg| + print msg.text if msg.is_a?(ClaudeAgent::AssistantMessage) +end +``` + +**receive_response** returns an `Enumerator` of messages for the current turn (until a `ResultMessage`). No event dispatch or `TurnResult` accumulation -- you process each message yourself. + +```ruby +client.send_message("Hello") +client.receive_response.each do |msg| + case msg + when ClaudeAgent::AssistantMessage + print msg.text + when ClaudeAgent::ResultMessage + puts "\nDone: #{msg.total_cost_usd}" + end +end +``` + +**receive_messages** returns an `Enumerator` over all messages until the connection closes (not just one turn): + +```ruby +client.receive_messages.each { |msg| handle(msg) } +``` + +## Event Handlers + +Register typed callbacks that fire automatically during `receive_turn` and `send_and_receive`. Handlers persist across turns -- register once, and they fire on every subsequent turn. + +### Registering handlers + +Use `on` with a symbol, or the `on_*` convenience methods: + +```ruby +client.on(:text) { |text| print text } +client.on(:tool_use) { |tool| puts "Using: #{tool.display_label}" } +client.on(:result) { |result| puts "Cost: $#{result.total_cost_usd}" } + +# Equivalent convenience methods: +client.on_text { |text| print text } +client.on_tool_use { |tool| puts "Using: #{tool.display_label}" } +client.on_result { |result| puts "Cost: $#{result.total_cost_usd}" } +``` + +### Chaining + +All registration methods return `self`, so they chain: + +```ruby +client + .on(:text) { |text| print text } + .on(:tool_use) { |tool| show_spinner(tool) } + .on(:result) { |r| puts "\nDone!" } +``` + +### Event hierarchy + +Events fire in three layers for each message: + +1. **Catch-all** -- `:message` fires for every message +2. **Type-based** -- the message's type fires (e.g., `:assistant`, `:stream_event`, `:status`) +3. **Decomposed** -- convenience events extracted from rich content types + +**Decomposed events:** + +| Event | Argument | Source | +|----------------|--------------------------------------------|--------------------------------------------------------------| +| `:text` | `String` | Text from `AssistantMessage` | +| `:thinking` | `String` | Thinking from `AssistantMessage` | +| `:tool_use` | `ToolUseBlock` or `ServerToolUseBlock` | Tool call from `AssistantMessage` | +| `:tool_result` | `ToolResultBlock`, `ToolUseBlock` or `nil` | Tool result from `UserMessage`, paired with original request | + +**Type-based events:** + +`:user`, `:assistant`, `:system`, `:result`, `:stream_event`, `:compact_boundary`, `:status`, `:tool_progress`, `:hook_response`, `:auth_status`, `:task_notification`, `:hook_started`, `:hook_progress`, `:tool_use_summary`, `:task_started`, `:task_progress`, `:rate_limit_event`, `:prompt_suggestion`, `:files_persisted`, `:elicitation_complete`, `:local_command_output` + +## Control Methods + +These methods send control commands to the running CLI process. All require an active connection. + +### set_model + +Change the model for subsequent turns: + +```ruby +client.set_model("claude-sonnet-4-5-20250514") +client.set_model(nil) # revert to default +``` + +### set_permission_mode + +Change the permission mode: + +```ruby +client.set_permission_mode("acceptEdits") +``` + +### set_max_thinking_tokens + +Set or reset the maximum thinking tokens: + +```ruby +client.set_max_thinking_tokens(10_000) +client.set_max_thinking_tokens(nil) # reset to default +``` + +### stop_task + +Stop a running background task by its ID (from `task_notification` events): + +```ruby +client.stop_task("task-123") +``` + +### apply_flag_settings + +Merge settings into the flag settings layer: + +```ruby +client.apply_flag_settings({ "model" => "claude-sonnet-4-5-20250514" }) +``` + +## File Checkpointing + +When `enable_file_checkpointing: true` is set in Options and UUIDs are passed with messages, you can rewind files to the state at a specific user message. + +### rewind_files + +```ruby +result = client.rewind_files("user-message-uuid") +result.can_rewind # => true +result.files_changed # => ["src/foo.rb", "src/bar.rb"] +result.insertions # => 10 +result.deletions # => 5 +result.error # => nil +``` + +Dry-run mode previews changes without modifying files: + +```ruby +result = client.rewind_files("user-message-uuid", dry_run: true) +``` + +Returns a `RewindFilesResult` with fields: `can_rewind`, `error`, `files_changed`, `insertions`, `deletions`. + +## Dynamic MCP Management + +Manage MCP (Model Context Protocol) servers on a live connection without restarting. + +### set_mcp_servers + +Replace the set of dynamically-added MCP servers. New servers are connected; removed servers are disconnected. + +```ruby +result = client.set_mcp_servers({ + "my-server" => { type: "stdio", command: "node", args: ["server.js"] } +}) +result.added # => ["my-server"] +result.removed # => ["old-server"] +result.errors # => {} or {"server2" => "Connection failed"} +``` + +Returns a `McpSetServersResult` with fields: `added`, `removed`, `errors`. + +### mcp_reconnect + +Reconnect to a disconnected or errored MCP server: + +```ruby +client.mcp_reconnect("my-server") +``` + +### mcp_toggle + +Enable or disable a server without removing its configuration: + +```ruby +client.mcp_toggle("my-server", enabled: false) +client.mcp_toggle("my-server", enabled: true) +``` + +### mcp_authenticate + +Initiate OAuth authentication for a remote MCP server: + +```ruby +client.mcp_authenticate("my-remote-server") +``` + +### mcp_clear_auth + +Clear stored authentication credentials for an MCP server: + +```ruby +client.mcp_clear_auth("my-remote-server") +``` + +## Query Capabilities + +Query the CLI for available resources. + +### supported_commands + +```ruby +commands = client.supported_commands +# => [#, ...] +``` + +Returns `Array`. + +### supported_models + +```ruby +models = client.supported_models +models.each { |m| puts "#{m.value}: #{m.display_name}" } +``` + +Returns `Array`. + +### supported_agents + +```ruby +agents = client.supported_agents +agents.each { |a| puts "#{a.name}: #{a.description}" } +``` + +Returns `Array`. + +### mcp_server_status + +```ruby +statuses = client.mcp_server_status +statuses.each { |s| puts "#{s.name}: #{s.status}" } +``` + +Returns `Array`. Status values: `"connected"`, `"failed"`, `"needs-auth"`, `"pending"`. + +### account_info + +```ruby +info = client.account_info +puts "#{info.email} (#{info.organization})" +``` + +Returns an `AccountInfo` with fields: `email`, `organization`, `subscription_type`, `token_source`, `api_key_source`. + +## Permission Queue + +When `permission_queue: true` is set in Options (or `can_use_tool` defers a request), the CLI routes tool permission prompts through a thread-safe queue instead of requiring synchronous callback resolution. This is useful for UI-driven applications where a separate thread or event loop handles approval dialogs. + +### pending_permission + +Non-blocking poll for the next pending request. Returns `nil` if the queue is empty. + +```ruby +if request = client.pending_permission + puts "Tool: #{request.tool_name}" + puts "Input: #{request.input}" + puts "Label: #{request.display_label}" + + request.allow! + # or: request.deny!(message: "Not allowed in production") +end +``` + +### pending_permissions? + +Check whether any requests are waiting: + +```ruby +client.pending_permissions? # => true/false +``` + +### permission_queue + +Direct access to the underlying `PermissionQueue` for blocking waits or batch draining: + +```ruby +# Blocking wait (with optional timeout) +request = client.permission_queue.pop(timeout: 30) + +# Drain all pending (used during cleanup) +client.permission_queue.drain!(reason: "Shutting down") +``` + +`PermissionRequest` methods: + +| Method | Description | +|------------------------------------------------|-----------------------------------------------------------------| +| `allow!(updated_input:, updated_permissions:)` | Allow execution, optionally modifying input or permission rules | +| `deny!(message:, interrupt:)` | Deny execution with a reason; optionally interrupt the agent | +| `tool_name` | Name of the tool requesting permission | +| `input` | Tool input hash | +| `display_label` | Human-readable label (e.g., `"Read(path: /tmp/file.txt)"`) | +| `summary(max:)` | Detailed summary, truncated to `max` characters | +| `pending?` / `resolved?` | Resolution state | +| `created_at` | Timestamp of the request | + +## Cumulative Usage + +The client tracks token usage, cost, and timing across all turns. + +```ruby +usage = client.cumulative_usage +puts "Input tokens: #{usage.input_tokens}" +puts "Output tokens: #{usage.output_tokens}" +puts "Cache read: #{usage.cache_read_input_tokens}" +puts "Cache created: #{usage.cache_creation_input_tokens}" +puts "Total cost: $#{usage.total_cost_usd}" +puts "Turns: #{usage.num_turns}" +puts "Duration: #{usage.duration_ms}ms" +puts "API duration: #{usage.duration_api_ms}ms" +``` + +Returns a `CumulativeUsage` instance. Token counts are summed across turns. Cost and turn count reflect session-cumulative values from the CLI. + +## Streaming Input + +Send multiple messages from an enumerable source. + +### Without block (send only) + +```ruby +client.stream_input(["Hello", "How are you?"]) +client.receive_response.each { |msg| puts msg } +``` + +### With block (concurrent send/receive) + +Messages are sent in a background thread while responses are yielded to the block: + +```ruby +client.stream_input(["Hello", "Follow up"], session_id: "default") do |msg| + case msg + when ClaudeAgent::AssistantMessage + puts msg.text + when ClaudeAgent::ResultMessage + puts "Done!" + end +end +``` + +## Abort and Interrupt + +### interrupt + +Send an interrupt signal to the CLI, stopping the current generation: + +```ruby +client.interrupt +``` + +### abort! + +Abort all pending operations. Triggers the abort controller (if configured), drains the permission queue, and terminates the transport. + +```ruby +client.abort! +client.abort!("User cancelled") +``` + +### AbortController + +For cross-thread cancellation, configure an `AbortController` on Options: + +```ruby +controller = ClaudeAgent::AbortController.new + +options = ClaudeAgent::Options.new(abort_controller: controller) +client = ClaudeAgent::Client.new(options: options) +client.connect + +# In another thread: +Thread.new { sleep(5); controller.abort("Timeout") } + +begin + turn = client.send_and_receive("Long running task") +rescue ClaudeAgent::AbortError => e + partial = e.partial_turn + puts partial.text # text accumulated before abort + puts partial.tool_uses # tools that ran before abort +end +``` + +The `AbortSignal` is thread-safe and supports callbacks: + +```ruby +controller.signal.on_abort { |reason| puts "Aborted: #{reason}" } +controller.signal.aborted? # => false +controller.abort("Done") +controller.signal.aborted? # => true +controller.signal.reason # => "Done" +``` + +Call `controller.reset!` to reuse the controller for another turn. `Conversation` does this automatically. + +## Disconnect + +```ruby +client.disconnect +client.connected? # => false +``` + +Disconnecting drains the permission queue (denying all pending requests with "Client disconnected"), stops the protocol, and terminates the CLI subprocess. Calling `disconnect` on an already-disconnected client is a no-op. + +`Client.open` calls `disconnect` automatically in its `ensure` block. diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..79ddbbb --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,571 @@ +# Configuration + +The ClaudeAgent Ruby SDK uses a layered configuration system inspired by the Stripe Ruby gem. Global defaults are set once at boot and apply to every request. Per-request overrides refine or replace those defaults for a single `ask`, `chat`, or `Conversation`. + +## Table of Contents + +- [Global Configuration](#global-configuration) +- [Configuration Tiers](#configuration-tiers) +- [Per-Request Overrides](#per-request-overrides) +- [Options Class Reference](#options-class-reference) +- [Tools Preset](#tools-preset) +- [Sandbox Settings](#sandbox-settings) +- [Custom Agents](#custom-agents) +- [Environment Variables](#environment-variables) + +--- + +## Global Configuration + +### Module-level setters + +Set individual defaults directly on the `ClaudeAgent` module: + +```ruby +ClaudeAgent.model = "opus" +ClaudeAgent.permission_mode = "acceptEdits" +ClaudeAgent.max_turns = 10 +ClaudeAgent.debug = true +``` + +### Block-based bulk configuration + +Use `configure` to set multiple defaults in one call: + +```ruby +ClaudeAgent.configure do |c| + c.model = "opus" + c.permission_mode = "acceptEdits" + c.max_turns = 10 + c.max_budget_usd = 1.00 + c.system_prompt = "You are a code review assistant." + c.cwd = "/path/to/project" +end +``` + +### Resetting to defaults + +```ruby +ClaudeAgent.reset_config! +``` + +This replaces the current `Configuration` with a fresh instance where all fields are `nil` (or their constructor defaults). + +### Global permissions + +Register a declarative permission policy that applies to all requests: + +```ruby +ClaudeAgent.permissions do |p| + p.allow "Read", "Grep", "Glob" + p.deny "Bash", message: "Bash not allowed" + p.deny_all +end +``` + +### Global hooks + +Register hooks that fire for all requests: + +```ruby +ClaudeAgent.hooks do |h| + h.before_tool_use(/Bash/) { |input, ctx| { continue_: true } } + h.on_session_start { |input, ctx| { continue_: true } } +end +``` + +### Global MCP servers + +Register MCP servers that are included in all requests: + +```ruby +server = ClaudeAgent::MCP::Server.new(name: "calculator") do |s| + s.tool("add", "Add numbers", { a: :number, b: :number }) do |args| + args[:a] + args[:b] + end +end + +ClaudeAgent.register_mcp_server(server) +``` + +--- + +## Configuration Tiers + +The `Configuration` class organizes fields into three tiers based on how they are typically used. + +### Tier 1 – Module-level delegators + +These fields have convenience delegators on the `ClaudeAgent` module itself (`ClaudeAgent.model = "opus"`). They represent settings that are typically set once at boot time. + +| Field | Type | Description | +|------------------------|-------------------|--------------------------------------------------------------------------| +| `model` | `String` | Model name or alias (e.g., `"opus"`, `"sonnet"`) | +| `permission_mode` | `String` | One of: `default`, `acceptEdits`, `plan`, `bypassPermissions`, `dontAsk` | +| `max_turns` | `Integer` | Maximum agentic turns per request | +| `max_budget_usd` | `Float` | Maximum cost in USD per request | +| `system_prompt` | `String`, `Hash` | Custom system prompt (replaces default) | +| `append_system_prompt` | `String` | Appended to the default system prompt | +| `cli_path` | `String` | Path to `claude` CLI binary | +| `cwd` | `String` | Working directory for CLI process | +| `sandbox` | `SandboxSettings` | Sandbox configuration (see below) | +| `debug` | `Boolean` | Enable CLI `--debug` flag | +| `effort` | `String` | Effort level: `low`, `medium`, `high`, `max` | +| `persist_session` | `Boolean` | Persist session to disk (default: `true`) | +| `fallback_model` | `String` | Fallback model when primary is unavailable | + +### Tier 2 – Per-request overrides + +These fields are commonly set as keyword arguments on `ask`, `chat`, or `Conversation.new`. They are also configurable via the global `Configuration`. + +| Field | Type | Description | +|--------------------|--------------------------------|--------------------------------------------------------------------------------------------------------------| +| `tools` | `Array`, `ToolsPreset`, `Hash` | Tools available to the model | +| `allowed_tools` | `Array` | Allowlist of tool names | +| `disallowed_tools` | `Array` | Denylist of tool names | +| `thinking` | `Hash` | Thinking config: `{ type: "adaptive" }`, `{ type: "enabled", budget_tokens: 10000 }`, `{ type: "disabled" }` | +| `output_format` | `Hash` | JSON Schema for structured output | + +### Tier 3 – Advanced + +These fields are accessible via `ClaudeAgent.configure` or by constructing `Options` directly. They cover MCP servers, hooks, environment, plugins, and internal SDK plumbing. + +| Field | Type | Description | +|-----------------------------|------------------------|-----------------------------------------------------------| +| `mcp_servers` | `Hash` | MCP server configurations | +| `hooks` | `Hash`, `HookRegistry` | Hook event handlers | +| `env` | `Hash` | Extra environment variables for CLI process | +| `extra_args` | `Hash` | Additional CLI flags (`{ "--flag" => "value" }`) | +| `agents` | `Hash` | Custom agent definitions (see below) | +| `setting_sources` | `Array` | Setting source overrides | +| `settings` | `String`, `Hash` | Inline settings or path to settings file | +| `plugins` | `Array` | Plugin directories | +| `betas` | `Array` | Beta feature flags | +| `spawn_claude_code_process` | `Proc` | Custom spawn function (Docker, SSH, etc.) | +| `agent` | `String` | Built-in agent to use (e.g., `"Explore"`) | +| `add_dirs` | `Array` | Additional directories to include | +| `max_buffer_size` | `Integer` | Max JSON buffer size (default: 1MB) | +| `stderr_callback` | `Proc` | Callback for stderr output | +| `include_partial_messages` | `Boolean` | Include partial streaming messages | +| `enable_file_checkpointing` | `Boolean` | Enable file checkpointing | +| `prompt_suggestions` | `Boolean` | Enable prompt suggestions | +| `strict_mcp_config` | `Boolean` | Strict MCP config validation | +| `tool_config` | `Hash` | Per-tool configuration | +| `agent_progress_summaries` | any | Agent progress summary configuration | +| `max_thinking_tokens` | `Integer` | Max thinking tokens (standalone, without `thinking` hash) | +| `debug_file` | `String` | Path to debug log file | + +--- + +## Per-Request Overrides + +When you call `ask`, `chat`, or create a `Conversation`, keyword arguments merge with the global configuration. Per-request values always win over config defaults. + +### With `ask` + +```ruby +# Uses global config defaults +turn = ClaudeAgent.ask("What is 2+2?") + +# Per-request overrides: model and max_turns override config +turn = ClaudeAgent.ask("Fix the bug", + model: "opus", + max_turns: 5, + system_prompt: "You are a debugging expert." +) + +# Event callbacks are also passed as kwargs +turn = ClaudeAgent.ask("Explain Ruby", + on_stream: ->(text) { print text }, + on_tool_use: ->(tool) { puts "Tool: #{tool.name}" } +) +``` + +### With `chat` + +```ruby +# Global config applies to the conversation +ClaudeAgent.chat(model: "opus", max_turns: 10) do |c| + c.say("Hello") + c.say("Now add tests") +end + +# Without block -- caller manages lifecycle +c = ClaudeAgent.chat(permission_mode: "acceptEdits") +c.say("Refactor this module") +c.close +``` + +### With `Conversation.new` + +```ruby +conversation = ClaudeAgent::Conversation.new( + model: "opus", + max_turns: 10, + on_stream: ->(text) { print text }, + on_permission: :accept_edits +) +``` + +### Merge behavior + +The merge follows a simple rule: **per-request wins**. + +```ruby +ClaudeAgent.model = "sonnet" +ClaudeAgent.max_turns = 20 + +# model = "opus" (overridden), max_turns = 20 (from config) +turn = ClaudeAgent.ask("Hello", model: "opus") +``` + +Internally, `Configuration#to_options` iterates all fields. If a per-request override is provided (even if `nil`), it takes precedence. If no override is provided, the config default is used. The result is a fully resolved `Options` instance. + +### Pre-built Options + +You can bypass the config merge entirely by passing a pre-built `Options` object: + +```ruby +opts = ClaudeAgent::Options.new( + model: "opus", + max_turns: 5, + permission_mode: "acceptEdits" +) + +# Global config is ignored -- opts is used directly +turn = ClaudeAgent.ask("Fix the bug", options: opts) +``` + +--- + +## Options Class Reference + +`ClaudeAgent::Options` is the fully-resolved configuration object passed to the transport layer. It validates all fields at construction time and serializes them to CLI arguments and environment variables. + +### Constructor + +```ruby +options = ClaudeAgent::Options.new( + # --- Model --- + model: "opus", + fallback_model: "sonnet", + + # --- Tools --- + tools: ["Read", "Write", "Bash"], # Array of tool names + # tools: ToolsPreset.new(preset: "claude_code"), # Or a preset + # tools: { type: "preset", preset: "claude_code" }, # Or Hash shorthand + allowed_tools: [], # Allowlist (default: []) + disallowed_tools: [], # Denylist (default: []) + + # --- System prompt --- + system_prompt: "You are a helpful assistant.", + append_system_prompt: "Always respond in JSON.", + + # --- Permissions --- + permission_mode: "acceptEdits", # "default", "acceptEdits", "plan", "bypassPermissions", "dontAsk" + permission_prompt_tool_name: nil, # Auto-set to "stdio" when can_use_tool is present + can_use_tool: ->(name, input, ctx) { { behavior: "allow" } }, + on_elicitation: ->(data) { { behavior: "allow" } }, + allow_dangerously_skip_permissions: false, # Required for bypassPermissions mode + permission_queue: nil, # Enable permission queue (Conversation default) + + # --- Session --- + continue_conversation: false, + resume: nil, # Session ID to resume + fork_session: false, + resume_session_at: nil, + session_id: nil, + persist_session: true, # Default: true + + # --- Limits --- + max_turns: nil, # Positive integer + max_budget_usd: nil, # Positive number + effort: nil, # "low", "medium", "high", "max" + + # --- Thinking --- + thinking: nil, # { type: "adaptive" }, { type: "enabled", budget_tokens: 10000 }, { type: "disabled" } + max_thinking_tokens: nil, # Standalone (without thinking hash) + + # --- MCP --- + mcp_servers: {}, # Default: {} + strict_mcp_config: false, + + # --- Hooks --- + hooks: nil, # Hash or HookRegistry + + # --- Sandbox --- + sandbox: nil, # SandboxSettings instance + + # --- Environment --- + cwd: nil, + add_dirs: [], # Default: [] + env: {}, # Default: {} + agent: nil, # Built-in agent name + agents: nil, # Hash of AgentDefinition + cli_path: nil, + + # --- Output --- + output_format: nil, # JSON Schema for structured output + include_partial_messages: false, + enable_file_checkpointing: false, + prompt_suggestions: false, + + # --- Advanced --- + extra_args: {}, # Default: {} + setting_sources: nil, + settings: nil, + plugins: [], # Default: [] + betas: [], # Default: [] + max_buffer_size: nil, # Default: 1MB + stderr_callback: nil, + abort_controller: nil, + spawn_claude_code_process: nil, + tool_config: nil, + agent_progress_summaries: nil, + logger: nil, # Per-instance logger override + + # --- Debug --- + debug: false, + debug_file: nil +) +``` + +### Defaults + +Fields with non-nil defaults: + +| Field | Default | +|--------------------------------------|---------| +| `allowed_tools` | `[]` | +| `disallowed_tools` | `[]` | +| `allow_dangerously_skip_permissions` | `false` | +| `continue_conversation` | `false` | +| `fork_session` | `false` | +| `strict_mcp_config` | `false` | +| `mcp_servers` | `{}` | +| `add_dirs` | `[]` | +| `env` | `{}` | +| `extra_args` | `{}` | +| `plugins` | `[]` | +| `include_partial_messages` | `false` | +| `enable_file_checkpointing` | `false` | +| `persist_session` | `true` | +| `betas` | `[]` | +| `prompt_suggestions` | `false` | +| `debug` | `false` | + +### Validation + +`Options` validates at construction and raises `ClaudeAgent::ConfigurationError` for: + +- Invalid `permission_mode` (must be one of `default`, `acceptEdits`, `plan`, `bypassPermissions`, `dontAsk`) +- `bypassPermissions` without `allow_dangerously_skip_permissions: true` +- Non-callable `can_use_tool` (must respond to `#call`) +- Non-callable `on_elicitation` (must respond to `#call`) +- Non-positive `max_turns` or `max_budget_usd` +- Invalid `thinking` hash (`:type` must be `adaptive`, `enabled`, or `disabled`) +- Invalid `effort` (must be `low`, `medium`, `high`, or `max`) +- `session_id` with `continue_conversation` or `resume` unless `fork_session` is also set + +### Serialization + +`Options` includes a `Serializer` module that converts to CLI arguments and environment variables: + +```ruby +options.to_cli_args # => ["--model", "opus", "--max-turns", "10", ...] +options.to_env # => {"CLAUDE_CODE_ENTRYPOINT" => "sdk-rb", ...} +``` + +--- + +## Tools Preset + +Use `ToolsPreset` to select a named preset instead of listing individual tools: + +```ruby +preset = ClaudeAgent::ToolsPreset.new(preset: "claude_code") +options = ClaudeAgent::Options.new(tools: preset) +``` + +The `type` field defaults to `"preset"`. You can also pass a plain Hash: + +```ruby +options = ClaudeAgent::Options.new( + tools: { type: "preset", preset: "claude_code" } +) +``` + +Or pass an Array of tool name strings: + +```ruby +options = ClaudeAgent::Options.new( + tools: ["Read", "Write", "Bash", "Grep", "Glob"] +) +``` + +--- + +## Sandbox Settings + +`SandboxSettings` configures execution sandboxing for the CLI process. It is an immutable `Data.define` type. + +### Basic sandbox + +```ruby +sandbox = ClaudeAgent::SandboxSettings.new(enabled: true) + +ClaudeAgent.sandbox = sandbox +# or +ClaudeAgent.configure { |c| c.sandbox = sandbox } +``` + +### Full configuration + +```ruby +sandbox = ClaudeAgent::SandboxSettings.new( + enabled: true, + auto_allow_bash_if_sandboxed: true, + excluded_commands: ["docker"], + allow_unsandboxed_commands: false, + enable_weaker_nested_sandbox: false, + enable_weaker_network_isolation: false, + network: ClaudeAgent::SandboxNetworkConfig.new( + allowed_domains: ["api.example.com", "registry.npmjs.org"], + allow_local_binding: true, + allow_unix_sockets: ["/var/run/docker.sock"], + allow_all_unix_sockets: false, + allow_managed_domains_only: false, + http_proxy_port: nil, + socks_proxy_port: nil + ), + filesystem: ClaudeAgent::SandboxFilesystemConfig.new( + allow_write: ["/tmp/*"], + deny_write: ["/etc/*"], + deny_read: ["/secrets/*"] + ), + ignore_violations: ClaudeAgent::SandboxIgnoreViolations.new( + file: ["/tmp/*"], + network: ["localhost:*"] + ), + ripgrep: ClaudeAgent::SandboxRipgrepConfig.new( + command: "/usr/local/bin/rg", + args: ["--hidden"] + ) +) +``` + +### Sub-configuration types + +| Type | Fields | +|---------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `SandboxSettings` | `enabled`, `auto_allow_bash_if_sandboxed`, `excluded_commands`, `allow_unsandboxed_commands`, `network`, `ignore_violations`, `enable_weaker_nested_sandbox`, `enable_weaker_network_isolation`, `ripgrep`, `filesystem` | +| `SandboxNetworkConfig` | `allowed_domains`, `allow_local_binding`, `allow_unix_sockets`, `allow_all_unix_sockets`, `allow_managed_domains_only`, `http_proxy_port`, `socks_proxy_port` | +| `SandboxFilesystemConfig` | `allow_write`, `deny_write`, `deny_read` | +| `SandboxIgnoreViolations` | `file`, `network` | +| `SandboxRipgrepConfig` | `command`, `args` | + +All sandbox types implement `to_h` for serialization to the CLI's JSON format. + +--- + +## Custom Agents + +Define custom subagents using `AgentDefinition`: + +```ruby +test_runner = ClaudeAgent::AgentDefinition.new( + description: "Runs tests and reports results", + prompt: "You are a test runner. Run the specified tests and report pass/fail status.", + tools: ["Read", "Grep", "Glob", "Bash"], + model: "haiku", + max_turns: 10 +) + +research = ClaudeAgent::AgentDefinition.new( + description: "Research agent with specialized skills", + prompt: "You are a research expert. Find and summarize information.", + skills: ["web-search", "summarization"], + disallowed_tools: ["Write", "Edit"], + mcp_servers: { "search" => { "command" => "npx", "args" => ["-y", "search-server"] } }, + critical_system_reminder: "Never modify files." +) + +options = ClaudeAgent::Options.new( + agents: { + "test_runner" => test_runner, + "research" => research + } +) +``` + +### AgentDefinition fields + +| Field | Type | Required | Description | +|----------------------------|-----------------|----------|--------------------------------------| +| `description` | `String` | Yes | What this agent does | +| `prompt` | `String` | Yes | System prompt for the agent | +| `tools` | `Array` | No | Tools available to this agent | +| `disallowed_tools` | `Array` | No | Tools denied to this agent | +| `model` | `String` | No | Model override for this agent | +| `mcp_servers` | `Hash` | No | MCP servers for this agent | +| `critical_system_reminder` | `String` | No | Critical reminder appended to prompt | +| `skills` | `Array` | No | Skills available to this agent | +| `max_turns` | `Integer` | No | Max turns for this agent | + +--- + +## Environment Variables + +The SDK sets and reads several environment variables. + +### Set by the SDK + +These are automatically set in the CLI process environment via `Options#to_env`: + +| Variable | Value | Description | +|---------------------------------------------|-----------------|------------------------------------------------| +| `CLAUDE_CODE_ENTRYPOINT` | `"sdk-rb"` | Identifies the SDK to the CLI | +| `CLAUDE_AGENT_SDK_VERSION` | Current version | SDK version string (e.g., `"0.7.15"`) | +| `CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING` | `"true"` | Set when `enable_file_checkpointing` is true | +| `PWD` | `cwd` value | Working directory override (when `cwd` is set) | + +### Read by the SDK + +These environment variables influence SDK behavior at runtime: + +| Variable | Effect | +|---------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------| +| `CLAUDE_AGENT_DEBUG` | When set (any truthy value), enables debug-level logging to stderr automatically at boot. Equivalent to calling `ClaudeAgent.debug!`. | +| `CLAUDE_AGENT_SDK_SKIP_VERSION_CHECK` | When set to `"true"`, skips the CLI version check that runs before spawning the subprocess. Useful in CI or when using a custom CLI build. | + +### Passing custom environment variables + +Use the `env` option to pass additional environment variables to the CLI process: + +```ruby +ClaudeAgent.configure do |c| + c.env = { + "ANTHROPIC_API_KEY" => "sk-...", + "MY_CUSTOM_VAR" => "value" + } +end + +# Or per-request +turn = ClaudeAgent.ask("Hello", env: { "CUSTOM" => "value" }) +``` + +### Debug logging + +Three ways to enable debug logging: + +```ruby +# 1. Environment variable (auto-detected at boot) +# CLAUDE_AGENT_DEBUG=1 ruby my_script.rb + +# 2. Convenience method +ClaudeAgent.debug! +ClaudeAgent.debug!(output: File.open("debug.log", "a")) + +# 3. Custom logger +ClaudeAgent.logger = Logger.new($stderr, level: :debug) +``` diff --git a/docs/conversations.md b/docs/conversations.md new file mode 100644 index 0000000..baef7db --- /dev/null +++ b/docs/conversations.md @@ -0,0 +1,461 @@ +# Conversations + +The `Conversation` class is the primary interface for multi-turn interactions with Claude. It manages the full lifecycle of a conversation: connecting to the CLI, sending messages, tracking turns, accumulating tool activity, and cleaning up resources. + +Under the hood, `Conversation` wraps a `Client` and composes `TurnResult`, `EventHandler`, `CumulativeUsage`, and `PermissionQueue` into a single stateful object. It auto-connects on the first call to `say`, tracks multi-turn history, and builds a unified tool activity timeline across all turns. + +## Creating a Conversation + +There are several ways to create a conversation, depending on how much control you need over its lifecycle. + +### `ClaudeAgent.chat` + +The top-level entry point. Merges global configuration defaults automatically. + +```ruby +# Block form -- auto-closes when the block exits +ClaudeAgent.chat(model: "claude-sonnet-4-5-20250514") do |c| + c.say("Hello") + c.say("Goodbye") +end + +# Without a block -- caller is responsible for closing +c = ClaudeAgent.chat(model: "claude-sonnet-4-5-20250514") +c.say("Hello") +c.close +``` + +### `ClaudeAgent.conversation` + +Creates a `Conversation` without merging global configuration defaults. Accepts the same keyword arguments as `Conversation.new`. + +```ruby +c = ClaudeAgent.conversation(max_turns: 5) +c.say("Help me debug this") +c.close +``` + +### `Conversation.new` + +Direct instantiation. Accepts all `Options` keyword arguments plus conversation-level callbacks (any `on_*` keyword). + +```ruby +conversation = ClaudeAgent::Conversation.new( + model: "claude-sonnet-4-5-20250514", + max_turns: 10, + on_text: ->(text) { print text }, + on_result: ->(result) { puts "\nCost: $#{result.total_cost_usd}" } +) +turn = conversation.say("Fix the bug in auth.rb") +conversation.close +``` + +### `Conversation.open` + +Block form with automatic cleanup. The conversation is closed when the block exits, even if an exception is raised. + +```ruby +ClaudeAgent::Conversation.open(permission_mode: "default") do |c| + c.say("Help me write a function") + c.say("Now add tests") + puts "Total cost: $#{c.total_cost}" +end +``` + +## Sending Messages + +Use `say` to send a message and receive the complete turn result. The conversation auto-connects on the first call. + +```ruby +conversation = ClaudeAgent::Conversation.new(max_turns: 5) + +turn = conversation.say("Fix the bug in auth.rb") +puts turn.text +puts "Tools used: #{turn.tool_uses.size}" +``` + +### Block Form for Streaming + +Pass a block to `say` to receive each message as it streams in. + +```ruby +conversation.say("Explain how authentication works") do |message| + case message + when ClaudeAgent::AssistantMessage + print message.text + when ClaudeAgent::ResultMessage + puts "\nDone! Cost: $#{message.total_cost_usd}" + end +end +``` + +### Multiple Turns + +Each call to `say` is a new turn. Context is preserved across the conversation. + +```ruby +ClaudeAgent::Conversation.open(max_turns: 3) do |c| + c.say("Remember the secret word: PINEAPPLE") + turn = c.say("What was the secret word?") + puts turn.text # => "PINEAPPLE" + puts c.turns.size # => 2 +end +``` + +## TurnResult + +Every call to `say` returns a `TurnResult` -- an accumulation of all messages received during that turn. It provides convenient accessors so you never need to write `case` statements over raw message types. + +### Text and Thinking + +| Method | Return Type | Description | +|------------|-------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `text` | `String` | All text content concatenated across assistant messages. Falls back to accumulated streaming deltas if the turn was aborted before an `AssistantMessage` arrived. | +| `thinking` | `String` | All thinking content concatenated across assistant messages. | + +```ruby +turn = conversation.say("Explain Ruby blocks") +puts turn.text +puts "Thinking: #{turn.thinking}" unless turn.thinking.empty? +``` + +### Tool Use + +| Method | Return Type | Description | +|-------------------|-------------------------------------------------|------------------------------------------------------------------------------------------| +| `tool_uses` | `Array` | All tool use blocks across all assistant messages. | +| `tool_results` | `Array` | All tool result blocks from user messages (system-generated tool responses). | +| `tool_executions` | `Array` | Tool use/result pairs matched by ID. Each entry has `:tool_use` and `:tool_result` keys. | + +```ruby +turn = conversation.say("Read the config file and fix the typo") + +turn.tool_uses.each do |tool| + puts "Used: #{tool.display_label}" +end + +turn.tool_executions.each do |exec| + puts "#{exec[:tool_use].name}: #{exec[:tool_result]&.content&.to_s&.slice(0, 80)}" +end +``` + +### Result Accessors + +These delegate to the underlying `ResultMessage`. They return `nil` if the turn is still in progress. + +| Method | Return Type | Description | +|---------------------|----------------|-----------------------------------------------| +| `cost` | `Float, nil` | Total cost in USD for this turn. | +| `duration_ms` | `Integer, nil` | Wall-clock duration in milliseconds. | +| `duration_api_ms` | `Integer, nil` | API-only duration in milliseconds. | +| `session_id` | `String, nil` | Session ID for resumption. | +| `model` | `String, nil` | Model used (from first assistant message). | +| `stop_reason` | `String, nil` | Why the model stopped generating. | +| `usage` | `Hash, nil` | Token usage breakdown. | +| `model_usage` | `Hash, nil` | Per-model usage breakdown. | +| `structured_output` | `Object, nil` | Structured output (if requested via options). | +| `num_turns` | `Integer, nil` | Number of turns in the session. | + +```ruby +turn = conversation.say("What is 2+2?") +puts "Model: #{turn.model}" +puts "Cost: $#{turn.cost}" +puts "Duration: #{turn.duration_ms}ms" +puts "Session: #{turn.session_id}" +``` + +### Status + +| Method | Return Type | Description | +|----------------------|------------------------------|--------------------------------------------------------------| +| `complete?` | `Boolean` | Whether a `ResultMessage` has been received. | +| `success?` | `Boolean` | Whether the turn completed successfully. | +| `error?` | `Boolean` | Whether the turn ended with an error. | +| `subtype` | `String, nil` | The result subtype (e.g., `"success"`, `"error_max_turns"`). | +| `errors` | `Array` | Errors from the result. | +| `permission_denials` | `Array` | Tools that were denied by permission callbacks. | + +```ruby +turn = conversation.say("Deploy to production") +if turn.success? + puts "Deployed successfully" +elsif turn.error? + puts "Errors: #{turn.errors.join(", ")}" +end +``` + +### Filtered Message Access + +| Method | Return Type | Description | +|----------------------|------------------------------------------------------|-----------------------------------------------------------------------| +| `messages` | `Array` | All messages received during this turn. | +| `assistant_messages` | `Array` | Only assistant messages. | +| `user_messages` | `Array` | Only user messages (including system-generated tool result messages). | +| `stream_events` | `Array` | Only stream events. | +| `content_blocks` | `Array` | All content blocks across all assistant messages. | + +## Callbacks + +Register callbacks when creating the conversation to react to events as they happen. Callbacks persist across turns. + +| Callback | Arguments | Description | +|--------------------|----------------------------------|---------------------------------------------------------------------------------------------------------| +| `on_text` | `(text)` | Fires when the assistant produces text content. | +| `on_stream` | `(text)` | Alias for `on_text`. | +| `on_thinking` | `(thinking)` | Fires when the assistant produces thinking content. | +| `on_tool_use` | `(tool_use)` | Fires when the assistant requests a tool use. The argument is a `ToolUseBlock` or `ServerToolUseBlock`. | +| `on_tool_result` | `(tool_result, tool_use)` | Fires when a tool result is received. The second argument is the matched `ToolUseBlock` (or `nil`). | +| `on_result` | `(result)` | Fires when the turn completes. The argument is a `ResultMessage`. | +| `on_message` | `(message)` | Fires for every message (catch-all). | +| `on_stream_event` | `(stream_event)` | Fires for raw stream events. | +| `on_status` | `(status_message)` | Fires for status messages (e.g., compacting). | +| `on_tool_progress` | `(tool_progress_message)` | Fires for tool progress updates. | +| `on_permission` | `Symbol, Proc, PermissionPolicy` | Controls permission handling. See below. | + +```ruby +conversation = ClaudeAgent::Conversation.new( + on_text: ->(text) { print text }, + on_thinking: ->(thinking) { $stderr.puts "[thinking] #{thinking}" }, + on_tool_use: ->(tool) { puts "\nUsing tool: #{tool.display_label}" }, + on_tool_result: ->(result, _tool_use) { puts " Result: #{result.content.to_s.slice(0, 60)}" }, + on_result: ->(r) { puts "\nCost: $#{r.total_cost_usd}" }, + on_message: ->(msg) { $stderr.puts "[#{msg.type}]" } +) +``` + +### Permission Handling + +The `on_permission` parameter controls how tool permission requests are handled: + +- **`:queue`** (default) -- Permission requests are queued. Poll with `conversation.pending_permission`. +- **`:default`**, **`:accept_edits`**, **`:plan`**, **`:bypass_permissions`**, **`:dont_ask`** -- Maps to CLI permission modes. +- **A callable** (Proc/Lambda) -- Used as `can_use_tool` callback. +- **A `PermissionPolicy`** -- Compiled to a `can_use_tool` callback. + +```ruby +# Queue mode (default) -- poll for permissions +conversation = ClaudeAgent::Conversation.new +# ... in a UI loop: +if request = conversation.pending_permission + show_dialog(request) +end + +# Callable mode +conversation = ClaudeAgent::Conversation.new( + on_permission: ->(name, input, context) { + ClaudeAgent::PermissionResultAllow.new + } +) + +# Symbol mode +conversation = ClaudeAgent::Conversation.new(on_permission: :accept_edits) +``` + +## Tool Activity Timeline + +After each turn, the conversation builds `ToolActivity` entries from tool use/result pairs. These form a unified timeline across all turns with timing information. + +```ruby +ClaudeAgent::Conversation.open(max_turns: 10) do |c| + c.say("Read the config, fix the bug, and write tests") + + c.tool_activity.each do |activity| + status = activity.error? ? "FAILED" : "OK" + duration = activity.duration ? "#{activity.duration.round(2)}s" : "n/a" + puts "#{activity.display_label} [#{status}] (#{duration}) -- turn #{activity.turn_index}" + end +end +``` + +### ToolActivity Accessors + +Each `ToolActivity` is an immutable `Data.define` object built after a turn completes. + +| Method | Return Type | Description | +|-----------------|------------------------|---------------------------------------------------------------| +| `name` | `String` | Tool name (e.g., `"Read"`, `"Write"`, `"Bash"`). | +| `display_label` | `String` | Human-readable label. | +| `summary(max:)` | `String` | Detailed summary, truncated to `max` characters (default 60). | +| `file_path` | `String, nil` | File path if this is a file-based tool. | +| `id` | `String` | Tool use ID. | +| `tool_use` | `ToolUseBlock` | The original tool use block. | +| `tool_result` | `ToolResultBlock, nil` | The matching result block (nil if not yet received). | +| `turn_index` | `Integer` | Which turn this tool was used in (zero-indexed). | +| `started_at` | `Time, nil` | When the tool use was detected. | +| `completed_at` | `Time, nil` | When the tool result was received. | +| `duration` | `Float, nil` | Duration in seconds (nil if timing not available). | +| `error?` | `Boolean` | Whether the tool produced an error result. | +| `complete?` | `Boolean` | Whether the tool execution is complete (has a result). | + +## Live Tool Tracking + +For real-time UIs that need to show tool progress as it happens (spinners, progress bars, status indicators), enable live tracking with `track_tools: true`. + +```ruby +conversation = ClaudeAgent::Conversation.new(track_tools: true) +tracker = conversation.tool_tracker +``` + +The `ToolActivityTracker` is an `Enumerable` collection of `LiveToolActivity` entries that updates in real time as tools start, progress, and complete. It resets at the start of each call to `say`. + +### Registering Tracker Callbacks + +```ruby +tracker = conversation.tool_tracker + +tracker.on_start do |entry| + puts "Started: #{entry.display_label}" +end + +tracker.on_progress do |entry| + puts " #{entry.name}: #{entry.elapsed&.round(1)}s elapsed..." +end + +tracker.on_complete do |entry| + status = entry.error? ? "FAILED" : "done" + puts "Finished: #{entry.display_label} (#{status})" +end + +# Catch-all -- fires in addition to specific callbacks +tracker.on_change do |event, entry| + # event is :started, :completed, or :progress + log("#{event}: #{entry.id}") +end +``` + +### Querying Tracker State + +```ruby +tracker.running # => Array currently in progress +tracker.done # => Array completed successfully +tracker.errored # => Array completed with errors + +tracker.size # => Integer total count +tracker.empty? # => Boolean + +tracker.find_by_id("tool_123") # => LiveToolActivity or nil +tracker["tool_123"] # => same as find_by_id + +tracker.each { |entry| render(entry) } +``` + +### LiveToolActivity + +Unlike `ToolActivity` (immutable, built after a turn completes), `LiveToolActivity` is mutable and tracks status changes as they happen. + +| Method | Return Type | Description | +|-----------------|------------------------|---------------------------------------------------------| +| `id` | `String` | Tool use ID. | +| `name` | `String` | Tool name. | +| `input` | `Hash` | Tool input parameters. | +| `display_label` | `String` | Human-readable label. | +| `summary(max:)` | `String` | Detailed summary. | +| `file_path` | `String, nil` | File path if applicable. | +| `tool_use` | `ToolUseBlock` | The tool use block. | +| `tool_result` | `ToolResultBlock, nil` | The tool result (nil while running). | +| `status` | `Symbol` | `:running`, `:done`, or `:error`. | +| `started_at` | `Time` | When the tool started. | +| `elapsed` | `Float, nil` | Elapsed time in seconds (updated by progress events). | +| `running?` | `Boolean` | Whether the tool is currently running. | +| `done?` | `Boolean` | Whether the tool completed successfully. | +| `error?` | `Boolean` | Whether the tool completed with an error. | +| `complete?` | `Boolean` | Whether the tool execution is complete (done or error). | + +## Conversation Accessors + +These accessors are available on the `Conversation` object itself. + +| Method | Return Type | Description | +|------------------------|----------------------------|------------------------------------------------------------| +| `turns` | `Array` | All completed turns. | +| `messages` | `Array` | All messages across all turns. | +| `tool_activity` | `Array` | Unified tool timeline across all turns. | +| `tool_tracker` | `ToolActivityTracker, nil` | Live tool tracker (nil unless `track_tools: true`). | +| `total_cost` | `Float` | Total cost across all turns (session-cumulative from CLI). | +| `session_id` | `String, nil` | Session ID from the most recent turn. | +| `usage` | `CumulativeUsage` | Cumulative usage stats. | +| `open?` | `Boolean` | Whether the conversation is open (client connected). | +| `closed?` | `Boolean` | Whether the conversation has been closed. | +| `pending_permission` | `PermissionRequest, nil` | Next pending permission request (non-blocking poll). | +| `pending_permissions?` | `Boolean` | Whether any permission requests are pending. | + +```ruby +ClaudeAgent::Conversation.open(max_turns: 10) do |c| + c.say("Refactor the auth module") + c.say("Now add integration tests") + + puts "Turns: #{c.turns.size}" + puts "Messages: #{c.messages.size}" + puts "Tools: #{c.tool_activity.size}" + puts "Session: #{c.session_id}" + puts "Total cost: $#{c.total_cost}" + puts "Input tokens: #{c.usage.input_tokens}" + puts "Output tokens: #{c.usage.output_tokens}" +end +``` + +## Resuming a Conversation + +Resume a previous conversation by session ID. The CLI restores the conversation context from the session transcript. + +### `Conversation.resume` + +```ruby +conversation = ClaudeAgent::Conversation.resume("session-abc-123") +turn = conversation.say("Continue where we left off") +puts turn.text +conversation.close +``` + +### `ClaudeAgent.resume_conversation` + +Module-level convenience that delegates to `Conversation.resume`. + +```ruby +conversation = ClaudeAgent.resume_conversation("session-abc-123", + max_turns: 5, + on_text: ->(text) { print text } +) +turn = conversation.say("What did we discuss last time?") +conversation.close +``` + +Both methods accept the same keyword arguments as `Conversation.new` for callbacks and options. + +## Cumulative Usage + +The `CumulativeUsage` object tracks token counts, cost, and duration across all turns in a conversation. + +Access it via `conversation.usage`: + +```ruby +ClaudeAgent::Conversation.open do |c| + c.say("Hello") + c.say("Follow up question") + + usage = c.usage + puts "Input tokens: #{usage.input_tokens}" + puts "Output tokens: #{usage.output_tokens}" + puts "Cache read: #{usage.cache_read_input_tokens}" + puts "Cache create: #{usage.cache_creation_input_tokens}" + puts "Total cost: $#{usage.total_cost_usd}" + puts "Turns: #{usage.num_turns}" + puts "Duration: #{usage.duration_ms}ms" + puts "API duration: #{usage.duration_api_ms}ms" +end +``` + +### CumulativeUsage Fields + +| Field | Type | Description | +|-------------------------------|-----------|--------------------------------------------------------------------------------| +| `input_tokens` | `Integer` | Sum of input tokens across all turns. | +| `output_tokens` | `Integer` | Sum of output tokens across all turns. | +| `cache_read_input_tokens` | `Integer` | Sum of cache-read input tokens across all turns. | +| `cache_creation_input_tokens` | `Integer` | Sum of cache-creation input tokens across all turns. | +| `total_cost_usd` | `Float` | Session-cumulative cost from the CLI (not summed -- replaced each turn). | +| `num_turns` | `Integer` | Session-cumulative turn count from the CLI (not summed -- replaced each turn). | +| `duration_ms` | `Integer` | Sum of wall-clock duration across all turns. | +| `duration_api_ms` | `Integer` | Sum of API-only duration across all turns. | + +Token counts are summed across turns because the CLI reports per-turn values. Cost and turn count are session-cumulative values from the CLI and are replaced (not summed) on each result. diff --git a/docs/errors.md b/docs/errors.md new file mode 100644 index 0000000..6ceb14c --- /dev/null +++ b/docs/errors.md @@ -0,0 +1,127 @@ +# Error Handling + +All errors inherit from `ClaudeAgent::Error`, which inherits from `StandardError`. You can rescue the base class to catch any SDK error, or rescue specific subclasses for targeted handling. + +## Error Hierarchy + +``` +StandardError + ClaudeAgent::Error + CLINotFoundError — Claude Code CLI binary not found + CLIVersionError — CLI version below MINIMUM_VERSION ("2.0.0") + CLIConnectionError — connection to CLI process failed + ProcessError — CLI process exited with error + JSONDecodeError — JSON parsing failed + MessageParseError — message structure could not be parsed + TimeoutError — control protocol request timed out + ConfigurationError — invalid option provided + NotFoundError — resource not found (e.g., Session.retrieve) + AbortError — operation aborted/cancelled +``` + +## Error Details + +| Error | Attributes | Raised When | +|----------------------|---------------------------------|---------------------------------------------------------------------------------| +| `CLINotFoundError` | -- | CLI binary not at expected path | +| `CLIVersionError` | -- | `claude -v` returns < 2.0.0 | +| `CLIConnectionError` | -- | Pipe broken, stdin/stdout closed, already/not connected | +| `ProcessError` | `exit_code`, `stderr` | CLI process exits non-zero | +| `JSONDecodeError` | `raw_content` | Malformed JSON from CLI, buffer overflow | +| `MessageParseError` | `raw_message` | Message structure unrecognizable | +| `TimeoutError` | `request_id`, `timeout_seconds` | Control protocol request exceeds deadline | +| `ConfigurationError` | -- | Invalid `Options` values (bad permission_mode, non-callable can_use_tool, etc.) | +| `NotFoundError` | -- | `Session.retrieve` or `Session.info` for nonexistent session | +| `AbortError` | `partial_turn` | `AbortController#abort` called, or session closed mid-turn | + +## Handling Pattern + +```ruby +require "claude_agent" + +begin + turn = ClaudeAgent.ask("Fix the failing tests") + puts turn.text + +rescue ClaudeAgent::CLINotFoundError + # Install or update PATH + abort "Claude Code CLI is not installed." + +rescue ClaudeAgent::CLIVersionError + # Upgrade CLI + abort "Claude Code CLI is too old. Run: claude update" + +rescue ClaudeAgent::CLIConnectionError => e + # Pipe broken, process died, etc. + $stderr.puts "Connection lost: #{e.message}" + +rescue ClaudeAgent::ProcessError => e + # CLI exited with an error code + $stderr.puts "CLI failed (exit #{e.exit_code}): #{e.stderr}" + +rescue ClaudeAgent::JSONDecodeError => e + # Corrupted output from CLI + $stderr.puts "Bad JSON: #{e.raw_content&.slice(0, 200)}" + +rescue ClaudeAgent::MessageParseError => e + # Unexpected message structure + $stderr.puts "Unparseable message: #{e.raw_message.inspect}" + +rescue ClaudeAgent::TimeoutError => e + # Control protocol deadline exceeded + $stderr.puts "Timed out after #{e.timeout_seconds}s (request: #{e.request_id})" + +rescue ClaudeAgent::ConfigurationError => e + # Bad options (caught at Options construction time) + abort "Invalid config: #{e.message}" + +rescue ClaudeAgent::NotFoundError => e + # Session.retrieve for a session that does not exist + $stderr.puts "Not found: #{e.message}" + +rescue ClaudeAgent::AbortError => e + # Operation cancelled -- see next section for partial results + $stderr.puts "Aborted: #{e.message}" + +rescue ClaudeAgent::Error => e + # Catch-all for any SDK error not handled above + $stderr.puts "ClaudeAgent error: #{e.message}" +end +``` + +## AbortError and Partial Results + +When a turn is aborted mid-flight (via `AbortController` or session close), the `AbortError` carries a `partial_turn` -- a `TurnResult` containing whatever was accumulated before cancellation. + +```ruby +controller = ClaudeAgent::AbortController.new + +# Abort after 5 seconds +Thread.new { sleep 5; controller.abort("Taking too long") } + +begin + conversation = ClaudeAgent::Conversation.new( + options: ClaudeAgent::Options.new(abort_controller: controller) + ) + turn = conversation.say("Refactor the entire codebase") +rescue ClaudeAgent::AbortError => e + turn = e.partial_turn + + if turn + # Text accumulated from assistant messages (or streamed fragments) + puts "Partial text: #{turn.text}" unless turn.text.empty? + + # Tools that were invoked before the abort + turn.tool_uses.each do |tool| + puts "Tool called: #{tool.name}" + end + + # All raw messages received so far + puts "Messages received: #{turn.messages.size}" + else + puts "Aborted before any messages were received." + end +end +``` + +`partial_turn` is `nil` if the abort happened before any messages arrived. Always check before accessing its fields. diff --git a/docs/events.md b/docs/events.md new file mode 100644 index 0000000..318de32 --- /dev/null +++ b/docs/events.md @@ -0,0 +1,225 @@ +# Events + +The `EventHandler` dispatches typed events as messages flow through a conversation turn, replacing manual `case` statements over raw message types. + +Three event layers fire for every message, in order: + +1. **Catch-all** -- `:message` fires for every message regardless of type. +2. **Type-based** -- the message's `type` fires (e.g., `:assistant`, `:result`, `:stream_event`). +3. **Decomposed** -- convenience events extracted from rich content (`:text`, `:thinking`, `:tool_use`, `:tool_result`). + +## Quick start + +### Block DSL with `EventHandler.define` + +Build a handler in a single expression using the block DSL. The block is evaluated in the context of the new handler, so `on_*` methods are available directly: + +```ruby +handler = ClaudeAgent::EventHandler.define do + on_text { |text| print text } + on_result { |r| puts "\nCost: $#{r.total_cost_usd}" } + on_tool_use { |tool| puts "Tool: #{tool.display_label}" } +end +``` + +Pass the handler to `query_turn`: + +```ruby +turn = ClaudeAgent.query_turn(prompt: "Explain Ruby", events: handler) +``` + +### Traditional construction + +Create an instance and chain `on_*` calls. Each returns `self`, so calls can be chained: + +```ruby +handler = ClaudeAgent::EventHandler.new + .on_text { |text| print text } + .on_thinking { |thought| $stderr.puts "[thinking] #{thought}" } + .on_tool_use { |tool| puts "Using: #{tool.display_label}" } + .on_result { |r| puts "\nDone (cost=$#{r.total_cost_usd})" } +``` + +Or register handlers one at a time: + +```ruby +handler = ClaudeAgent::EventHandler.new +handler.on_text { |text| print text } +handler.on_result { |r| puts "Done!" } +``` + +### Via Conversation + +`Conversation.new` accepts `on_*` keyword arguments for any event. These are registered on the underlying `Client` and persist across turns: + +```ruby +conversation = ClaudeAgent::Conversation.new( + model: "claude-sonnet-4-5-20250514", + on_text: ->(text) { print text }, + on_thinking: ->(thought) { $stderr.puts thought }, + on_tool_use: ->(tool) { puts "Tool: #{tool.display_label}" }, + on_tool_result: ->(result, tool_use) { puts "Result for #{tool_use&.name}" }, + on_result: ->(r) { puts "\nCost: $#{r.total_cost_usd}" }, + on_message: ->(msg) { log(msg) }, + on_stream_event: ->(evt) { handle_stream(evt) }, + on_status: ->(status) { show_status(status) }, + on_tool_progress: ->(prog) { update_spinner(prog) } +) + +turn = conversation.say("Fix the bug in auth.rb") +conversation.close +``` + +`on_stream` is an alias for `on_text`: + +```ruby +conversation = ClaudeAgent::Conversation.new( + on_stream: ->(text) { print text } +) +``` + +### Via Client + +Register event handlers directly on a `Client`. Handlers persist across turns and fire automatically during `receive_turn` and `send_and_receive`: + +```ruby +client = ClaudeAgent::Client.new +client.on_text { |text| print text } +client.on_tool_use { |tool| puts "Using: #{tool.display_label}" } +client.on_result { |r| puts "\nDone!" } + +client.connect +turn = client.send_and_receive("Fix the bug") +client.disconnect +``` + +The generic `on` method also works: + +```ruby +client.on(:text) { |text| print text } +client.on(:stream_event) { |evt| handle_stream(evt) } +``` + +### Via one-shot query + +Pass an `events:` keyword to `ClaudeAgent.query_turn` for one-shot queries: + +```ruby +events = ClaudeAgent::EventHandler.define do + on_text { |text| print text } + on_result { |r| puts "\nCost: $#{r.total_cost_usd}" } +end + +turn = ClaudeAgent.query_turn(prompt: "Explain concurrency", events: events) +puts turn.text +``` + +The handler's `reset!` is called automatically when the turn completes. + +## Standalone usage + +Create a handler and dispatch messages manually with `handle`: + +```ruby +handler = ClaudeAgent::EventHandler.new +handler.on_text { |text| print text } +handler.on_tool_use { |tool| log_tool(tool) } +handler.on_tool_result { |result, tool_use| log_result(result, tool_use) } + +client.receive_response.each { |msg| handler.handle(msg) } +handler.reset! +``` + +`handle` fires events in order: + +1. `:message` (catch-all) +2. `message.type` (type-based) +3. Decomposed events extracted from the message content + +Call `reset!` between turns to clear internal state (pending tool use tracking). When used via `Client` or `query_turn`, this is called automatically. + +## Tool use and tool result pairing + +The handler tracks pending tool uses internally. When a `:tool_result` event fires, it receives both the result block and the original tool use block that triggered it: + +```ruby +handler.on_tool_use do |tool| + puts "Requested: #{tool.name} (#{tool.id})" +end + +handler.on_tool_result do |result, tool_use| + puts "Result for: #{tool_use&.name}" # tool_use is the matched ToolUseBlock + puts "Error: #{result.is_error}" if result.is_error +end +``` + +The `tool_use` argument is `nil` if no matching tool use was found (which should not happen in normal operation). + +## Utility methods + +### `has_handlers?` + +Returns whether any handlers have been registered: + +```ruby +handler = ClaudeAgent::EventHandler.new +handler.has_handlers? # => false + +handler.on_text { |t| print t } +handler.has_handlers? # => true +``` + +### `reset!` + +Clears turn-level tracking state (pending tool uses). Does not remove registered handlers: + +```ruby +handler.reset! # Clear pending tool uses between turns +``` + +## Event reference + +### Meta events + +| Event | Receives | Description | +|------------|--------------------|-------------------------------------| +| `:message` | Any message object | Fires for every message (catch-all) | + +### Type-based events + +Each fires when a message with the matching `type` is received. The handler receives the full message object. + +| Event | Description | +|-------------------------|-------------------------------------------------------| +| `:user` | User message | +| `:assistant` | Assistant message (contains text, thinking, tool use) | +| `:system` | System message (init, session info) | +| `:result` | End-of-turn result (cost, usage, session ID) | +| `:stream_event` | Raw stream event | +| `:compact_boundary` | Context window compaction boundary | +| `:status` | Status update | +| `:tool_progress` | Tool execution progress | +| `:hook_response` | Hook execution response | +| `:auth_status` | Authentication status | +| `:task_notification` | Background task notification | +| `:hook_started` | Hook execution started | +| `:hook_progress` | Hook execution progress | +| `:tool_use_summary` | Tool use summary | +| `:task_started` | Background task started | +| `:task_progress` | Background task progress | +| `:rate_limit_event` | Rate limit information | +| `:prompt_suggestion` | Suggested follow-up prompt | +| `:files_persisted` | File checkpoint persisted | +| `:elicitation_complete` | Elicitation completed | +| `:local_command_output` | Local command output | + +### Decomposed events + +Extracted from the content of assistant and user messages. These fire after the type-based event. + +| Event | Receives | Description | +|----------------|--------------------------------------------|-------------------------------------------------------------------------| +| `:text` | `String` | Concatenated text from an assistant message | +| `:thinking` | `String` | Concatenated thinking from an assistant message | +| `:tool_use` | `ToolUseBlock` or `ServerToolUseBlock` | A tool use request from an assistant message | +| `:tool_result` | `ToolResultBlock`, `ToolUseBlock` or `nil` | A tool result from a user message, paired with its originating tool use | diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..02ad047 --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,310 @@ +# Getting Started + +This guide walks you through the ClaudeAgent Ruby SDK from first install to multi-turn conversations. Each section builds on the last, starting with the simplest API. + +## Requirements + +- **Ruby 3.2+** (the SDK uses `Data.define` for immutable types) +- **Claude Code CLI v2.0.0+** ([install guide](https://code.claude.com/docs/en/getting-started)) + +Verify both are available: + +```bash +ruby -v # >= 3.2.0 +claude -v # >= 2.0.0 +``` + +## Installation + +Add to your Gemfile: + +```ruby +gem "claude_agent" +``` + +Then: + +```bash +bundle install +``` + +Or install directly: + +```bash +gem install claude_agent +``` + +## Your First Query + +The fastest way to get a response is `ClaudeAgent.ask`. It sends a single prompt and returns a `TurnResult`: + +```ruby +require "claude_agent" + +turn = ClaudeAgent.ask("What is the capital of France?") +puts turn.text +# => "The capital of France is Paris." +``` + +`TurnResult` gives you structured access to everything that happened during the turn: + +```ruby +turn = ClaudeAgent.ask("Explain Ruby's GIL in one sentence.") + +puts turn.text # Combined text from all assistant messages +puts turn.cost # Total cost in USD (e.g., 0.003) +puts turn.duration_ms # Wall-clock time in milliseconds +puts turn.session_id # Session ID (for resuming later) +puts turn.model # Model that handled the request +puts turn.tool_uses.size # Number of tools Claude invoked +puts turn.success? # true if no errors +``` + +### Passing Options + +Override defaults with keyword arguments: + +```ruby +turn = ClaudeAgent.ask("Fix the bug in auth.rb", + model: "opus", + max_turns: 5, + permission_mode: "acceptEdits" +) +``` + +## Streaming + +Pass a block to `ask` to receive each message as it arrives: + +```ruby +turn = ClaudeAgent.ask("Explain how TCP works") do |message| + case message + when ClaudeAgent::AssistantMessage + print message.text + when ClaudeAgent::ResultMessage + puts "\n--- Done (cost: $#{message.total_cost_usd}) ---" + end +end +``` + +The block receives every message in the protocol stream. The return value is still a `TurnResult` with the full accumulated data. + +### Streaming with Callbacks + +For cleaner streaming, use `on_stream` to receive just the text: + +```ruby +turn = ClaudeAgent.ask("Write a haiku about Ruby", + on_stream: ->(text) { print text } +) +puts "\nCost: $#{turn.cost}" +``` + +## Multi-Turn Conversations + +Use `ClaudeAgent.chat` for back-and-forth conversations. The block form auto-closes the connection when done: + +```ruby +ClaudeAgent.chat do |c| + c.say("What files are in the current directory?") + c.say("Which one is the largest?") + c.say("Show me the first 10 lines of that file.") + + puts "Total cost: $#{c.total_cost}" +end +``` + +Each call to `say` returns a `TurnResult`: + +```ruby +ClaudeAgent.chat(model: "opus") do |c| + turn = c.say("Write a function that reverses a string") + puts turn.text + + turn = c.say("Now add error handling") + puts turn.text + puts "Tools used: #{turn.tool_uses.map(&:name).join(", ")}" +end +``` + +### Without a Block + +If you need the conversation to outlive a block, skip it: + +```ruby +conversation = ClaudeAgent.chat(model: "sonnet") +conversation.say("Hello") +conversation.say("Tell me more") +conversation.close # Always close when done +``` + +### Streaming in Conversations + +Pass `on_stream` to print text as it arrives: + +```ruby +ClaudeAgent.chat(on_stream: ->(text) { print text }) do |c| + c.say("What is 2+2?") + puts # newline after streamed output + c.say("Now multiply that by 10") + puts +end +``` + +Or stream per-turn with a block on `say`: + +```ruby +ClaudeAgent.chat do |c| + c.say("Explain monads") do |message| + print message.text if message.is_a?(ClaudeAgent::AssistantMessage) + end +end +``` + +## Global Configuration + +Set defaults that apply to every `ask` and `chat` call: + +```ruby +ClaudeAgent.model = "opus" +ClaudeAgent.max_turns = 10 +ClaudeAgent.permission_mode = "acceptEdits" +ClaudeAgent.system_prompt = "You are a helpful coding assistant." +``` + +Or configure in bulk: + +```ruby +ClaudeAgent.configure do |c| + c.model = "opus" + c.max_turns = 10 + c.permission_mode = "acceptEdits" + c.system_prompt = "You are a helpful coding assistant." + c.max_budget_usd = 1.0 + c.cwd = "/path/to/project" +end +``` + +Per-request keyword arguments always override global defaults: + +```ruby +ClaudeAgent.model = "sonnet" + +# This request uses opus despite the global default +turn = ClaudeAgent.ask("Complex question", model: "opus") +``` + +Reset to defaults with: + +```ruby +ClaudeAgent.reset_config! +``` + +See [Configuration](configuration.md) for the full list of options. + +## The Conversation Class + +For full control, use `ClaudeAgent::Conversation` directly. It supports callbacks, permission handling, tool tracking, and session management. + +```ruby +conversation = ClaudeAgent::Conversation.open( + model: "opus", + max_turns: 10, + permission_mode: "acceptEdits", + on_stream: ->(text) { print text }, + on_tool_use: ->(tool) { puts "\nUsing tool: #{tool.name}" }, + on_result: ->(result) { puts "\nTurn cost: $#{result.total_cost_usd}" } +) do |c| + c.say("Read the file config.rb and explain what it does") + c.say("Refactor it to use keyword arguments") + + puts "Session: #{c.session_id}" + puts "Total cost: $#{c.total_cost}" + puts "Turns: #{c.turns.size}" +end +``` + +### Available Callbacks + +| Callback | Receives | When | +|------------------|---------------------------------|-------------------------------------------| +| `on_stream` | `String` | Each text chunk as it streams | +| `on_text` | `String` | Full text from each assistant message | +| `on_thinking` | `String` | Thinking/reasoning content | +| `on_tool_use` | `ToolUseBlock` | Claude requests a tool | +| `on_tool_result` | `ToolResultBlock, ToolUseBlock` | Tool result arrives (paired with request) | +| `on_result` | `ResultMessage` | Turn completes | +| `on_message` | `Message` | Every message (catch-all) | + +### Permission Handling + +By default, Conversation queues permission requests for you to handle. You can also set a mode or provide a custom callback: + +```ruby +# Use a CLI permission mode +ClaudeAgent::Conversation.open(on_permission: :accept_edits) do |c| + c.say("Fix the typo in README.md") +end + +# Or provide a lambda +ClaudeAgent::Conversation.open( + on_permission: ->(tool_name, input, ctx) { + ClaudeAgent::PermissionResultAllow.new + } +) do |c| + c.say("Update the config file") +end +``` + +For declarative permission rules, see [Permissions](permissions.md). + +## Resuming Sessions + +Every turn returns a `session_id`. Save it to resume the conversation later: + +```ruby +# First session +session_id = nil +ClaudeAgent.chat do |c| + c.say("Let's work on the authentication module") + session_id = c.session_id +end + +# Later — resume where you left off +ClaudeAgent.resume_conversation(session_id) do |c| + c.say("Continue with the tests we discussed") +end +``` + +`resume_conversation` returns a `Conversation`, so it supports the same block form and callbacks. + +You can also resume via `Conversation.resume` directly: + +```ruby +conversation = ClaudeAgent::Conversation.resume(session_id, + on_stream: ->(text) { print text } +) +conversation.say("Pick up where we left off") +conversation.close +``` + +### Listing Past Sessions + +Browse previous sessions without spawning a CLI process: + +```ruby +sessions = ClaudeAgent.list_sessions(dir: "/path/to/project", limit: 10) +sessions.each do |session| + puts "#{session.session_id}: #{session.title} (#{session.updated_at})" +end +``` + +## What's Next + +- [Configuration](configuration.md) -- full list of options, permission modes, and sandbox settings +- [Conversations](conversations.md) -- advanced multi-turn patterns, tool tracking, and abort handling +- [Permissions](permissions.md) -- declarative permission policies and the `can_use_tool` callback +- [Hooks](hooks.md) -- lifecycle hooks for tool use, session events, and more +- [Messages](messages.md) -- all message types, content blocks, and the event system +- [MCP Servers](mcp-servers.md) -- integrating external tools via Model Context Protocol +- [Sessions](sessions.md) -- listing, reading, forking, and managing past sessions diff --git a/docs/hooks.md b/docs/hooks.md new file mode 100644 index 0000000..9239e3b --- /dev/null +++ b/docs/hooks.md @@ -0,0 +1,380 @@ +# Hooks + +Hooks let you intercept and respond to events during a Claude Code CLI session. When the CLI triggers an event (tool use, session start, notification, etc.), your Ruby callback is invoked with a typed input object and context. The callback returns a response hash that controls how the CLI proceeds. + +## HookRegistry DSL + +The recommended way to define hooks is through `HookRegistry`, a declarative builder that maps idiomatic Ruby method names to CLI hook events. + +### Global hooks + +Set hooks that apply to all queries and conversations: + +```ruby +ClaudeAgent.hooks do |h| + h.before_tool_use(/Bash/) do |input, ctx| + puts "About to run Bash: #{input.tool_input}" + { continue_: true } + end + + h.on_session_start do |input, ctx| + puts "Session started from #{input.source}" + { continue_: true } + end +end +``` + +Global hooks are stored in `ClaudeAgent.config.default_hooks` and are merged into every `Options` instance produced by `Configuration#to_options`. + +### Per-conversation hooks + +Pass a `HookRegistry` (or compiled hooks hash) to a specific conversation or query: + +```ruby +hooks = ClaudeAgent::HookRegistry.new do |h| + h.before_tool_use("Write") do |input, ctx| + if input.tool_input[:file_path]&.end_with?(".env") + { continue_: false } # Block writes to .env files + else + { continue_: true } + end + end + + h.after_tool_use do |input, ctx| + puts "#{input.tool_name} completed" + { continue_: true } + end +end + +# With Conversation +ClaudeAgent.chat(hooks: hooks) do |c| + c.say("Refactor the auth module") +end + +# With ask +turn = ClaudeAgent.ask("Fix the tests", hooks: hooks) + +# With explicit Options +opts = ClaudeAgent::Options.new(hooks: hooks, model: "opus") +``` + +When both global and per-conversation hooks are set, they are merged additively -- per-conversation hooks are appended to global hooks for the same event. + +### Matchers + +Each hook method accepts an optional first argument that filters which tool names trigger the callback. The matcher is passed as the first positional argument, before any keyword arguments. + +| Matcher type | Behavior | Example | +|---|---|---| +| `nil` (omitted) | Catch-all, fires for every tool | `h.before_tool_use { \|i, c\| ... }` | +| `String` | Treated as a regex pattern | `h.before_tool_use("Bash") { \|i, c\| ... }` | +| `Regexp` | Normalized to its `source` string | `h.before_tool_use(/Bash\|Write/) { \|i, c\| ... }` | + +A `Regexp` is converted to its `.source` string internally so it can be serialized over the control protocol. This means flags like `Regexp::IGNORECASE` are not preserved. + +### Timeout + +Pass a `timeout:` keyword argument to set a per-hook timeout in seconds: + +```ruby +ClaudeAgent.hooks do |h| + h.before_tool_use("Bash", timeout: 30) do |input, ctx| + # Must return within 30 seconds + { continue_: validate_command(input.tool_input) } + end +end +``` + +### Chaining + +Each DSL method returns `self`, so you can chain registrations: + +```ruby +hooks = ClaudeAgent::HookRegistry.new +hooks + .before_tool_use("Bash") { |i, _| { continue_: true } } + .after_tool_use { |i, _| { continue_: true } } + .on_stop { |i, _| { continue_: true } } +``` + +### Merging registries + +Combine two registries additively with `merge`. The original registries are not modified. + +```ruby +security_hooks = ClaudeAgent::HookRegistry.new do |h| + h.before_tool_use(/Bash|Write/) { |i, _| audit(i); { continue_: true } } +end + +logging_hooks = ClaudeAgent::HookRegistry.new do |h| + h.on_session_start { |i, _| log_start(i); { continue_: true } } + h.on_session_end { |i, _| log_end(i); { continue_: true } } +end + +combined = security_hooks.merge(logging_hooks) +# combined has 1 PreToolUse matcher + 1 SessionStart matcher + 1 SessionEnd matcher + +ClaudeAgent.configure do |c| + c.default_hooks = combined +end +``` + +### Multiple matchers per event + +You can register multiple callbacks for the same event. Each produces a separate `HookMatcher`: + +```ruby +ClaudeAgent.hooks do |h| + h.before_tool_use("Bash") { |i, _| log_bash(i); { continue_: true } } + h.before_tool_use("Write") { |i, _| validate_write(i); { continue_: true } } + h.before_tool_use { |i, _| audit_all(i); { continue_: true } } +end +``` + +## Event Mapping Table + +All 22 hook events with their Ruby DSL method, CLI event name, and description: + +| Ruby method | CLI event | Description | +|--------------------------|----------------------|-------------------------------------------------| +| `before_tool_use` | `PreToolUse` | Before a tool is executed. Can block execution. | +| `after_tool_use` | `PostToolUse` | After a tool executes successfully. | +| `after_tool_use_failure` | `PostToolUseFailure` | After a tool execution fails. | +| `on_notification` | `Notification` | When the CLI emits a notification. | +| `on_user_prompt_submit` | `UserPromptSubmit` | When a user prompt is submitted. | +| `on_session_start` | `SessionStart` | When a session begins. | +| `on_session_end` | `SessionEnd` | When a session ends. | +| `on_stop` | `Stop` | When the agent stops. | +| `on_subagent_start` | `SubagentStart` | When a subagent is spawned. | +| `on_subagent_stop` | `SubagentStop` | When a subagent stops. | +| `before_compact` | `PreCompact` | Before context compaction. | +| `after_compact` | `PostCompact` | After context compaction. | +| `on_permission_request` | `PermissionRequest` | When a permission prompt is shown. | +| `on_setup` | `Setup` | During initialization or maintenance. | +| `on_teammate_idle` | `TeammateIdle` | When a teammate agent becomes idle. | +| `on_task_completed` | `TaskCompleted` | When an agent task completes. | +| `on_elicitation` | `Elicitation` | When an MCP server requests user input. | +| `on_elicitation_result` | `ElicitationResult` | After an elicitation is resolved. | +| `on_config_change` | `ConfigChange` | When a configuration file changes. | +| `on_worktree_create` | `WorktreeCreate` | When a git worktree is created. | +| `on_worktree_remove` | `WorktreeRemove` | When a git worktree is removed. | +| `on_instructions_loaded` | `InstructionsLoaded` | When instructions files are loaded. | + +## Hook Input Types + +Every hook callback receives `(input, context)`. The `input` argument is a subclass of `BaseHookInput`. + +### Base fields + +All input types inherit these fields from `BaseHookInput`: + +| Field | Type | Description | +|-------------------|----------|-------------------------------------------| +| `hook_event_name` | `String` | The CLI event name (e.g., `"PreToolUse"`) | +| `session_id` | `String` | Current session ID | +| `transcript_path` | `String` | Path to the session transcript file | +| `cwd` | `String` | Current working directory | +| `permission_mode` | `String` | Active permission mode | +| `agent_id` | `String` | Agent identifier | +| `agent_type` | `String` | Agent type | + +### Event-specific fields + +| CLI event | Input class | Key fields | +|----------------------|---------------------------|-----------------------------------------------------------------------------------------------------------------------------------------| +| `PreToolUse` | `PreToolUseInput` | `tool_name`, `tool_input`, `tool_use_id` | +| `PostToolUse` | `PostToolUseInput` | `tool_name`, `tool_input`, `tool_response`, `tool_use_id` | +| `PostToolUseFailure` | `PostToolUseFailureInput` | `tool_name`, `tool_input`, `error`, `tool_use_id`, `is_interrupt` | +| `Notification` | `NotificationInput` | `message`, `title`, `notification_type` | +| `UserPromptSubmit` | `UserPromptSubmitInput` | `prompt` | +| `SessionStart` | `SessionStartInput` | `source`, `agent_type`, `model` | +| `SessionEnd` | `SessionEndInput` | `reason` | +| `Stop` | `StopInput` | `stop_hook_active`, `last_assistant_message` | +| `SubagentStart` | `SubagentStartInput` | `agent_id`, `agent_type` | +| `SubagentStop` | `SubagentStopInput` | `stop_hook_active`, `agent_id`, `agent_transcript_path`, `agent_type`, `last_assistant_message` | +| `PreCompact` | `PreCompactInput` | `trigger`, `custom_instructions` | +| `PostCompact` | `PostCompactInput` | `trigger`, `compact_summary` | +| `PermissionRequest` | `PermissionRequestInput` | `tool_name`, `tool_input`, `permission_suggestions` | +| `Setup` | `SetupInput` | `trigger` (also has `init?` and `maintenance?` predicates) | +| `TeammateIdle` | `TeammateIdleInput` | `teammate_name`, `team_name` | +| `TaskCompleted` | `TaskCompletedInput` | `task_id`, `task_subject`, `task_description`, `teammate_name`, `team_name` | +| `Elicitation` | `ElicitationInput` | `mcp_server_name`, `message`, `mode`, `url`, `elicitation_id`, `requested_schema` | +| `ElicitationResult` | `ElicitationResultInput` | `mcp_server_name`, `action`, `elicitation_id`, `mode`, `content` | +| `ConfigChange` | `ConfigChangeInput` | `source`, `file_path` (constant: `SOURCES`) | +| `WorktreeCreate` | `WorktreeCreateInput` | `name` | +| `WorktreeRemove` | `WorktreeRemoveInput` | `worktree_path` | +| `InstructionsLoaded` | `InstructionsLoadedInput` | `file_path`, `memory_type`, `load_reason`, `globs`, `trigger_file_path`, `parent_file_path` (constants: `MEMORY_TYPES`, `LOAD_REASONS`) | + +### Context + +The `context` argument is a `Hash` with: + +| Field | Type | Description | +|---------------|----------|--------------------------------------------------| +| `tool_use_id` | `String` | The tool use ID (present for tool-related hooks) | + +## Hook Response Format + +Callbacks must return a `Hash`. The SDK normalizes Ruby-style keys to the camelCase format expected by the CLI. + +### Key mapping + +| Ruby key | CLI key | Type | Description | +|------------------------|----------------------|-----------|-------------------------------------------------------------------------------------------------------| +| `continue_` | `continue` | `Boolean` | Whether to continue execution. Note the trailing underscore -- `continue` is a reserved word in Ruby. | +| `decision` | `decision` | `String` | Permission decision: `"allow"` or `"deny"` | +| `reason` | `reason` | `String` | Explanation for the decision | +| `suppress_output` | `suppressOutput` | `Boolean` | Whether to suppress tool output | +| `stop_reason` | `stopReason` | `String` | Reason for stopping | +| `system_message` | `systemMessage` | `String` | System message to inject | +| `async_` | `async` | `Boolean` | Whether to handle asynchronously | +| `async_timeout` | `asyncTimeout` | `Integer` | Timeout for async operations | +| `hook_specific_output` | `hookSpecificOutput` | `Hash` | Event-specific output (keys are auto-camelCased) | + +The plain `continue` key also works (it is mapped identically), but `continue_` is preferred for consistency with Ruby conventions. + +### Common response patterns + +**Allow execution to proceed:** + +```ruby +{ continue_: true } +``` + +**Block execution:** + +```ruby +{ continue_: false } +``` + +**Block with a reason:** + +```ruby +{ continue_: false, reason: "Writes to .env files are not allowed" } +``` + +**Inject a system message:** + +```ruby +{ continue_: true, system_message: "Remember to add tests for any new code." } +``` + +**Suppress tool output:** + +```ruby +{ continue_: true, suppress_output: true } +``` + +## Raw Options Approach + +As an alternative to the DSL, you can construct the hooks hash directly using `HookMatcher` instances. This is the underlying format that `HookRegistry#to_hooks_hash` produces. + +```ruby +hooks = { + "PreToolUse" => [ + ClaudeAgent::HookMatcher.new( + matcher: "Bash|Write", + callbacks: [ + ->(input, ctx) { { continue_: true } } + ], + timeout: 30 + ) + ], + "SessionStart" => [ + ClaudeAgent::HookMatcher.new( + matcher: nil, + callbacks: [ + ->(input, ctx) { puts "Session started"; { continue_: true } } + ] + ) + ] +} + +opts = ClaudeAgent::Options.new(hooks: hooks) +turn = ClaudeAgent.ask("Hello", options: opts) +``` + +Each key is a CLI event name string (e.g., `"PreToolUse"`). Each value is an array of `HookMatcher` instances. A `HookMatcher` is a `Data.define` with three fields: + +| Field | Type | Description | +|-------------|------------------|--------------------------------------------------------------| +| `matcher` | `String`, `nil` | Regex pattern string to match tool names. `nil` matches all. | +| `callbacks` | `Array` | Array of callback procs. Each receives `(input, context)`. | +| `timeout` | `Integer`, `nil` | Optional timeout in seconds. | + +`HookMatcher#matches?(tool_name)` tests whether a tool name matches the pattern. A pipe-separated string like `"Bash|Write"` matches if the tool name equals any segment; other strings are treated as regex patterns. + +## Hook Lifecycle Messages + +When hooks execute, the CLI emits lifecycle messages that appear in your message stream: + +| Message type | Class | Description | +|---------------|-----------------------|----------------------------------------------------------| +| Hook started | `HookStartedMessage` | Emitted when hook execution begins | +| Hook progress | `HookProgressMessage` | Reports progress during execution (stdout/stderr/output) | +| Hook response | `HookResponseMessage` | Final result with exit code and outcome | + +`HookResponseMessage` provides convenience predicates: `success?`, `error?`, `cancelled?`. + +```ruby +ClaudeAgent.ask("Run tests") do |msg| + case msg + when ClaudeAgent::HookStartedMessage + puts "Hook #{msg.hook_name} started (event: #{msg.hook_event})" + when ClaudeAgent::HookResponseMessage + if msg.error? + warn "Hook #{msg.hook_name} failed: #{msg.stderr}" + end + end +end +``` + +## Full Example + +```ruby +# Global audit hooks +ClaudeAgent.hooks do |h| + h.before_tool_use(/Bash/) do |input, _ctx| + command = input.tool_input[:command] || input.tool_input["command"] + if command&.include?("rm -rf") + { continue_: false, reason: "Destructive commands are blocked" } + else + { continue_: true } + end + end + + h.before_tool_use("Write") do |input, _ctx| + path = input.tool_input[:file_path] || input.tool_input["file_path"] + if path&.match?(/\.(env|pem|key)$/) + { continue_: false, reason: "Cannot write to sensitive files" } + else + { continue_: true } + end + end + + h.after_tool_use do |input, _ctx| + log_tool_use(input.tool_name, input.tool_input) + { continue_: true } + end + + h.on_session_start do |input, _ctx| + puts "Session started (source=#{input.source}, model=#{input.model})" + { continue_: true } + end + + h.on_stop do |input, _ctx| + puts "Agent stopped" + { continue_: true } + end +end + +# Per-conversation hooks layered on top +review_hooks = ClaudeAgent::HookRegistry.new do |h| + h.before_tool_use("Write") do |input, _ctx| + { continue_: true, system_message: "Always add inline comments explaining changes." } + end +end + +turn = ClaudeAgent.ask("Refactor the auth module", hooks: review_hooks) +puts turn.text +``` diff --git a/docs/logging.md b/docs/logging.md new file mode 100644 index 0000000..895c002 --- /dev/null +++ b/docs/logging.md @@ -0,0 +1,96 @@ +# Logging + +The SDK ships with a `NullLogger` by default -- zero overhead, no output. Enable logging when you need visibility into transport lifecycle, message routing, and protocol decisions. + +## Quick Debug + +Turn on debug logging to stderr with one call: + +```ruby +ClaudeAgent.debug! +``` + +Log to a file instead: + +```ruby +ClaudeAgent.debug!(output: File.open("claude_agent.log", "a")) +``` + +Or set the environment variable before your process starts: + +```bash +CLAUDE_AGENT_DEBUG=1 ruby my_script.rb +``` + +## Custom Logger + +Assign any `Logger`-compatible instance at the module level. All queries and conversations will use it unless overridden per-query. + +```ruby +ClaudeAgent.logger = Logger.new($stderr, level: :info) +``` + +To use the SDK's compact formatter with a custom logger: + +```ruby +ClaudeAgent.logger = Logger.new($stderr, level: :debug).tap do |l| + l.formatter = ClaudeAgent::LOG_FORMATTER +end +``` + +## Per-Query Logger + +Pass a `logger` to `Options` to override the module-level logger for a single query or conversation. This is useful when running multiple queries concurrently with separate log destinations. + +```ruby +query_logger = Logger.new("query_debug.log", level: :debug) +query_logger.formatter = ClaudeAgent::LOG_FORMATTER + +turn = ClaudeAgent.ask("What is 2+2?", + logger: query_logger +) +``` + +The resolution order is: `Options#logger` > `ClaudeAgent.logger` > `NullLogger`. This is handled by `Options#effective_logger`. + +## Log Output Format + +All log lines follow this format: + +``` +[ClaudeAgent] [HH:MM:SS.mmm] LEVEL -- tag: message +``` + +Example output: + +``` +[ClaudeAgent] [14:32:01.456] INFO -- transport: Process spawned (pid=12345) +[ClaudeAgent] [14:32:01.457] DEBUG -- transport: Command: claude --print --output-format json +[ClaudeAgent] [14:32:01.789] INFO -- protocol: Starting control protocol (streaming=true) +[ClaudeAgent] [14:32:02.012] INFO -- protocol: Initialize complete +[ClaudeAgent] [14:32:02.345] DEBUG -- parser: Parsing message: assistant +[ClaudeAgent] [14:32:03.678] INFO -- query: Query complete (1.89s, cost=$0.003) +``` + +The `tag` identifies the component: `transport`, `protocol`, `parser`, `query`, `client`, `conversation`, `mcp.`. + +## Log Levels + +| Level | What Gets Logged | +|-----------|-------------------------------------------------------------------------------------------------------------------------------------------| +| **ERROR** | Control protocol request failures, unknown error conditions | +| **WARN** | Force kills, message parse errors, unknown message types, unknown MCP tools | +| **INFO** | Process spawn/close, protocol start/stop, initialize completion, query timing and cost, tool calls, permission decisions, auto-connect | +| **DEBUG** | Full CLI commands, working directory, raw bytes written, message routing, control request/response details, protocol reader thread events | + +## NullLogger + +The default logger. All methods (`debug`, `info`, `warn`, `error`, `fatal`) return `true` immediately without performing any I/O. Level predicates (`debug?`, `info?`, etc.) return `false`, so guarded log blocks are never evaluated: + +```ruby +logger = ClaudeAgent::NullLogger.new +logger.info? # => false +logger.info("transport") { "This is discarded" } # => true (no-op) +``` + +This means logging calls in hot paths have no measurable cost when logging is not enabled. diff --git a/docs/mcp.md b/docs/mcp.md new file mode 100644 index 0000000..685349e --- /dev/null +++ b/docs/mcp.md @@ -0,0 +1,308 @@ +# MCP Tools + +The ClaudeAgent Ruby SDK lets you define MCP (Model Context Protocol) tool servers that run in-process -- in the same Ruby process as your application. Unlike external MCP servers that launch as subprocesses communicating over stdio, SDK servers handle tool calls directly via method dispatch. This means faster execution, shared state with your application, and straightforward debugging. + +## Block DSL (Recommended) + +The most concise way to define an MCP server is the block DSL. Pass a block to `Server.new` and call `tool` to register each tool inline: + +```ruby +server = ClaudeAgent::MCP::Server.new(name: "calculator") do |s| + s.tool("add", "Add two numbers", { a: :number, b: :number }) do |args| + (args[:a] + args[:b]).to_s + end + + s.tool("multiply", "Multiply two numbers", { a: :number, b: :number }) do |args| + (args[:a] * args[:b]).to_s + end +end +``` + +Each call to `s.tool(name, description, schema, &handler)` creates a `ClaudeAgent::MCP::Tool` and registers it on the server. The handler block receives a **symbol-keyed** Hash of the arguments Claude provides. + +## Traditional Approach + +Create `Tool` objects first, then pass them to the server constructor: + +```ruby +add_tool = ClaudeAgent::MCP::Tool.new( + name: "add", + description: "Add two numbers", + schema: { a: Float, b: Float } +) { |args| (args[:a] + args[:b]).to_s } + +subtract_tool = ClaudeAgent::MCP::Tool.new( + name: "subtract", + description: "Subtract two numbers", + schema: { a: Float, b: Float } +) { |args| (args[:a] - args[:b]).to_s } + +server = ClaudeAgent::MCP::Server.new( + name: "calculator", + tools: [add_tool, subtract_tool] +) +``` + +You can also add and remove tools after construction: + +```ruby +server.add_tool(new_tool) +server.remove_tool("old_tool") +``` + +## Convenience Methods + +`ClaudeAgent::MCP` provides module-level shortcuts for creating tools and servers without fully qualifying the class names: + +```ruby +tool = ClaudeAgent::MCP.tool("greet", "Greet someone", { name: String }) do |args| + "Hello, #{args[:name]}!" +end + +server = ClaudeAgent::MCP.create_server(name: "greeter", tools: [tool]) +``` + +## Tool Schema + +The schema defines the JSON Schema for the tool's input parameters. Three formats are supported. + +### Ruby class mapping + +Pass a Hash mapping parameter names to Ruby classes. All parameters are marked as required: + +```ruby +schema: { name: String, age: Integer, score: Float } +``` + +| Ruby Class | JSON Schema type | +|---------------------------|------------------| +| `String` | `string` | +| `Integer` | `integer` | +| `Float`, `Numeric` | `number` | +| `TrueClass`, `FalseClass` | `boolean` | +| `Array` | `array` | +| `Hash` | `object` | + +### Symbol shortcuts + +Use symbols instead of classes for a more compact definition: + +```ruby +schema: { name: :string, count: :integer, ratio: :number, active: :boolean } +``` + +| Symbol | JSON Schema type | +|---------------------------------|------------------| +| `:string`, `:str` | `string` | +| `:integer`, `:int` | `integer` | +| `:number`, `:float`, `:numeric` | `number` | +| `:boolean`, `:bool` | `boolean` | +| `:array` | `array` | +| `:object`, `:hash` | `object` | + +### Raw JSON Schema + +For full control (enums, optional fields, nested objects), pass a standard JSON Schema Hash directly. The SDK detects raw schemas by the presence of a `type` or `properties` key and passes them through unchanged: + +```ruby +schema: { + type: "object", + properties: { + operation: { type: "string", enum: ["add", "subtract", "multiply"] }, + a: { type: "number" }, + b: { type: "number" } + }, + required: ["operation", "a", "b"] +} +``` + +## Tool Annotations + +MCP tool annotations provide hints to the model about tool behavior. Pass an `annotations` Hash when creating a tool: + +```ruby +server = ClaudeAgent::MCP::Server.new(name: "files") do |s| + s.tool("read_file", "Read a file", { path: :string }, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + title: "Read File" + } + ) do |args| + File.read(args[:path]) + end +end +``` + +| Annotation | Type | Description | +|-------------------|---------|---------------------------------------------------| +| `readOnlyHint` | Boolean | Tool does not modify state | +| `destructiveHint` | Boolean | Tool may perform destructive operations | +| `idempotentHint` | Boolean | Repeated calls with same args produce same result | +| `openWorldHint` | Boolean | Tool interacts with external systems | +| `title` | String | Human-readable display name | + +Annotations are omitted from the MCP definition when `nil` or empty. + +## Tool Return Values + +The handler block's return value is automatically formatted into the MCP response structure. Several return types are supported: + +**String** -- wrapped in a text content block: + +```ruby +s.tool("greet", "Greet", { name: :string }) do |args| + "Hello, #{args[:name]}!" +end +# => { content: [{ type: "text", text: "Hello, World!" }], isError: false } +``` + +**Numeric or other non-String** -- converted via `to_s` and wrapped in a text content block: + +```ruby +s.tool("add", "Add", { a: :number, b: :number }) do |args| + args[:a] + args[:b] +end +# => { content: [{ type: "text", text: "7.5" }], isError: false } +``` + +**Hash with `:content` key** -- used as-is, letting you return custom content blocks: + +```ruby +s.tool("image", "Generate image", {}) do |args| + { content: [{ type: "image", data: base64_data, mimeType: "image/png" }] } +end +``` + +**Array** -- treated as a pre-built content block array: + +```ruby +s.tool("multi", "Multiple outputs", {}) do |args| + [ + { type: "text", text: "Part 1" }, + { type: "text", text: "Part 2" } + ] +end +``` + +**Exceptions** -- if the handler raises, the error is caught and returned as an error response: + +```ruby +s.tool("divide", "Divide", { a: :number, b: :number }) do |args| + raise "Division by zero" if args[:b] == 0 + (args[:a] / args[:b]).to_s +end +# When b=0: { content: [{ type: "text", text: "Error: Division by zero" }], isError: true } +``` + +## Using with Options + +To attach an MCP server to a query, pass it via the `mcp_servers` option. Each server entry is keyed by name and must include `type: "sdk"` and `instance:` pointing to the server object: + +```ruby +server = ClaudeAgent::MCP::Server.new(name: "calculator") do |s| + s.tool("add", "Add two numbers", { a: :number, b: :number }) do |args| + (args[:a] + args[:b]).to_s + end +end + +options = ClaudeAgent::Options.new( + mcp_servers: { + "calculator" => { type: "sdk", instance: server } + } +) + +turn = ClaudeAgent.ask("What is 2 + 3?", options: options) +``` + +The server also provides a `to_config` shortcut that builds the config Hash for you: + +```ruby +options = ClaudeAgent::Options.new( + mcp_servers: { + "calculator" => server.to_config + } +) +``` + +## Global Registration + +Register an MCP server globally so it is available to all queries without passing it in every `Options`: + +```ruby +server = ClaudeAgent::MCP::Server.new(name: "calculator") do |s| + s.tool("add", "Add two numbers", { a: :number, b: :number }) do |args| + (args[:a] + args[:b]).to_s + end +end + +ClaudeAgent.register_mcp_server(server) + +# Now every query can use the calculator tools +turn = ClaudeAgent.ask("What is 2 + 3?") +``` + +Globally registered servers are stored in `ClaudeAgent.config.default_mcp_servers` and merged into the effective `Options` for each request. + +## External MCP Servers + +In addition to in-process SDK servers, you can configure external MCP servers that run as subprocesses. These use the `stdio` transport type and are passed directly to the Claude Code CLI: + +```ruby +options = ClaudeAgent::Options.new( + mcp_servers: { + "filesystem" => { + type: "stdio", + command: "npx", + args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"] + }, + "database" => { + type: "stdio", + command: "python", + args: ["-m", "mcp_server_sqlite", "--db", "app.db"] + } + } +) +``` + +You can mix SDK and external servers in the same `mcp_servers` Hash. The SDK filters out external servers and passes their configuration to the CLI's `--mcp-config` flag, while SDK servers are handled in-process. + +## MCP Elicitation + +MCP elicitation allows an MCP server to request additional input from the user (or your application) during a tool call -- for example, to handle OAuth consent or collect form data. + +Set the `on_elicitation` callback in your `Options`. The callback receives a request Hash and must return a response Hash: + +```ruby +options = ClaudeAgent::Options.new( + on_elicitation: ->(request, signal:) { + # request keys: + # :server_name - which MCP server triggered this + # :message - display message from the server + # :mode - elicitation mode + # :url - URL for OAuth flows (if applicable) + # :elicitation_id - unique ID for this elicitation + # :requested_schema - schema describing expected input + + case request[:mode] + when "oauth" + # Handle OAuth flow, return approval + { action: "approve", content: { token: "..." } } + else + # Decline unknown elicitation types + { action: "decline" } + end + } +) +``` + +The callback must return a Hash with an `action` key. Supported actions: + +| Action | Description | +|-------------|--------------------------------------------------------------------| +| `"approve"` | Accept the elicitation and optionally provide `content` | +| `"decline"` | Reject the elicitation (this is the default if no callback is set) | + +If no `on_elicitation` callback is configured, all elicitation requests are declined automatically. diff --git a/docs/messages.md b/docs/messages.md new file mode 100644 index 0000000..4c54caa --- /dev/null +++ b/docs/messages.md @@ -0,0 +1,871 @@ +# Messages & Content Blocks Reference + +Complete reference for all message types and content blocks in the ClaudeAgent Ruby SDK. + +All types are immutable (`Data.define`, frozen at construction). All types include the `ClaudeAgent::Message` module. + +## Message Module + +Every message and content block type includes `ClaudeAgent::Message`, which provides: + +| Method | Returns | Description | +|--------------------|-----------|--------------------------------------------------------------------------------------------------------| +| `text_content` | `String` | Universal text extraction. Works on any message or block type. Returns `""` when no text is available. | +| `session_message?` | `Boolean` | `true` if the message has non-nil `uuid` and `session_id`. | +| `identifiable?` | `Boolean` | `true` if the message has a non-nil `uuid`. | +| `deconstruct_keys` | `Hash` | Injects `:type` as a virtual key for pattern matching. | + +### text_content behavior by type + +| Type | Extracts from | +|------------------------------------|-----------------------------------------------------| +| `AssistantMessage` | Concatenated `TextBlock` text via `#text` | +| `UserMessage`, `UserMessageReplay` | `content` if it is a `String` | +| `TextBlock` | `text` | +| `ThinkingBlock` | `thinking` | +| `StreamEvent` | `delta_text` | +| `GenericMessage` | `raw[:text]` or `raw["text"]` | +| Everything else | `text` if the object responds to it, otherwise `""` | + +## Pattern Matching + +The `Message` module overrides `deconstruct_keys` to inject a `:type` virtual key, enabling Ruby pattern matching on message types: + +```ruby +case message +in { type: :assistant } + puts message.text_content +in { type: :result, is_error: true } + warn "Error: #{message.errors}" +in { type: :result } + puts "Cost: $#{message.total_cost_usd}" +in { type: :system, subtype: "init" } + puts "Session initialized" +in { type: :stream_event } + print message.delta_text +end +``` + +You can also combine pattern matching with field extraction: + +```ruby +case message +in { type: :assistant, model: } + puts "Model: #{model}" +in { type: :hook_response, outcome: "error", stderr: } + warn stderr +end +``` + +--- + +## Message Types + +22 message types, grouped by category. + +### Conversation Messages + +#### UserMessage + +User message sent to Claude. + +```ruby +UserMessage = Data.define(:content, :uuid, :session_id, :parent_tool_use_id) +``` + +| Field | Type | Default | +|----------------------|---------------------|----------| +| `content` | `String` or `Array` | required | +| `uuid` | `String, nil` | `nil` | +| `session_id` | `String, nil` | `nil` | +| `parent_tool_use_id` | `String, nil` | `nil` | + +Methods: + +- `type` -- `:user` +- `text` -- returns `content` if it is a `String`, else `nil` +- `replay?` -- always `false` + +#### UserMessageReplay + +Replayed user message from a resumed session. + +```ruby +UserMessageReplay = Data.define( + :content, :uuid, :session_id, :parent_tool_use_id, + :is_replay, :is_synthetic, :tool_use_result +) +``` + +| Field | Type | Default | +|----------------------|---------------------|----------| +| `content` | `String` or `Array` | required | +| `uuid` | `String, nil` | `nil` | +| `session_id` | `String, nil` | `nil` | +| `parent_tool_use_id` | `String, nil` | `nil` | +| `is_replay` | `Boolean` | `true` | +| `is_synthetic` | `Boolean, nil` | `nil` | +| `tool_use_result` | `Hash, nil` | `nil` | + +Methods: + +- `type` -- `:user` +- `text` -- returns `content` if it is a `String`, else `nil` +- `replay?` -- `true` if `is_replay == true` +- `synthetic?` -- `true` if `is_synthetic == true` + +#### AssistantMessage + +Response from Claude containing content blocks. + +```ruby +AssistantMessage = Data.define(:content, :model, :uuid, :session_id, :error, :parent_tool_use_id) +``` + +| Field | Type | Default | +|----------------------|-----------------------|----------| +| `content` | `Array` | required | +| `model` | `String` | required | +| `uuid` | `String, nil` | `nil` | +| `session_id` | `String, nil` | `nil` | +| `error` | `Hash, nil` | `nil` | +| `parent_tool_use_id` | `String, nil` | `nil` | + +Methods: + +- `type` -- `:assistant` +- `text` -- concatenated text from all `TextBlock`s +- `thinking` -- concatenated text from all `ThinkingBlock`s +- `tool_uses` -- `Array` from content +- `has_tool_use?` -- `true` if any content block is a `ToolUseBlock` + +```ruby +msg.text # => "Hello, world!" +msg.tool_uses # => [#] +msg.has_tool_use? # => true +``` + +### Result + +#### ResultMessage + +Final message with cost, usage, and outcome info. + +```ruby +ResultMessage = Data.define( + :subtype, :duration_ms, :duration_api_ms, :is_error, :num_turns, + :session_id, :uuid, :total_cost_usd, :usage, :result, :structured_output, + :errors, :permission_denials, :model_usage, :stop_reason, :fast_mode_state +) +``` + +| Field | Type | Default | +|----------------------|----------------------|----------| +| `subtype` | `String` | required | +| `duration_ms` | `Integer` | required | +| `duration_api_ms` | `Integer` | required | +| `is_error` | `Boolean` | required | +| `num_turns` | `Integer` | required | +| `session_id` | `String` | required | +| `uuid` | `String, nil` | `nil` | +| `total_cost_usd` | `Float, nil` | `nil` | +| `usage` | `Hash, nil` | `nil` | +| `result` | `String, nil` | `nil` | +| `structured_output` | `Hash, nil` | `nil` | +| `errors` | `Array, nil` | `nil` | +| `permission_denials` | `Array, nil` | `nil` | +| `model_usage` | `Hash, nil` | `nil` | +| `stop_reason` | `String, nil` | `nil` | +| `fast_mode_state` | `Hash, nil` | `nil` | + +Methods: + +- `type` -- `:result` +- `error?` -- `true` if `is_error` +- `success?` -- `true` if not `is_error` + +### System & Status + +#### SystemMessage + +Internal system event (e.g., session init). + +```ruby +SystemMessage = Data.define(:subtype, :data) +``` + +| Field | Type | Default | +|-----------|----------|----------| +| `subtype` | `String` | required | +| `data` | `Hash` | required | + +Methods: + +- `type` -- `:system` + +#### CompactBoundaryMessage + +Conversation compaction marker. + +```ruby +CompactBoundaryMessage = Data.define(:uuid, :session_id, :compact_metadata) +``` + +| Field | Type | Default | +|--------------------|----------|----------| +| `uuid` | `String` | required | +| `session_id` | `String` | required | +| `compact_metadata` | `Hash` | required | + +Methods: + +- `type` -- `:compact_boundary` +- `trigger` -- compaction trigger type (`"manual"` or `"auto"`) +- `pre_tokens` -- token count before compaction + +#### StatusMessage + +Session status report (e.g., `"compacting"`). + +```ruby +StatusMessage = Data.define(:uuid, :session_id, :status, :permission_mode) +``` + +| Field | Type | Default | +|-------------------|---------------|----------| +| `uuid` | `String` | required | +| `session_id` | `String` | required | +| `status` | `String` | required | +| `permission_mode` | `String, nil` | `nil` | + +Methods: + +- `type` -- `:status` + +### Streaming + +#### StreamEvent + +Partial message during streaming. + +```ruby +StreamEvent = Data.define(:uuid, :session_id, :event, :parent_tool_use_id) +``` + +| Field | Type | Default | +|----------------------|---------------|----------| +| `uuid` | `String` | required | +| `session_id` | `String` | required | +| `event` | `Hash` | required | +| `parent_tool_use_id` | `String, nil` | `nil` | + +Methods: + +- `type` -- `:stream_event` +- `event_type` -- raw event type string (e.g., `"content_block_delta"`) +- `delta_text` -- text delta content, or `nil` if not a text delta +- `delta_type` -- delta type string (e.g., `"text_delta"`, `"thinking_delta"`) +- `thinking_text` -- thinking delta text, or `nil` if not a thinking delta +- `content_index` -- content block index within the message + +```ruby +event.delta_text # => "Hello" +event.delta_type # => "text_delta" +event.thinking_text # => nil (only set for thinking deltas) +``` + +#### RateLimitEvent + +Rate limit status and utilization info. + +```ruby +RateLimitEvent = Data.define(:rate_limit_info, :uuid, :session_id) +``` + +| Field | Type | Default | +|-------------------|---------------|----------| +| `rate_limit_info` | `Hash` | required | +| `uuid` | `String, nil` | `nil` | +| `session_id` | `String, nil` | `nil` | + +Methods: + +- `type` -- `:rate_limit_event` +- `status` -- rate limit status string (e.g., `"allowed_warning"`) + +#### PromptSuggestionMessage + +Suggested prompt for the user. + +```ruby +PromptSuggestionMessage = Data.define(:uuid, :session_id, :suggestion) +``` + +| Field | Type | Default | +|--------------|---------------|----------| +| `uuid` | `String, nil` | `nil` | +| `session_id` | `String, nil` | `nil` | +| `suggestion` | `String` | required | + +Methods: + +- `type` -- `:prompt_suggestion` + +### Tool Lifecycle + +#### ToolProgressMessage + +Progress during long-running tool execution. + +```ruby +ToolProgressMessage = Data.define( + :uuid, :session_id, :tool_use_id, :tool_name, + :parent_tool_use_id, :elapsed_time_seconds, :task_id +) +``` + +| Field | Type | Default | +|------------------------|---------------|----------| +| `uuid` | `String` | required | +| `session_id` | `String` | required | +| `tool_use_id` | `String` | required | +| `tool_name` | `String` | required | +| `elapsed_time_seconds` | `Float` | required | +| `parent_tool_use_id` | `String, nil` | `nil` | +| `task_id` | `String, nil` | `nil` | + +Methods: + +- `type` -- `:tool_progress` + +#### ToolUseSummaryMessage + +Summary of tool use for collapsed display. + +```ruby +ToolUseSummaryMessage = Data.define(:uuid, :session_id, :summary, :preceding_tool_use_ids) +``` + +| Field | Type | Default | +|--------------------------|-----------------|----------| +| `uuid` | `String` | required | +| `session_id` | `String` | required | +| `summary` | `String` | required | +| `preceding_tool_use_ids` | `Array` | `[]` | + +Methods: + +- `type` -- `:tool_use_summary` + +#### LocalCommandOutputMessage + +Output from a local command execution. + +```ruby +LocalCommandOutputMessage = Data.define(:uuid, :session_id, :content) +``` + +| Field | Type | Default | +|--------------|----------|---------| +| `uuid` | `String` | `""` | +| `session_id` | `String` | `""` | +| `content` | `String` | `""` | + +Methods: + +- `type` -- `:local_command_output` + +### Hook Lifecycle + +#### HookStartedMessage + +Sent when a hook execution starts. + +```ruby +HookStartedMessage = Data.define(:uuid, :session_id, :hook_id, :hook_name, :hook_event) +``` + +| Field | Type | Default | +|--------------|----------|----------| +| `uuid` | `String` | required | +| `session_id` | `String` | required | +| `hook_id` | `String` | required | +| `hook_name` | `String` | required | +| `hook_event` | `String` | required | + +Methods: + +- `type` -- `:hook_started` + +#### HookProgressMessage + +Progress during hook execution. + +```ruby +HookProgressMessage = Data.define( + :uuid, :session_id, :hook_id, :hook_name, :hook_event, + :stdout, :stderr, :output +) +``` + +| Field | Type | Default | +|--------------|----------|----------| +| `uuid` | `String` | required | +| `session_id` | `String` | required | +| `hook_id` | `String` | required | +| `hook_name` | `String` | required | +| `hook_event` | `String` | required | +| `stdout` | `String` | `""` | +| `stderr` | `String` | `""` | +| `output` | `String` | `""` | + +Methods: + +- `type` -- `:hook_progress` + +#### HookResponseMessage + +Final result of a hook execution. + +```ruby +HookResponseMessage = Data.define( + :uuid, :session_id, :hook_id, :hook_name, :hook_event, + :stdout, :stderr, :output, :exit_code, :outcome +) +``` + +| Field | Type | Default | +|--------------|----------------|----------| +| `uuid` | `String` | required | +| `session_id` | `String` | required | +| `hook_id` | `String, nil` | `nil` | +| `hook_name` | `String` | required | +| `hook_event` | `String` | required | +| `stdout` | `String` | `""` | +| `stderr` | `String` | `""` | +| `output` | `String` | `""` | +| `exit_code` | `Integer, nil` | `nil` | +| `outcome` | `String, nil` | `nil` | + +Methods: + +- `type` -- `:hook_response` +- `success?` -- `true` if `outcome == "success"` +- `error?` -- `true` if `outcome == "error"` +- `cancelled?` -- `true` if `outcome == "cancelled"` + +### Task Lifecycle + +#### TaskStartedMessage + +Sent when a new task (subagent) starts. + +```ruby +TaskStartedMessage = Data.define( + :uuid, :session_id, :task_id, :tool_use_id, + :description, :task_type, :prompt +) +``` + +| Field | Type | Default | +|---------------|---------------|----------| +| `uuid` | `String` | required | +| `session_id` | `String` | required | +| `task_id` | `String` | required | +| `tool_use_id` | `String, nil` | `nil` | +| `description` | `String, nil` | `nil` | +| `task_type` | `String, nil` | `nil` | +| `prompt` | `String, nil` | `nil` | + +Methods: + +- `type` -- `:task_started` + +#### TaskProgressMessage + +Progress during background task (subagent) execution. + +```ruby +TaskProgressMessage = Data.define( + :uuid, :session_id, :task_id, :tool_use_id, + :description, :usage, :last_tool_name, :summary +) +``` + +| Field | Type | Default | +|------------------|---------------|----------| +| `uuid` | `String` | required | +| `session_id` | `String` | required | +| `task_id` | `String` | required | +| `description` | `String` | required | +| `tool_use_id` | `String, nil` | `nil` | +| `usage` | `Hash, nil` | `nil` | +| `last_tool_name` | `String, nil` | `nil` | +| `summary` | `String, nil` | `nil` | + +Methods: + +- `type` -- `:task_progress` + +#### TaskNotificationMessage + +Sent when a background task completes, fails, or is stopped. + +```ruby +TaskNotificationMessage = Data.define( + :uuid, :session_id, :task_id, :status, + :output_file, :summary, :tool_use_id, :usage +) +``` + +| Field | Type | Default | +|---------------|---------------|----------| +| `uuid` | `String` | required | +| `session_id` | `String` | required | +| `task_id` | `String` | required | +| `status` | `String` | required | +| `output_file` | `String` | required | +| `summary` | `String` | required | +| `tool_use_id` | `String, nil` | `nil` | +| `usage` | `Hash, nil` | `nil` | + +Methods: + +- `type` -- `:task_notification` +- `completed?` -- `true` if `status == "completed"` +- `failed?` -- `true` if `status == "failed"` +- `stopped?` -- `true` if `status == "stopped"` + +### Other + +#### FilesPersistedEvent + +Sent when files are persisted to storage. + +```ruby +FilesPersistedEvent = Data.define(:uuid, :session_id, :files, :failed, :processed_at) +``` + +| Field | Type | Default | +|----------------|---------------|----------| +| `uuid` | `String` | required | +| `session_id` | `String` | required | +| `files` | `Array` | `[]` | +| `failed` | `Array` | `[]` | +| `processed_at` | `String, nil` | `nil` | + +Methods: + +- `type` -- `:files_persisted` + +#### ElicitationCompleteMessage + +Sent when an MCP server elicitation request completes. + +```ruby +ElicitationCompleteMessage = Data.define(:uuid, :session_id, :mcp_server_name, :elicitation_id) +``` + +| Field | Type | Default | +|-------------------|----------|---------| +| `uuid` | `String` | `""` | +| `session_id` | `String` | `""` | +| `mcp_server_name` | `String` | `""` | +| `elicitation_id` | `String` | `""` | + +Methods: + +- `type` -- `:elicitation_complete` + +#### AuthStatusMessage + +Authentication status during login flows. + +```ruby +AuthStatusMessage = Data.define(:uuid, :session_id, :is_authenticating, :output, :error) +``` + +| Field | Type | Default | +|---------------------|---------------|----------| +| `uuid` | `String` | required | +| `session_id` | `String` | required | +| `is_authenticating` | `Boolean` | required | +| `output` | `Array` | `[]` | +| `error` | `String, nil` | `nil` | + +Methods: + +- `type` -- `:auth_status` + +#### GenericMessage + +Catch-all for unknown/future protocol message types. Supports dynamic field access. + +```ruby +GenericMessage = Data.define(:message_type, :raw) +``` + +| Field | Type | Default | +|----------------|----------|----------| +| `message_type` | `String` | required | +| `raw` | `Hash` | required | + +Methods: + +- `type` -- `message_type` as a symbol, or `:unknown` +- `to_h` -- returns `raw` +- `[](key)` -- hash-style access into `raw` +- `method_missing` -- dynamic field access into `raw` + +```ruby +msg = GenericMessage.new(message_type: "fancy_new", raw: { data: "hello" }) +msg.type # => :fancy_new +msg[:data] # => "hello" +msg.data # => "hello" +``` + +--- + +## Content Blocks + +8 content block types. Found inside `AssistantMessage#content` arrays. + +### TextBlock + +Plain text content. + +```ruby +TextBlock = Data.define(:text) +``` + +| Field | Type | Default | +|--------|----------|----------| +| `text` | `String` | required | + +Methods: + +- `type` -- `:text` +- `to_h` -- `{ type: "text", text: text }` + +### ThinkingBlock + +Extended thinking content. + +```ruby +ThinkingBlock = Data.define(:thinking, :signature) +``` + +| Field | Type | Default | +|-------------|----------|----------| +| `thinking` | `String` | required | +| `signature` | `String` | required | + +Methods: + +- `type` -- `:thinking` +- `to_h` -- `{ type: "thinking", thinking: thinking, signature: signature }` + +### ToolUseBlock + +Tool use request from Claude. + +```ruby +ToolUseBlock = Data.define(:id, :name, :input) +``` + +| Field | Type | Default | +|---------|----------|----------| +| `id` | `String` | required | +| `name` | `String` | required | +| `input` | `Hash` | required | + +Methods: + +- `type` -- `:tool_use` +- `file_path` -- file path for file-based tools (`Read`, `Write`, `Edit`, `NotebookEdit`), else `nil` +- `display_label` -- one-line human-readable label (e.g., `"Read config/app.rb"`, `"Bash: ls -la"`) +- `summary(max: 60)` -- detailed summary, truncated to `max` characters +- `to_h` -- `{ type: "tool_use", id: id, name: name, input: input }` + +```ruby +block = ToolUseBlock.new(id: "tool_1", name: "Read", input: { file_path: "/tmp/file.rb" }) +block.file_path # => "/tmp/file.rb" +block.display_label # => "Read file.rb" +block.summary # => "Read: /tmp/file.rb" +``` + +### ToolResultBlock + +Result returned from a tool execution. + +```ruby +ToolResultBlock = Data.define(:tool_use_id, :content, :is_error) +``` + +| Field | Type | Default | +|---------------|----------------|----------| +| `tool_use_id` | `String` | required | +| `content` | `String, nil` | `nil` | +| `is_error` | `Boolean, nil` | `nil` | + +Methods: + +- `type` -- `:tool_result` +- `to_h` -- includes `content` and `is_error` only when non-nil + +### ServerToolUseBlock + +Tool use request for an MCP server tool. + +```ruby +ServerToolUseBlock = Data.define(:id, :name, :input, :server_name) +``` + +| Field | Type | Default | +|---------------|----------|----------| +| `id` | `String` | required | +| `name` | `String` | required | +| `input` | `Hash` | required | +| `server_name` | `String` | required | + +Methods: + +- `type` -- `:server_tool_use` +- `file_path` -- file path for file-based tools, else `nil` +- `display_label` -- label with server context (e.g., `"my-server/tool-name"`) +- `summary(max: 60)` -- detailed summary with server context, truncated +- `to_h` -- `{ type: "server_tool_use", id: id, name: name, input: input, server_name: server_name }` + +### ServerToolResultBlock + +Result from an MCP server tool execution. + +```ruby +ServerToolResultBlock = Data.define(:tool_use_id, :content, :is_error, :server_name) +``` + +| Field | Type | Default | +|---------------|----------------|----------| +| `tool_use_id` | `String` | required | +| `server_name` | `String` | required | +| `content` | `String, nil` | `nil` | +| `is_error` | `Boolean, nil` | `nil` | + +Methods: + +- `type` -- `:server_tool_result` +- `to_h` -- includes `content` and `is_error` only when non-nil + +### ImageContentBlock + +Image content (base64-encoded or URL-sourced). + +```ruby +ImageContentBlock = Data.define(:source) +``` + +| Field | Type | Default | +|----------|--------|----------| +| `source` | `Hash` | required | + +Methods: + +- `type` -- `:image` +- `source_type` -- `"base64"` or `"url"` +- `media_type` -- MIME type (e.g., `"image/png"`) +- `data` -- base64-encoded image data +- `url` -- URL for URL-sourced images +- `to_h` -- `{ type: "image", source: source }` + +```ruby +block = ImageContentBlock.new(source: { type: "base64", media_type: "image/png", data: "..." }) +block.source_type # => "base64" +block.media_type # => "image/png" +``` + +### GenericBlock + +Catch-all for unknown/future content block types. Supports dynamic field access. + +```ruby +GenericBlock = Data.define(:block_type, :raw) +``` + +| Field | Type | Default | +|--------------|----------|----------| +| `block_type` | `String` | required | +| `raw` | `Hash` | required | + +Methods: + +- `type` -- `block_type` as a symbol, or `:unknown` +- `to_h` -- returns `raw` +- `[](key)` -- hash-style access into `raw` +- `method_missing` -- dynamic field access into `raw` + +--- + +## Common Patterns + +### Iterating content blocks with case + +```ruby +message.content.each do |block| + case block + when ClaudeAgent::TextBlock + puts block.text + when ClaudeAgent::ThinkingBlock + puts "[thinking] #{block.thinking}" + when ClaudeAgent::ToolUseBlock + puts "Tool: #{block.display_label}" + when ClaudeAgent::ServerToolUseBlock + puts "MCP Tool: #{block.display_label}" + when ClaudeAgent::ImageContentBlock + puts "Image (#{block.media_type})" + else + puts "Unknown block: #{block.type}" + end +end +``` + +### Filtering message streams + +```ruby +messages = ClaudeAgent.query(prompt: "Hello", options: options).to_a + +# Find the final result +result = messages.find { |m| m.is_a?(ClaudeAgent::ResultMessage) } + +# Collect all assistant text +text = messages + .select { |m| m.is_a?(ClaudeAgent::AssistantMessage) } + .map(&:text) + .join + +# Stream deltas +messages.each do |msg| + case msg + when ClaudeAgent::StreamEvent + print msg.delta_text if msg.delta_text + when ClaudeAgent::ResultMessage + puts "\nDone (#{msg.duration_ms}ms, $#{msg.total_cost_usd})" + end +end +``` + +### Using text_content universally + +```ruby +messages.each do |msg| + text = msg.text_content + puts text unless text.empty? +end +``` + +### Checking message identity + +```ruby +messages.select(&:session_message?).each do |msg| + puts "#{msg.type} [#{msg.uuid}] session=#{msg.session_id}" +end +``` diff --git a/docs/permissions.md b/docs/permissions.md new file mode 100644 index 0000000..463d7f9 --- /dev/null +++ b/docs/permissions.md @@ -0,0 +1,611 @@ +# Permissions + +The ClaudeAgent Ruby SDK provides layered control over which tools Claude can +use during a conversation. Start with the declarative `PermissionPolicy` DSL +for most use cases. Drop down to the raw `can_use_tool` lambda when you need +full control. + +## Permission Modes + +Set a CLI-level permission mode via `permission_mode`. This controls how the +Claude Code CLI handles tool-permission prompts before your SDK callback is +ever invoked. + +| Mode | Effect | +|-----------------------|------------------------------------------------| +| `"default"` | CLI prompts for each tool (normal behavior) | +| `"acceptEdits"` | Auto-accept file-edit tools, prompt for others | +| `"plan"` | Plan mode -- no tool execution | +| `"dontAsk"` | Auto-accept all tools, never prompt | +| `"bypassPermissions"` | Skip all permission checks (dangerous) | + +```ruby +options = ClaudeAgent::Options.new( + permission_mode: "acceptEdits" +) +``` + +`"bypassPermissions"` requires an explicit opt-in: + +```ruby +options = ClaudeAgent::Options.new( + permission_mode: "bypassPermissions", + allow_dangerously_skip_permissions: true +) +``` + +Valid modes are defined in `ClaudeAgent::PERMISSION_MODES`. + +--- + +## PermissionPolicy DSL + +`PermissionPolicy` is a declarative builder that compiles allow/deny rules into +a `can_use_tool` lambda. Rules are evaluated in declaration order; first match +wins. + +### Basic usage + +```ruby +policy = ClaudeAgent::PermissionPolicy.new do |p| + p.allow "Read", "Grep", "Glob" + p.deny "Bash", message: "Shell access not allowed" + p.deny_all +end +``` + +### Rule methods + +#### `allow(*tool_names)` + +Allow one or more tools by exact name. + +```ruby +p.allow "Read" +p.allow "Write", "Edit" +``` + +#### `deny(*tool_names, message:, interrupt:)` + +Deny one or more tools by exact name. Optional `message` is returned to +Claude as the denial reason. Set `interrupt: true` to stop the conversation +instead of letting Claude retry with a different approach. + +```ruby +p.deny "Bash", message: "Bash is disabled" +p.deny "Write", "Edit", message: "Read-only mode", interrupt: true +``` + +#### `allow_matching(pattern)` + +Allow tools whose name matches a `Regexp` or string pattern. + +```ruby +p.allow_matching(/^mcp__/) # Regexp +p.allow_matching("^mcp__myserver__") # String (compiled to Regexp) +``` + +#### `deny_matching(pattern, message:, interrupt:)` + +Deny tools whose name matches a pattern. + +```ruby +p.deny_matching(/^Write|^Edit/, message: "Read-only mode") +``` + +#### `allow_all` + +Set the fallback for unmatched tools to allow. + +```ruby +p.deny "Bash" +p.allow_all # everything except Bash is allowed +``` + +#### `deny_all(message:)` + +Set the fallback for unmatched tools to deny. + +```ruby +p.allow "Read", "Grep" +p.deny_all # everything except Read and Grep is denied +``` + +Default message is `"Denied by policy"`. + +#### `ask(&handler)` + +Set a custom fallback handler for tools that don't match any rule. The block +receives `(tool_name, tool_input, context)` and must return a +`PermissionResultAllow` or `PermissionResultDeny`. + +```ruby +p.allow "Read" +p.ask do |name, input, context| + if name.start_with?("mcp__") + ClaudeAgent::PermissionResultAllow.new + else + ClaudeAgent::PermissionResultDeny.new(message: "Unknown tool: #{name}") + end +end +``` + +### First match wins + +Rules are evaluated top-to-bottom. The first matching rule determines the +outcome. If no rule matches and no fallback is set, the default is allow. + +```ruby +policy = ClaudeAgent::PermissionPolicy.new do |p| + p.allow "Bash" # this wins for "Bash" + p.deny "Bash" # never reached +end +``` + +### Compiling to a lambda + +`to_can_use_tool` compiles the policy into a lambda compatible with +`Options#can_use_tool`: + +```ruby +handler = policy.to_can_use_tool +result = handler.call("Read", { file_path: "/tmp/foo" }, context) +result.behavior # => "allow" +``` + +All rule methods return `self`, so you can chain: + +```ruby +policy = ClaudeAgent::PermissionPolicy.new +policy.allow("Read").deny("Bash").deny_all +``` + +### Global permissions + +Set a policy that applies to all `ClaudeAgent.ask` and `ClaudeAgent.chat` +calls: + +```ruby +ClaudeAgent.permissions do |p| + p.allow "Read", "Grep", "Glob" + p.deny "Bash" + p.deny_all +end + +# This call inherits the global policy +turn = ClaudeAgent.ask("Summarize the codebase") +``` + +Per-request `can_use_tool` overrides the global policy. + +### Per-conversation permissions + +Pass a `PermissionPolicy` as `on_permission` in `Conversation`: + +```ruby +policy = ClaudeAgent::PermissionPolicy.new do |p| + p.allow "Read" + p.deny_all +end + +ClaudeAgent::Conversation.open(on_permission: policy) do |c| + c.say("Read the README") +end +``` + +--- + +## Symbol Permission Modes in Conversation + +`Conversation` accepts Ruby symbols for `on_permission` as a shorthand for +CLI permission modes: + +| Symbol | Maps to CLI mode | +|-----------------------|--------------------------| +| `:default` | `"default"` | +| `:accept_edits` | `"acceptEdits"` | +| `:plan` | `"plan"` | +| `:dont_ask` | `"dontAsk"` | +| `:bypass_permissions` | `"bypassPermissions"` | +| `:queue` | Enables permission queue | + +```ruby +ClaudeAgent::Conversation.open(on_permission: :accept_edits) do |c| + c.say("Fix the typo in README.md") +end + +ClaudeAgent::Conversation.open(on_permission: :dont_ask) do |c| + c.say("Refactor the auth module") +end +``` + +When `on_permission` is `nil` (the default), the Conversation enables the +permission queue automatically so permission requests can be resolved +programmatically. + +--- + +## can_use_tool Callback (Advanced) + +For full control, pass a lambda directly as `can_use_tool`. It receives three +arguments and must return a `PermissionResultAllow` or `PermissionResultDeny`. + +```ruby +options = ClaudeAgent::Options.new( + can_use_tool: ->(tool_name, tool_input, context) { + case tool_name + when "Read", "Grep", "Glob" + ClaudeAgent::PermissionResultAllow.new + when "Bash" + if tool_input[:command]&.start_with?("ls") + ClaudeAgent::PermissionResultAllow.new + else + ClaudeAgent::PermissionResultDeny.new( + message: "Only ls commands allowed", + interrupt: false + ) + end + else + ClaudeAgent::PermissionResultDeny.new(message: "Not allowed") + end + } +) +``` + +### ToolPermissionContext + +The third argument is a `ToolPermissionContext` with these fields: + +| Field | Type | Description | +|--------------------------|---------------------|----------------------------------------------------------| +| `permission_suggestions` | `Array, nil` | Suggested permission updates from the CLI | +| `blocked_path` | `String, nil` | Path that triggered the permission check | +| `decision_reason` | `String, nil` | Why the CLI is asking for permission | +| `tool_use_id` | `String, nil` | Unique ID for this tool invocation | +| `agent_id` | `String, nil` | ID of the agent requesting the tool | +| `description` | `String, nil` | Human-readable description of the tool action | +| `signal` | `AbortSignal, nil` | Abort signal for cancellation | +| `request` | `PermissionRequest` | The underlying request object (for hybrid/deferred mode) | + +```ruby +can_use_tool: ->(name, input, context) { + puts "Tool: #{name}" + puts "Reason: #{context.decision_reason}" + puts "Blocked path: #{context.blocked_path}" + + ClaudeAgent::PermissionResultAllow.new +} +``` + +### Auto-configuration + +When `can_use_tool` or `permission_queue` is set, the SDK automatically sets +`permission_prompt_tool_name` to `"stdio"` so the CLI routes permission +prompts through the control protocol instead of interactive terminal prompts. + +--- + +## Permission Results + +Both the `PermissionPolicy` DSL and the raw `can_use_tool` callback return +typed result objects. + +### PermissionResultAllow + +```ruby +# Simple allow +ClaudeAgent::PermissionResultAllow.new + +# Allow with modified input (the tool sees this instead of the original) +ClaudeAgent::PermissionResultAllow.new( + updated_input: { command: "ls -la /tmp" } +) + +# Allow with permission updates (persist new rules) +ClaudeAgent::PermissionResultAllow.new( + updated_permissions: [ + ClaudeAgent::PermissionUpdate.new( + type: "addRules", + rules: [{ tool_name: "Read", rule_content: "/**" }], + behavior: "allow" + ) + ] +) +``` + +Fields: + +| Field | Type | Default | Description | +|-----------------------|--------------------------------|---------|-----------------------------------------------------------------------------| +| `updated_input` | `Hash, nil` | `nil` | Modified tool input | +| `updated_permissions` | `Array, nil` | `nil` | Permission rule updates to apply | +| `tool_use_id` | `String, nil` | `nil` | Tool invocation ID (set automatically when resolving via PermissionRequest) | + +### PermissionResultDeny + +```ruby +# Simple deny +ClaudeAgent::PermissionResultDeny.new(message: "Not allowed") + +# Deny and interrupt the conversation +ClaudeAgent::PermissionResultDeny.new( + message: "Dangerous operation blocked", + interrupt: true +) +``` + +Fields: + +| Field | Type | Default | Description | +|---------------|---------------|---------|-----------------------------------------------------------------------------| +| `message` | `String` | `""` | Denial reason (shown to Claude) | +| `interrupt` | `Boolean` | `false` | If true, stop the conversation entirely | +| `tool_use_id` | `String, nil` | `nil` | Tool invocation ID (set automatically when resolving via PermissionRequest) | + +Both types respond to `behavior` (`"allow"` or `"deny"`) and `to_h` for +serialization. + +--- + +## Permission Queue + +Queue-based permissions let you handle permission requests asynchronously, +which is useful for UI-driven applications where a human reviews each +request. + +### Enabling the queue + +The queue is enabled automatically in `Conversation` when no `on_permission` +or `can_use_tool` is provided: + +```ruby +# Queue is enabled by default +conversation = ClaudeAgent::Conversation.new +``` + +You can also enable it explicitly: + +```ruby +# Via symbol +conversation = ClaudeAgent::Conversation.new(on_permission: :queue) + +# Via Options +options = ClaudeAgent::Options.new(permission_queue: true) +client = ClaudeAgent::Client.new(options: options) +``` + +### Consuming the queue + +Poll for pending requests from a UI thread or event loop. Each request is a +`PermissionRequest` that you resolve by calling `allow!` or `deny!`. + +```ruby +conversation = ClaudeAgent::Conversation.new + +# Send a message in a background thread +thread = Thread.new { conversation.say("Refactor the auth module") } + +# Poll for permission requests from the main thread +loop do + if request = conversation.pending_permission + puts "Tool: #{request.tool_name}" + puts "Input: #{request.input}" + puts "Label: #{request.display_label}" + + # Resolve the request + request.allow! + # or: request.deny!(message: "Not now") + end + + break unless thread.alive? + sleep 0.05 +end + +thread.join +``` + +### PermissionRequest API + +| Method / Field | Description | +|-----------------|--------------------------------------------------------------------| +| `tool_name` | Name of the tool requesting permission | +| `input` | Tool input (Hash with symbol keys) | +| `context` | `ToolPermissionContext` with metadata | +| `request_id` | Unique ID for this request | +| `created_at` | `Time` when the request was created | +| `allow!` | Allow the tool (optional `updated_input:`, `updated_permissions:`) | +| `deny!` | Deny the tool (optional `message:`, `interrupt:`) | +| `defer!` | Mark as deferred (for hybrid mode) | +| `pending?` | `true` if not yet resolved | +| `resolved?` | `true` if resolved | +| `deferred?` | `true` if deferred via `defer!` | +| `result` | The resolution result, or `nil` | +| `wait` | Block until resolved (used internally) | +| `display_label` | Human-readable label (e.g., `"Bash: rm -rf /tmp"`) | +| `summary(max:)` | Detailed summary, truncated to `max` chars | + +### PermissionQueue API + +| Method | Description | +|-------------------|--------------------------------------------------| +| `poll` | Non-blocking; returns next request or `nil` | +| `pop(timeout:)` | Blocking; waits for a request (optional timeout) | +| `empty?` | Whether the queue has pending requests | +| `size` | Number of pending requests | +| `drain!(reason:)` | Deny all pending requests and clear the queue | + +On `Client`, use the convenience methods: + +```ruby +client.pending_permission # => PermissionRequest or nil +client.pending_permissions? # => true/false +``` + +On `Conversation`, the same methods are available: + +```ruby +conversation.pending_permission +conversation.pending_permissions? +``` + +### Thread safety + +`PermissionRequest` uses a `Mutex` and `ConditionVariable` internally. +Multiple threads can safely race to resolve the same request -- the first +wins, and subsequent calls raise `ClaudeAgent::Error`. + +--- + +## Hybrid Mode + +Combine a synchronous `can_use_tool` callback with the deferred queue. In +the callback, return a result for tools you can decide on immediately, and +call `context.request.defer!` for tools that need human review. + +```ruby +options = ClaudeAgent::Options.new( + permission_queue: true, + can_use_tool: ->(name, input, context) { + case name + when "Read", "Grep", "Glob" + # Auto-allow read-only tools + ClaudeAgent::PermissionResultAllow.new + when "Bash" + if input[:command]&.start_with?("ls", "cat", "echo") + ClaudeAgent::PermissionResultAllow.new + else + # Defer dangerous commands to the UI queue + context.request.defer! + end + else + # Defer everything else + context.request.defer! + end + } +) + +client = ClaudeAgent::Client.new(options: options) +client.connect + +# Background: send message and receive response +thread = Thread.new do + client.send_and_receive("Deploy the application") +end + +# Main thread: handle deferred permission requests +loop do + if request = client.pending_permission + puts "Approve #{request.display_label}? (y/n)" + answer = gets.chomp + if answer == "y" + request.allow! + else + request.deny!(message: "User declined") + end + end + + break unless thread.alive? + sleep 0.05 +end + +thread.join +client.disconnect +``` + +When `defer!` is called, the protocol enqueues the request and blocks the +reader thread until the request is resolved via `allow!` or `deny!` from +another thread. + +--- + +## Permission Updates + +Permission updates let you modify the CLI's permission rules at runtime as +part of an allow response. + +### PermissionUpdate + +```ruby +ClaudeAgent::PermissionUpdate.new( + type: "addRules", + rules: [ + { tool_name: "Read", rule_content: "/home/user/project/**" } + ], + behavior: "allow", + destination: "session" +) +``` + +| Field | Type | Description | +|---------------|----------------------|----------------------------------| +| `type` | `String` | Update operation type (required) | +| `rules` | `Array, nil` | Rules to add/replace/remove | +| `behavior` | `String, nil` | `"allow"` or `"deny"` | +| `mode` | `String, nil` | Permission mode (for `setMode`) | +| `directories` | `Array, nil` | Directories to add/remove | +| `destination` | `String, nil` | Where to persist the update | + +#### Update types + +| Type | Purpose | +|-----------------------|------------------------------| +| `"addRules"` | Add new permission rules | +| `"replaceRules"` | Replace all rules for a tool | +| `"removeRules"` | Remove specific rules | +| `"setMode"` | Change the permission mode | +| `"addDirectories"` | Add allowed directories | +| `"removeDirectories"` | Remove allowed directories | + +#### Destinations + +| Destination | Scope | +|---------------------|--------------------------------------| +| `"userSettings"` | User-wide settings | +| `"projectSettings"` | Project `.claude/` settings | +| `"localSettings"` | Local `.claude/*.local.*` settings | +| `"session"` | Current session only | +| `"cliArg"` | CLI argument scope | + +### PermissionRuleValue + +Individual rules use `PermissionRuleValue`: + +```ruby +rule = ClaudeAgent::PermissionRuleValue.new( + tool_name: "Write", + rule_content: "/tmp/**" +) + +rule.to_h # => { toolName: "Write", ruleContent: "/tmp/**" } +``` + +### Applying updates via PermissionResultAllow + +```ruby +can_use_tool: ->(name, input, context) { + ClaudeAgent::PermissionResultAllow.new( + updated_permissions: [ + ClaudeAgent::PermissionUpdate.new( + type: "addRules", + rules: [{ tool_name: name, rule_content: "/**" }], + behavior: "allow", + destination: "session" + ) + ] + ) +} +``` + +### Applying updates via PermissionRequest + +```ruby +request.allow!( + updated_permissions: [ + ClaudeAgent::PermissionUpdate.new( + type: "setMode", + mode: "acceptEdits", + destination: "session" + ) + ] +) +``` diff --git a/docs/queries.md b/docs/queries.md new file mode 100644 index 0000000..d8a3be0 --- /dev/null +++ b/docs/queries.md @@ -0,0 +1,227 @@ +# One-Shot Queries + +One-shot queries send a single prompt to the Claude Code CLI and return the response. No persistent connection or multi-turn state is maintained. For multi-turn conversations, see `Conversation`. + +The SDK provides three query methods at increasing levels of control: + +| Method | Returns | Config integration | Streaming | Best for | +|--------------------------|-----------------------|----------------------------|----------------------|--------------------------------------| +| `ClaudeAgent.ask` | `TurnResult` | Yes (merges global config) | Block form | Most applications | +| `ClaudeAgent.query_turn` | `TurnResult` | No (explicit Options) | Block + EventHandler | Custom transports, event dispatch | +| `ClaudeAgent.query` | `Enumerator` | No (explicit Options) | Enumerator | Full control over message processing | + +## ClaudeAgent.ask + +The primary entry point. Merges per-request keyword arguments with the global `Configuration`, builds an `Options` instance, and delegates to `query_turn`. + +```ruby +turn = ClaudeAgent.ask("What is 2+2?") +puts turn.text # => "4" +puts turn.cost # => 0.002 +``` + +### With configuration overrides + +Keyword arguments override global config for this request only: + +```ruby +turn = ClaudeAgent.ask("Fix the bug in auth.rb", + model: "opus", + max_turns: 5, + permission_mode: "acceptEdits" +) +``` + +### With callbacks + +Pass `on_*` lambdas to receive events as they stream in. The method still returns a `TurnResult` after the turn completes. + +```ruby +turn = ClaudeAgent.ask("Explain Ruby GC", + on_text: ->(text) { print text }, + on_tool_use: ->(tool) { puts "\nUsing: #{tool.name}" }, + on_result: ->(result) { puts "\nCost: $#{result.total_cost_usd}" } +) +``` + +Available callback keys correspond to `EventHandler` events: `on_text`, `on_thinking`, `on_tool_use`, `on_tool_result`, `on_result`, `on_assistant`, `on_stream_event`, `on_message` (catch-all), and others. See `EventHandler::EVENTS` for the full list. + +### With streaming block + +The block receives each raw message as it arrives: + +```ruby +turn = ClaudeAgent.ask("Explain Ruby GC") do |msg| + case msg + when ClaudeAgent::AssistantMessage + print msg.text + when ClaudeAgent::ResultMessage + puts "\nDone in #{msg.duration_ms}ms" + end +end +``` + +### With explicit Options + +Pass a pre-built `Options` to bypass the global `Configuration` entirely: + +```ruby +opts = ClaudeAgent::Options.new( + model: "claude-sonnet-4-5-20250514", + max_turns: 3, + permission_mode: "acceptEdits", + tools: ["Read", "Bash"] +) + +turn = ClaudeAgent.ask("List files in /tmp", options: opts) +``` + +### With global configuration + +Set defaults once, then call `ask` without repeating them: + +```ruby +ClaudeAgent.configure do |c| + c.model = "opus" + c.max_turns = 10 + c.permission_mode = "acceptEdits" +end + +# These calls inherit the global config +turn = ClaudeAgent.ask("Fix the failing test") +turn = ClaudeAgent.ask("Now update the docs", max_turns: 3) # override max_turns +``` + +## ClaudeAgent.query_turn + +Wraps `query` and accumulates all messages into a `TurnResult`. Use this when you need to pass an explicit `Options` or `EventHandler` without going through `Configuration`. + +```ruby +def query_turn(prompt:, options: nil, transport: nil, events: nil, &block) +``` + +### Basic usage + +```ruby +turn = ClaudeAgent.query_turn(prompt: "What is 2+2?") +puts turn.text +puts turn.cost +``` + +### With EventHandler + +Build an `EventHandler` for typed event dispatch: + +```ruby +events = ClaudeAgent::EventHandler.new + .on_text { |text| print text } + .on_tool_use { |tool| puts "Tool: #{tool.name}" } + .on_result { |r| puts "\nCost: $#{r.total_cost_usd}" } + +turn = ClaudeAgent.query_turn( + prompt: "Refactor the parser", + options: ClaudeAgent::Options.new(model: "opus", max_turns: 5), + events: events +) +``` + +### With block + +The block receives each message, just like the block form of `ask`: + +```ruby +turn = ClaudeAgent.query_turn(prompt: "Explain closures") do |msg| + print msg.text if msg.is_a?(ClaudeAgent::AssistantMessage) +end + +puts turn.session_id +``` + +### With custom transport + +Inject a transport for testing or custom subprocess management: + +```ruby +transport = ClaudeAgent::Transport::Subprocess.new(options: opts) +turn = ClaudeAgent.query_turn(prompt: "Hello", options: opts, transport: transport) +``` + +## ClaudeAgent.query + +The lowest-level one-shot interface. Returns an `Enumerator` that yields each `Message` as it arrives from the CLI. You are responsible for iterating and interpreting message types. + +```ruby +def query(prompt:, options: nil, transport: nil) +``` + +### Basic usage with case statement + +```ruby +ClaudeAgent.query(prompt: "What is 2+2?").each do |message| + case message + when ClaudeAgent::SystemMessage + # Init message with session metadata + when ClaudeAgent::AssistantMessage + print message.text + when ClaudeAgent::UserMessage + # Tool results (system-generated) + when ClaudeAgent::StreamEvent + # Streaming deltas + when ClaudeAgent::ResultMessage + puts "\nCost: $#{message.total_cost_usd}" + puts "Duration: #{message.duration_ms}ms" + puts "Session: #{message.session_id}" + end +end +``` + +### Collecting all messages + +```ruby +messages = ClaudeAgent.query(prompt: "Hello").to_a +result = messages.find { |m| m.is_a?(ClaudeAgent::ResultMessage) } +puts result.total_cost_usd +``` + +### With custom options + +```ruby +options = ClaudeAgent::Options.new( + model: "claude-sonnet-4-5-20250514", + max_turns: 5, + permission_mode: "acceptEdits" +) + +ClaudeAgent.query(prompt: "Fix the bug", options: options).each do |message| + # ... +end +``` + +## TurnResult + +All three methods ultimately produce a `TurnResult` (for `query`, you build one yourself or use `query_turn`). Key accessors: + +| Accessor | Type | Description | +|-------------------|-----------------|---------------------------------------------| +| `text` | `String` | All assistant text concatenated | +| `thinking` | `String` | All thinking content concatenated | +| `tool_uses` | `Array` | Tool use blocks from assistant messages | +| `tool_results` | `Array` | Tool result blocks from user messages | +| `tool_executions` | `Array` | Matched `{ tool_use:, tool_result: }` pairs | +| `result` | `ResultMessage` | Final result message (nil if incomplete) | +| `cost` | `Float` | Total cost in USD | +| `usage` | `Hash` | Token usage breakdown | +| `duration_ms` | `Integer` | Wall-clock duration | +| `session_id` | `String` | Session ID for resumption | +| `model` | `String` | Model used | +| `success?` | `Boolean` | Whether the turn completed without error | +| `error?` | `Boolean` | Whether the turn ended with an error | +| `messages` | `Array` | All raw messages received | + +## Choosing the Right Method + +**Use `ask`** when you want the simplest path with global configuration support. This is the right choice for most applications. + +**Use `query_turn`** when you need explicit `Options` or `EventHandler` without going through `Configuration`, or when injecting a custom transport. + +**Use `query`** when you need full control over message iteration -- for example, to build custom accumulators, forward messages to another system, or handle message types that `TurnResult` does not expose. diff --git a/docs/sessions.md b/docs/sessions.md new file mode 100644 index 0000000..03d77ac --- /dev/null +++ b/docs/sessions.md @@ -0,0 +1,335 @@ +# Sessions + +Claude Code CLI persists every conversation as a session on disk. The SDK can find, inspect, mutate, fork, and resume these sessions without spawning a CLI subprocess -- all operations read and write the session JSONL files directly. + +## Session Resource + +The `Session` class wraps `SessionInfo` with a rich, Rails-like API for discovering and working with past sessions. + +### Finding a Session + +Use `Session.find` for a safe lookup that returns `nil` when the session does not exist, or `Session.retrieve` when you want an exception on missing sessions. + +```ruby +# Returns Session or nil (targeted lookup by ID, not a full scan) +session = ClaudeAgent::Session.find("abc-123-def-456") + +# Returns Session or raises ClaudeAgent::NotFoundError +session = ClaudeAgent::Session.retrieve("abc-123-def-456") +``` + +Both accept an optional `dir:` keyword to scope the search to a specific project directory: + +```ruby +session = ClaudeAgent::Session.find("abc-123-def-456", dir: "/path/to/project") +``` + +### Listing Sessions + +```ruby +# All sessions across all projects, sorted by last modified (most recent first) +sessions = ClaudeAgent::Session.all + +# With optional filters +sessions = ClaudeAgent::Session.where(dir: "/path/to/project", limit: 10) +``` + +### Fields + +Every `Session` exposes the following attributes: + +| Field | Type | Description | +|-----------------|------------------|-----------------------------------------------------------------------------------| +| `session_id` | `String` | UUID of the session. | +| `summary` | `String` | Display summary: custom title, last auto-summary, first prompt, or `"(session)"`. | +| `last_modified` | `Integer` | Last modification time as epoch milliseconds. | +| `file_size` | `Integer` | Size of the session JSONL file in bytes. | +| `custom_title` | `String`, `nil` | User-assigned title, if any. | +| `first_prompt` | `String`, `nil` | First meaningful user prompt (truncated to 200 chars). | +| `git_branch` | `String`, `nil` | Git branch active during the session. | +| `cwd` | `String`, `nil` | Working directory the session was started in. | +| `tag` | `String`, `nil` | User-assigned tag, if any. | +| `created_at` | `Integer`, `nil` | Creation timestamp (epoch milliseconds), if available. | + +```ruby +session = ClaudeAgent::Session.retrieve("abc-123-def-456") + +puts session.summary # => "Fix login bug" +puts session.git_branch # => "fix/login" +puts session.custom_title # => nil (no custom title set) +puts session.cwd # => "/Users/dev/myapp" +``` + +### Messages + +`session.messages` returns a chainable, `Enumerable` `SessionMessageRelation`. Messages are loaded lazily on first access. + +```ruby +session = ClaudeAgent::Session.retrieve("abc-123-def-456") + +# All messages +session.messages.each { |m| puts "#{m.type}: #{m.uuid}" } + +# Pagination via .where +session.messages.where(limit: 10).to_a +session.messages.where(limit: 10, offset: 5).to_a + +# Enumerable methods work directly +session.messages.first +session.messages.count +session.messages.select { |m| m.type == "assistant" } +session.messages.map(&:uuid) +``` + +Each message in the relation is a `SessionMessage` with these fields: + +| Field | Type | Description | +|----------------------|-----------------|---------------------------------------------------| +| `type` | `String` | `"user"` or `"assistant"`. | +| `uuid` | `String` | Message UUID. | +| `session_id` | `String` | Session UUID this message belongs to. | +| `message` | `Hash` | Raw message payload (role, content blocks, etc.). | +| `parent_tool_use_id` | `String`, `nil` | Parent tool use ID for tool result messages. | + +### Mutations + +#### Renaming + +```ruby +session.rename("My descriptive title") +session.custom_title # => "My descriptive title" +``` + +Appends a `custom-title` JSONL entry to the session file. The `custom_title` attribute is updated in place. + +#### Tagging + +```ruby +session.tag_session("important") +session.tag # => "important" + +# Clear the tag +session.tag_session(nil) +session.tag # => nil +``` + +Appends a `tag` JSONL entry. Unicode zero-width and directional characters are automatically stripped from tag values. + +Both mutations return `self` for chaining: + +```ruby +session.rename("Refactored auth module").tag_session("refactor") +``` + +### Forking + +Create a new session by copying an existing one. All UUIDs in the forked session are remapped to fresh values. + +```ruby +# Fork the entire session +forked = session.fork +forked.session_id # => new UUID + +# Fork up to a specific message (inclusive) +forked = session.fork(up_to: "message-uuid-here") + +# Fork with a custom title +forked = session.fork(title: "Branch: try alternative approach") +``` + +The returned value is a new `Session` instance pointing to the forked session file. + +### Reloading + +Re-read session metadata from disk to pick up external changes: + +```ruby +session.reload +session.summary # reflects current file state +``` + +Raises `ClaudeAgent::NotFoundError` if the session file no longer exists. + +### Resuming + +Open a `Conversation` that continues from this session. The CLI restores full conversation context from the session transcript. + +```ruby +# Block form -- auto-closes when the block exits +session.resume(model: "opus") do |c| + turn = c.say("Continue where we left off") + puts turn.text +end + +# Without a block -- caller is responsible for closing +conversation = session.resume(max_turns: 5) +conversation.say("What did we discuss last time?") +conversation.close +``` + +Accepts the same keyword arguments as `Conversation.new`. + +## Functional API + +The module-level methods provide direct access to session operations without wrapping results in `Session` objects. These return the underlying data types (`SessionInfo`, `SessionMessage`, `ForkSessionResult`) and are useful when you need lower-level control. + +### `ClaudeAgent.list_sessions` + +```ruby +# All sessions +sessions = ClaudeAgent.list_sessions +# => Array + +# Scoped to a directory with pagination +sessions = ClaudeAgent.list_sessions( + dir: "/path/to/project", + limit: 20, + offset: 10, + include_worktrees: true # default: true +) +``` + +When `dir` is inside a git repository and `include_worktrees` is `true`, sessions from all git worktree paths are included automatically. + +### `ClaudeAgent.get_session_info` + +Targeted lookup of a single session by UUID. Returns `SessionInfo` or `nil`. + +```ruby +info = ClaudeAgent.get_session_info("abc-123-def-456") +info = ClaudeAgent.get_session_info("abc-123-def-456", dir: "/path/to/project") +``` + +### `ClaudeAgent.get_session_messages` + +Read the conversation transcript for a session. Returns user and assistant messages in chronological order, reconstructing the main conversation thread from branches and forks. + +```ruby +messages = ClaudeAgent.get_session_messages("abc-123-def-456") +# => Array + +messages = ClaudeAgent.get_session_messages("abc-123-def-456", + dir: "/path/to/project", + limit: 10, + offset: 5 +) +``` + +### `ClaudeAgent.rename_session` + +```ruby +ClaudeAgent.rename_session("abc-123-def-456", "New title") +ClaudeAgent.rename_session("abc-123-def-456", "New title", dir: "/path/to/project") +``` + +Raises `ArgumentError` if the title is empty, `ClaudeAgent::Error` if the session is not found. + +### `ClaudeAgent.tag_session` + +```ruby +ClaudeAgent.tag_session("abc-123-def-456", "important") +ClaudeAgent.tag_session("abc-123-def-456", nil) # clear tag +ClaudeAgent.tag_session("abc-123-def-456", "v2", dir: "/path/to/project") +``` + +Raises `ClaudeAgent::Error` if the session is not found. + +### `ClaudeAgent.fork_session` + +```ruby +result = ClaudeAgent.fork_session("abc-123-def-456") +# => ForkSessionResult + +result.session_id # => new UUID + +result = ClaudeAgent.fork_session("abc-123-def-456", + up_to_message_id: "msg-uuid", + title: "Forked conversation", + dir: "/path/to/project" +) +``` + +Raises `ArgumentError` if `up_to_message_id` is provided but not found in the session transcript. + +## V2 Session API (Unstable) + +> **Warning:** The V2 Session API is unstable and may change without notice in any release. It is marked `@alpha` in the source and should not be used in production. + +The V2 API provides a lower-level, multi-turn session interface that maps directly to the TypeScript SDK's `SDKSession` pattern. Unlike `Conversation`, it gives you explicit control over send/stream cycles. + +### Creating a Session + +```ruby +session = ClaudeAgent.unstable_v2_create_session( + model: "claude-sonnet-4-5-20250929", + permission_mode: "acceptEdits" +) +``` + +### Sending and Streaming + +```ruby +session.send("Hello, Claude!") + +session.stream.each do |msg| + case msg + when ClaudeAgent::AssistantMessage + print msg.text + when ClaudeAgent::ResultMessage + puts "\nDone!" + end +end +``` + +### Resuming + +```ruby +session = ClaudeAgent.unstable_v2_resume_session( + "session-abc-123", + model: "claude-sonnet-4-5-20250929" +) +session.send("Continue our conversation") +session.stream.each { |msg| puts msg.inspect } +session.close +``` + +### One-Shot Prompt + +```ruby +result = ClaudeAgent.unstable_v2_prompt( + "What files are in this directory?", + model: "claude-sonnet-4-5-20250929" +) +puts result.text +``` + +### SessionOptions + +`SessionOptions` is a `Data.define` type with the following fields: + +| Field | Type | Description | +|----------------------------------|-----------------|----------------------------------------------------| +| `model` | `String` | Model identifier (required). | +| `path_to_claude_code_executable` | `String`, `nil` | Custom path to the Claude Code CLI binary. | +| `env` | `Hash`, `nil` | Environment variables to pass to the CLI process. | +| `allowed_tools` | `Array`, `nil` | Tools the agent is allowed to use. | +| `disallowed_tools` | `Array`, `nil` | Tools the agent is not allowed to use. | +| `can_use_tool` | `Proc`, `nil` | Callback for dynamic tool permission decisions. | +| `hooks` | `Hash`, `nil` | Hook configuration. | +| `permission_mode` | `String`, `nil` | Permission mode (e.g., `"acceptEdits"`, `"plan"`). | + +### Lifecycle + +Always close V2 sessions when done to clean up the underlying CLI subprocess: + +```ruby +session = ClaudeAgent.unstable_v2_create_session(model: "claude-sonnet-4-5-20250929") +begin + session.send("Do something") + session.stream.each { |msg| process(msg) } +ensure + session.close +end + +session.closed? # => true +``` diff --git a/lib/claude_agent.rb b/lib/claude_agent.rb index e70d06d..9e34784 100644 --- a/lib/claude_agent.rb +++ b/lib/claude_agent.rb @@ -13,6 +13,9 @@ require_relative "claude_agent/options" require_relative "claude_agent/content_blocks" require_relative "claude_agent/messages" +require_relative "claude_agent/message" # Shared interface module for all message/block types +require_relative "claude_agent/permission_policy" # Declarative permission DSL +require_relative "claude_agent/hook_registry" # Declarative hooks DSL require_relative "claude_agent/message_parser" require_relative "claude_agent/hooks" require_relative "claude_agent/permissions" @@ -40,10 +43,135 @@ require_relative "claude_agent/get_session_info" # Single session lookup require_relative "claude_agent/fork_session" # Session forking (TypeScript SDK v0.2.76 parity) require_relative "claude_agent/v2_session" # V2 Session API (unstable) +require_relative "claude_agent/configuration" # Stripe-style global config require_relative "claude_agent/session" # Session finder module ClaudeAgent + require "forwardable" + + @config = Configuration.setup + class << self + extend Forwardable + attr_reader :config + + # --- Tier 1 delegators (set once at boot) --- + + def_delegators :@config, :model, :model=, + :permission_mode, :permission_mode=, + :max_turns, :max_turns=, + :max_budget_usd, :max_budget_usd=, + :system_prompt, :system_prompt=, + :append_system_prompt, :append_system_prompt=, + :cli_path, :cli_path=, + :cwd, :cwd=, + :sandbox, :sandbox=, + :debug, :debug=, + :effort, :effort=, + :persist_session, :persist_session=, + :fallback_model, :fallback_model= + + # Block-based bulk configuration. + # + # @example + # ClaudeAgent.configure do |c| + # c.model = "opus" + # c.max_turns = 10 + # end + # + # @yield [Configuration] + # @return [void] + def configure + yield @config + end + + # Reset configuration to defaults. + # @return [Configuration] + def reset_config! + @config = Configuration.setup + end + + # --- Primary entry points --- + + # One-shot query — the simple path returns a TurnResult. + # + # @param prompt [String] The prompt to send to Claude + # @param options [Options, nil] Pre-built Options (bypasses Configuration merge) + # @param kwargs Overrides merged with Configuration defaults + # @yield [Message] Each message as it streams in (optional) + # @return [TurnResult] + # + # @example Simple + # turn = ClaudeAgent.ask("What is 2+2?") + # puts turn.text + # + # @example With overrides + # turn = ClaudeAgent.ask("Fix the bug", model: "opus", max_turns: 5) + # + # @example With streaming + # turn = ClaudeAgent.ask("Explain Ruby") { |msg| print msg.text_content } + # + def ask(prompt, options: nil, **kwargs, &block) + callbacks, config_overrides = extract_callbacks(kwargs) + + opts = options || @config.to_options(**config_overrides) + events = build_events(callbacks) + + query_turn(prompt: prompt, options: opts, events: events, &block) + end + + # Multi-turn conversation — block form auto-cleans, no block returns Conversation. + # + # @param kwargs Overrides merged with Configuration defaults + # @yield [Conversation] Block form with auto-cleanup + # @return [Conversation, Object] Conversation (no block) or block return value + # + # @example Block form + # ClaudeAgent.chat(model: "opus") do |c| + # c.say("Hello") + # c.say("Goodbye") + # end + # + # @example No block + # c = ClaudeAgent.chat(model: "opus") + # c.say("Hello") + # c.close + # + def chat(**kwargs, &block) + # Merge global config defaults into kwargs for Conversation + merged = merge_config_into_kwargs(kwargs) + + if block + Conversation.open(**merged, &block) + else + Conversation.new(**merged) + end + end + + # Set a global permission policy. + # + # @yield [PermissionPolicy] DSL block + # @return [void] + def permissions(&block) + @config.default_permissions = PermissionPolicy.new(&block) + end + + # Set global hooks. + # + # @yield [HookRegistry] DSL block + # @return [void] + def hooks(&block) + @config.default_hooks = HookRegistry.new(&block) + end + + # Register a global MCP server. + # + # @param server [MCP::Server] Server instance + # @return [void] + def register_mcp_server(server) + @config.default_mcp_servers[server.name] = server.to_config + end + # Create a new Conversation # # @see Conversation#initialize @@ -130,5 +258,52 @@ def fork_session(session_id, up_to_message_id: nil, title: nil, dir: nil) def resume_conversation(session_id, **kwargs) Conversation.resume(session_id, **kwargs) end + + private + + # Separate on_* callbacks from config overrides in kwargs. + def extract_callbacks(kwargs) + callbacks = {} + config_overrides = {} + + kwargs.each do |key, value| + if key.to_s.start_with?("on_") || key == :can_use_tool + callbacks[key] = value + else + config_overrides[key] = value + end + end + + [ callbacks, config_overrides ] + end + + # Build an EventHandler from on_* callback kwargs. + def build_events(callbacks) + return nil if callbacks.empty? + + events = EventHandler.new + callbacks.each do |key, value| + next unless value + next if key == :can_use_tool + + event = Conversation::CALLBACK_ALIASES[key] || key.to_s.delete_prefix("on_").to_sym + events.on(event, &value) + end + events.has_handlers? ? events : nil + end + + # Merge global config defaults into Conversation kwargs. + def merge_config_into_kwargs(kwargs) + merged = {} + + # Apply config defaults for fields Conversation forwards to Options + Configuration::ALL_FIELDS.each do |field| + config_val = @config.public_send(field) + merged[field] = config_val unless config_val.nil? + end + + # Per-request kwargs override config + merged.merge(kwargs) + end end end diff --git a/lib/claude_agent/configuration.rb b/lib/claude_agent/configuration.rb new file mode 100644 index 0000000..511426b --- /dev/null +++ b/lib/claude_agent/configuration.rb @@ -0,0 +1,129 @@ +# frozen_string_literal: true + +module ClaudeAgent + # Global configuration object (Stripe-style). + # + # Holds default values for all configurable Options fields. When a + # query, conversation, or ask/chat call is made without explicit + # Options, these defaults are merged with per-request overrides to + # produce the final Options instance. + # + # @example Module-level setters + # ClaudeAgent.model = "claude-sonnet-4-5-20250514" + # ClaudeAgent.permission_mode = "acceptEdits" + # ClaudeAgent.max_turns = 10 + # + # @example Block-based bulk config + # ClaudeAgent.configure do |c| + # c.model = "claude-sonnet-4-5-20250514" + # c.permission_mode = "acceptEdits" + # c.max_turns = 10 + # end + # + # @example Per-request overrides (via ask) + # ClaudeAgent.ask("Fix the bug", model: "opus", max_turns: 5) + # + class Configuration + # Tier 1: Module-level delegators (set once at boot) + TIER1_FIELDS = %i[ + model permission_mode max_turns max_budget_usd + system_prompt append_system_prompt cli_path cwd + sandbox debug effort persist_session fallback_model + ].freeze + + # Tier 2: Per-request overrides (common kwargs on ask/chat) + TIER2_FIELDS = %i[ + tools allowed_tools disallowed_tools thinking output_format + ].freeze + + # Tier 3: Advanced (accessible via config.xxx= or raw Options.new) + TIER3_FIELDS = %i[ + mcp_servers hooks env extra_args agents setting_sources settings + plugins betas spawn_claude_code_process agent add_dirs + max_buffer_size stderr_callback include_partial_messages + enable_file_checkpointing prompt_suggestions strict_mcp_config + tool_config agent_progress_summaries max_thinking_tokens + debug_file + ].freeze + + # All configurable fields + ALL_FIELDS = (TIER1_FIELDS + TIER2_FIELDS + TIER3_FIELDS).freeze + + attr_accessor(*ALL_FIELDS) + + # Global PermissionPolicy + attr_accessor :default_permissions + + # Global HookRegistry + attr_accessor :default_hooks + + # Global MCP server registrations + attr_accessor :default_mcp_servers + + # Create a new Configuration with all fields at nil/default. + # + # @return [Configuration] + def self.setup + new + end + + def initialize + @default_mcp_servers = {} + end + + # Merge config defaults with per-request keyword overrides to produce an Options instance. + # + # nil overrides are ignored (config default wins). Explicit non-nil overrides win. + # + # @param overrides [Hash] Per-request keyword overrides + # @return [Options] + def to_options(**overrides) + merged = {} + + ALL_FIELDS.each do |field| + config_val = public_send(field) + override_val = overrides.key?(field) ? overrides[field] : nil + + # Per-request override wins if provided; config default otherwise + if overrides.key?(field) + merged[field] = override_val + elsif !config_val.nil? + merged[field] = config_val + end + end + + # Forward any extra keys not in ALL_FIELDS (e.g., can_use_tool, permission_queue, etc.) + overrides.each do |key, value| + merged[key] = value unless ALL_FIELDS.include?(key) + end + + # Wire in global permissions if no per-request can_use_tool provided + if default_permissions && !merged.key?(:can_use_tool) && !default_permissions.empty? + merged[:can_use_tool] = default_permissions.to_can_use_tool + end + + # Wire in global hooks (additive merge with per-request hooks) + if default_hooks && !default_hooks.empty? + request_hooks = merged[:hooks] + global_hooks = default_hooks.to_hooks_hash + if request_hooks.is_a?(Hash) + # Merge: global + request (request hooks take precedence for same event) + combined = global_hooks.dup + request_hooks.each do |event, matchers| + combined[event] = (combined[event] || []) + Array(matchers) + end + merged[:hooks] = combined + else + merged[:hooks] = global_hooks + end + end + + # Wire in global MCP servers + if !default_mcp_servers.empty? && !merged.key?(:mcp_servers) + merged[:mcp_servers] = default_mcp_servers.dup + end + + Options.new(**merged) + end + end +end diff --git a/lib/claude_agent/conversation.rb b/lib/claude_agent/conversation.rb index 914adce..97397bf 100644 --- a/lib/claude_agent/conversation.rb +++ b/lib/claude_agent/conversation.rb @@ -226,13 +226,38 @@ def partition_kwargs(kwargs) [ callbacks, conversation_kwargs, options_kwargs ] end + # Symbol → CLI permission mode mapping + SYMBOL_PERMISSION_MODES = { + default: "default", + accept_edits: "acceptEdits", + plan: "plan", + bypass_permissions: "bypassPermissions", + dont_ask: "dontAsk" + }.freeze + def build_options(options_kwargs, conversation_kwargs) permission = conversation_kwargs[:on_permission] - if permission.respond_to?(:call) - options_kwargs[:can_use_tool] = permission - elsif permission == :queue || permission.nil? + case permission + when PermissionPolicy + options_kwargs[:can_use_tool] = permission.to_can_use_tool + when Symbol + if (mode = SYMBOL_PERMISSION_MODES[permission]) + options_kwargs[:permission_mode] = mode + elsif permission == :queue + options_kwargs[:permission_queue] = true unless options_kwargs.key?(:can_use_tool) + end + when nil options_kwargs[:permission_queue] = true unless options_kwargs.key?(:can_use_tool) + else + if permission.respond_to?(:call) + options_kwargs[:can_use_tool] = permission + end + end + + # Handle HookRegistry in hooks option + if options_kwargs[:hooks].is_a?(HookRegistry) + options_kwargs[:hooks] = options_kwargs[:hooks].to_hooks_hash end Options.new(**options_kwargs) diff --git a/lib/claude_agent/errors.rb b/lib/claude_agent/errors.rb index 508c008..d743a6d 100644 --- a/lib/claude_agent/errors.rb +++ b/lib/claude_agent/errors.rb @@ -87,6 +87,9 @@ def initialize(message = "Request timed out", request_id: nil, timeout_seconds: # Raised when an invalid option is provided class ConfigurationError < Error; end + # Raised when a resource is not found (Stripe convention) + class NotFoundError < Error; end + # Raised when an operation is aborted/cancelled (TypeScript SDK parity) # # This error is raised when an operation is explicitly cancelled, diff --git a/lib/claude_agent/event_handler.rb b/lib/claude_agent/event_handler.rb index 7920fd7..e1a85bb 100644 --- a/lib/claude_agent/event_handler.rb +++ b/lib/claude_agent/event_handler.rb @@ -54,6 +54,20 @@ class EventHandler # All known events EVENTS = (META_EVENTS + TYPE_EVENTS + DECOMPOSED_EVENTS).freeze + # Create an EventHandler with block-based DSL. + # + # @example + # handler = ClaudeAgent::EventHandler.define do + # on_text { |t| print t } + # on_result { |r| puts "\nCost: $#{r.total_cost_usd}" } + # end + # + # @yield [self] Block evaluated in the context of the new handler + # @return [EventHandler] + def self.define(&block) + new.tap { |h| h.instance_eval(&block) } + end + def initialize @handlers = Hash.new { |h, k| h[k] = [] } @pending_tool_uses = {} diff --git a/lib/claude_agent/hook_registry.rb b/lib/claude_agent/hook_registry.rb new file mode 100644 index 0000000..6e166dc --- /dev/null +++ b/lib/claude_agent/hook_registry.rb @@ -0,0 +1,110 @@ +# frozen_string_literal: true + +module ClaudeAgent + # Declarative hook registry with Ruby-friendly method names. + # + # Maps idiomatic Ruby method names to CLI hook event names and + # compiles to the Hash format consumed by Options#hooks. + # + # @example Basic usage + # hooks = ClaudeAgent::HookRegistry.new do |h| + # h.before_tool_use(/Bash/) { |input, ctx| { continue_: true } } + # h.after_tool_use("Read") { |input, ctx| { continue_: true } } + # end + # + # @example Module-level convenience + # ClaudeAgent.hooks do |h| + # h.on_session_start { |input, ctx| { continue_: true } } + # end + # + class HookRegistry + # Ruby method name → CLI event name mapping + EVENT_MAP = { + before_tool_use: "PreToolUse", + after_tool_use: "PostToolUse", + after_tool_use_failure: "PostToolUseFailure", + on_notification: "Notification", + on_user_prompt_submit: "UserPromptSubmit", + on_session_start: "SessionStart", + on_session_end: "SessionEnd", + on_stop: "Stop", + on_subagent_start: "SubagentStart", + on_subagent_stop: "SubagentStop", + before_compact: "PreCompact", + after_compact: "PostCompact", + on_permission_request: "PermissionRequest", + on_setup: "Setup", + on_teammate_idle: "TeammateIdle", + on_task_completed: "TaskCompleted", + on_elicitation: "Elicitation", + on_elicitation_result: "ElicitationResult", + on_config_change: "ConfigChange", + on_worktree_create: "WorktreeCreate", + on_worktree_remove: "WorktreeRemove", + on_instructions_loaded: "InstructionsLoaded" + }.freeze + + # @param block [Proc] DSL block yielding self + def initialize(&block) + @matchers = Hash.new { |h, k| h[k] = [] } + yield self if block_given? + end + + # Define DSL methods for each event + EVENT_MAP.each do |method_name, cli_event| + define_method(method_name) do |matcher = nil, timeout: nil, &callback| + @matchers[cli_event] << HookMatcher.new( + matcher: normalize_matcher(matcher), + callbacks: [ callback ], + timeout: timeout + ) + self + end + end + + # Compile to the hooks Hash format consumed by Options. + # + # @return [Hash{String => Array}] + def to_hooks_hash + @matchers.transform_values(&:dup) + end + + # Merge another HookRegistry additively (concatenates matchers per event). + # + # @param other [HookRegistry] Registry to merge + # @return [HookRegistry] New registry with combined matchers + def merge(other) + merged = self.class.new + @matchers.each { |event, matchers| matchers.each { |m| merged.instance_variable_get(:@matchers)[event] << m } } + other.instance_variable_get(:@matchers).each { |event, matchers| matchers.each { |m| merged.instance_variable_get(:@matchers)[event] << m } } + merged + end + + # Whether any hooks have been registered. + # @return [Boolean] + def empty? + @matchers.empty? + end + + # Number of total matchers across all events. + # @return [Integer] + def size + @matchers.values.sum(&:size) + end + + private + + def normalize_matcher(matcher) + case matcher + when Regexp + matcher.source + when String + matcher + when nil + nil + else + matcher.to_s + end + end + end +end diff --git a/lib/claude_agent/mcp/server.rb b/lib/claude_agent/mcp/server.rb index ef5f02d..824d82b 100644 --- a/lib/claude_agent/mcp/server.rb +++ b/lib/claude_agent/mcp/server.rb @@ -35,11 +35,33 @@ class Server # @param name [String] Server name # @param tools [Array] Tools to expose # @param logger [Logger, nil] Optional logger instance + # @yield [self] Optional block for DSL-style tool definition + # + # @example Block DSL + # server = ClaudeAgent::MCP::Server.new(name: "calc") do |s| + # s.tool("add", "Add numbers", { a: :number, b: :number }) { |args| args[:a] + args[:b] } + # end + # def initialize(name:, tools: [], logger: nil) @name = name.to_s @tools = {} @logger = logger tools.each { |tool| add_tool(tool) } + yield self if block_given? + end + + # Define and add a tool in one step (DSL convenience) + # + # @param name [String] Tool name + # @param description [String] Tool description + # @param schema [Hash] Input schema + # @param annotations [Hash, nil] MCP tool annotations + # @yield [Hash] Tool arguments + # @return [Tool] The created tool + def tool(name, description, schema = {}, annotations: nil, &handler) + new_tool = Tool.new(name: name, description: description, schema: schema, annotations: annotations, &handler) + add_tool(new_tool) + new_tool end # Add a tool to the server diff --git a/lib/claude_agent/mcp/tool.rb b/lib/claude_agent/mcp/tool.rb index dfbf2cf..3727e14 100644 --- a/lib/claude_agent/mcp/tool.rb +++ b/lib/claude_agent/mcp/tool.rb @@ -73,12 +73,12 @@ def to_mcp_definition private - # Normalize schema from simple Ruby types to JSON Schema + # Normalize schema from simple Ruby types or symbol shortcuts to JSON Schema def normalize_schema(schema) return schema if json_schema?(schema) - # Convert simple {name: Type} format to JSON Schema - if schema.is_a?(Hash) && schema.values.all? { |v| v.is_a?(Class) || v.is_a?(Module) } + # Convert simple {name: Type} or {name: :type_symbol} format to JSON Schema + if schema.is_a?(Hash) && schema.values.all? { |v| v.is_a?(Class) || v.is_a?(Module) || v.is_a?(Symbol) } { type: "object", properties: schema.transform_values { |type| type_to_schema(type) }, @@ -96,7 +96,28 @@ def json_schema?(schema) schema.key?(:properties) || schema.key?("properties") end + # Symbol type shortcuts for schema definitions + SYMBOL_TYPE_MAP = { + string: "string", + str: "string", + integer: "integer", + int: "integer", + number: "number", + float: "number", + numeric: "number", + boolean: "boolean", + bool: "boolean", + array: "array", + object: "object", + hash: "object" + }.freeze + def type_to_schema(type) + if type.is_a?(Symbol) + json_type = SYMBOL_TYPE_MAP[type] || "string" + return { type: json_type } + end + case type.to_s when "String" { type: "string" } diff --git a/lib/claude_agent/message.rb b/lib/claude_agent/message.rb new file mode 100644 index 0000000..63a9ba4 --- /dev/null +++ b/lib/claude_agent/message.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +module ClaudeAgent + # Shared interface for all message and content block types. + # + # Provides a consistent API across the 22+ message types and 8 content + # block types without interfering with `Data.define` inheritance. + # + # @example Universal text extraction + # message.text_content # works on AssistantMessage, UserMessage, TextBlock, etc. + # + # @example Duck-type checks + # message.session_message? # has uuid + session_id? + # message.identifiable? # has uuid? + # + # @example Pattern matching + # case message + # in { type: :assistant } + # puts message.text_content + # in { type: :result } + # puts message.cost + # end + # + module Message + # Universal text extraction across all message and content block types. + # + # @return [String] Extracted text (empty string if no text available) + def text_content + case self + when AssistantMessage + text + when UserMessage, UserMessageReplay + content.is_a?(String) ? content : "" + when TextBlock + text + when ThinkingBlock + thinking + when StreamEvent + delta_text || "" + when ToolProgressMessage + "" + when ResultMessage + "" + when GenericMessage + raw.is_a?(Hash) ? (raw[:text] || raw["text"] || "").to_s : "" + else + respond_to?(:text) ? (text || "").to_s : "" + end + end + + # Whether this message carries session identity (uuid + session_id). + # + # @return [Boolean] + def session_message? + respond_to?(:uuid) && respond_to?(:session_id) && + !uuid.nil? && !session_id.nil? + end + + # Whether this message has a UUID. + # + # @return [Boolean] + def identifiable? + respond_to?(:uuid) && !uuid.nil? + end + + # Override deconstruct_keys to include :type for pattern matching. + # + # Allows `case msg; in { type: :assistant }` to work naturally. + # + # Data#deconstruct_keys stops early if it encounters a non-member key, + # so we filter out :type (a virtual key) before delegating to super. + # + # @param keys [Array, nil] + # @return [Hash] + def deconstruct_keys(keys) + if keys.nil? + { type: type }.merge(super) + elsif keys.include?(:type) + member_keys = keys - [ :type ] + base = member_keys.empty? ? {} : super(member_keys) + { type: type }.merge(base) + else + super + end + end + end + + # Prepend Message in all message types (prepend needed to override Data#deconstruct_keys) + MESSAGE_TYPES.each { |klass| klass.prepend(Message) } + + # Prepend Message in all content block types + CONTENT_BLOCK_TYPES.each { |klass| klass.prepend(Message) } +end diff --git a/lib/claude_agent/options.rb b/lib/claude_agent/options.rb index 225d840..04d48a7 100644 --- a/lib/claude_agent/options.rb +++ b/lib/claude_agent/options.rb @@ -122,10 +122,20 @@ def validate! "Must set allow_dangerously_skip_permissions: true to use bypassPermissions mode" end + # Auto-compile PermissionPolicy to can_use_tool lambda + if can_use_tool.is_a?(PermissionPolicy) + @can_use_tool = can_use_tool.to_can_use_tool + end + if can_use_tool && !can_use_tool.respond_to?(:call) raise ConfigurationError, "can_use_tool must be callable (Proc, Lambda, or object responding to #call)" end + # Auto-compile HookRegistry to hooks hash + if hooks.is_a?(HookRegistry) + @hooks = hooks.to_hooks_hash + end + if on_elicitation && !on_elicitation.respond_to?(:call) raise ConfigurationError, "on_elicitation must be callable (Proc, Lambda, or object responding to #call)" end diff --git a/lib/claude_agent/permission_policy.rb b/lib/claude_agent/permission_policy.rb new file mode 100644 index 0000000..46433d5 --- /dev/null +++ b/lib/claude_agent/permission_policy.rb @@ -0,0 +1,174 @@ +# frozen_string_literal: true + +module ClaudeAgent + # Declarative permission policy builder. + # + # Compiles allow/deny rules into a `can_use_tool` lambda for use + # with Options and Conversation. Rules are evaluated in order; + # first match wins. + # + # @example Basic usage + # policy = ClaudeAgent::PermissionPolicy.new do |p| + # p.allow "Read", "Grep", "Glob" + # p.deny "Bash", message: "Bash not allowed" + # p.deny_all + # end + # + # @example With regex matching + # policy = ClaudeAgent::PermissionPolicy.new do |p| + # p.allow_matching(/^mcp__/) + # p.deny_all + # end + # + # @example With custom handler for unmatched tools + # policy = ClaudeAgent::PermissionPolicy.new do |p| + # p.allow "Read" + # p.ask { |name, input, ctx| PermissionResultAllow.new } + # end + # + # @example Module-level convenience + # ClaudeAgent.permissions do |p| + # p.allow "Read", "Grep" + # p.deny "Bash" + # end + # + class PermissionPolicy + # @param block [Proc] DSL block yielding self + def initialize(&block) + @rules = [] + @fallback = nil + yield self if block_given? + end + + # Allow specific tools by exact name. + # + # @param tool_names [Array] Tool names to allow + # @return [self] + def allow(*tool_names) + tool_names.flatten.each do |name| + @rules << { type: :exact, name: name.to_s, action: :allow } + end + self + end + + # Deny specific tools by exact name. + # + # @param tool_names [Array] Tool names to deny + # @param message [String] Denial message + # @param interrupt [Boolean] Whether to interrupt the conversation + # @return [self] + def deny(*tool_names, message: "", interrupt: false) + tool_names.flatten.each do |name| + @rules << { type: :exact, name: name.to_s, action: :deny, message: message, interrupt: interrupt } + end + self + end + + # Allow tools matching a pattern. + # + # @param pattern [Regexp, String] Pattern to match against tool names + # @return [self] + def allow_matching(pattern) + @rules << { type: :pattern, pattern: to_regexp(pattern), action: :allow } + self + end + + # Deny tools matching a pattern. + # + # @param pattern [Regexp, String] Pattern to match against tool names + # @param message [String] Denial message + # @param interrupt [Boolean] Whether to interrupt the conversation + # @return [self] + def deny_matching(pattern, message: "", interrupt: false) + @rules << { type: :pattern, pattern: to_regexp(pattern), action: :deny, message: message, interrupt: interrupt } + self + end + + # Set a custom handler for tools that don't match any rule. + # + # @yield [name, input, context] Called for unmatched tools + # @return [self] + def ask(&handler) + @fallback = handler + self + end + + # Set fallback to allow all unmatched tools. + # @return [self] + def allow_all + @fallback = :allow + self + end + + # Set fallback to deny all unmatched tools. + # + # @param message [String] Denial message for unmatched tools + # @return [self] + def deny_all(message: "Denied by policy") + @fallback = { action: :deny, message: message } + self + end + + # Compile this policy into a `can_use_tool` lambda. + # + # @return [Proc] Lambda compatible with Options#can_use_tool + def to_can_use_tool + rules = @rules.dup.freeze + fallback = @fallback + + ->(tool_name, tool_input, context) { + # Check rules in order, first match wins + rules.each do |rule| + next unless matches?(rule, tool_name) + + case rule[:action] + when :allow + return PermissionResultAllow.new + when :deny + return PermissionResultDeny.new(message: rule[:message], interrupt: rule[:interrupt]) + end + end + + # No rule matched, use fallback + case fallback + when :allow + PermissionResultAllow.new + when Hash + PermissionResultDeny.new(message: fallback[:message]) + when Proc + fallback.call(tool_name, tool_input, context) + else + # Default: allow (no policy restriction) + PermissionResultAllow.new + end + } + end + + # Whether any rules have been defined. + # @return [Boolean] + def empty? + @rules.empty? && @fallback.nil? + end + + private + + def matches?(rule, tool_name) + case rule[:type] + when :exact + rule[:name] == tool_name.to_s + when :pattern + rule[:pattern].match?(tool_name.to_s) + else + false + end + end + + def to_regexp(pattern) + case pattern + when Regexp then pattern + when String then Regexp.new(pattern) + else raise ArgumentError, "Pattern must be a Regexp or String, got #{pattern.class}" + end + end + end +end diff --git a/lib/claude_agent/session.rb b/lib/claude_agent/session.rb index 254eb3a..16f759b 100644 --- a/lib/claude_agent/session.rb +++ b/lib/claude_agent/session.rb @@ -1,24 +1,30 @@ # frozen_string_literal: true module ClaudeAgent - # Historical session finder with Rails-like API. + # Historical session finder with Rails-like API and Stripe-style resource methods. # - # Wraps SessionInfo with a rich interface for finding sessions - # and querying their message transcripts. + # Wraps SessionInfo with a rich interface for finding sessions, + # querying their message transcripts, and mutating session metadata. # # @example Find a session by ID # session = ClaudeAgent::Session.find("abc-123") # session.summary # => "Fix login bug" # - # @example List all sessions - # sessions = ClaudeAgent::Session.all(limit: 10) + # @example Retrieve (raises on not found) + # session = ClaudeAgent::Session.retrieve("abc-123") # - # @example Query messages with chainable relation - # session.messages.where(limit: 5).each { |m| puts m.type } + # @example Mutations + # session.rename("My Session") + # session.tag("important") + # forked = session.fork(title: "Branch off") + # + # @example Resume a conversation + # session.resume(model: "opus") { |c| c.say("Continue") } # class Session attr_reader :session_id, :summary, :last_modified, :file_size, - :custom_title, :first_prompt, :git_branch, :cwd + :custom_title, :first_prompt, :git_branch, :cwd, + :tag, :created_at def initialize(session_info) @session_id = session_info.session_id @@ -29,6 +35,8 @@ def initialize(session_info) @first_prompt = session_info.first_prompt @git_branch = session_info.git_branch @cwd = session_info.cwd + @tag = session_info.tag + @created_at = session_info.created_at @dir = session_info.cwd end @@ -39,18 +47,99 @@ def messages SessionMessageRelation.new(session_id, dir: @dir) end + # --- Instance Mutation Methods --- + + # Rename this session. + # + # @param title [String] New title + # @return [self] + def rename(title) + SessionMutations.rename_session(session_id, title, dir: @dir) + @custom_title = title + self + end + + # Tag this session. + # + # @param value [String, nil] Tag value. Pass nil to clear. + # @return [self] + def tag_session(value) + SessionMutations.tag_session(session_id, value, dir: @dir) + @tag = value + self + end + + # Fork this session. + # + # @param up_to [String, nil] Truncate at this message UUID (inclusive) + # @param title [String, nil] Title for the forked session + # @return [Session] The new forked session + def fork(up_to: nil, title: nil) + result = ForkSession.call(session_id, up_to_message_id: up_to, title: title, dir: @dir) + info = GetSessionInfo.call(result.session_id, dir: @dir) + info ? Session.new(info) : Session.new(SessionInfo.new( + session_id: result.session_id, + summary: title || @summary, + last_modified: Time.now.to_i * 1000, + file_size: 0 + )) + end + + # Re-read session metadata from disk. + # + # @return [self] + def reload + info = GetSessionInfo.call(session_id, dir: @dir) + raise NotFoundError, "Session not found: #{session_id}" unless info + + @summary = info.summary + @last_modified = info.last_modified + @file_size = info.file_size + @custom_title = info.custom_title + @first_prompt = info.first_prompt + @git_branch = info.git_branch + @cwd = info.cwd + @tag = info.tag + @created_at = info.created_at + self + end + + # Resume this session as a Conversation. + # + # @param kwargs Options/Conversation keyword arguments + # @yield [Conversation] Block form with auto-cleanup + # @return [Conversation, Object] + def resume(**kwargs, &block) + if block + Conversation.open(resume: session_id, **kwargs, &block) + else + Conversation.resume(session_id, **kwargs) + end + end + class << self - # Find a session by its UUID. + # Find a session by its UUID (targeted lookup, not full scan). # # @param session_id [String] UUID of the session # @param dir [String, nil] Directory to scope the search # @return [Session, nil] The session, or nil if not found def find(session_id, dir: nil) - sessions = ListSessions.call(dir: dir) - info = sessions.find { |s| s.session_id == session_id } + info = GetSessionInfo.call(session_id, dir: dir) info ? new(info) : nil end + # Retrieve a session by UUID. Raises if not found (Stripe convention). + # + # @param session_id [String] UUID of the session + # @param dir [String, nil] Directory to scope the search + # @return [Session] + # @raise [NotFoundError] If session is not found + def retrieve(session_id, dir: nil) + info = GetSessionInfo.call(session_id, dir: dir) + raise NotFoundError, "Session not found: #{session_id}" unless info + new(info) + end + # List all sessions. # # @return [Array] diff --git a/lib/claude_agent/session_paths.rb b/lib/claude_agent/session_paths.rb index 59267d7..5b80117 100644 --- a/lib/claude_agent/session_paths.rb +++ b/lib/claude_agent/session_paths.rb @@ -93,9 +93,12 @@ def find_project_dir(path) # @param path [String] # @return [String] def realpath(path) - File.realpath(path).unicode_normalize(:nfc) + resolved = File.realpath(path) + resolved = resolved.encode("UTF-8") unless resolved.encoding == Encoding::UTF_8 + resolved.unicode_normalize(:nfc) rescue SystemCallError - path.unicode_normalize(:nfc) + safe = path.encode("UTF-8") rescue path + safe.unicode_normalize(:nfc) rescue safe end # Get git worktree paths for a directory. diff --git a/test/claude_agent/test_configuration.rb b/test/claude_agent/test_configuration.rb new file mode 100644 index 0000000..528bc08 --- /dev/null +++ b/test/claude_agent/test_configuration.rb @@ -0,0 +1,266 @@ +# frozen_string_literal: true + +require "test_helper" + +class TestClaudeAgentConfiguration < ActiveSupport::TestCase + setup do + @original_config = ClaudeAgent.config + ClaudeAgent.reset_config! + end + + teardown do + ClaudeAgent.instance_variable_set(:@config, @original_config) + end + + # --- Configuration defaults --- + + test "config starts with nil fields" do + config = ClaudeAgent::Configuration.setup + assert_nil config.model + assert_nil config.permission_mode + assert_nil config.max_turns + assert_nil config.system_prompt + end + + # --- Module-level delegators --- + + test "module-level model setter and getter" do + ClaudeAgent.model = "opus" + assert_equal "opus", ClaudeAgent.model + end + + test "module-level permission_mode" do + ClaudeAgent.permission_mode = "acceptEdits" + assert_equal "acceptEdits", ClaudeAgent.permission_mode + end + + test "module-level max_turns" do + ClaudeAgent.max_turns = 10 + assert_equal 10, ClaudeAgent.max_turns + end + + test "module-level system_prompt" do + ClaudeAgent.system_prompt = "Be helpful" + assert_equal "Be helpful", ClaudeAgent.system_prompt + end + + test "module-level effort" do + ClaudeAgent.effort = "high" + assert_equal "high", ClaudeAgent.effort + end + + # --- Block-based configure --- + + test "configure block sets fields" do + ClaudeAgent.configure do |c| + c.model = "opus" + c.max_turns = 5 + c.system_prompt = "You are helpful" + end + + assert_equal "opus", ClaudeAgent.model + assert_equal 5, ClaudeAgent.max_turns + assert_equal "You are helpful", ClaudeAgent.system_prompt + end + + # --- reset_config! --- + + test "reset_config! clears all fields" do + ClaudeAgent.model = "opus" + ClaudeAgent.max_turns = 10 + ClaudeAgent.reset_config! + + assert_nil ClaudeAgent.model + assert_nil ClaudeAgent.max_turns + end + + # --- to_options --- + + test "to_options merges config defaults" do + config = ClaudeAgent::Configuration.setup + config.model = "opus" + config.max_turns = 10 + + options = config.to_options + assert_equal "opus", options.model + assert_equal 10, options.max_turns + end + + test "to_options per-request overrides win" do + config = ClaudeAgent::Configuration.setup + config.model = "opus" + config.max_turns = 10 + + options = config.to_options(model: "sonnet", max_turns: 5) + assert_equal "sonnet", options.model + assert_equal 5, options.max_turns + end + + test "to_options nil config values do not override Options defaults" do + config = ClaudeAgent::Configuration.setup + options = config.to_options + + # Options defaults should still be in effect + assert_equal [], options.allowed_tools + assert_equal({}, options.mcp_servers) + assert_equal true, options.persist_session + end + + test "to_options forwards extra keys" do + config = ClaudeAgent::Configuration.setup + callback = ->(name, input, ctx) { { behavior: "allow" } } + + options = config.to_options(can_use_tool: callback) + assert_equal callback, options.can_use_tool + end + + test "to_options wires default_permissions" do + config = ClaudeAgent::Configuration.setup + config.default_permissions = ClaudeAgent::PermissionPolicy.new do |p| + p.allow "Read" + p.deny_all + end + + options = config.to_options + assert_not_nil options.can_use_tool + assert options.can_use_tool.respond_to?(:call) + + # Verify the compiled lambda works + result = options.can_use_tool.call("Read", {}, nil) + assert_equal "allow", result.behavior + + result = options.can_use_tool.call("Bash", {}, nil) + assert_equal "deny", result.behavior + end + + test "to_options skips default_permissions when can_use_tool provided" do + config = ClaudeAgent::Configuration.setup + config.default_permissions = ClaudeAgent::PermissionPolicy.new { |p| p.deny_all } + + custom = ->(name, input, ctx) { ClaudeAgent::PermissionResultAllow.new } + options = config.to_options(can_use_tool: custom) + assert_equal custom, options.can_use_tool + end + + test "to_options wires default_hooks" do + config = ClaudeAgent::Configuration.setup + config.default_hooks = ClaudeAgent::HookRegistry.new do |h| + h.before_tool_use("Bash") { |_, _| { continue_: true } } + end + + options = config.to_options + assert options.has_hooks? + assert options.hooks.key?("PreToolUse") + end + + test "to_options wires default_mcp_servers" do + config = ClaudeAgent::Configuration.setup + config.default_mcp_servers["calc"] = { type: "sdk", name: "calc" } + + options = config.to_options + assert_equal({ "calc" => { type: "sdk", name: "calc" } }, options.mcp_servers) + end + + # --- ask --- + + test "ask builds options from config" do + ClaudeAgent.model = "opus" + + # Mock the query_turn method to capture what Options are built + options_captured = nil + ClaudeAgent.stubs(:query_turn).with do |prompt:, options:, events:| + options_captured = options + true + end.returns(ClaudeAgent::TurnResult.new) + + ClaudeAgent.ask("test") + assert_equal "opus", options_captured.model + end + + test "ask per-request overrides" do + ClaudeAgent.model = "opus" + + options_captured = nil + ClaudeAgent.stubs(:query_turn).with do |prompt:, options:, events:| + options_captured = options + true + end.returns(ClaudeAgent::TurnResult.new) + + ClaudeAgent.ask("test", model: "sonnet") + assert_equal "sonnet", options_captured.model + end + + test "ask bypasses config with explicit options" do + ClaudeAgent.model = "opus" + + explicit = ClaudeAgent::Options.new(model: "haiku") + options_captured = nil + ClaudeAgent.stubs(:query_turn).with do |prompt:, options:, events:| + options_captured = options + true + end.returns(ClaudeAgent::TurnResult.new) + + ClaudeAgent.ask("test", options: explicit) + assert_equal "haiku", options_captured.model + end + + # --- chat --- + + test "chat without block returns Conversation" do + result = ClaudeAgent.chat + assert_instance_of ClaudeAgent::Conversation, result + result.close rescue nil + end + + test "chat with block yields and auto-closes" do + transport = MockTransport.new + client = ClaudeAgent::Client.new(transport: transport) + conversation_ref = nil + + transport.add_response({ + "type" => "assistant", + "message" => { "role" => "assistant", "content" => [ { "type" => "text", "text" => "hi" } ], "model" => "claude" } + }) + transport.add_response({ + "type" => "result", "subtype" => "success", + "duration_ms" => 100, "duration_api_ms" => 80, + "is_error" => false, "num_turns" => 1, + "session_id" => "s1", "total_cost_usd" => 0.01, + "usage" => { "input_tokens" => 10, "output_tokens" => 5 } + }) + + ClaudeAgent.chat(client: client) do |c| + conversation_ref = c + c.say("Hello") + end + + assert conversation_ref.closed? + end + + # --- Module-level DSL --- + + test "permissions sets default_permissions" do + ClaudeAgent.permissions do |p| + p.allow "Read" + p.deny_all + end + + assert_instance_of ClaudeAgent::PermissionPolicy, ClaudeAgent.config.default_permissions + end + + test "hooks sets default_hooks" do + ClaudeAgent.hooks do |h| + h.before_tool_use("Bash") { |_, _| {} } + end + + assert_instance_of ClaudeAgent::HookRegistry, ClaudeAgent.config.default_hooks + end + + test "register_mcp_server adds to default_mcp_servers" do + server = ClaudeAgent::MCP::Server.new(name: "calc") + ClaudeAgent.register_mcp_server(server) + + assert ClaudeAgent.config.default_mcp_servers.key?("calc") + assert_equal "sdk", ClaudeAgent.config.default_mcp_servers["calc"][:type] + end +end diff --git a/test/claude_agent/test_global_defaults.rb b/test/claude_agent/test_global_defaults.rb new file mode 100644 index 0000000..922a448 --- /dev/null +++ b/test/claude_agent/test_global_defaults.rb @@ -0,0 +1,168 @@ +# frozen_string_literal: true + +require "test_helper" + +class TestClaudeAgentGlobalDefaults < ActiveSupport::TestCase + setup do + @original_config = ClaudeAgent.config + ClaudeAgent.reset_config! + end + + teardown do + ClaudeAgent.instance_variable_set(:@config, @original_config) + end + + # --- PermissionPolicy flows through to Options --- + + test "PermissionPolicy in Options#validate! compiles to lambda" do + policy = ClaudeAgent::PermissionPolicy.new do |p| + p.allow "Read" + p.deny_all + end + + options = ClaudeAgent::Options.new(can_use_tool: policy) + assert options.can_use_tool.respond_to?(:call) + + result = options.can_use_tool.call("Read", {}, nil) + assert_equal "allow", result.behavior + end + + test "HookRegistry in Options#validate! compiles to hash" do + registry = ClaudeAgent::HookRegistry.new do |h| + h.before_tool_use("Bash") { |_, _| { continue_: true } } + end + + options = ClaudeAgent::Options.new(hooks: registry) + assert options.hooks.is_a?(Hash) + assert options.hooks.key?("PreToolUse") + end + + # --- Symbol permission modes in Conversation --- + + test "on_permission :accept_edits sets permission mode" do + conversation = ClaudeAgent::Conversation.new(on_permission: :accept_edits) + # The conversation was created without error — the permission mode was set + assert_instance_of ClaudeAgent::Conversation, conversation + end + + test "on_permission :dont_ask sets permission mode" do + conversation = ClaudeAgent::Conversation.new(on_permission: :dont_ask) + assert_instance_of ClaudeAgent::Conversation, conversation + end + + test "on_permission with PermissionPolicy compiles" do + policy = ClaudeAgent::PermissionPolicy.new do |p| + p.allow "Read" + p.deny_all + end + + conversation = ClaudeAgent::Conversation.new(on_permission: policy) + assert_instance_of ClaudeAgent::Conversation, conversation + end + + test "HookRegistry in Conversation hooks option compiles" do + registry = ClaudeAgent::HookRegistry.new do |h| + h.before_tool_use("Bash") { |_, _| { continue_: true } } + end + + conversation = ClaudeAgent::Conversation.new(hooks: registry) + assert_instance_of ClaudeAgent::Conversation, conversation + end + + # --- Global defaults flow into ask --- + + test "global permissions flow into ask options" do + ClaudeAgent.permissions do |p| + p.allow "Read" + p.deny_all + end + + options_captured = nil + ClaudeAgent.stubs(:query_turn).with do |prompt:, options:, events:| + options_captured = options + true + end.returns(ClaudeAgent::TurnResult.new) + + ClaudeAgent.ask("test") + + assert_not_nil options_captured.can_use_tool + result = options_captured.can_use_tool.call("Read", {}, nil) + assert_equal "allow", result.behavior + end + + test "global hooks flow into ask options" do + ClaudeAgent.hooks do |h| + h.before_tool_use("Bash") { |_, _| { continue_: true } } + end + + options_captured = nil + ClaudeAgent.stubs(:query_turn).with do |prompt:, options:, events:| + options_captured = options + true + end.returns(ClaudeAgent::TurnResult.new) + + ClaudeAgent.ask("test") + + assert options_captured.has_hooks? + assert options_captured.hooks.key?("PreToolUse") + end + + test "global MCP servers flow into ask options" do + server = ClaudeAgent::MCP::Server.new(name: "calc") + ClaudeAgent.register_mcp_server(server) + + options_captured = nil + ClaudeAgent.stubs(:query_turn).with do |prompt:, options:, events:| + options_captured = options + true + end.returns(ClaudeAgent::TurnResult.new) + + ClaudeAgent.ask("test") + + assert options_captured.mcp_servers.key?("calc") + end + + # --- EventHandler.define --- + + test "EventHandler.define with block DSL" do + texts = [] + handler = ClaudeAgent::EventHandler.define do + on_text { |t| texts << t } + end + + assert handler.has_handlers? + + msg = ClaudeAgent::AssistantMessage.new( + content: [ ClaudeAgent::TextBlock.new(text: "Hello!") ], + model: "claude" + ) + handler.handle(msg) + + assert_equal [ "Hello!" ], texts + end + + # --- MCP block DSL --- + + test "Server block DSL creates tools" do + server = ClaudeAgent::MCP::Server.new(name: "calc") do |s| + s.tool("add", "Add numbers", { a: :number, b: :number }) { |args| (args[:a] + args[:b]).to_s } + end + + assert_equal 1, server.tools.size + assert server.tools["add"] + end + + test "MCP Tool symbol schema shortcuts" do + tool = ClaudeAgent::MCP::Tool.new( + name: "test", + description: "Test", + schema: { name: :string, count: :integer, ratio: :float, active: :boolean } + ) { |_| "ok" } + + props = tool.schema[:properties] + assert_equal "string", props[:name][:type] + assert_equal "integer", props[:count][:type] + assert_equal "number", props[:ratio][:type] + assert_equal "boolean", props[:active][:type] + end +end diff --git a/test/claude_agent/test_hook_registry.rb b/test/claude_agent/test_hook_registry.rb new file mode 100644 index 0000000..de89df7 --- /dev/null +++ b/test/claude_agent/test_hook_registry.rb @@ -0,0 +1,169 @@ +# frozen_string_literal: true + +require "test_helper" + +class TestClaudeAgentHookRegistry < ActiveSupport::TestCase + # --- Basic construction --- + + test "empty registry" do + registry = ClaudeAgent::HookRegistry.new + assert registry.empty? + assert_equal 0, registry.size + end + + test "registry with block" do + registry = ClaudeAgent::HookRegistry.new do |h| + h.before_tool_use { |input, ctx| { continue_: true } } + end + refute registry.empty? + assert_equal 1, registry.size + end + + # --- Event mapping --- + + test "before_tool_use maps to PreToolUse" do + registry = ClaudeAgent::HookRegistry.new do |h| + h.before_tool_use { |input, ctx| { continue_: true } } + end + hooks = registry.to_hooks_hash + assert hooks.key?("PreToolUse") + assert_equal 1, hooks["PreToolUse"].size + end + + test "after_tool_use maps to PostToolUse" do + registry = ClaudeAgent::HookRegistry.new do |h| + h.after_tool_use { |input, ctx| { continue_: true } } + end + hooks = registry.to_hooks_hash + assert hooks.key?("PostToolUse") + end + + test "on_session_start maps to SessionStart" do + registry = ClaudeAgent::HookRegistry.new do |h| + h.on_session_start { |input, ctx| { continue_: true } } + end + hooks = registry.to_hooks_hash + assert hooks.key?("SessionStart") + end + + test "on_stop maps to Stop" do + registry = ClaudeAgent::HookRegistry.new do |h| + h.on_stop { |input, ctx| { continue_: true } } + end + hooks = registry.to_hooks_hash + assert hooks.key?("Stop") + end + + test "all 22 events are mapped" do + assert_equal 22, ClaudeAgent::HookRegistry::EVENT_MAP.size + end + + # --- Matcher normalization --- + + test "string matcher passes through" do + registry = ClaudeAgent::HookRegistry.new do |h| + h.before_tool_use("Bash") { |_, _| { continue_: true } } + end + matcher = registry.to_hooks_hash["PreToolUse"].first + assert_equal "Bash", matcher.matcher + end + + test "regexp matcher normalizes to source string" do + registry = ClaudeAgent::HookRegistry.new do |h| + h.before_tool_use(/Bash|Write/) { |_, _| { continue_: true } } + end + matcher = registry.to_hooks_hash["PreToolUse"].first + assert_equal "Bash|Write", matcher.matcher + end + + test "nil matcher passes through" do + registry = ClaudeAgent::HookRegistry.new do |h| + h.before_tool_use { |_, _| { continue_: true } } + end + matcher = registry.to_hooks_hash["PreToolUse"].first + assert_nil matcher.matcher + end + + # --- Timeout --- + + test "timeout passes through to HookMatcher" do + registry = ClaudeAgent::HookRegistry.new do |h| + h.before_tool_use(timeout: 30) { |_, _| { continue_: true } } + end + matcher = registry.to_hooks_hash["PreToolUse"].first + assert_equal 30, matcher.timeout + end + + # --- Multiple matchers per event --- + + test "multiple matchers for same event" do + registry = ClaudeAgent::HookRegistry.new do |h| + h.before_tool_use("Bash") { |_, _| { continue_: true } } + h.before_tool_use("Write") { |_, _| { continue_: false } } + end + hooks = registry.to_hooks_hash + assert_equal 2, hooks["PreToolUse"].size + assert_equal 2, registry.size + end + + # --- Merge --- + + test "merge is additive" do + r1 = ClaudeAgent::HookRegistry.new do |h| + h.before_tool_use("Bash") { |_, _| { continue_: true } } + end + + r2 = ClaudeAgent::HookRegistry.new do |h| + h.before_tool_use("Write") { |_, _| { continue_: true } } + h.on_stop { |_, _| { continue_: true } } + end + + merged = r1.merge(r2) + hooks = merged.to_hooks_hash + assert_equal 2, hooks["PreToolUse"].size + assert_equal 1, hooks["Stop"].size + assert_equal 3, merged.size + end + + test "merge does not modify originals" do + r1 = ClaudeAgent::HookRegistry.new do |h| + h.before_tool_use("Bash") { |_, _| { continue_: true } } + end + + r2 = ClaudeAgent::HookRegistry.new do |h| + h.before_tool_use("Write") { |_, _| { continue_: true } } + end + + r1.merge(r2) + assert_equal 1, r1.size + assert_equal 1, r2.size + end + + # --- to_hooks_hash format --- + + test "to_hooks_hash returns HookMatcher instances" do + registry = ClaudeAgent::HookRegistry.new do |h| + h.before_tool_use("Bash") { |_, _| { continue_: true } } + end + hooks = registry.to_hooks_hash + matcher = hooks["PreToolUse"].first + assert_instance_of ClaudeAgent::HookMatcher, matcher + end + + test "to_hooks_hash callbacks are wrapped in array" do + callback = ->(_input, _ctx) { { continue_: true } } + registry = ClaudeAgent::HookRegistry.new do |h| + h.before_tool_use("Bash", &callback) + end + matcher = registry.to_hooks_hash["PreToolUse"].first + assert_equal [ callback ], matcher.callbacks + end + + # --- Chaining --- + + test "methods return self for chaining" do + registry = ClaudeAgent::HookRegistry.new + result = registry.before_tool_use { |_, _| {} } + assert_equal registry, result + end +end diff --git a/test/claude_agent/test_message_module.rb b/test/claude_agent/test_message_module.rb new file mode 100644 index 0000000..765fc0a --- /dev/null +++ b/test/claude_agent/test_message_module.rb @@ -0,0 +1,164 @@ +# frozen_string_literal: true + +require "test_helper" + +class TestClaudeAgentMessageModule < ActiveSupport::TestCase + # --- text_content --- + + test "text_content on AssistantMessage" do + msg = ClaudeAgent::AssistantMessage.new( + content: [ ClaudeAgent::TextBlock.new(text: "Hello!") ], + model: "claude" + ) + assert_equal "Hello!", msg.text_content + end + + test "text_content on UserMessage with string content" do + msg = ClaudeAgent::UserMessage.new(content: "Hello!") + assert_equal "Hello!", msg.text_content + end + + test "text_content on UserMessage with array content" do + msg = ClaudeAgent::UserMessage.new(content: [ { type: "text", text: "hi" } ]) + assert_equal "", msg.text_content + end + + test "text_content on TextBlock" do + block = ClaudeAgent::TextBlock.new(text: "block text") + assert_equal "block text", block.text_content + end + + test "text_content on ThinkingBlock" do + block = ClaudeAgent::ThinkingBlock.new(thinking: "thinking...", signature: "abc") + assert_equal "thinking...", block.text_content + end + + test "text_content on ResultMessage" do + msg = ClaudeAgent::ResultMessage.new( + subtype: "success", duration_ms: 100, duration_api_ms: 80, + is_error: false, num_turns: 1, session_id: "s1" + ) + assert_equal "", msg.text_content + end + + test "text_content on StreamEvent with text delta" do + msg = ClaudeAgent::StreamEvent.new( + uuid: "e1", session_id: "s1", + event: { type: "content_block_delta", delta: { type: "text_delta", text: "streamed" } } + ) + assert_equal "streamed", msg.text_content + end + + test "text_content on StreamEvent without text delta" do + msg = ClaudeAgent::StreamEvent.new( + uuid: "e1", session_id: "s1", + event: { type: "content_block_start" } + ) + assert_equal "", msg.text_content + end + + test "text_content on SystemMessage" do + msg = ClaudeAgent::SystemMessage.new(subtype: "init", data: {}) + assert_equal "", msg.text_content + end + + # --- session_message? --- + + test "session_message? true for AssistantMessage with both fields" do + msg = ClaudeAgent::AssistantMessage.new( + content: [], model: "claude", uuid: "u1", session_id: "s1" + ) + assert msg.session_message? + end + + test "session_message? false for AssistantMessage without uuid" do + msg = ClaudeAgent::AssistantMessage.new(content: [], model: "claude") + refute msg.session_message? + end + + test "session_message? false for SystemMessage without uuid" do + msg = ClaudeAgent::SystemMessage.new(subtype: "init", data: {}) + refute msg.session_message? + end + + test "session_message? false for TextBlock" do + block = ClaudeAgent::TextBlock.new(text: "hello") + refute block.session_message? + end + + # --- identifiable? --- + + test "identifiable? true with uuid" do + msg = ClaudeAgent::StatusMessage.new(uuid: "u1", session_id: "s1", status: "ok") + assert msg.identifiable? + end + + test "identifiable? false without uuid" do + block = ClaudeAgent::TextBlock.new(text: "hello") + refute block.identifiable? + end + + # --- deconstruct_keys (pattern matching) --- + + test "deconstruct_keys includes type" do + msg = ClaudeAgent::TextBlock.new(text: "hello") + keys = msg.deconstruct_keys(nil) + assert_equal :text, keys[:type] + assert_equal "hello", keys[:text] + end + + test "deconstruct_keys with specific keys includes type" do + msg = ClaudeAgent::TextBlock.new(text: "hello") + keys = msg.deconstruct_keys([ :type, :text ]) + assert_equal :text, keys[:type] + assert_equal "hello", keys[:text] + end + + test "deconstruct_keys without type key omits type" do + msg = ClaudeAgent::TextBlock.new(text: "hello") + keys = msg.deconstruct_keys([ :text ]) + refute keys.key?(:type) + assert_equal "hello", keys[:text] + end + + test "pattern matching works with message types" do + msg = ClaudeAgent::TextBlock.new(text: "hello") + matched = case msg + in { type: :text, text: String => t } + t + else + nil + end + assert_equal "hello", matched + end + + test "pattern matching works with assistant message" do + msg = ClaudeAgent::AssistantMessage.new( + content: [ ClaudeAgent::TextBlock.new(text: "hi") ], + model: "claude" + ) + matched = case msg + in { type: :assistant } + true + else + false + end + assert matched + end + + # --- Module inclusion --- + + test "all MESSAGE_TYPES include Message" do + ClaudeAgent::MESSAGE_TYPES.each do |klass| + assert klass.ancestors.include?(ClaudeAgent::Message), + "#{klass} should include ClaudeAgent::Message" + end + end + + test "all CONTENT_BLOCK_TYPES include Message" do + ClaudeAgent::CONTENT_BLOCK_TYPES.each do |klass| + assert klass.ancestors.include?(ClaudeAgent::Message), + "#{klass} should include ClaudeAgent::Message" + end + end +end diff --git a/test/claude_agent/test_permission_policy.rb b/test/claude_agent/test_permission_policy.rb new file mode 100644 index 0000000..ec7cba5 --- /dev/null +++ b/test/claude_agent/test_permission_policy.rb @@ -0,0 +1,185 @@ +# frozen_string_literal: true + +require "test_helper" + +class TestClaudeAgentPermissionPolicy < ActiveSupport::TestCase + # --- Basic construction --- + + test "empty policy" do + policy = ClaudeAgent::PermissionPolicy.new + assert policy.empty? + end + + test "policy with block" do + policy = ClaudeAgent::PermissionPolicy.new do |p| + p.allow "Read" + end + refute policy.empty? + end + + # --- Allow rules --- + + test "allow exact match" do + policy = ClaudeAgent::PermissionPolicy.new do |p| + p.allow "Read", "Grep" + p.deny_all + end + + handler = policy.to_can_use_tool + result = handler.call("Read", {}, nil) + assert_equal "allow", result.behavior + + result = handler.call("Grep", {}, nil) + assert_equal "allow", result.behavior + end + + test "deny exact match" do + policy = ClaudeAgent::PermissionPolicy.new do |p| + p.deny "Bash", message: "No shell access" + end + + handler = policy.to_can_use_tool + result = handler.call("Bash", {}, nil) + assert_equal "deny", result.behavior + assert_equal "No shell access", result.message + end + + test "deny with interrupt" do + policy = ClaudeAgent::PermissionPolicy.new do |p| + p.deny "Bash", message: "Stop", interrupt: true + end + + handler = policy.to_can_use_tool + result = handler.call("Bash", {}, nil) + assert_equal "deny", result.behavior + assert result.interrupt + end + + # --- Pattern matching --- + + test "allow_matching with regexp" do + policy = ClaudeAgent::PermissionPolicy.new do |p| + p.allow_matching(/^mcp__/) + p.deny_all + end + + handler = policy.to_can_use_tool + result = handler.call("mcp__server__tool", {}, nil) + assert_equal "allow", result.behavior + + result = handler.call("Read", {}, nil) + assert_equal "deny", result.behavior + end + + test "deny_matching with string pattern" do + policy = ClaudeAgent::PermissionPolicy.new do |p| + p.deny_matching("^Write|^Edit", message: "Read-only") + p.allow_all + end + + handler = policy.to_can_use_tool + result = handler.call("Write", {}, nil) + assert_equal "deny", result.behavior + + result = handler.call("Edit", {}, nil) + assert_equal "deny", result.behavior + + result = handler.call("Read", {}, nil) + assert_equal "allow", result.behavior + end + + # --- First match wins --- + + test "first matching rule wins" do + policy = ClaudeAgent::PermissionPolicy.new do |p| + p.allow "Bash" + p.deny "Bash" + end + + handler = policy.to_can_use_tool + result = handler.call("Bash", {}, nil) + assert_equal "allow", result.behavior + end + + # --- Fallback behaviors --- + + test "allow_all fallback" do + policy = ClaudeAgent::PermissionPolicy.new do |p| + p.deny "Bash" + p.allow_all + end + + handler = policy.to_can_use_tool + result = handler.call("Bash", {}, nil) + assert_equal "deny", result.behavior + + result = handler.call("Read", {}, nil) + assert_equal "allow", result.behavior + end + + test "deny_all fallback" do + policy = ClaudeAgent::PermissionPolicy.new do |p| + p.allow "Read" + p.deny_all + end + + handler = policy.to_can_use_tool + result = handler.call("Read", {}, nil) + assert_equal "allow", result.behavior + + result = handler.call("Bash", {}, nil) + assert_equal "deny", result.behavior + end + + test "custom fallback handler" do + policy = ClaudeAgent::PermissionPolicy.new do |p| + p.allow "Read" + p.ask { |name, _input, _ctx| ClaudeAgent::PermissionResultDeny.new(message: "Unknown: #{name}") } + end + + handler = policy.to_can_use_tool + result = handler.call("Bash", {}, nil) + assert_equal "deny", result.behavior + assert_equal "Unknown: Bash", result.message + end + + test "default fallback is allow" do + policy = ClaudeAgent::PermissionPolicy.new do |p| + p.deny "Bash" + end + + handler = policy.to_can_use_tool + result = handler.call("Read", {}, nil) + assert_equal "allow", result.behavior + end + + # --- to_can_use_tool returns PermissionResult types --- + + test "to_can_use_tool returns PermissionResultAllow" do + policy = ClaudeAgent::PermissionPolicy.new { |p| p.allow "Read" } + handler = policy.to_can_use_tool + + result = handler.call("Read", {}, nil) + assert_instance_of ClaudeAgent::PermissionResultAllow, result + end + + test "to_can_use_tool returns PermissionResultDeny" do + policy = ClaudeAgent::PermissionPolicy.new { |p| p.deny "Bash" } + handler = policy.to_can_use_tool + + result = handler.call("Bash", {}, nil) + assert_instance_of ClaudeAgent::PermissionResultDeny, result + end + + # --- Chaining --- + + test "methods return self for chaining" do + policy = ClaudeAgent::PermissionPolicy.new + assert_equal policy, policy.allow("Read") + assert_equal policy, policy.deny("Bash") + assert_equal policy, policy.allow_matching(/test/) + assert_equal policy, policy.deny_matching(/test/) + assert_equal policy, policy.allow_all + assert_equal policy, policy.deny_all + end +end diff --git a/test/claude_agent/test_session.rb b/test/claude_agent/test_session.rb index ee7e1cb..f2bb84d 100644 --- a/test/claude_agent/test_session.rb +++ b/test/claude_agent/test_session.rb @@ -112,11 +112,13 @@ def sample_session_info(overrides = {}) }.merge(overrides)) end - # --- Session.find --- + # --- Session.find (uses GetSessionInfo, not ListSessions) --- test "find returns session when found" do info = sample_session_info - ClaudeAgent::ListSessions.stubs(:call).with(dir: nil).returns([ info ]) + ClaudeAgent::GetSessionInfo.stubs(:call) + .with("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", dir: nil) + .returns(info) session = ClaudeAgent::Session.find("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee") @@ -132,7 +134,9 @@ def sample_session_info(overrides = {}) end test "find returns nil when not found" do - ClaudeAgent::ListSessions.stubs(:call).with(dir: nil).returns([]) + ClaudeAgent::GetSessionInfo.stubs(:call) + .with("nonexistent-id", dir: nil) + .returns(nil) session = ClaudeAgent::Session.find("nonexistent-id") @@ -141,13 +145,114 @@ def sample_session_info(overrides = {}) test "find passes dir option" do info = sample_session_info - ClaudeAgent::ListSessions.stubs(:call).with(dir: "/my/project").returns([ info ]) + ClaudeAgent::GetSessionInfo.stubs(:call) + .with("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", dir: "/my/project") + .returns(info) session = ClaudeAgent::Session.find("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", dir: "/my/project") assert_instance_of ClaudeAgent::Session, session end + # --- Session.retrieve --- + + test "retrieve returns session when found" do + info = sample_session_info + ClaudeAgent::GetSessionInfo.stubs(:call) + .with("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", dir: nil) + .returns(info) + + session = ClaudeAgent::Session.retrieve("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee") + + assert_instance_of ClaudeAgent::Session, session + assert_equal "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", session.session_id + end + + test "retrieve raises NotFoundError when not found" do + ClaudeAgent::GetSessionInfo.stubs(:call) + .with("nonexistent-id", dir: nil) + .returns(nil) + + assert_raises(ClaudeAgent::NotFoundError) do + ClaudeAgent::Session.retrieve("nonexistent-id") + end + end + + # --- Session#tag and Session#created_at --- + + test "tag and created_at attributes exposed" do + info = sample_session_info(tag: "important", created_at: 1700000000000) + session = ClaudeAgent::Session.new(info) + + assert_equal "important", session.tag + assert_equal 1700000000000, session.created_at + end + + # --- Instance mutation methods --- + + test "rename delegates to SessionMutations and updates custom_title" do + info = sample_session_info + session = ClaudeAgent::Session.new(info) + + ClaudeAgent::SessionMutations.expects(:rename_session) + .with("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", "New Title", dir: "/Users/dev/myapp") + + result = session.rename("New Title") + assert_equal "New Title", session.custom_title + assert_equal session, result + end + + test "tag_session delegates to SessionMutations and updates tag" do + info = sample_session_info + session = ClaudeAgent::Session.new(info) + + ClaudeAgent::SessionMutations.expects(:tag_session) + .with("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", "important", dir: "/Users/dev/myapp") + + result = session.tag_session("important") + assert_equal "important", session.tag + assert_equal session, result + end + + test "reload re-reads from disk" do + info = sample_session_info + session = ClaudeAgent::Session.new(info) + + updated_info = sample_session_info(summary: "Updated summary", custom_title: "Updated Title") + ClaudeAgent::GetSessionInfo.stubs(:call) + .with("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", dir: "/Users/dev/myapp") + .returns(updated_info) + + result = session.reload + assert_equal "Updated summary", session.summary + assert_equal "Updated Title", session.custom_title + assert_equal session, result + end + + test "reload raises NotFoundError when session missing" do + info = sample_session_info + session = ClaudeAgent::Session.new(info) + + ClaudeAgent::GetSessionInfo.stubs(:call) + .with("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", dir: "/Users/dev/myapp") + .returns(nil) + + assert_raises(ClaudeAgent::NotFoundError) do + session.reload + end + end + + test "resume returns Conversation" do + info = sample_session_info + session = ClaudeAgent::Session.new(info) + + transport = MockTransport.new + client = ClaudeAgent::Client.new(transport: transport) + conversation = session.resume(client: client) + + assert_instance_of ClaudeAgent::Conversation, conversation + end + # --- Session.all --- test "all returns array of all sessions" do diff --git a/test/integration/test_conversation_scenarios.rb b/test/integration/test_conversation_scenarios.rb index b128534..7f9ae06 100644 --- a/test/integration/test_conversation_scenarios.rb +++ b/test/integration/test_conversation_scenarios.rb @@ -93,4 +93,50 @@ class TestIntegrationConversationScenarios < IntegrationTestCase ensure conversation&.close end + + test "chat: block form, config merge, multi-turn, Session resource" do + original_config = ClaudeAgent.config + ClaudeAgent.reset_config! + ClaudeAgent.max_turns = 1 + + session_id = nil + conversation_ref = nil + + # --- chat block form with global config --- + ClaudeAgent.chat do |c| + conversation_ref = c + turn = c.say("Remember the word: MANGO. Reply with exactly: REMEMBERED") + assert turn.success? + assert_includes turn.text, "REMEMBERED" + + turn2 = c.say("What word did I say? Reply with just the word.") + assert turn2.success? + assert turn2.text.downcase.include?("mango") + + session_id = c.session_id + assert_not_nil session_id + + assert_equal 2, c.turns.size + assert c.total_cost >= 0 + end + + # --- auto-closed --- + assert conversation_ref.closed? + + # --- Session resource: find, retrieve, rename, reload --- + skip "No session_id returned" unless session_id + + session = ClaudeAgent::Session.find(session_id) + assert_not_nil session, "Session.find should return the session" + assert_equal session_id, session.session_id + + retrieved = ClaudeAgent::Session.retrieve(session_id) + assert_equal session_id, retrieved.session_id + + session.rename("Chat Integration Test") + session.reload + assert_equal "Chat Integration Test", session.custom_title + ensure + ClaudeAgent.instance_variable_set(:@config, original_config) + end end diff --git a/test/integration/test_query_scenarios.rb b/test/integration/test_query_scenarios.rb index 247978e..26583c0 100644 --- a/test/integration/test_query_scenarios.rb +++ b/test/integration/test_query_scenarios.rb @@ -59,4 +59,53 @@ class TestIntegrationQueryScenarios < IntegrationTestCase assert_not_nil result assert result.num_turns <= 1, "Expected max 1 turn" end + + test "ask: returns TurnResult, streams via block, respects config overrides" do + # Set global config + original_config = ClaudeAgent.config + ClaudeAgent.reset_config! + ClaudeAgent.max_turns = 1 + + streamed = [] + turn = ClaudeAgent.ask("Reply with exactly: ASK_TEST") { |msg| streamed << msg } + + # --- Returns TurnResult --- + assert_instance_of ClaudeAgent::TurnResult, turn + assert turn.complete? + assert turn.success? + assert_includes turn.text, "ASK_TEST" + + # --- Block receives messages --- + assert streamed.any?, "Expected block to receive messages" + assert streamed.any? { |m| m.is_a?(ClaudeAgent::AssistantMessage) } + assert streamed.any? { |m| m.is_a?(ClaudeAgent::ResultMessage) } + + # --- Message module works on real messages --- + assistant = streamed.find { |m| m.is_a?(ClaudeAgent::AssistantMessage) } + assert_not_empty assistant.text_content + assert assistant.session_message? + assert assistant.identifiable? + + # --- Per-request override wins --- + turn2 = ClaudeAgent.ask("Reply with exactly: OVERRIDE", system_prompt: "Be terse.") + assert turn2.success? + ensure + ClaudeAgent.instance_variable_set(:@config, original_config) + end + + test "ask: PermissionPolicy flows through to CLI" do + policy = ClaudeAgent::PermissionPolicy.new do |p| + p.allow "Read", "Grep", "Glob" + p.deny_all message: "Denied by test policy" + end + + # The policy compiles and runs against the real CLI without error + turn = ClaudeAgent.ask( + "Reply with exactly: POLICY_TEST", + options: ClaudeAgent::Options.new(max_turns: 1, can_use_tool: policy) + ) + + assert turn.success? + assert_includes turn.text, "POLICY_TEST" + end end diff --git a/test/smoke/test_basic.rb b/test/smoke/test_basic.rb index 92cfada..0644927 100644 --- a/test/smoke/test_basic.rb +++ b/test/smoke/test_basic.rb @@ -44,4 +44,12 @@ class TestSmokeBasic < SmokeTestCase ensure conversation&.close end + + test "ask returns TurnResult" do + turn = ClaudeAgent.ask("Reply with exactly: ASK_SMOKE", options: test_options) + + assert_instance_of ClaudeAgent::TurnResult, turn + assert turn.complete? + assert turn.success? + end end