Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Replaced all 63 `Data.define` types with plain class inheritance from `ImmutableRecord` base class
- `Message` module is now `include`d instead of `prepend`ed into message and content block types
- RBS signatures now use real supertype inheritance (`< ImmutableRecord`)
- Replaced 23 per-event `*Input` classes (`PreToolUseInput`, `StopInput`, etc.) with a single dynamic `HookInput` class that uses `method_missing` for event-specific fields
- Renamed `HookMatcher` to `Hook` base class; `HookRegistry.register` now generates typed subclasses (e.g., `PreToolUseHook`, `SessionStartHook`) and DSL methods from a single CLI event name
- `HookRegistry` is now `Enumerable` and persists as the data structure in `Options#hooks` — no more compile-to-hash step
- Moved hooks code into `hooks/` directory (`hook.rb`, `hook_context.rb`, `hook_input.rb`, `hook_registry.rb`)

### Added
- `HookRegistry.register(cli_event)` — registers a new hook event, generating both a `*Hook` subclass and a DSL method by convention
- `HookRegistry.wrap(input)` — normalizes `HookRegistry`, `Hash`, or `nil` into a `HookRegistry`
- `HookRegistry.from_hash(hash)` — builds a registry from a raw hooks hash
- `Hook#to_config` — builds CLI config entry and registers callbacks, replacing inline logic in `build_hooks_config`
- `Hook#dispatch` — wraps raw CLI data in `HookInput` and `HookContext` before invoking callbacks
- `Hook#event_name` — CLI event name derived from the class name by convention (e.g., `PreToolUseHook` → `"PreToolUse"`)
- `Hook::CallbackEntry` — nested `ImmutableRecord` pairing a hook with a callback in the registry

### Removed
- `HOOK_EVENTS` constant (no longer needed — the CLI is the source of truth)
- `BaseHookInput` class and `define_input` DSL (replaced by `HookInput`)
- `HookRegistry::EVENT_MAP` constant (replaced by `HookRegistry.register` and `KNOWN_EVENTS`)
- `HookMatcher` class (renamed to `Hook`)
- `HookRegistry#to_hooks_hash` (registry is now used directly)

## [0.7.18] - 2026-03-20

Expand Down
2 changes: 1 addition & 1 deletion SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -480,7 +480,7 @@ Event hooks for intercepting and modifying SDK behavior.
| `WorktreeRemoveHookInput` | ✅ | ❌ | ✅ |
| `InstructionsLoadedHookInput` | ✅ | ❌ | ✅ |

#### BaseHookInput Fields
#### HookInput Base Fields

| Field | TypeScript | Python | Ruby | Notes |
|-------------------|:----------:|:------:|:----:|--------------------------------------------------|
Expand Down
9 changes: 6 additions & 3 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,9 +94,12 @@ Internal architecture of the ClaudeAgent Ruby SDK.

### 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<HookMatcher>}` format consumed by `Options#hooks`. Supports regex/string tool matchers and additive merge. |
| 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.). `register` generates typed `Hook` subclasses and DSL methods by convention. Compiles to the `Hash{String => Array<Hook>}` format consumed by `Options#hooks`. Supports regex/string tool matchers and additive merge. |
| `Hook` | Base hook type. Subclasses (e.g., `PreToolUseHook`) are generated per event by `HookRegistry.register`. Owns matching (`matches?`), CLI config serialization (`to_config`), and callback dispatch (`dispatch`). `event_name` is derived from the class name by convention. |
| `HookInput` | Dynamic wrapper for CLI hook input data. Base fields (`session_id`, `cwd`, etc.) are first-class readers; event-specific fields use `method_missing`. Frozen at construction. |
| `HookContext` | Structured context passed to hook callbacks. Currently carries `tool_use_id`. |

### MCP Layer

Expand Down
90 changes: 51 additions & 39 deletions docs/hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ end

### Multiple matchers per event

You can register multiple callbacks for the same event. Each produces a separate `HookMatcher`:
You can register multiple callbacks for the same event. Each produces a separate `Hook`:

```ruby
ClaudeAgent.hooks do |h|
Expand Down Expand Up @@ -163,11 +163,11 @@ All 23 hook events with their Ruby DSL method, CLI event name, and description:

## Hook Input Types

Every hook callback receives `(input, context)`. The `input` argument is a subclass of `BaseHookInput`.
Every hook callback receives `(input, context)`. The `input` is a `HookInput` instance. Base fields are first-class readers; event-specific fields are accessed dynamically via `method_missing`.

### Base fields

All input types inherit these fields from `BaseHookInput`:
All `HookInput` instances have these base fields:

| Field | Type | Description |
|-------------------|----------|-------------------------------------------|
Expand All @@ -181,35 +181,37 @@ All input types inherit these fields from `BaseHookInput`:

### 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` |
| `StopFailure` | `StopFailureInput` | `error`, `error_details`, `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`) |
All event-specific fields are accessed dynamically on `HookInput` via `method_missing`. The fields available depend on the CLI event:

