diff --git a/CHANGELOG.md b/CHANGELOG.md index 225485c..316bf56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/SPEC.md b/SPEC.md index cf87b43..d80ef54 100644 --- a/SPEC.md +++ b/SPEC.md @@ -480,7 +480,7 @@ Event hooks for intercepting and modifying SDK behavior. | `WorktreeRemoveHookInput` | ✅ | ❌ | ✅ | | `InstructionsLoadedHookInput` | ✅ | ❌ | ✅ | -#### BaseHookInput Fields +#### HookInput Base Fields | Field | TypeScript | Python | Ruby | Notes | |-------------------|:----------:|:------:|:----:|--------------------------------------------------| diff --git a/docs/architecture.md b/docs/architecture.md index edde88c..e0f7679 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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}` 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}` 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 diff --git a/docs/hooks.md b/docs/hooks.md index 12d9c3d..9c78f8c 100644 --- a/docs/hooks.md +++ b/docs/hooks.md @@ -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| @@ -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 | |-------------------|----------|-------------------------------------------| @@ -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 | |---------------|----------|--------------------------------------------------| @@ -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 } } @@ -283,7 +285,7 @@ hooks = { ) ], "SessionStart" => [ - ClaudeAgent::HookMatcher.new( + ClaudeAgent::SessionStartHook.new( matcher: nil, callbacks: [ ->(input, ctx) { puts "Session started"; { continue_: true } } @@ -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` | 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` | 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 diff --git a/lib/claude_agent.rb b/lib/claude_agent.rb index f85b361..82ead66 100644 --- a/lib/claude_agent.rb +++ b/lib/claude_agent.rb @@ -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" diff --git a/lib/claude_agent/configuration.rb b/lib/claude_agent/configuration.rb index 511426b..37348cb 100644 --- a/lib/claude_agent/configuration.rb +++ b/lib/claude_agent/configuration.rb @@ -104,18 +104,8 @@ def to_options(**overrides) # 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 + request_hooks = HookRegistry.wrap(merged[:hooks]) + merged[:hooks] = request_hooks ? default_hooks.merge(request_hooks) : default_hooks end # Wire in global MCP servers diff --git a/lib/claude_agent/control_protocol/primitives.rb b/lib/claude_agent/control_protocol/primitives.rb index 7aff2d5..c3767f6 100644 --- a/lib/claude_agent/control_protocol/primitives.rb +++ b/lib/claude_agent/control_protocol/primitives.rb @@ -133,26 +133,11 @@ def send_initialize def build_hooks_config return nil unless options.has_hooks? - config = {} - - options.hooks.each do |event, matchers| - config[event] = matchers.map.with_index do |matcher, idx| - callback_ids = matcher.callbacks.map.with_index do |callback, cidx| - callback_id = "hook_#{event}_#{idx}_#{cidx}" - @hook_callbacks[callback_id] = callback - callback_id - end - - entry = { - matcher: matcher.matcher, - hookCallbackIds: callback_ids - } - entry[:timeout] = matcher.timeout if matcher.timeout - entry + options.hooks.each_with_object({}) do |(event, hooks), config| + config[event] = hooks.each_with_index.map do |hook, idx| + hook.to_config(idx, @hook_callbacks) end end - - config end # Extract SDK MCP server names from options diff --git a/lib/claude_agent/control_protocol/request_handling.rb b/lib/claude_agent/control_protocol/request_handling.rb index 2237f17..f4a8507 100644 --- a/lib/claude_agent/control_protocol/request_handling.rb +++ b/lib/claude_agent/control_protocol/request_handling.rb @@ -129,18 +129,17 @@ def normalize_permission_result(result, tool_name, input) # @return [Hash] Response def handle_hook_callback(request) callback_id = request["callback_id"] - input = (request["input"] || {}).deep_symbolize_keys + raw_input = (request["input"] || {}).deep_symbolize_keys tool_use_id = request["tool_use_id"] - callback = @hook_callbacks[callback_id] - unless callback + entry = @hook_callbacks[callback_id] + unless entry logger.debug("protocol") { "Hook callback not found: #{callback_id}" } return {} end logger.debug("protocol") { "Hook callback: #{callback_id}" } - context = { tool_use_id: tool_use_id } - result = callback.call(input, context) + result = entry.hook.dispatch(entry.callback, raw_input, tool_use_id: tool_use_id) # Normalize result - convert Ruby field names to CLI field names normalize_hook_response(result || {}) diff --git a/lib/claude_agent/conversation.rb b/lib/claude_agent/conversation.rb index 97397bf..f1869a0 100644 --- a/lib/claude_agent/conversation.rb +++ b/lib/claude_agent/conversation.rb @@ -255,11 +255,6 @@ def build_options(options_kwargs, conversation_kwargs) 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) end diff --git a/lib/claude_agent/hook_registry.rb b/lib/claude_agent/hook_registry.rb deleted file mode 100644 index 3849640..0000000 --- a/lib/claude_agent/hook_registry.rb +++ /dev/null @@ -1,111 +0,0 @@ -# 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_stop_failure: "StopFailure", - 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/hooks.rb b/lib/claude_agent/hooks.rb deleted file mode 100644 index a3c11c7..0000000 --- a/lib/claude_agent/hooks.rb +++ /dev/null @@ -1,237 +0,0 @@ -# frozen_string_literal: true - -module ClaudeAgent - # Hook events that can be intercepted (TypeScript SDK parity) - HOOK_EVENTS = %w[ - PreToolUse - PostToolUse - PostToolUseFailure - Notification - UserPromptSubmit - SessionStart - SessionEnd - Stop - StopFailure - SubagentStart - SubagentStop - PreCompact - PostCompact - PermissionRequest - Setup - TeammateIdle - TaskCompleted - Elicitation - ElicitationResult - ConfigChange - WorktreeCreate - WorktreeRemove - InstructionsLoaded - ].freeze - - # Matcher configuration for hooks - # - # @example Basic usage - # matcher = HookMatcher.new( - # matcher: "Bash|Write", - # callbacks: [->(input, context) { {continue_: true} }], - # timeout: 30 - # ) - # - class HookMatcher < ImmutableRecord - attribute :matcher - attribute :callbacks - attribute :timeout, default: nil - - # Check if this matcher matches a tool name - # @param tool_name [String] Tool name to check - # @return [Boolean] - def matches?(tool_name) - case matcher - when String - if matcher.include?("|") - matcher.split("|").any? { |m| tool_name == m } - else - Regexp.new(matcher).match?(tool_name) - end - when Regexp - matcher.match?(tool_name) - else - true - end - end - end - - # Context passed to hook callbacks - # - class HookContext < ImmutableRecord - attribute :tool_use_id, default: nil - end - - # Base class for hook input types (TypeScript SDK parity) - # - # Subclasses are generated declaratively via {.define_input}, which creates - # a class with attr_readers, a keyword-argument initializer, and automatic - # hook_event_name/base field inheritance. - # - class BaseHookInput - attr_reader :hook_event_name, :session_id, :transcript_path, :cwd, :permission_mode, :agent_id, :agent_type - - def initialize(hook_event_name:, session_id: nil, transcript_path: nil, cwd: nil, permission_mode: nil, agent_id: nil, agent_type: nil, **kwargs) - @hook_event_name = hook_event_name - @session_id = session_id - @transcript_path = transcript_path - @cwd = cwd - @permission_mode = permission_mode - @agent_id = agent_id - @agent_type = agent_type - end - - # Define a hook input subclass declaratively - # - # Generates a complete input class with attr_readers, a keyword-argument - # initializer, and automatic hook_event_name forwarding. The generated class - # is registered as ClaudeAgent::{event_name}Input. - # - # @param event_name [String] Hook event name (e.g., "PreToolUse") - # @param required [Array] Required keyword arguments - # @param optional [Hash] Optional keyword arguments with defaults - # @param constants [Hash] Constants to define on the class - # @param block [Proc] Optional block for additional instance methods - # @return [Class] The generated subclass - # - # @example Simple declaration - # BaseHookInput.define_input "PreToolUse", - # required: [:tool_name, :tool_input], - # optional: { tool_use_id: nil } - # - # @example With custom behavior - # BaseHookInput.define_input "Setup", - # required: [:trigger] do - # def init? = trigger == "init" - # def maintenance? = trigger == "maintenance" - # end - # - def self.define_input(event_name, required: [], optional: {}, constants: {}, &block) - klass = Class.new(self) - all_fields = required + optional.keys - klass.attr_reader(*all_fields) - - # Build keyword argument signature - params = required.map { |f| "#{f}:" } - params += optional.map { |f, default| "#{f}: #{default.inspect}" } - params << "**kwargs" - - # Build instance variable assignments - assignments = all_fields.map { |f| "@#{f} = #{f}" }.join("\n ") - - klass.class_eval(<<~RUBY, __FILE__, __LINE__ + 1) - def initialize(#{params.join(", ")}) - super(hook_event_name: "#{event_name}", **kwargs) - #{assignments} - end - RUBY - - constants.each { |name, value| klass.const_set(name, value) } - klass.class_eval(&block) if block - - ClaudeAgent.const_set("#{event_name}Input", klass) - end - end - - # --- Hook Input Declarations --- - # - # Each declaration generates a complete input class with: - # - attr_readers for all fields - # - initialize with required/optional keyword arguments - # - Automatic hook_event_name and base field inheritance - - BaseHookInput.define_input "PreToolUse", - required: [ :tool_name, :tool_input ], - optional: { tool_use_id: nil } - - BaseHookInput.define_input "PostToolUse", - required: [ :tool_name, :tool_input, :tool_response ], - optional: { tool_use_id: nil } - - BaseHookInput.define_input "PostToolUseFailure", - required: [ :tool_name, :tool_input, :error ], - optional: { tool_use_id: nil, is_interrupt: nil } - - BaseHookInput.define_input "Notification", - required: [ :message ], - optional: { title: nil, notification_type: nil } - - BaseHookInput.define_input "UserPromptSubmit", - required: [ :prompt ] - - BaseHookInput.define_input "SessionStart", - required: [ :source ], - optional: { agent_type: nil, model: nil } - - BaseHookInput.define_input "SessionEnd", - required: [ :reason ] - - BaseHookInput.define_input "Stop", - optional: { stop_hook_active: false, last_assistant_message: nil } - - BaseHookInput.define_input "StopFailure", - required: [ :error ], - optional: { error_details: nil, last_assistant_message: nil } - - BaseHookInput.define_input "SubagentStart", - required: [ :agent_id, :agent_type ] - - BaseHookInput.define_input "SubagentStop", - optional: { stop_hook_active: false, agent_id: nil, agent_transcript_path: nil, agent_type: nil, last_assistant_message: nil } - - BaseHookInput.define_input "PreCompact", - required: [ :trigger ], - optional: { custom_instructions: nil } - - BaseHookInput.define_input "PostCompact", - required: [ :trigger, :compact_summary ] - - BaseHookInput.define_input "PermissionRequest", - required: [ :tool_name, :tool_input ], - optional: { permission_suggestions: nil } - - BaseHookInput.define_input "Setup", - required: [ :trigger ] do - def init? = trigger == "init" - def maintenance? = trigger == "maintenance" - end - - BaseHookInput.define_input "TeammateIdle", - required: [ :teammate_name, :team_name ] - - BaseHookInput.define_input "TaskCompleted", - required: [ :task_id, :task_subject ], - optional: { task_description: nil, teammate_name: nil, team_name: nil } - - BaseHookInput.define_input "Elicitation", - required: [ :mcp_server_name, :message ], - optional: { mode: nil, url: nil, elicitation_id: nil, requested_schema: nil } - - BaseHookInput.define_input "ElicitationResult", - required: [ :mcp_server_name, :action ], - optional: { elicitation_id: nil, mode: nil, content: nil } - - BaseHookInput.define_input "ConfigChange", - required: [ :source ], - optional: { file_path: nil }, - constants: { SOURCES: %w[user_settings project_settings local_settings policy_settings skills].freeze } - - BaseHookInput.define_input "WorktreeCreate", - required: [ :name ] - - BaseHookInput.define_input "WorktreeRemove", - required: [ :worktree_path ] - - BaseHookInput.define_input "InstructionsLoaded", - required: [ :file_path, :memory_type, :load_reason ], - optional: { globs: nil, trigger_file_path: nil, parent_file_path: nil }, - constants: { - MEMORY_TYPES: %w[User Project Local Managed].freeze, - LOAD_REASONS: %w[session_start nested_traversal path_glob_match include].freeze - } -end diff --git a/lib/claude_agent/hooks/hook.rb b/lib/claude_agent/hooks/hook.rb new file mode 100644 index 0000000..0e35ced --- /dev/null +++ b/lib/claude_agent/hooks/hook.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +module ClaudeAgent + # Base hook type. Subclasses are generated per event by {HookRegistry.register}. + # + # @example + # hook = ClaudeAgent::PreToolUseHook.new( + # matcher: "Bash|Write", + # callbacks: [->(input, context) { {continue_: true} }], + # timeout: 30 + # ) + # hook.event_name #=> "PreToolUse" + # + class Hook < ImmutableRecord + # Pairs a Hook with one of its callbacks in the callback registry. + class CallbackEntry < ImmutableRecord + attribute :hook + attribute :callback + end + + attribute :matcher + attribute :callbacks + attribute :timeout, default: nil + + # CLI event name derived from class name by convention. + # @example PreToolUseHook → "PreToolUse" + # @return [String, nil] nil for base Hook class + def event_name + self.class.event_name + end + + # @return [String, nil] + def self.event_name + name&.demodulize&.delete_suffix("Hook").presence + end + + # Build the CLI config entry for this hook, registering callbacks in the registry. + # + # @param index [Integer] Hook index within the event + # @param registry [Hash] Callback registry to populate (callback_id → CallbackEntry) + # @return [Hash] CLI-ready config entry + def to_config(index, registry) + callback_ids = callbacks.each_with_index.map do |callback, cidx| + callback_id = "hook_#{event_name}_#{index}_#{cidx}" + registry[callback_id] = CallbackEntry.new(hook: self, callback: callback) + callback_id + end + + entry = { matcher: matcher, hookCallbackIds: callback_ids } + entry[:timeout] = timeout if timeout + entry + end + + # Dispatch a callback with structured input and context. + # + # @param callback [Proc] The callback to invoke + # @param raw_input [Hash] Raw input hash from the CLI + # @param tool_use_id [String, nil] Tool use ID from the request + # @return [Hash] Callback result + def dispatch(callback, raw_input, tool_use_id: nil) + input = HookInput.new(hook_event_name: event_name, **raw_input) + context = HookContext.new(tool_use_id: tool_use_id) + callback.call(input, context) + end + + # Check if this hook matches a tool name + # @param tool_name [String] Tool name to check + # @return [Boolean] + def matches?(tool_name) + case matcher + when String + if matcher.include?("|") + matcher.split("|").any? { |m| tool_name == m } + else + Regexp.new(matcher).match?(tool_name) + end + when Regexp + matcher.match?(tool_name) + else + true + end + end + end +end diff --git a/lib/claude_agent/hooks/hook_context.rb b/lib/claude_agent/hooks/hook_context.rb new file mode 100644 index 0000000..421caad --- /dev/null +++ b/lib/claude_agent/hooks/hook_context.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module ClaudeAgent + # Context passed to hook callbacks + # + class HookContext < ImmutableRecord + attribute :tool_use_id, default: nil + end +end diff --git a/lib/claude_agent/hooks/hook_input.rb b/lib/claude_agent/hooks/hook_input.rb new file mode 100644 index 0000000..97953e0 --- /dev/null +++ b/lib/claude_agent/hooks/hook_input.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +module ClaudeAgent + # Dynamic hook input that wraps whatever the CLI sends. + # + # Base fields (session_id, cwd, etc.) are first-class readers. + # Event-specific fields (tool_name, trigger, etc.) are accessed + # dynamically via method_missing — no per-event subclass needed. + # + # @example + # input = HookInput.new(hook_event_name: "PreToolUse", tool_name: "Bash", tool_input: {command: "ls"}) + # input.hook_event_name #=> "PreToolUse" + # input.tool_name #=> "Bash" + # input[:tool_input] #=> {command: "ls"} + # input.to_h #=> {hook_event_name: "PreToolUse", tool_name: "Bash", ...} + # + class HookInput + BASE_FIELDS = %i[hook_event_name session_id transcript_path cwd permission_mode agent_id agent_type].freeze + + attr_reader(*BASE_FIELDS) + + # Extra event-specific fields as a frozen hash + attr_reader :fields + + def initialize(hook_event_name: nil, session_id: nil, transcript_path: nil, cwd: nil, permission_mode: nil, agent_id: nil, agent_type: nil, **extra) + @hook_event_name = hook_event_name + @session_id = session_id + @transcript_path = transcript_path + @cwd = cwd + @permission_mode = permission_mode + @agent_id = agent_id + @agent_type = agent_type + @fields = extra.freeze + freeze + end + + # Hash-style access for any field (base or extra) + # @param key [Symbol, String] Field name + # @return [Object, nil] + def [](key) + key = key.to_sym + if BASE_FIELDS.include?(key) + public_send(key) + else + @fields[key] + end + end + + # @return [Hash] All fields merged into a single hash + def to_h + BASE_FIELDS.each_with_object({}) do |field, hash| + value = public_send(field) + hash[field] = value unless value.nil? + end.merge(@fields) + end + + private + + def respond_to_missing?(name, include_private = false) + @fields.key?(name) || super + end + + def method_missing(name, *args) + if @fields.key?(name) + @fields[name] + else + super + end + end + end +end diff --git a/lib/claude_agent/hooks/hook_registry.rb b/lib/claude_agent/hooks/hook_registry.rb new file mode 100644 index 0000000..2835a8b --- /dev/null +++ b/lib/claude_agent/hooks/hook_registry.rb @@ -0,0 +1,205 @@ +# frozen_string_literal: true + +module ClaudeAgent + # Declarative hook registry with Ruby-friendly method names. + # + # Real methods are generated from {KNOWN_EVENTS} via naming convention: + # PreToolUse → before_tool_use + # PostCompact → after_compact + # SessionStart → on_session_start + # + # For events the gem doesn't know about yet, call {.register}: + # ClaudeAgent::HookRegistry.register("SomeFutureEvent") + # # Now: registry.on_some_future_event { |input, ctx| ... } + # + # @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 + include Enumerable + + # Prefix mapping for CLI ↔ Ruby name conversion. + PREFIXES = { + "before_" => "Pre", + "after_" => "Post", + "on_" => "" + }.freeze + + # Register a CLI hook event, generating a Hook subclass and DSL method. + # + # @param cli_event [String] CLI event name (e.g., "PreToolUse", "SessionStart") + # @return [Symbol] The generated Ruby method name + # + # @example Register an event the gem doesn't know about yet + # ClaudeAgent::HookRegistry.register("SomeFutureEvent") + # # Generates: ClaudeAgent::SomeFutureEventHook (class) + # # Generates: registry.on_some_future_event(matcher, timeout:) { |input, ctx| ... } + # + def self.register(cli_event) + # Generate Hook subclass (e.g., ClaudeAgent::PreToolUseHook) + # event_name is derived from the class name by convention. + hook_class_name = "#{cli_event}Hook" + unless ClaudeAgent.const_defined?(hook_class_name) + ClaudeAgent.const_set(hook_class_name, Class.new(Hook)) + end + + # Generate DSL method (e.g., before_tool_use) + method_name = cli_to_ruby_method(cli_event) + define_method(method_name) do |matcher = nil, timeout: nil, &callback| + add_matcher(cli_event, matcher, timeout: timeout, &callback) + end + method_name + end + + # Convert a CLI event name to a Ruby method name. + # @example "PreToolUse" → :before_tool_use + def self.cli_to_ruby_method(cli_event) + prefix, replacement = PREFIXES.detect { |_, v| cli_event.start_with?(v) && !v.empty? } + if prefix + remainder = cli_event.delete_prefix(replacement) + :"#{prefix}#{remainder.underscore}" + else + :"on_#{cli_event.underscore}" + end + end + + # Normalize any hooks input into a HookRegistry. + # @param input [HookRegistry, Hash, nil] + # @return [HookRegistry, nil] + def self.wrap(input) + case input + when HookRegistry then input + when Hash then from_hash(input) + end + end + + # Build a HookRegistry from a raw hooks hash. + # @param hash [Hash{String => Array}] + # @return [HookRegistry] + def self.from_hash(hash) + registry = new + hash.each do |event, hooks| + Array(hooks).each { |hook| registry[event] << hook } + end + registry + end + + # CLI event names that ship with the gem. + KNOWN_EVENTS = %w[ + PreToolUse + PostToolUse + PostToolUseFailure + Notification + UserPromptSubmit + SessionStart + SessionEnd + Stop + StopFailure + SubagentStart + SubagentStop + PreCompact + PostCompact + PermissionRequest + Setup + TeammateIdle + TaskCompleted + Elicitation + ElicitationResult + ConfigChange + WorktreeCreate + WorktreeRemove + InstructionsLoaded + ].freeze + + KNOWN_EVENTS.each { |event| register(event) } + + # @param block [Proc] DSL block yielding self + def initialize(&block) + @matchers = Hash.new { |h, k| h[k] = [] } + yield self if block_given? + end + + # Iterate over registered events and their hooks. + # @yield [event, hooks] CLI event name and its array of Hook instances + def each(&block) + @matchers.each(&block) + end + + # Access hooks for a specific event. + # @param event [String] CLI event name + # @return [Array] + def [](event) + @matchers[event] + end + + # Check if hooks are registered for a specific event. + # @param event [String] CLI event name + # @return [Boolean] + def key?(event) + @matchers.key?(event) + 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, hooks| hooks.each { |h| merged.matchers[event] << h } } + other.matchers.each { |event, hooks| hooks.each { |h| merged.matchers[event] << h } } + 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 + + protected + + # @return [Hash{String => Array}] + def matchers + @matchers + end + + private + + def add_matcher(cli_event, matcher, timeout: nil, &callback) + hook_class = ClaudeAgent.const_get("#{cli_event}Hook") + @matchers[cli_event] << hook_class.new( + matcher: normalize_matcher(matcher), + callbacks: [ callback ], + timeout: timeout + ) + self + end + + 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/options.rb b/lib/claude_agent/options.rb index 04d48a7..2d092a1 100644 --- a/lib/claude_agent/options.rb +++ b/lib/claude_agent/options.rb @@ -93,7 +93,7 @@ def has_sdk_mcp_servers? # Check if hooks are configured # @return [Boolean] def has_hooks? - hooks.is_a?(Hash) && hooks.any? + hooks&.any? || false end # Get the abort signal from the controller @@ -131,10 +131,8 @@ def validate! 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 + # Normalize hooks to HookRegistry + @hooks = HookRegistry.wrap(hooks) if hooks && !hooks.is_a?(HookRegistry) if on_elicitation && !on_elicitation.respond_to?(:call) raise ConfigurationError, "on_elicitation must be callable (Proc, Lambda, or object responding to #call)" diff --git a/sig/claude_agent.rbs b/sig/claude_agent.rbs index 6f290fe..e9f843d 100644 --- a/sig/claude_agent.rbs +++ b/sig/claude_agent.rbs @@ -416,7 +416,7 @@ module ClaudeAgent attr_accessor mcp_servers: Hash[String, Hash[Symbol, untyped]] # Hooks - attr_accessor hooks: Hash[String, Array[HookMatcher]]? + attr_accessor hooks: Hash[String, Array[Hook]]? # Sandbox attr_accessor sandbox: SandboxSettings? @@ -921,14 +921,12 @@ module ClaudeAgent end # Hook types - HOOK_EVENTS: Array[String] - - class HookMatcher < ImmutableRecord + class Hook < ImmutableRecord attr_reader matcher: String | Regexp - attr_reader callbacks: Array[^(BaseHookInput, HookContext) -> Hash[Symbol, untyped]] + attr_reader callbacks: Array[^(HookInput, HookContext) -> Hash[Symbol, untyped]] attr_reader timeout: Integer? - def initialize: (matcher: String | Regexp, callbacks: Array[^(BaseHookInput, HookContext) -> Hash[Symbol, untyped]], ?timeout: Integer?) -> void + def initialize: (matcher: String | Regexp, callbacks: Array[^(HookInput, HookContext) -> Hash[Symbol, untyped]], ?timeout: Integer?) -> void def matches?: (String tool_name) -> bool end @@ -938,209 +936,21 @@ module ClaudeAgent def initialize: (?tool_use_id: String?) -> void end - class BaseHookInput - attr_reader hook_event_name: String + class HookInput + BASE_FIELDS: Array[Symbol] + + attr_reader hook_event_name: String? attr_reader session_id: String? attr_reader transcript_path: String? attr_reader cwd: String? attr_reader permission_mode: String? attr_reader agent_id: String? attr_reader agent_type: String? + attr_reader fields: Hash[Symbol, untyped] - def initialize: (hook_event_name: String, ?session_id: String?, ?transcript_path: String?, ?cwd: String?, ?permission_mode: String?, ?agent_id: String?, ?agent_type: String?, **untyped) -> void - - def self.define_input: (String event_name, ?required: Array[Symbol], ?optional: Hash[Symbol, untyped], ?constants: Hash[Symbol, untyped]) -> Class - | (String event_name, ?required: Array[Symbol], ?optional: Hash[Symbol, untyped], ?constants: Hash[Symbol, untyped]) { () -> void } -> Class - end - - class PreToolUseInput < BaseHookInput - attr_reader tool_name: String - attr_reader tool_input: Hash[String, untyped] - attr_reader tool_use_id: String? - - def initialize: (tool_name: String, tool_input: Hash[String, untyped], ?tool_use_id: String?, **untyped) -> void - end - - class PostToolUseInput < BaseHookInput - attr_reader tool_name: String - attr_reader tool_input: Hash[String, untyped] - attr_reader tool_response: untyped - attr_reader tool_use_id: String? - - def initialize: (tool_name: String, tool_input: Hash[String, untyped], tool_response: untyped, ?tool_use_id: String?, **untyped) -> void - end - - class PostToolUseFailureInput < BaseHookInput - attr_reader tool_name: String - attr_reader tool_input: Hash[String, untyped] - attr_reader tool_use_id: String? - attr_reader error: String - attr_reader is_interrupt: bool? - - def initialize: (tool_name: String, tool_input: Hash[String, untyped], error: String, ?tool_use_id: String?, ?is_interrupt: bool?, **untyped) -> void - end - - class NotificationInput < BaseHookInput - attr_reader message: String - attr_reader title: String? - attr_reader notification_type: String? - - def initialize: (message: String, ?title: String?, ?notification_type: String?, **untyped) -> void - end - - class UserPromptSubmitInput < BaseHookInput - attr_reader prompt: String - - def initialize: (prompt: String, **untyped) -> void - end - - class SessionStartInput < BaseHookInput - attr_reader source: String - attr_reader agent_type: String? - attr_reader model: String? - - def initialize: (source: String, ?agent_type: String?, ?model: String?, **untyped) -> void - end - - class SessionEndInput < BaseHookInput - attr_reader reason: String - - def initialize: (reason: String, **untyped) -> void - end - - class StopInput < BaseHookInput - attr_reader stop_hook_active: bool - attr_reader last_assistant_message: String? - - def initialize: (?stop_hook_active: bool, ?last_assistant_message: String?, **untyped) -> void - end - - class StopFailureInput < BaseHookInput - attr_reader error: String - attr_reader error_details: String? - attr_reader last_assistant_message: String? - - def initialize: (error: String, ?error_details: String?, ?last_assistant_message: String?, **untyped) -> void - end - - class SubagentStartInput < BaseHookInput - attr_reader agent_id: String - attr_reader agent_type: String - - def initialize: (agent_id: String, agent_type: String, **untyped) -> void - end - - class SubagentStopInput < BaseHookInput - attr_reader stop_hook_active: bool - attr_reader agent_id: String? - attr_reader agent_transcript_path: String? - attr_reader agent_type: String? - attr_reader last_assistant_message: String? - - def initialize: (?stop_hook_active: bool, ?agent_id: String?, ?agent_transcript_path: String?, ?agent_type: String?, ?last_assistant_message: String?, **untyped) -> void - end - - class PreCompactInput < BaseHookInput - attr_reader trigger: String - attr_reader custom_instructions: String? - - def initialize: (trigger: String, ?custom_instructions: String?, **untyped) -> void - end - - class PostCompactInput < BaseHookInput - attr_reader trigger: String - attr_reader compact_summary: String - - def initialize: (trigger: String, compact_summary: String, **untyped) -> void - end - - class PermissionRequestInput < BaseHookInput - attr_reader tool_name: String - attr_reader tool_input: Hash[String, untyped] - attr_reader permission_suggestions: untyped - - def initialize: (tool_name: String, tool_input: Hash[String, untyped], ?permission_suggestions: untyped, **untyped) -> void - end - - class SetupInput < BaseHookInput - attr_reader trigger: String - - def initialize: (trigger: String, **untyped) -> void - def init?: () -> bool - def maintenance?: () -> bool - end - - class TeammateIdleInput < BaseHookInput - attr_reader teammate_name: String - attr_reader team_name: String - - def initialize: (teammate_name: String, team_name: String, **untyped) -> void - end - - class TaskCompletedInput < BaseHookInput - attr_reader task_id: String - attr_reader task_subject: String - attr_reader task_description: String? - attr_reader teammate_name: String? - attr_reader team_name: String? - - def initialize: (task_id: String, task_subject: String, ?task_description: String?, ?teammate_name: String?, ?team_name: String?, **untyped) -> void - end - - class ElicitationInput < BaseHookInput - attr_reader mcp_server_name: String - attr_reader message: String - attr_reader mode: String? - attr_reader url: String? - attr_reader elicitation_id: String? - attr_reader requested_schema: untyped - - def initialize: (mcp_server_name: String, message: String, ?mode: String?, ?url: String?, ?elicitation_id: String?, ?requested_schema: untyped, **untyped) -> void - end - - class ElicitationResultInput < BaseHookInput - attr_reader mcp_server_name: String - attr_reader action: String - attr_reader elicitation_id: String? - attr_reader mode: String? - attr_reader content: untyped - - def initialize: (mcp_server_name: String, action: String, ?elicitation_id: String?, ?mode: String?, ?content: untyped, **untyped) -> void - end - - class ConfigChangeInput < BaseHookInput - attr_reader source: String - attr_reader file_path: String? - - SOURCES: Array[String] - - def initialize: (source: String, ?file_path: String?, **untyped) -> void - end - - class WorktreeCreateInput < BaseHookInput - attr_reader name: String - - def initialize: (name: String, **untyped) -> void - end - - class WorktreeRemoveInput < BaseHookInput - attr_reader worktree_path: String - - def initialize: (worktree_path: String, **untyped) -> void - end - - class InstructionsLoadedInput < BaseHookInput - attr_reader file_path: String - attr_reader memory_type: String - attr_reader load_reason: String - attr_reader globs: Array[String]? - attr_reader trigger_file_path: String? - attr_reader parent_file_path: String? - - MEMORY_TYPES: Array[String] - LOAD_REASONS: Array[String] - - def initialize: (file_path: String, memory_type: String, load_reason: String, ?globs: Array[String]?, ?trigger_file_path: String?, ?parent_file_path: String?, **untyped) -> void + def initialize: (?hook_event_name: String?, ?session_id: String?, ?transcript_path: String?, ?cwd: String?, ?permission_mode: String?, ?agent_id: String?, ?agent_type: String?, **untyped) -> void + def []: (Symbol | String key) -> untyped + def to_h: () -> Hash[Symbol, untyped] end # Permission types @@ -1781,10 +1591,10 @@ module ClaudeAgent attr_reader allowed_tools: Array[String]? attr_reader disallowed_tools: Array[String]? attr_reader can_use_tool: (^(String, Hash[String, untyped], untyped) -> permission_result)? - attr_reader hooks: Hash[String, Array[HookMatcher]]? + attr_reader hooks: Hash[String, Array[Hook]]? attr_reader permission_mode: String? - def initialize: (model: String, ?path_to_claude_code_executable: String?, ?env: Hash[String, String]?, ?allowed_tools: Array[String]?, ?disallowed_tools: Array[String]?, ?can_use_tool: (^(String, Hash[String, untyped], untyped) -> permission_result)?, ?hooks: Hash[String, Array[HookMatcher]]?, ?permission_mode: String?) -> void + def initialize: (model: String, ?path_to_claude_code_executable: String?, ?env: Hash[String, String]?, ?allowed_tools: Array[String]?, ?disallowed_tools: Array[String]?, ?can_use_tool: (^(String, Hash[String, untyped], untyped) -> permission_result)?, ?hooks: Hash[String, Array[Hook]]?, ?permission_mode: String?) -> void end # V2 Session interface for multi-turn conversations diff --git a/test/claude_agent/control_protocol/test_primitives.rb b/test/claude_agent/control_protocol/test_primitives.rb index ce6ee5c..3caccb2 100644 --- a/test/claude_agent/control_protocol/test_primitives.rb +++ b/test/claude_agent/control_protocol/test_primitives.rb @@ -22,14 +22,14 @@ class TestClaudeAgentControlProtocolPrimitives < ActiveSupport::TestCase options = ClaudeAgent::Options.new( hooks: { "PreToolUse" => [ - ClaudeAgent::HookMatcher.new( + ClaudeAgent::PreToolUseHook.new( matcher: "Bash|Write", callbacks: [ ->(i, c) { {} } ], timeout: 30 ) ], "PostToolUse" => [ - ClaudeAgent::HookMatcher.new( + ClaudeAgent::PostToolUseHook.new( matcher: ".*", callbacks: [ ->(i, c) { {} }, ->(i, c) { {} } ], timeout: nil diff --git a/test/claude_agent/control_protocol/test_request_handling.rb b/test/claude_agent/control_protocol/test_request_handling.rb index bb13054..c1dabaa 100644 --- a/test/claude_agent/control_protocol/test_request_handling.rb +++ b/test/claude_agent/control_protocol/test_request_handling.rb @@ -91,15 +91,17 @@ class TestClaudeAgentControlProtocolRequestHandling < ActiveSupport::TestCase test "handle hook callback" do callback_called = false callback_input = nil + callback_context = nil options = ClaudeAgent::Options.new( hooks: { "PreToolUse" => [ - ClaudeAgent::HookMatcher.new( + ClaudeAgent::PreToolUseHook.new( matcher: "Read", callbacks: [ ->(input, context) { callback_called = true callback_input = input + callback_context = context { continue_: true } } ], timeout: nil @@ -120,7 +122,12 @@ class TestClaudeAgentControlProtocolRequestHandling < ActiveSupport::TestCase result = protocol.send(:handle_hook_callback, request) assert callback_called - assert_equal({ tool_name: "Read", tool_input: {} }, callback_input) + assert_instance_of ClaudeAgent::HookInput, callback_input + assert_equal "PreToolUse", callback_input.hook_event_name + assert_equal "Read", callback_input.tool_name + assert_equal({}, callback_input.tool_input) + assert_instance_of ClaudeAgent::HookContext, callback_context + assert_equal "tool_123", callback_context.tool_use_id assert_equal true, result["continue"] end diff --git a/test/claude_agent/test_global_defaults.rb b/test/claude_agent/test_global_defaults.rb index 922a448..ab54c5a 100644 --- a/test/claude_agent/test_global_defaults.rb +++ b/test/claude_agent/test_global_defaults.rb @@ -27,13 +27,13 @@ class TestClaudeAgentGlobalDefaults < ActiveSupport::TestCase assert_equal "allow", result.behavior end - test "HookRegistry in Options#validate! compiles to hash" do + test "HookRegistry in Options stays as registry" 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.is_a?(ClaudeAgent::HookRegistry) assert options.hooks.key?("PreToolUse") end diff --git a/test/claude_agent/test_hook_registry.rb b/test/claude_agent/test_hook_registry.rb index 0630a1c..5bb1e45 100644 --- a/test/claude_agent/test_hook_registry.rb +++ b/test/claude_agent/test_hook_registry.rb @@ -19,43 +19,64 @@ class TestClaudeAgentHookRegistry < ActiveSupport::TestCase assert_equal 1, registry.size end - # --- Event mapping --- + # --- Convention-based event mapping --- - test "before_tool_use maps to PreToolUse" do + test "before_ prefix maps to Pre" do registry = ClaudeAgent::HookRegistry.new do |h| - h.before_tool_use { |input, ctx| { continue_: true } } + h.before_tool_use { |_, _| { continue_: true } } + h.before_compact { |_, _| { continue_: true } } end - hooks = registry.to_hooks_hash - assert hooks.key?("PreToolUse") - assert_equal 1, hooks["PreToolUse"].size + assert registry.key?("PreToolUse") + assert registry.key?("PreCompact") end - test "after_tool_use maps to PostToolUse" do + test "after_ prefix maps to Post" do registry = ClaudeAgent::HookRegistry.new do |h| - h.after_tool_use { |input, ctx| { continue_: true } } + h.after_tool_use { |_, _| { continue_: true } } + h.after_compact { |_, _| { continue_: true } } + h.after_tool_use_failure { |_, _| { continue_: true } } end - hooks = registry.to_hooks_hash - assert hooks.key?("PostToolUse") + assert registry.key?("PostToolUse") + assert registry.key?("PostCompact") + assert registry.key?("PostToolUseFailure") end - test "on_session_start maps to SessionStart" do + test "on_ prefix maps to bare event name" do registry = ClaudeAgent::HookRegistry.new do |h| - h.on_session_start { |input, ctx| { continue_: true } } + h.on_session_start { |_, _| { continue_: true } } + h.on_stop { |_, _| { continue_: true } } + h.on_notification { |_, _| { continue_: true } } end - hooks = registry.to_hooks_hash - assert hooks.key?("SessionStart") + assert registry.key?("SessionStart") + assert registry.key?("Stop") + assert registry.key?("Notification") end - test "on_stop maps to Stop" do + test "unknown method raises NoMethodError" do + registry = ClaudeAgent::HookRegistry.new + assert_raises(NoMethodError) { registry.not_a_hook_method } + end + + # --- Register --- + + test "register generates a concrete method and hook class" do + ClaudeAgent::HookRegistry.register("SomeFutureEvent") + + # Generates the DSL method registry = ClaudeAgent::HookRegistry.new do |h| - h.on_stop { |input, ctx| { continue_: true } } + h.on_some_future_event { |_, _| { continue_: true } } end - hooks = registry.to_hooks_hash - assert hooks.key?("Stop") + assert registry.key?("SomeFutureEvent") + + # Generates the Hook subclass + assert ClaudeAgent::SomeFutureEventHook < ClaudeAgent::Hook + assert_equal "SomeFutureEvent", ClaudeAgent::SomeFutureEventHook.event_name + assert_instance_of ClaudeAgent::SomeFutureEventHook, registry["SomeFutureEvent"].first end - test "all 23 events are mapped" do - assert_equal 23, ClaudeAgent::HookRegistry::EVENT_MAP.size + test "register returns the generated method name" do + method_name = ClaudeAgent::HookRegistry.register("AnotherFutureEvent") + assert_equal :on_another_future_event, method_name end # --- Matcher normalization --- @@ -64,48 +85,59 @@ class TestClaudeAgentHookRegistry < ActiveSupport::TestCase 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 + hook = registry["PreToolUse"].first + assert_equal "Bash", hook.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 + hook = registry["PreToolUse"].first + assert_equal "Bash|Write", hook.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 + hook = registry["PreToolUse"].first + assert_nil hook.matcher end # --- Timeout --- - test "timeout passes through to HookMatcher" do + test "timeout passes through to Hook" 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 + hook = registry["PreToolUse"].first + assert_equal 30, hook.timeout end - # --- Multiple matchers per event --- + # --- Multiple hooks per event --- - test "multiple matchers for same event" do + test "multiple hooks 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["PreToolUse"].size assert_equal 2, registry.size end + # --- Enumerable --- + + test "each iterates over events and hooks" do + registry = ClaudeAgent::HookRegistry.new do |h| + h.before_tool_use { |_, _| {} } + h.on_stop { |_, _| {} } + end + events = registry.map { |event, _| event } + assert_includes events, "PreToolUse" + assert_includes events, "Stop" + end + # --- Merge --- test "merge is additive" do @@ -119,9 +151,8 @@ class TestClaudeAgentHookRegistry < ActiveSupport::TestCase end merged = r1.merge(r2) - hooks = merged.to_hooks_hash - assert_equal 2, hooks["PreToolUse"].size - assert_equal 1, hooks["Stop"].size + assert_equal 2, merged["PreToolUse"].size + assert_equal 1, merged["Stop"].size assert_equal 3, merged.size end @@ -139,24 +170,36 @@ class TestClaudeAgentHookRegistry < ActiveSupport::TestCase assert_equal 1, r2.size end - # --- to_hooks_hash format --- + # --- Hook type --- - test "to_hooks_hash returns HookMatcher instances" do + test "hooks are typed subclass 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 + hook = registry["PreToolUse"].first + assert_instance_of ClaudeAgent::PreToolUseHook, hook + assert_kind_of ClaudeAgent::Hook, hook + assert_equal "PreToolUse", hook.event_name end - test "to_hooks_hash callbacks are wrapped in array" do + test "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 + hook = registry["PreToolUse"].first + assert_equal [ callback ], hook.callbacks + end + + # --- from_hash --- + + test "from_hash wraps a plain hooks hash" do + hook = ClaudeAgent::PreToolUseHook.new(matcher: "Bash", callbacks: [ ->(i, c) { {} } ]) + registry = ClaudeAgent::HookRegistry.from_hash("PreToolUse" => [ hook ]) + + assert registry.key?("PreToolUse") + assert_equal 1, registry["PreToolUse"].size + assert_equal hook, registry["PreToolUse"].first end # --- Chaining --- diff --git a/test/claude_agent/test_hooks.rb b/test/claude_agent/test_hooks.rb index b5ae90f..df7c002 100644 --- a/test/claude_agent/test_hooks.rb +++ b/test/claude_agent/test_hooks.rb @@ -3,847 +3,308 @@ require "test_helper" class TestClaudeAgentHooks < ActiveSupport::TestCase - # --- Hook Events --- - - test "hook_events_constant" do - assert_includes ClaudeAgent::HOOK_EVENTS, "PreToolUse" - assert_includes ClaudeAgent::HOOK_EVENTS, "PostToolUse" - assert_includes ClaudeAgent::HOOK_EVENTS, "PostToolUseFailure" - assert_includes ClaudeAgent::HOOK_EVENTS, "Notification" - assert_includes ClaudeAgent::HOOK_EVENTS, "UserPromptSubmit" - assert_includes ClaudeAgent::HOOK_EVENTS, "SessionStart" - assert_includes ClaudeAgent::HOOK_EVENTS, "SessionEnd" - assert_includes ClaudeAgent::HOOK_EVENTS, "Stop" - assert_includes ClaudeAgent::HOOK_EVENTS, "SubagentStart" - assert_includes ClaudeAgent::HOOK_EVENTS, "SubagentStop" - assert_includes ClaudeAgent::HOOK_EVENTS, "PreCompact" - assert_includes ClaudeAgent::HOOK_EVENTS, "PostCompact" - assert_includes ClaudeAgent::HOOK_EVENTS, "PermissionRequest" - assert_includes ClaudeAgent::HOOK_EVENTS, "InstructionsLoaded" - end - - # --- HookMatcher --- - - test "hook_matcher" do - matcher = ClaudeAgent::HookMatcher.new( + # --- Hook: attributes --- + + test "hook attributes" do + hook = ClaudeAgent::PreToolUseHook.new( matcher: "Bash|Write", callbacks: [ ->(input, context) { { continue_: true } } ], timeout: 30 ) - assert_equal "Bash|Write", matcher.matcher - assert_equal 30, matcher.timeout - assert_equal 1, matcher.callbacks.size + assert_equal "Bash|Write", hook.matcher + assert_equal 30, hook.timeout + assert_equal 1, hook.callbacks.size end - test "hook_matcher_matches_pipe_separated" do - matcher = ClaudeAgent::HookMatcher.new(matcher: "Bash|Write", callbacks: []) - assert matcher.matches?("Bash") - assert matcher.matches?("Write") - refute matcher.matches?("Read") - end + # --- Hook: event_name convention --- - test "hook_matcher_matches_regex" do - matcher = ClaudeAgent::HookMatcher.new(matcher: "^Read.*", callbacks: []) - assert matcher.matches?("Read") - assert matcher.matches?("ReadFile") - refute matcher.matches?("Write") + test "event_name derived from subclass name" do + assert_equal "PreToolUse", ClaudeAgent::PreToolUseHook.event_name + assert_equal "SessionStart", ClaudeAgent::SessionStartHook.event_name + assert_equal "PostCompact", ClaudeAgent::PostCompactHook.event_name + assert_equal "Stop", ClaudeAgent::StopHook.event_name end - # --- HookContext --- - - test "hook_context" do - context = ClaudeAgent::HookContext.new(tool_use_id: "tool-123") - assert_equal "tool-123", context.tool_use_id + test "event_name on instance delegates to class" do + hook = ClaudeAgent::PreToolUseHook.new(matcher: nil, callbacks: []) + assert_equal "PreToolUse", hook.event_name end - test "hook_context_defaults" do - context = ClaudeAgent::HookContext.new - assert_nil context.tool_use_id + test "event_name is nil for base Hook class" do + assert_nil ClaudeAgent::Hook.event_name end - # --- BaseHookInput --- + # --- Hook: matches? --- - test "base_hook_input" do - input = ClaudeAgent::BaseHookInput.new( - hook_event_name: "TestEvent", - session_id: "sess-123", - transcript_path: "/path/to/transcript", - cwd: "/home/user", - permission_mode: "acceptEdits" - ) - assert_equal "TestEvent", input.hook_event_name - assert_equal "sess-123", input.session_id - assert_equal "/path/to/transcript", input.transcript_path - assert_equal "/home/user", input.cwd - assert_equal "acceptEdits", input.permission_mode - end - - # --- PreToolUseInput --- - - test "pre_tool_use_input" do - input = ClaudeAgent::PreToolUseInput.new( - tool_name: "Bash", - tool_input: { command: "ls" }, - tool_use_id: "tool-123", - session_id: "sess-abc" - ) - assert_equal "PreToolUse", input.hook_event_name - assert_equal "Bash", input.tool_name - assert_equal({ command: "ls" }, input.tool_input) - assert_equal "tool-123", input.tool_use_id - assert_equal "sess-abc", input.session_id + test "matches pipe-separated tool names" do + hook = ClaudeAgent::PreToolUseHook.new(matcher: "Bash|Write", callbacks: []) + assert hook.matches?("Bash") + assert hook.matches?("Write") + refute hook.matches?("Read") end - test "pre_tool_use_input_without_tool_use_id" do - input = ClaudeAgent::PreToolUseInput.new( - tool_name: "Read", - tool_input: { file_path: "/tmp" } - ) - assert_nil input.tool_use_id + test "matches regex patterns" do + hook = ClaudeAgent::PreToolUseHook.new(matcher: "^Read.*", callbacks: []) + assert hook.matches?("Read") + assert hook.matches?("ReadFile") + refute hook.matches?("Write") end - # --- PostToolUseInput --- - - test "post_tool_use_input" do - input = ClaudeAgent::PostToolUseInput.new( - tool_name: "Bash", - tool_input: { command: "ls" }, - tool_response: "file1.txt\nfile2.txt", - tool_use_id: "tool-456", - session_id: "sess-abc" - ) - assert_equal "PostToolUse", input.hook_event_name - assert_equal "Bash", input.tool_name - assert_equal({ command: "ls" }, input.tool_input) - assert_equal "file1.txt\nfile2.txt", input.tool_response - assert_equal "tool-456", input.tool_use_id - end - - test "post_tool_use_input_without_tool_use_id" do - input = ClaudeAgent::PostToolUseInput.new( - tool_name: "Read", - tool_input: {}, - tool_response: "content" - ) - assert_nil input.tool_use_id - end - - # --- PostToolUseFailureInput --- - - test "post_tool_use_failure_input" do - input = ClaudeAgent::PostToolUseFailureInput.new( - tool_name: "Bash", - tool_input: { command: "invalid_cmd" }, - error: "Command not found", - tool_use_id: "tool-789", - is_interrupt: false - ) - assert_equal "PostToolUseFailure", input.hook_event_name - assert_equal "Bash", input.tool_name - assert_equal({ command: "invalid_cmd" }, input.tool_input) - assert_equal "Command not found", input.error - assert_equal "tool-789", input.tool_use_id - assert_equal false, input.is_interrupt - end - - test "post_tool_use_failure_input_with_interrupt" do - input = ClaudeAgent::PostToolUseFailureInput.new( - tool_name: "Bash", - tool_input: {}, - error: "Interrupted", - is_interrupt: true - ) - assert_equal true, input.is_interrupt - end - - # --- NotificationInput --- - - test "notification_input" do - input = ClaudeAgent::NotificationInput.new( - message: "Task completed", - title: "Success", - notification_type: "info" - ) - assert_equal "Notification", input.hook_event_name - assert_equal "Task completed", input.message - assert_equal "Success", input.title - assert_equal "info", input.notification_type - end - - test "notification_input_without_type" do - input = ClaudeAgent::NotificationInput.new(message: "Something happened") - assert_nil input.notification_type - assert_nil input.title - end - - # --- UserPromptSubmitInput --- - - test "user_prompt_submit_input" do - input = ClaudeAgent::UserPromptSubmitInput.new(prompt: "Help me debug this") - assert_equal "UserPromptSubmit", input.hook_event_name - assert_equal "Help me debug this", input.prompt - end - - # --- SessionStartInput --- - - test "session_start_input" do - input = ClaudeAgent::SessionStartInput.new( - source: "startup", - agent_type: "Plan" - ) - assert_equal "SessionStart", input.hook_event_name - assert_equal "startup", input.source - assert_equal "Plan", input.agent_type + test "nil matcher matches everything" do + hook = ClaudeAgent::PreToolUseHook.new(matcher: nil, callbacks: []) + assert hook.matches?("Anything") end - test "session_start_input_without_agent_type" do - input = ClaudeAgent::SessionStartInput.new(source: "resume") - assert_equal "resume", input.source - assert_nil input.agent_type - end + # --- Hook: to_config --- - test "session_start_input_with_model" do - input = ClaudeAgent::SessionStartInput.new( - source: "startup", - model: "claude-sonnet-4-5-20250514" + test "to_config builds CLI entry and registers callbacks" do + callback = ->(input, ctx) { { continue_: true } } + hook = ClaudeAgent::PreToolUseHook.new( + matcher: "Bash", + callbacks: [ callback ], + timeout: 30 ) - assert_equal "startup", input.source - assert_equal "claude-sonnet-4-5-20250514", input.model - end - test "session_start_input_model_default_nil" do - input = ClaudeAgent::SessionStartInput.new(source: "startup") - assert_nil input.model - end + registry = {} + entry = hook.to_config(0, registry) - # --- SessionEndInput --- + assert_equal "Bash", entry[:matcher] + assert_equal 30, entry[:timeout] + assert_equal [ "hook_PreToolUse_0_0" ], entry[:hookCallbackIds] - test "session_end_input" do - input = ClaudeAgent::SessionEndInput.new(reason: "user_interrupt") - assert_equal "SessionEnd", input.hook_event_name - assert_equal "user_interrupt", input.reason + registered = registry["hook_PreToolUse_0_0"] + assert_instance_of ClaudeAgent::Hook::CallbackEntry, registered + assert_equal hook, registered.hook + assert_equal callback, registered.callback end - # --- StopInput --- - - test "stop_input" do - input = ClaudeAgent::StopInput.new(stop_hook_active: true) - assert_equal "Stop", input.hook_event_name - assert_equal true, input.stop_hook_active - end + test "to_config omits timeout when nil" do + hook = ClaudeAgent::StopHook.new(matcher: nil, callbacks: [ ->(i, c) { {} } ]) + entry = hook.to_config(0, {}) - test "stop_input_defaults" do - input = ClaudeAgent::StopInput.new - assert_equal false, input.stop_hook_active - assert_nil input.last_assistant_message + refute entry.key?(:timeout) end - test "stop_input_with_last_assistant_message" do - input = ClaudeAgent::StopInput.new( - stop_hook_active: true, - last_assistant_message: "I've completed the task." - ) - assert_equal true, input.stop_hook_active - assert_equal "I've completed the task.", input.last_assistant_message - end + test "to_config handles multiple callbacks" do + cb1 = ->(i, c) { {} } + cb2 = ->(i, c) { {} } + hook = ClaudeAgent::PostToolUseHook.new(matcher: ".*", callbacks: [ cb1, cb2 ]) - # --- StopFailureInput --- + registry = {} + entry = hook.to_config(0, registry) - test "stop_failure_input" do - input = ClaudeAgent::StopFailureInput.new(error: "rate_limit") - assert_equal "StopFailure", input.hook_event_name - assert_equal "rate_limit", input.error + assert_equal 2, entry[:hookCallbackIds].size + assert_equal "hook_PostToolUse_0_0", entry[:hookCallbackIds][0] + assert_equal "hook_PostToolUse_0_1", entry[:hookCallbackIds][1] + assert_equal cb1, registry["hook_PostToolUse_0_0"].callback + assert_equal cb2, registry["hook_PostToolUse_0_1"].callback end - test "stop_failure_input_defaults" do - input = ClaudeAgent::StopFailureInput.new(error: "server_error") - assert_nil input.error_details - assert_nil input.last_assistant_message - end + # --- Hook: dispatch --- - test "stop_failure_input_with_all_fields" do - input = ClaudeAgent::StopFailureInput.new( - error: "rate_limit", - error_details: "Rate limit exceeded for model", - last_assistant_message: "I was working on..." - ) - assert_equal "rate_limit", input.error - assert_equal "Rate limit exceeded for model", input.error_details - assert_equal "I was working on...", input.last_assistant_message - end + test "dispatch wraps input in HookInput with event_name" do + received_input = nil + callback = ->(input, ctx) { received_input = input; {} } + hook = ClaudeAgent::PreToolUseHook.new(matcher: nil, callbacks: [ callback ]) - # --- SubagentStartInput --- + hook.dispatch(callback, { tool_name: "Bash", tool_input: {} }) - test "subagent_start_input" do - input = ClaudeAgent::SubagentStartInput.new( - agent_id: "agent-123", - agent_type: "Explore" - ) - assert_equal "SubagentStart", input.hook_event_name - assert_equal "agent-123", input.agent_id - assert_equal "Explore", input.agent_type + assert_instance_of ClaudeAgent::HookInput, received_input + assert_equal "PreToolUse", received_input.hook_event_name + assert_equal "Bash", received_input.tool_name end - # --- SubagentStopInput --- - - test "subagent_stop_input" do - input = ClaudeAgent::SubagentStopInput.new( - stop_hook_active: true, - agent_id: "agent-123", - agent_transcript_path: "/path/to/transcript.json" - ) - assert_equal "SubagentStop", input.hook_event_name - assert_equal true, input.stop_hook_active - assert_equal "agent-123", input.agent_id - assert_equal "/path/to/transcript.json", input.agent_transcript_path - end + test "dispatch wraps context in HookContext" do + received_context = nil + callback = ->(input, ctx) { received_context = ctx; {} } + hook = ClaudeAgent::SessionStartHook.new(matcher: nil, callbacks: [ callback ]) - test "subagent_stop_input_defaults" do - input = ClaudeAgent::SubagentStopInput.new - assert_equal false, input.stop_hook_active - assert_nil input.agent_id - assert_nil input.agent_transcript_path - assert_nil input.agent_type - assert_nil input.last_assistant_message - end + hook.dispatch(callback, { source: "startup" }, tool_use_id: "tool-123") - test "subagent_stop_input_with_agent_type_and_last_assistant_message" do - input = ClaudeAgent::SubagentStopInput.new( - agent_id: "agent-123", - agent_type: "Explore", - last_assistant_message: "Search complete." - ) - assert_equal "agent-123", input.agent_id - assert_equal "Explore", input.agent_type - assert_equal "Search complete.", input.last_assistant_message + assert_instance_of ClaudeAgent::HookContext, received_context + assert_equal "tool-123", received_context.tool_use_id end - # --- PreCompactInput --- + test "dispatch returns callback result" do + callback = ->(input, ctx) { { continue_: true, reason: "approved" } } + hook = ClaudeAgent::PreToolUseHook.new(matcher: nil, callbacks: [ callback ]) - test "pre_compact_input" do - input = ClaudeAgent::PreCompactInput.new( - trigger: "auto", - custom_instructions: "Summarize the key points" - ) - assert_equal "PreCompact", input.hook_event_name - assert_equal "auto", input.trigger - assert_equal "Summarize the key points", input.custom_instructions - end + result = hook.dispatch(callback, { tool_name: "Read" }) - test "pre_compact_input_without_instructions" do - input = ClaudeAgent::PreCompactInput.new(trigger: "manual") - assert_nil input.custom_instructions + assert_equal true, result[:continue_] + assert_equal "approved", result[:reason] end - # --- PostCompactInput --- + # --- HookContext --- - test "post_compact_event_in_hook_events" do - assert_includes ClaudeAgent::HOOK_EVENTS, "PostCompact" + test "hook_context" do + context = ClaudeAgent::HookContext.new(tool_use_id: "tool-123") + assert_equal "tool-123", context.tool_use_id end - test "post_compact_input" do - input = ClaudeAgent::PostCompactInput.new( - trigger: "auto", - compact_summary: "Summarized the conversation context", - session_id: "sess-123" - ) - assert_equal "PostCompact", input.hook_event_name - assert_equal "auto", input.trigger - assert_equal "Summarized the conversation context", input.compact_summary - assert_equal "sess-123", input.session_id + test "hook_context_defaults" do + context = ClaudeAgent::HookContext.new + assert_nil context.tool_use_id end - test "post_compact_input_requires_trigger_and_compact_summary" do - assert_raises(ArgumentError) do - ClaudeAgent::PostCompactInput.new(trigger: "auto") - # Missing required :compact_summary - end - - assert_raises(ArgumentError) do - ClaudeAgent::PostCompactInput.new(compact_summary: "summary") - # Missing required :trigger - end - end + # --- HookInput: base fields --- - test "post_compact_input_inherits_base_fields" do - input = ClaudeAgent::PostCompactInput.new( - trigger: "manual", - compact_summary: "Key points summarized", + test "base fields are accessible as readers" do + input = ClaudeAgent::HookInput.new( + hook_event_name: "PreToolUse", + session_id: "sess-123", transcript_path: "/path/to/transcript", cwd: "/home/user", - permission_mode: "default", + permission_mode: "acceptEdits", agent_id: "agent-abc", agent_type: "Explore" ) + assert_equal "PreToolUse", input.hook_event_name + assert_equal "sess-123", input.session_id assert_equal "/path/to/transcript", input.transcript_path assert_equal "/home/user", input.cwd - assert_equal "default", input.permission_mode + assert_equal "acceptEdits", input.permission_mode assert_equal "agent-abc", input.agent_id assert_equal "Explore", input.agent_type end - # --- PermissionRequestInput --- - - test "permission_request_input" do - input = ClaudeAgent::PermissionRequestInput.new( - tool_name: "Write", - tool_input: { file_path: "/etc/passwd" }, - permission_suggestions: [ { type: "addRules" } ] - ) - assert_equal "PermissionRequest", input.hook_event_name - assert_equal "Write", input.tool_name - assert_equal({ file_path: "/etc/passwd" }, input.tool_input) - assert_equal [ { type: "addRules" } ], input.permission_suggestions - end - - test "permission_request_input_without_suggestions" do - input = ClaudeAgent::PermissionRequestInput.new( - tool_name: "Bash", - tool_input: { command: "rm -rf /" } - ) - assert_nil input.permission_suggestions - end - - # --- SetupInput --- - - test "setup_input" do - input = ClaudeAgent::SetupInput.new( - trigger: "init", - session_id: "abc-123" - ) - assert_equal "Setup", input.hook_event_name - assert_equal "init", input.trigger - assert_equal "abc-123", input.session_id - end - - test "setup_input_init?" do - input = ClaudeAgent::SetupInput.new(trigger: "init") - assert input.init? - refute input.maintenance? - end - - test "setup_input_maintenance?" do - input = ClaudeAgent::SetupInput.new(trigger: "maintenance") - refute input.init? - assert input.maintenance? - end - - test "setup_event_in_hook_events" do - assert_includes ClaudeAgent::HOOK_EVENTS, "Setup" - end - - # --- TeammateIdleInput --- - - test "teammate_idle_event_in_hook_events" do - assert_includes ClaudeAgent::HOOK_EVENTS, "TeammateIdle" - end - - test "teammate_idle_input" do - input = ClaudeAgent::TeammateIdleInput.new( - teammate_name: "alice", - team_name: "backend-team", - session_id: "sess-456" - ) - assert_equal "TeammateIdle", input.hook_event_name - assert_equal "alice", input.teammate_name - assert_equal "backend-team", input.team_name - assert_equal "sess-456", input.session_id - end - - test "teammate_idle_input_inherits_base_fields" do - input = ClaudeAgent::TeammateIdleInput.new( - teammate_name: "bob", - team_name: "frontend-team", - transcript_path: "/path/to/transcript", - cwd: "/home/user", - permission_mode: "default" - ) - assert_equal "/path/to/transcript", input.transcript_path - assert_equal "/home/user", input.cwd - assert_equal "default", input.permission_mode - end - - # --- TaskCompletedInput --- - - test "task_completed_event_in_hook_events" do - assert_includes ClaudeAgent::HOOK_EVENTS, "TaskCompleted" - end - - test "task_completed_input" do - input = ClaudeAgent::TaskCompletedInput.new( - task_id: "task-789", - task_subject: "Fix login bug", - task_description: "The login form fails on mobile", - teammate_name: "alice", - team_name: "backend-team", - session_id: "sess-789" - ) - assert_equal "TaskCompleted", input.hook_event_name - assert_equal "task-789", input.task_id - assert_equal "Fix login bug", input.task_subject - assert_equal "The login form fails on mobile", input.task_description - assert_equal "alice", input.teammate_name - assert_equal "backend-team", input.team_name - assert_equal "sess-789", input.session_id - end - - test "task_completed_input_with_required_fields_only" do - input = ClaudeAgent::TaskCompletedInput.new( - task_id: "task-001", - task_subject: "Update docs" - ) - assert_equal "TaskCompleted", input.hook_event_name - assert_equal "task-001", input.task_id - assert_equal "Update docs", input.task_subject - assert_nil input.task_description - assert_nil input.teammate_name - assert_nil input.team_name - end - - test "task_completed_input_inherits_base_fields" do - input = ClaudeAgent::TaskCompletedInput.new( - task_id: "task-002", - task_subject: "Deploy service", - transcript_path: "/path/to/transcript", - cwd: "/home/user", - permission_mode: "acceptEdits" - ) - assert_equal "/path/to/transcript", input.transcript_path - assert_equal "/home/user", input.cwd - assert_equal "acceptEdits", input.permission_mode - end - - # --- ElicitationInput --- - - test "elicitation_event_in_hook_events" do - assert_includes ClaudeAgent::HOOK_EVENTS, "Elicitation" - end - - test "elicitation_input" do - input = ClaudeAgent::ElicitationInput.new( - mcp_server_name: "my-server", - message: "Please provide credentials", - mode: "modal", - url: "https://example.com", - elicitation_id: "elic-123", - requested_schema: { type: "object" }, - session_id: "sess-abc" - ) - assert_equal "Elicitation", input.hook_event_name - assert_equal "my-server", input.mcp_server_name - assert_equal "Please provide credentials", input.message - assert_equal "modal", input.mode - assert_equal "https://example.com", input.url - assert_equal "elic-123", input.elicitation_id - assert_equal({ type: "object" }, input.requested_schema) - assert_equal "sess-abc", input.session_id - end - - test "elicitation_input_with_required_fields_only" do - input = ClaudeAgent::ElicitationInput.new( - mcp_server_name: "server", - message: "Auth needed" - ) - assert_equal "Elicitation", input.hook_event_name - assert_equal "server", input.mcp_server_name - assert_equal "Auth needed", input.message - assert_nil input.mode - assert_nil input.url - assert_nil input.elicitation_id - assert_nil input.requested_schema - end - - # --- ElicitationResultInput --- - - test "elicitation_result_event_in_hook_events" do - assert_includes ClaudeAgent::HOOK_EVENTS, "ElicitationResult" + test "base fields default to nil" do + input = ClaudeAgent::HookInput.new + assert_nil input.hook_event_name + assert_nil input.session_id + assert_nil input.agent_id end - test "elicitation_result_input" do - input = ClaudeAgent::ElicitationResultInput.new( - mcp_server_name: "my-server", - action: "approve", - elicitation_id: "elic-123", - mode: "modal", - content: { token: "abc" }, - session_id: "sess-abc" - ) - assert_equal "ElicitationResult", input.hook_event_name - assert_equal "my-server", input.mcp_server_name - assert_equal "approve", input.action - assert_equal "elic-123", input.elicitation_id - assert_equal "modal", input.mode - assert_equal({ token: "abc" }, input.content) - assert_equal "sess-abc", input.session_id - end + # --- HookInput: dynamic fields via method_missing --- - test "elicitation_result_input_with_required_fields_only" do - input = ClaudeAgent::ElicitationResultInput.new( - mcp_server_name: "server", - action: "decline" + test "event-specific fields are accessible via method_missing" do + input = ClaudeAgent::HookInput.new( + hook_event_name: "PreToolUse", + tool_name: "Bash", + tool_input: { command: "ls" }, + tool_use_id: "tool-123" ) - assert_equal "ElicitationResult", input.hook_event_name - assert_equal "server", input.mcp_server_name - assert_equal "decline", input.action - assert_nil input.elicitation_id - assert_nil input.mode - assert_nil input.content - end - - # --- ConfigChangeInput --- - - test "config_change_event_in_hook_events" do - assert_includes ClaudeAgent::HOOK_EVENTS, "ConfigChange" + assert_equal "Bash", input.tool_name + assert_equal({ command: "ls" }, input.tool_input) + assert_equal "tool-123", input.tool_use_id end - test "config_change_input" do - input = ClaudeAgent::ConfigChangeInput.new( - source: "user_settings", - file_path: "~/.claude/settings.json", - session_id: "sess-123" - ) - assert_equal "ConfigChange", input.hook_event_name - assert_equal "user_settings", input.source - assert_equal "~/.claude/settings.json", input.file_path - assert_equal "sess-123", input.session_id + test "respond_to? returns true for dynamic fields" do + input = ClaudeAgent::HookInput.new(hook_event_name: "Stop", stop_hook_active: true) + assert_respond_to input, :stop_hook_active + assert_respond_to input, :hook_event_name end - test "config_change_input_without_file_path" do - input = ClaudeAgent::ConfigChangeInput.new(source: "skills") - assert_equal "ConfigChange", input.hook_event_name - assert_equal "skills", input.source - assert_nil input.file_path + test "respond_to? returns false for missing fields" do + input = ClaudeAgent::HookInput.new(hook_event_name: "Stop") + refute_respond_to input, :nonexistent_field end - test "config_change_input_sources_constant" do - assert_includes ClaudeAgent::ConfigChangeInput::SOURCES, "user_settings" - assert_includes ClaudeAgent::ConfigChangeInput::SOURCES, "project_settings" - assert_includes ClaudeAgent::ConfigChangeInput::SOURCES, "local_settings" - assert_includes ClaudeAgent::ConfigChangeInput::SOURCES, "policy_settings" - assert_includes ClaudeAgent::ConfigChangeInput::SOURCES, "skills" + test "unknown field raises NoMethodError" do + input = ClaudeAgent::HookInput.new(hook_event_name: "PreToolUse") + assert_raises(NoMethodError) { input.nonexistent_field } end - # --- WorktreeCreateInput --- - - test "worktree_create_event_in_hook_events" do - assert_includes ClaudeAgent::HOOK_EVENTS, "WorktreeCreate" - end + # --- HookInput: [] accessor --- - test "worktree_create_input" do - input = ClaudeAgent::WorktreeCreateInput.new( - name: "feature-branch", - session_id: "sess-123" - ) - assert_equal "WorktreeCreate", input.hook_event_name - assert_equal "feature-branch", input.name - assert_equal "sess-123", input.session_id + test "hash-style access for base fields" do + input = ClaudeAgent::HookInput.new(hook_event_name: "Setup", session_id: "sess-1") + assert_equal "Setup", input[:hook_event_name] + assert_equal "sess-1", input[:session_id] end - test "worktree_create_input_inherits_base_fields" do - input = ClaudeAgent::WorktreeCreateInput.new( - name: "my-worktree", - transcript_path: "/path/to/transcript", - cwd: "/home/user", - permission_mode: "default" - ) - assert_equal "/path/to/transcript", input.transcript_path - assert_equal "/home/user", input.cwd - assert_equal "default", input.permission_mode + test "hash-style access for dynamic fields" do + input = ClaudeAgent::HookInput.new(hook_event_name: "PreToolUse", tool_name: "Read") + assert_equal "Read", input[:tool_name] end - # --- WorktreeRemoveInput --- - - test "worktree_remove_event_in_hook_events" do - assert_includes ClaudeAgent::HOOK_EVENTS, "WorktreeRemove" + test "hash-style access returns nil for missing fields" do + input = ClaudeAgent::HookInput.new(hook_event_name: "Stop") + assert_nil input[:nonexistent] end - test "worktree_remove_input" do - input = ClaudeAgent::WorktreeRemoveInput.new( - worktree_path: "/tmp/worktrees/feature-branch", - session_id: "sess-456" - ) - assert_equal "WorktreeRemove", input.hook_event_name - assert_equal "/tmp/worktrees/feature-branch", input.worktree_path - assert_equal "sess-456", input.session_id + test "hash-style access works with string keys" do + input = ClaudeAgent::HookInput.new(hook_event_name: "Stop", trigger: "auto") + assert_equal "auto", input["trigger"] end - test "worktree_remove_input_inherits_base_fields" do - input = ClaudeAgent::WorktreeRemoveInput.new( - worktree_path: "/tmp/worktrees/cleanup", - transcript_path: "/path/to/transcript", - cwd: "/home/user", - permission_mode: "acceptEdits" - ) - assert_equal "/path/to/transcript", input.transcript_path - assert_equal "/home/user", input.cwd - assert_equal "acceptEdits", input.permission_mode - end + # --- HookInput: to_h --- - test "config_change_input_inherits_base_fields" do - input = ClaudeAgent::ConfigChangeInput.new( - source: "project_settings", - file_path: ".claude/settings.json", - transcript_path: "/path/to/transcript", - cwd: "/home/user", - permission_mode: "default" + test "to_h includes base and extra fields" do + input = ClaudeAgent::HookInput.new( + hook_event_name: "PreToolUse", + session_id: "sess-1", + tool_name: "Bash", + tool_input: { command: "ls" } ) - assert_equal "/path/to/transcript", input.transcript_path - assert_equal "/home/user", input.cwd - assert_equal "default", input.permission_mode - end - - # --- define_input DSL --- - - test "define_input generates class inheriting from BaseHookInput" do - assert ClaudeAgent::PreToolUseInput < ClaudeAgent::BaseHookInput - assert ClaudeAgent::StopInput < ClaudeAgent::BaseHookInput - assert ClaudeAgent::SetupInput < ClaudeAgent::BaseHookInput - end - - test "define_input generates all 22 input classes" do - ClaudeAgent::HOOK_EVENTS.each do |event| - klass = ClaudeAgent.const_get("#{event}Input") - assert klass < ClaudeAgent::BaseHookInput, "#{event}Input should inherit from BaseHookInput" - end - end - - test "define_input raises ArgumentError for missing required fields" do - assert_raises(ArgumentError) do - ClaudeAgent::PreToolUseInput.new(tool_name: "Bash") - # Missing required :tool_input - end - end - - test "define_input optional fields default correctly" do - input = ClaudeAgent::SubagentStopInput.new - assert_equal false, input.stop_hook_active - assert_nil input.agent_id - assert_nil input.agent_transcript_path + expected = { + hook_event_name: "PreToolUse", + session_id: "sess-1", + tool_name: "Bash", + tool_input: { command: "ls" } + } + assert_equal expected, input.to_h end - test "define_input with block adds custom methods" do - input = ClaudeAgent::SetupInput.new(trigger: "init") - assert_respond_to input, :init? - assert_respond_to input, :maintenance? - assert input.init? - refute input.maintenance? + test "to_h omits nil base fields" do + input = ClaudeAgent::HookInput.new(hook_event_name: "Stop", stop_hook_active: true) + hash = input.to_h + assert_equal "Stop", hash[:hook_event_name] + assert_equal true, hash[:stop_hook_active] + refute hash.key?(:session_id) + refute hash.key?(:cwd) end - test "define_input with constants defines class constants" do - assert_equal %w[user_settings project_settings local_settings policy_settings skills], - ClaudeAgent::ConfigChangeInput::SOURCES - end + # --- HookInput: fields reader --- - test "define_input forwards unknown kwargs to base class" do - input = ClaudeAgent::WorktreeCreateInput.new( - name: "feature", - session_id: "sess-123", - transcript_path: "/path", - cwd: "/home", - permission_mode: "default" - ) - assert_equal "WorktreeCreate", input.hook_event_name - assert_equal "feature", input.name - assert_equal "sess-123", input.session_id - assert_equal "/path", input.transcript_path - assert_equal "/home", input.cwd - assert_equal "default", input.permission_mode + test "fields exposes extra fields as frozen hash" do + input = ClaudeAgent::HookInput.new(hook_event_name: "PreToolUse", tool_name: "Bash") + assert_equal({ tool_name: "Bash" }, input.fields) + assert input.fields.frozen? end - # --- BaseHookInput agent_id and agent_type --- + # --- HookInput: realistic event shapes --- - test "base_hook_input_agent_id_and_agent_type" do - input = ClaudeAgent::BaseHookInput.new( - hook_event_name: "TestEvent", - agent_id: "agent-abc", - agent_type: "Explore" - ) - assert_equal "agent-abc", input.agent_id - assert_equal "Explore", input.agent_type - end - - test "base_hook_input_agent_id_and_agent_type_default_nil" do - input = ClaudeAgent::BaseHookInput.new(hook_event_name: "TestEvent") - assert_nil input.agent_id - assert_nil input.agent_type - end - - test "subclass_inherits_agent_id_and_agent_type" do - input = ClaudeAgent::PreToolUseInput.new( + test "works as PreToolUse input" do + input = ClaudeAgent::HookInput.new( + hook_event_name: "PreToolUse", tool_name: "Bash", tool_input: { command: "ls" }, - agent_id: "agent-xyz", - agent_type: "Plan" - ) - assert_equal "agent-xyz", input.agent_id - assert_equal "Plan", input.agent_type - end - - # --- InstructionsLoadedInput --- - - test "instructions_loaded_event_in_hook_events" do - assert_includes ClaudeAgent::HOOK_EVENTS, "InstructionsLoaded" - end - - test "instructions_loaded_input" do - input = ClaudeAgent::InstructionsLoadedInput.new( - file_path: "/home/user/.claude/CLAUDE.md", - memory_type: "User", - load_reason: "session_start", - globs: [ "*.rb" ], - trigger_file_path: "/home/user/project/src/app.rb", - parent_file_path: "/home/user/.claude/CLAUDE.md", - session_id: "sess-123" + tool_use_id: "tool-123", + session_id: "sess-abc" ) - assert_equal "InstructionsLoaded", input.hook_event_name - assert_equal "/home/user/.claude/CLAUDE.md", input.file_path - assert_equal "User", input.memory_type - assert_equal "session_start", input.load_reason - assert_equal [ "*.rb" ], input.globs - assert_equal "/home/user/project/src/app.rb", input.trigger_file_path - assert_equal "/home/user/.claude/CLAUDE.md", input.parent_file_path - assert_equal "sess-123", input.session_id + assert_equal "PreToolUse", input.hook_event_name + assert_equal "Bash", input.tool_name + assert_equal({ command: "ls" }, input.tool_input) + assert_equal "tool-123", input.tool_use_id + assert_equal "sess-abc", input.session_id end - test "instructions_loaded_input_with_required_fields_only" do - input = ClaudeAgent::InstructionsLoadedInput.new( - file_path: "/path/to/rules.md", - memory_type: "Project", - load_reason: "nested_traversal" + test "works as SessionStart input" do + input = ClaudeAgent::HookInput.new( + hook_event_name: "SessionStart", + source: "startup", + model: "claude-sonnet-4-5-20250514" ) - assert_equal "InstructionsLoaded", input.hook_event_name - assert_equal "/path/to/rules.md", input.file_path - assert_equal "Project", input.memory_type - assert_equal "nested_traversal", input.load_reason - assert_nil input.globs - assert_nil input.trigger_file_path - assert_nil input.parent_file_path - end - - test "instructions_loaded_input_memory_types_constant" do - assert_equal %w[User Project Local Managed], ClaudeAgent::InstructionsLoadedInput::MEMORY_TYPES + assert_equal "SessionStart", input.hook_event_name + assert_equal "startup", input.source + assert_equal "claude-sonnet-4-5-20250514", input.model end - test "instructions_loaded_input_load_reasons_constant" do - assert_equal %w[session_start nested_traversal path_glob_match include], - ClaudeAgent::InstructionsLoadedInput::LOAD_REASONS + test "works as Stop input with defaults" do + input = ClaudeAgent::HookInput.new(hook_event_name: "Stop") + assert_equal "Stop", input.hook_event_name + assert_nil input.session_id end - test "instructions_loaded_input_inherits_base_fields" do - input = ClaudeAgent::InstructionsLoadedInput.new( - file_path: "/path/to/file.md", - memory_type: "Local", - load_reason: "path_glob_match", - transcript_path: "/path/to/transcript", - cwd: "/home/user", - permission_mode: "default", - agent_id: "agent-abc", - agent_type: "Explore" + test "works as unknown future event" do + input = ClaudeAgent::HookInput.new( + hook_event_name: "SomeFutureEvent", + new_field: "value", + another_field: 42 ) - assert_equal "/path/to/transcript", input.transcript_path - assert_equal "/home/user", input.cwd - assert_equal "default", input.permission_mode - assert_equal "agent-abc", input.agent_id - assert_equal "Explore", input.agent_type + assert_equal "SomeFutureEvent", input.hook_event_name + assert_equal "value", input.new_field + assert_equal 42, input.another_field end end diff --git a/test/claude_agent/test_options.rb b/test/claude_agent/test_options.rb index e71ee42..21db493 100644 --- a/test/claude_agent/test_options.rb +++ b/test/claude_agent/test_options.rb @@ -227,6 +227,12 @@ class TestClaudeAgentOptions < ActiveSupport::TestCase refute options.has_hooks? options = ClaudeAgent::Options.new(hooks: { "PreToolUse" => [] }) + refute options.has_hooks?, "empty hooks array should not count as having hooks" + + registry = ClaudeAgent::HookRegistry.new do |h| + h.before_tool_use("Bash") { |_, _| { continue_: true } } + end + options = ClaudeAgent::Options.new(hooks: registry) assert options.has_hooks? end