| CLI event | Key fields |
|----------------------|---------------------------------------------------------------------------------------------------|
| `PreToolUse` | `tool_name`, `tool_input`, `tool_use_id` |
| `PostToolUse` | `tool_name`, `tool_input`, `tool_response`, `tool_use_id` |
| `PostToolUseFailure` | `tool_name`, `tool_input`, `error`, `tool_use_id`, `is_interrupt` |
| `Notification` | `message`, `title`, `notification_type` |
| `UserPromptSubmit` | `prompt` |
| `SessionStart` | `source`, `agent_type`, `model` |
| `SessionEnd` | `reason` |
| `Stop` | `stop_hook_active`, `last_assistant_message` |
| `StopFailure` | `error`, `error_details`, `last_assistant_message` |
| `SubagentStart` | `agent_id`, `agent_type` |
| `SubagentStop` | `stop_hook_active`, `agent_id`, `agent_transcript_path`, `agent_type`, `last_assistant_message` |
| `PreCompact` | `trigger`, `custom_instructions` |
| `PostCompact` | `trigger`, `compact_summary` |
| `PermissionRequest` | `tool_name`, `tool_input`, `permission_suggestions` |
| `Setup` | `trigger` |
| `TeammateIdle` | `teammate_name`, `team_name` |
| `TaskCompleted` | `task_id`, `task_subject`, `task_description`, `teammate_name`, `team_name` |
| `Elicitation` | `mcp_server_name`, `message`, `mode`, `url`, `elicitation_id`, `requested_schema` |
| `ElicitationResult` | `mcp_server_name`, `action`, `elicitation_id`, `mode`, `content` |
| `ConfigChange` | `source`, `file_path` |
| `WorktreeCreate` | `name` |
| `WorktreeRemove` | `worktree_path` |
| `InstructionsLoaded` | `file_path`, `memory_type`, `load_reason`, `globs`, `trigger_file_path`, `parent_file_path` |

### Context

The `context` argument is a `Hash` with:
The `context` argument is a `HookContext` instance with:

| Field | Type | Description |
|---------------|----------|--------------------------------------------------|
Expand Down Expand Up @@ -269,12 +271,12 @@ The plain `continue` key also works (it is mapped identically), but `continue_`

## 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.
As an alternative to the DSL, you can construct the hooks hash directly using typed `Hook` subclasses. This is the underlying format that `HookRegistry#to_hooks_hash` produces.

```ruby
hooks = {
"PreToolUse" => [
ClaudeAgent::HookMatcher.new(
ClaudeAgent::PreToolUseHook.new(
matcher: "Bash|Write",
callbacks: [
->(input, ctx) { { continue_: true } }
Expand All @@ -283,7 +285,7 @@ hooks = {
)
],
"SessionStart" => [
ClaudeAgent::HookMatcher.new(
ClaudeAgent::SessionStartHook.new(
matcher: nil,
callbacks: [
->(input, ctx) { puts "Session started"; { continue_: true } }
Expand All @@ -296,15 +298,25 @@ 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 an `ImmutableRecord` with three fields:
Each key is a CLI event name string (e.g., `"PreToolUse"`). Each value is an array of `Hook` subclass instances. `Hook` is an `ImmutableRecord` with three fields:

| Field | Type | Description |
|-------------|------------------|--------------------------------------------------------------|
| `matcher` | `String`, `nil` | Regex pattern string to match tool names. `nil` matches all. |
| `callbacks` | `Array<Proc>` | Array of callback procs. Each receives `(input, context)`. |
| `timeout` | `Integer`, `nil` | Optional timeout in seconds. |
| Field | Type | Description |
|-------------|------------------|--------------------------------------------------------------------|
| `matcher` | `String`, `nil` | Regex pattern string to match tool names. `nil` matches all. |
| `callbacks` | `Array<Proc>` | Array of callback procs. Each receives `(HookInput, HookContext)`. |
| `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#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.

## Registering Custom Events

For CLI hook events the gem doesn't ship with, call `HookRegistry.register`:

```ruby
ClaudeAgent::HookRegistry.register("SomeFutureEvent")
# Generates: ClaudeAgent::SomeFutureEventHook (class)
# Generates: registry.on_some_future_event(matcher, timeout:) { |input, ctx| ... }
```

## Hook Lifecycle Messages

Expand Down
6 changes: 4 additions & 2 deletions lib/claude_agent.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,11 @@
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/hooks/hook"
require_relative "claude_agent/hooks/hook_context"
require_relative "claude_agent/hooks/hook_input"
require_relative "claude_agent/hooks/hook_registry"
require_relative "claude_agent/message_parser"
require_relative "claude_agent/hooks"
require_relative "claude_agent/permissions"
require_relative "claude_agent/permission_request"
require_relative "claude_agent/permission_queue"
Expand Down
Loading
Loading