claude_hooks 1.1.0 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. checksums.yaml +4 -4
  2. data/.agents/skills/ci-monitoring/SKILL.md +103 -0
  3. data/.agents/skills/run-tests/SKILL.md +45 -0
  4. data/AGENTS.md +46 -0
  5. data/CHANGELOG.md +64 -0
  6. data/README.md +303 -239
  7. data/docs/1.0.0_MIGRATION_GUIDE.md +6 -7
  8. data/docs/API/COMMON.md +7 -1
  9. data/docs/API/CONFIG_CHANGE.md +39 -0
  10. data/docs/API/CWD_CHANGED.md +34 -0
  11. data/docs/API/ELICITATION.md +44 -0
  12. data/docs/API/ELICITATION_RESULT.md +43 -0
  13. data/docs/API/FILE_CHANGED.md +37 -0
  14. data/docs/API/INSTRUCTIONS_LOADED.md +30 -0
  15. data/docs/API/MESSAGE_DISPLAY.md +38 -0
  16. data/docs/API/PERMISSION_DENIED.md +37 -0
  17. data/docs/API/PERMISSION_REQUEST.md +11 -8
  18. data/docs/API/POST_COMPACT.md +30 -0
  19. data/docs/API/POST_TOOL_BATCH.md +39 -0
  20. data/docs/API/POST_TOOL_USE_FAILURE.md +31 -0
  21. data/docs/API/PRE_COMPACT.md +10 -2
  22. data/docs/API/PRE_TOOL_USE.md +5 -1
  23. data/docs/API/SETUP.md +37 -0
  24. data/docs/API/STOP_FAILURE.md +19 -0
  25. data/docs/API/SUBAGENT_START.md +40 -0
  26. data/docs/API/TASK_COMPLETED.md +43 -0
  27. data/docs/API/TASK_CREATED.md +43 -0
  28. data/docs/API/TEAMMATE_IDLE.md +40 -0
  29. data/docs/API/USER_PROMPT_EXPANSION.md +43 -0
  30. data/docs/API/WORKTREE_CREATE.md +39 -0
  31. data/docs/API/WORKTREE_REMOVE.md +33 -0
  32. data/docs/PROMPT_BASED_HOOKS.md +386 -0
  33. data/docs/external/claude-hooks-reference.md +2406 -854
  34. data/docs/mitts/ideabox.md +12 -0
  35. data/docs/mitts/prd.md +417 -0
  36. data/docs/mitts/setup.md +303 -0
  37. data/docs/mitts/task.md +44 -0
  38. data/example_dotclaude/hooks/entrypoints/session_end.rb +5 -22
  39. data/example_dotclaude/hooks/entrypoints/user_prompt_submit.rb +4 -26
  40. data/example_dotclaude/hooks/{handlers/pre_tool_use/github_guard.rb → github_guard.rb} +7 -13
  41. data/example_dotclaude/settings.json +1 -1
  42. data/lib/claude_hooks/base.rb +25 -0
  43. data/lib/claude_hooks/cli.rb +84 -149
  44. data/lib/claude_hooks/config_change.rb +28 -0
  45. data/lib/claude_hooks/cwd_changed.rb +28 -0
  46. data/lib/claude_hooks/elicitation.rb +61 -0
  47. data/lib/claude_hooks/elicitation_result.rb +57 -0
  48. data/lib/claude_hooks/file_changed.rb +41 -0
  49. data/lib/claude_hooks/instructions_loaded.rb +23 -0
  50. data/lib/claude_hooks/message_display.rb +58 -0
  51. data/lib/claude_hooks/output/base.rb +46 -1
  52. data/lib/claude_hooks/output/config_change.rb +47 -0
  53. data/lib/claude_hooks/output/cwd_changed.rb +42 -0
  54. data/lib/claude_hooks/output/elicitation.rb +67 -0
  55. data/lib/claude_hooks/output/elicitation_result.rb +63 -0
  56. data/lib/claude_hooks/output/file_changed.rb +42 -0
  57. data/lib/claude_hooks/output/instructions_loaded.rb +19 -0
  58. data/lib/claude_hooks/output/message_display.rb +49 -0
  59. data/lib/claude_hooks/output/permission_denied.rb +42 -0
  60. data/lib/claude_hooks/output/permission_request.rb +50 -25
  61. data/lib/claude_hooks/output/post_compact.rb +18 -0
  62. data/lib/claude_hooks/output/post_tool_batch.rb +51 -0
  63. data/lib/claude_hooks/output/post_tool_use.rb +12 -0
  64. data/lib/claude_hooks/output/post_tool_use_failure.rb +42 -0
  65. data/lib/claude_hooks/output/pre_compact.rb +34 -3
  66. data/lib/claude_hooks/output/pre_tool_use.rb +8 -2
  67. data/lib/claude_hooks/output/session_start.rb +30 -8
  68. data/lib/claude_hooks/output/setup.rb +42 -0
  69. data/lib/claude_hooks/output/stop.rb +15 -0
  70. data/lib/claude_hooks/output/stop_failure.rb +19 -0
  71. data/lib/claude_hooks/output/subagent_start.rb +42 -0
  72. data/lib/claude_hooks/output/task_completed.rb +18 -0
  73. data/lib/claude_hooks/output/task_created.rb +18 -0
  74. data/lib/claude_hooks/output/teammate_idle.rb +18 -0
  75. data/lib/claude_hooks/output/user_prompt_expansion.rb +51 -0
  76. data/lib/claude_hooks/output/worktree_create.rb +60 -0
  77. data/lib/claude_hooks/output/worktree_remove.rb +19 -0
  78. data/lib/claude_hooks/permission_denied.rb +41 -0
  79. data/lib/claude_hooks/permission_request.rb +16 -9
  80. data/lib/claude_hooks/post_compact.rb +23 -0
  81. data/lib/claude_hooks/post_tool_batch.rb +59 -0
  82. data/lib/claude_hooks/post_tool_use.rb +10 -0
  83. data/lib/claude_hooks/post_tool_use_failure.rb +45 -0
  84. data/lib/claude_hooks/pre_compact.rb +7 -0
  85. data/lib/claude_hooks/pre_tool_use.rb +20 -2
  86. data/lib/claude_hooks/session_start.rb +28 -0
  87. data/lib/claude_hooks/setup.rb +25 -0
  88. data/lib/claude_hooks/stop.rb +17 -0
  89. data/lib/claude_hooks/stop_failure.rb +27 -0
  90. data/lib/claude_hooks/subagent_start.rb +23 -0
  91. data/lib/claude_hooks/subagent_stop.rb +5 -1
  92. data/lib/claude_hooks/task_completed.rb +35 -0
  93. data/lib/claude_hooks/task_created.rb +35 -0
  94. data/lib/claude_hooks/teammate_idle.rb +23 -0
  95. data/lib/claude_hooks/user_prompt_expansion.rb +45 -0
  96. data/lib/claude_hooks/version.rb +1 -1
  97. data/lib/claude_hooks/worktree_create.rb +27 -0
  98. data/lib/claude_hooks/worktree_remove.rb +19 -0
  99. data/lib/claude_hooks.rb +40 -0
  100. metadata +71 -4
  101. data/example_dotclaude/hooks/entrypoints/pre_tool_use.rb +0 -25
data/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # Ruby DSL for Claude Code hooks
2
2
 
3
3
  > [!IMPORTANT]
4
- > v1.0.0 just released and is introducing breaking changes. Please read the [CHANGELOG](CHANGELOG.md) for more information.
4
+ > v1.2.0 just released and is introducing a great number of changes. Please read the [CHANGELOG](CHANGELOG.md) for more information.
5
5
 
6
6
 
7
7
  A Ruby DSL (Domain Specific Language) for creating Claude Code hooks. This will hopefully make creating and configuring new hooks way easier.
@@ -24,6 +24,7 @@ A Ruby DSL (Domain Specific Language) for creating Claude Code hooks. This will
24
24
  - [📝 Example: Tool usage monitor](#-example-tool-usage-monitor)
25
25
  - [🔄 Hook Output](#-hook-output)
26
26
  - [🔌 Plugin Hooks Support](#-plugin-hooks-support)
27
+ - [🛠️ MCP Tools Integration](#️-mcp-tools-integration)
27
28
  - [🚨 Advices](#-advices)
28
29
  - [⚠️ Troubleshooting](#️-troubleshooting)
29
30
  - [🧪 CLI Debugging](#-cli-debugging)
@@ -35,7 +36,11 @@ A Ruby DSL (Domain Specific Language) for creating Claude Code hooks. This will
35
36
  > [!TIP]
36
37
  > Examples are available in [`example_dotclaude/hooks/`](example_dotclaude/hooks/). The GithubGuard in particular is a good example of a solid hook. You can also check [Kyle's hooks for some great examples](https://github.com/kylesnowschwartz/dotfiles/blob/main/claude/hooks)
37
38
 
38
- Here's how to create a simple hook:
39
+ Claude Code supports two types of hooks:
40
+ - **Command hooks** (`type: "command"`) - Execute Ruby/bash scripts (what this DSL is for)
41
+ - **Prompt-based hooks** (`type: "prompt"`) - Delegate decisions to an LLM ([see guide](docs/PROMPT_BASED_HOOKS.md))
42
+
43
+ Here's how to create a simple command hook with this DSL:
39
44
 
40
45
  1. **Install the gem:**
41
46
  ```bash
@@ -45,10 +50,8 @@ Here's how to create a simple hook:
45
50
  1. **Create a simple hook script**
46
51
  ```ruby
47
52
  #!/usr/bin/env ruby
48
- require 'json'
49
53
  require 'claude_hooks'
50
54
 
51
- # Inherit from the right hook type class to get access to helper methods
52
55
  class AddContextAfterPrompt < ClaudeHooks::UserPromptSubmit
53
56
  def call
54
57
  log "User asked: #{prompt}"
@@ -57,18 +60,7 @@ class AddContextAfterPrompt < ClaudeHooks::UserPromptSubmit
57
60
  end
58
61
  end
59
62
 
60
- # Run the hook
61
- if __FILE__ == $0
62
- # Read Claude Code's input data from STDIN
63
- input_data = JSON.parse(STDIN.read)
64
-
65
- hook = AddContextAfterPrompt.new(input_data)
66
- hook.call
67
-
68
- # Handles output and exit code depending on the hook state.
69
- # In this case, uses exit code 0 (success) and prints output to STDOUT
70
- hook.output_and_exit
71
- end
63
+ ClaudeHooks::CLI.run_hook(AddContextAfterPrompt)
72
64
  ```
73
65
 
74
66
  3. ⚠️ **Make it executable**
@@ -97,7 +89,7 @@ echo '{"session_id":"test","prompt":"Hello!"}' | ./add_context_after_prompt.rb
97
89
  That's it! Your hook will now add context to every user prompt. 🎉
98
90
 
99
91
  > [!TIP]
100
- > This was a very simple example but we recommend using the entrypoints/handlers architecture [described below](#recommended-structure-for-your-claudehooks-directory) to create more complex hook systems.
92
+ > Need to run multiple hooks for the same event and merge their outputs? See [Multi-hook structure](#multi-hook-structure) below.
101
93
 
102
94
  ## 📦 Installation
103
95
 
@@ -227,50 +219,83 @@ end
227
219
  ### Core Components
228
220
 
229
221
  1. **`ClaudeHooks::Base`** - Base class with common functionality (logging, config, validation)
230
- 2. **Hook Handler Classes** - Self-contained classes (`ClaudeHooks::UserPromptSubmit`, `ClaudeHooks::PreToolUse`, etc.)
231
- 3. **Output Classes** - `ClaudeHooks::Output::UserPromptSubmit`, etc... are output objects that handle intelligent merging of multiple outputs, as well as using the right exit codes and outputting to the proper stream (`STDIN` or `STDERR`) depending on the the hook state.
232
- 4. **Configuration** - Shared configuration management via `ClaudeHooks::Configuration`
233
- 5. **Logger** - Dedicated logging class with multiline block support
222
+ 2. **Hook Classes** - One class per event type (`ClaudeHooks::UserPromptSubmit`, `ClaudeHooks::PreToolUse`, etc.) that you can inherit from in your hook scripts
223
+ 3. **Output Classes**: those hook classes return instances of output objects (`ClaudeHooks::Output::UserPromptSubmit`, etc.) that handle intelligent merging of multiple outputs, correct exit codes, and routing to `STDOUT` or `STDERR` depending on hook state
224
+ 4. **`ClaudeHooks::CLI`** - Entrypoint helpers: `CLI.run_hook` for production, `CLI.test_runner`/`CLI.run_with_sample_data` for local testing
225
+ 5. **Configuration** - Shared configuration management via `ClaudeHooks::Configuration`
226
+ 6. **Logger** - Dedicated logging class with multiline block support
227
+
228
+ ### Hook file structure
229
+
230
+ For simple cases like one hook class per event, a single file is all you need. Name each file after what it does — the event it runs on comes from where you register it in `settings.json`, not from the filename:
231
+
232
+ ```
233
+ .claude/hooks/
234
+ ├── github_guard.rb # PreToolUse — ClaudeHooks::CLI.run_hook(GithubGuard, on_error: :block)
235
+ ├── format_on_write.rb # PostToolUse — ClaudeHooks::CLI.run_hook(FormatOnWrite)
236
+ ├── load_project_rules.rb # SessionStart — ClaudeHooks::CLI.run_hook(LoadProjectRules)
237
+ └── append_rules.rb # UserPromptSubmit — ClaudeHooks::CLI.run_hook(AppendRules)
238
+ ```
234
239
 
235
- ### Recommended structure for your .claude/hooks/ directory
240
+ See [`example_dotclaude/hooks/github_guard.rb`](example_dotclaude/hooks/github_guard.rb) for a complete, self-contained `PreToolUse` hook wired up this way.
241
+
242
+ ### Multi-hook structure
243
+
244
+ When you need to run multiple hooks for the same event and merge their outputs, split into entrypoints and handlers:
236
245
 
237
246
  ```
238
247
  .claude/hooks/
239
- ├── entrypoints/ # Main entry points
240
-    ├── notification.rb
241
-    ├── pre_tool_use.rb
242
-    ├── post_tool_use.rb
243
- │   ├── pre_compact.rb
244
- │   ├── session_start.rb
245
-    ├── stop.rb
246
-    └── subagent_stop.rb
247
- |
248
- └── handlers/ # Hook handlers for specific hook type
249
- ├── user_prompt_submit/
250
- │ ├── append_rules.rb
251
- │ └── log_user_prompt.rb
252
- ├── pre_tool_use/
253
- │ ├── github_guard.rb
254
- │ └── tool_monitor.rb
255
- └── ...
248
+ ├── entrypoints/ # Coordinates multiple handlers per event
249
+ ├── session_end.rb
250
+ └── user_prompt_submit.rb
251
+
252
+ └── handlers/ # One class per concern
253
+ ├── session_end/
254
+ ├── cleanup_handler.rb
255
+ └── log_session_stats.rb
256
+ └── user_prompt_submit/
257
+ ├── append_rules.rb
258
+ └── log_user_prompt.rb
256
259
  ```
257
260
 
261
+ Use this structure only when you need `Output.merge` across multiple handlers — a single-handler entrypoint is just noise; register the hook class directly instead (see [Hook file structure](#hook-file-structure) above). See [`example_dotclaude/hooks/entrypoints/session_end.rb`](example_dotclaude/hooks/entrypoints/session_end.rb) for a working two-handler entrypoint.
262
+
258
263
  ## 🪝 Hook Types
259
264
 
260
265
  The framework supports the following hook types:
261
266
 
262
267
  | Hook Type | Class | Description |
263
268
  |-----------|-------|-------------|
264
- | **[SessionStart](docs/API/SESSION_START.md)** | `ClaudeHooks::SessionStart` | Hooks that run when Claude Code starts a new session, resumes, or compacts |
265
- | **[UserPromptSubmit](docs/API/USER_PROMPT_SUBMIT.md)** | `ClaudeHooks::UserPromptSubmit` | Hooks that run before the user's prompt is processed |
266
- | **[Notification](docs/API/NOTIFICATION.md)** | `ClaudeHooks::Notification` | Hooks that run when Claude Code sends notifications |
267
- | **[PreToolUse](docs/API/PRE_TOOL_USE.md)** | `ClaudeHooks::PreToolUse` | Hooks that run before a tool is used |
268
- | **[PermissionRequest](docs/API/PERMISSION_REQUEST.md)** | `ClaudeHooks::PermissionRequest` | Hooks that run when Claude requests permission |
269
- | **[PostToolUse](docs/API/POST_TOOL_USE.md)** | `ClaudeHooks::PostToolUse` | Hooks that run after a tool is used |
270
- | **[Stop](docs/API/STOP.md)** | `ClaudeHooks::Stop` | Hooks that run when Claude Code finishes responding |
271
- | **[SubagentStop](docs/API/SUBAGENT_STOP.md)** | `ClaudeHooks::SubagentStop` | Hooks that run when subagent tasks complete |
272
- | **[SessionEnd](docs/API/SESSION_END.md)** | `ClaudeHooks::SessionEnd` | Hooks that run when Claude Code sessions end |
273
- | **[PreCompact](docs/API/PRE_COMPACT.md)** | `ClaudeHooks::PreCompact` | Hooks that run before transcript compaction |
269
+ | **[SessionStart](docs/API/SESSION_START.md)** | `ClaudeHooks::SessionStart` | Runs when Claude Code starts, resumes, or compacts a session |
270
+ | **[Setup](docs/API/SETUP.md)** | `ClaudeHooks::Setup` | Runs once on Claude Code startup before any session |
271
+ | **[UserPromptSubmit](docs/API/USER_PROMPT_SUBMIT.md)** | `ClaudeHooks::UserPromptSubmit` | Runs before the user's prompt is processed |
272
+ | **[UserPromptExpansion](docs/API/USER_PROMPT_EXPANSION.md)** | `ClaudeHooks::UserPromptExpansion` | Runs when a slash command or prompt expansion is triggered |
273
+ | **[Notification](docs/API/NOTIFICATION.md)** | `ClaudeHooks::Notification` | Runs when Claude Code sends a notification |
274
+ | **[MessageDisplay](docs/API/MESSAGE_DISPLAY.md)** | `ClaudeHooks::MessageDisplay` | Runs when a message is about to be displayed |
275
+ | **[PreToolUse](docs/API/PRE_TOOL_USE.md)** | `ClaudeHooks::PreToolUse` | Runs before a tool is used; can allow, deny, defer or ask |
276
+ | **[PermissionRequest](docs/API/PERMISSION_REQUEST.md)** | `ClaudeHooks::PermissionRequest` | Runs when Claude requests an explicit permission |
277
+ | **[PermissionDenied](docs/API/PERMISSION_DENIED.md)** | `ClaudeHooks::PermissionDenied` | Runs when a permission request is denied; can request a retry |
278
+ | **[PostToolUse](docs/API/POST_TOOL_USE.md)** | `ClaudeHooks::PostToolUse` | Runs after a tool is used; can rewrite output |
279
+ | **[PostToolBatch](docs/API/POST_TOOL_BATCH.md)** | `ClaudeHooks::PostToolBatch` | Runs after a full batch of tool calls completes |
280
+ | **[PostToolUseFailure](docs/API/POST_TOOL_USE_FAILURE.md)** | `ClaudeHooks::PostToolUseFailure` | Runs when a tool call fails |
281
+ | **[Stop](docs/API/STOP.md)** | `ClaudeHooks::Stop` | Runs when Claude Code finishes responding; can force continuation |
282
+ | **[StopFailure](docs/API/STOP_FAILURE.md)** | `ClaudeHooks::StopFailure` | Runs when the stop phase itself errors; logging only |
283
+ | **[SubagentStart](docs/API/SUBAGENT_START.md)** | `ClaudeHooks::SubagentStart` | Runs when a subagent task starts |
284
+ | **[SubagentStop](docs/API/SUBAGENT_STOP.md)** | `ClaudeHooks::SubagentStop` | Runs when a subagent task completes |
285
+ | **[TaskCreated](docs/API/TASK_CREATED.md)** | `ClaudeHooks::TaskCreated` | Runs when a teammate task is created |
286
+ | **[TaskCompleted](docs/API/TASK_COMPLETED.md)** | `ClaudeHooks::TaskCompleted` | Runs when a teammate task completes |
287
+ | **[TeammateIdle](docs/API/TEAMMATE_IDLE.md)** | `ClaudeHooks::TeammateIdle` | Runs when a teammate goes idle |
288
+ | **[PreCompact](docs/API/PRE_COMPACT.md)** | `ClaudeHooks::PreCompact` | Runs before transcript compaction; can block it |
289
+ | **[PostCompact](docs/API/POST_COMPACT.md)** | `ClaudeHooks::PostCompact` | Runs after transcript compaction completes |
290
+ | **[ConfigChange](docs/API/CONFIG_CHANGE.md)** | `ClaudeHooks::ConfigChange` | Runs when Claude Code configuration changes; can block it |
291
+ | **[CwdChanged](docs/API/CWD_CHANGED.md)** | `ClaudeHooks::CwdChanged` | Runs when the working directory changes |
292
+ | **[FileChanged](docs/API/FILE_CHANGED.md)** | `ClaudeHooks::FileChanged` | Runs when a watched file is created, modified, or deleted |
293
+ | **[InstructionsLoaded](docs/API/INSTRUCTIONS_LOADED.md)** | `ClaudeHooks::InstructionsLoaded` | Runs when a CLAUDE.md instructions file is loaded |
294
+ | **[Elicitation](docs/API/ELICITATION.md)** | `ClaudeHooks::Elicitation` | Runs when an MCP server requests user input |
295
+ | **[ElicitationResult](docs/API/ELICITATION_RESULT.md)** | `ClaudeHooks::ElicitationResult` | Runs after an elicitation response is provided |
296
+ | **[WorktreeCreate](docs/API/WORKTREE_CREATE.md)** | `ClaudeHooks::WorktreeCreate` | Runs when Claude Code creates a git worktree |
297
+ | **[WorktreeRemove](docs/API/WORKTREE_REMOVE.md)** | `ClaudeHooks::WorktreeRemove` | Runs when a git worktree is removed |
298
+ | **[SessionEnd](docs/API/SESSION_END.md)** | `ClaudeHooks::SessionEnd` | Runs when a Claude Code session ends |
274
299
 
275
300
  ## 🚀 Claude Hook Flow
276
301
 
@@ -289,37 +314,11 @@ graph LR
289
314
  A[Hook triggers] --> B[JSON from STDIN] --> C[Hook does its thing] --> D[JSON to STDOUT or STDERR<br />Exit Code] --> E[Yields back to Claude Code] --> A
290
315
  ```
291
316
 
292
- The main issue is that there are many different types of hooks and they each have different expectations regarding the data outputted to `STDIN` or `STDERR` and Claude Code will react differently for each specific exit code used depending on the hook type.
293
-
294
- ### 🔄 Proposal: a more robust Claude Hook execution flow
295
-
296
- 1. An entrypoint for a hook is set in `~/.claude/settings.json`
297
- 2. Claude Code calls the entrypoint script (e.g., `hooks/entrypoints/pre_tool_use.rb`)
298
- 3. The entrypoint script reads STDIN and coordinates multiple **hook handlers**
299
- 4. Each **hook handler** executes and returns its output data
300
- 5. The entrypoint script combines/processes outputs from multiple **hook handlers**
301
- 6. And then returns final response to Claude Code with the correct exit code
302
-
303
- ```mermaid
304
- graph TD
305
- A[🔧 Hook Configuration<br/>settings.json] --> B
306
- B[🤖 Claude Code<br/><em>User submits prompt</em>] --> C[📋 Entrypoint<br />entrypoints/user_prompt_submit.rb]
307
-
308
- C --> D[📋 Entrypoint<br />Parses JSON from STDIN]
309
- D --> E[📋 Entrypoint<br />Calls hook handlers]
310
-
311
- E --> F[📝 Handler<br />AppendContextRules.call<br/><em>Returns output</em>]
312
- E --> G[📝 Handler<br />PromptGuard.call<br/><em>Returns output</em>]
313
-
314
- F --> J[📋 Entrypoint<br />Calls _ClaudeHooks::Output::UserPromptSubmit.merge_ to 🔀 merge outputs]
315
- G --> J
317
+ The main issue is that there are many different types of hooks and they each have different expectations regarding the data outputted to `STDOUT` or `STDERR` and Claude Code will react differently for each specific exit code used depending on the hook type. This DSL handles all of that for you.
316
318
 
317
- J --> K[📋 Entrypoint<br />- Writes output to STDOUT or STDERR<br />- Uses correct exit code]
318
- K --> L[🤖 Yields back to Claude Code]
319
- L --> B
320
- ```
319
+ ### Basic hook structure
321
320
 
322
- ### Basic Hook Handler Structure
321
+ The simplest pattern is a single file: define your hook class, call `CLI.run_hook`. It handles STDIN parsing, error handling, and correct exit codes.
323
322
 
324
323
  ```ruby
325
324
  #!/usr/bin/env ruby
@@ -328,47 +327,53 @@ require 'claude_hooks'
328
327
 
329
328
  class AddContextAfterPrompt < ClaudeHooks::UserPromptSubmit
330
329
  def call
331
- # Access input data
332
- log do
333
- "--- INPUT DATA ---"
334
- "session_id: #{session_id}"
335
- "cwd: #{cwd}"
336
- "hook_event_name: #{hook_event_name}"
337
- "prompt: #{current_prompt}"
338
- "---"
339
- end
340
-
330
+ log "session_id: #{session_id}, prompt: #{prompt}"
341
331
  log "Full conversation transcript: #{read_transcript}"
342
332
 
343
- # Use a Hook state method to modify what's sent back to Claude Code
344
333
  add_additional_context!("Some custom context")
345
334
 
346
- # Control execution, for instance: block the prompt
347
- if current_prompt.include?("bad word")
335
+ if prompt.include?("bad word")
348
336
  block_prompt!("Hmm no no no!")
349
- log "Prompt blocked: #{current_prompt} because of bad word"
350
337
  end
351
338
 
352
- # Return output if you need it
353
339
  output
354
340
  end
355
341
  end
356
342
 
357
- # Use your handler (usually from an entrypoint file, but this is an example)
358
- if __FILE__ == $0
359
- # Read Claude Code's input data from STDIN
360
- input_data = JSON.parse(STDIN.read)
343
+ ClaudeHooks::CLI.run_hook(AddContextAfterPrompt)
344
+ ```
361
345
 
362
- hook = AddContextAfterPrompt.new(input_data)
363
- # Call the hook
364
- hook.call
346
+ ### Multi-handler flow
365
347
 
366
- # Uses exit code 0 (success) and outputs to STDIN if the prompt wasn't blocked
367
- # Uses exit code 2 (blocking error) and outputs to STDERR if the prompt was blocked
368
- hook.output_and_exit
369
- end
348
+ When multiple hook classes need to respond to the same event, use an entrypoint file to coordinate them:
349
+
350
+ 1. A hook is registered in `~/.claude/settings.json`
351
+ 2. Claude Code calls an entrypoint script
352
+ 3. The entrypoint instantiates each handler and calls them
353
+ 4. Outputs are merged with `Output.merge` (most restrictive behavior wins)
354
+ 5. The merged output is returned to Claude Code with the correct exit code
355
+
356
+ ```mermaid
357
+ graph TD
358
+ A[🔧 Hook Configuration<br/>settings.json] --> B
359
+ B[🤖 Claude Code<br/><em>User submits prompt</em>] --> C[📋 Entrypoint<br />entrypoints/user_prompt_submit.rb]
360
+
361
+ C --> D[📋 Entrypoint<br />Parses JSON from STDIN]
362
+ D --> E[📋 Entrypoint<br />Calls hook handlers]
363
+
364
+ E --> F[📝 Handler<br />AppendRules.call<br/><em>Returns output</em>]
365
+ E --> G[📝 Handler<br />LogUserPrompt.call<br/><em>Returns output</em>]
366
+
367
+ F --> J[📋 Entrypoint<br />Calls _ClaudeHooks::Output::UserPromptSubmit.merge_ to 🔀 merge outputs]
368
+ G --> J
369
+
370
+ J --> K[📋 Entrypoint<br />- Writes output to STDOUT or STDERR<br />- Uses correct exit code]
371
+ K --> L[🤖 Yields back to Claude Code]
372
+ L --> B
370
373
  ```
371
374
 
375
+ See [Hook Output Merging](#-hook-output-merging) below for the entrypoint code that implements this flow, and [`example_dotclaude/hooks/entrypoints/user_prompt_submit.rb`](example_dotclaude/hooks/entrypoints/user_prompt_submit.rb) for a working `AppendRules` + `LogUserPrompt` example.
376
+
372
377
  ## 📚 API Reference
373
378
 
374
379
  The goal of those APIs is to simplify reading from `STDIN` and writing to `STDOUT` or `STDERR` as well as exiting with the right exit codes: the way Claude Code expects you to.
@@ -388,16 +393,36 @@ The framework supports all existing hook types with their respective input field
388
393
 
389
394
  | Hook Type | Input Fields |
390
395
  |-----------|--------------|
391
- | **Common** | `session_id`, `transcript_path`, `cwd`, `hook_event_name`, `permission_mode` |
396
+ | **Common (all hooks)** | `session_id`, `transcript_path`, `cwd`, `hook_event_name`, `permission_mode`, `prompt_id`, `agent_id`, `agent_type`, `effort` |
397
+ | **SessionStart** | `source`, `model`, `session_title` |
398
+ | **Setup** | `source` |
392
399
  | **UserPromptSubmit** | `prompt` |
400
+ | **UserPromptExpansion** | `expansion_type`, `command_name`, `command_args`, `command_source`, `prompt` |
401
+ | **Notification** | `message`, `notification_type` |
402
+ | **MessageDisplay** | `turn_id`, `message_id`, `index`, `final`, `delta` |
393
403
  | **PreToolUse** | `tool_name`, `tool_input`, `tool_use_id` |
394
- | **PermissionRequest** | `tool_name`, `tool_input`, `tool_use_id` |
404
+ | **PermissionRequest** | `tool_name`, `tool_input`, `tool_use_id`, `permission_suggestions` |
405
+ | **PermissionDenied** | `tool_name`, `tool_input`, `tool_use_id`, `reason` |
395
406
  | **PostToolUse** | `tool_name`, `tool_input`, `tool_response`, `tool_use_id` |
396
- | **Notification** | `message`, `notification_type` |
397
- | **Stop** | `stop_hook_active` |
398
- | **SubagentStop** | `stop_hook_active` |
407
+ | **PostToolBatch** | `tool_calls` |
408
+ | **PostToolUseFailure** | `tool_name`, `tool_input`, `tool_use_id`, `error`, `is_interrupt`, `duration_ms` |
409
+ | **Stop** | `stop_hook_active`, `last_assistant_message`, `background_tasks`, `session_crons` |
410
+ | **StopFailure** | `error`, `error_details`, `last_assistant_message` |
411
+ | **SubagentStart** | *(common only: `agent_id`, `agent_type`)* |
412
+ | **SubagentStop** | `stop_hook_active`, `agent_transcript_path` + common `agent_id`/`agent_type` |
413
+ | **TaskCreated** | `task_id`, `task_subject`, `task_description`, `teammate_name`, `team_name` |
414
+ | **TaskCompleted** | `task_id`, `task_subject`, `task_description`, `teammate_name`, `team_name` |
415
+ | **TeammateIdle** | `teammate_name`, `team_name` |
399
416
  | **PreCompact** | `trigger`, `custom_instructions` |
400
- | **SessionStart** | `source` |
417
+ | **PostCompact** | `trigger`, `compact_summary` |
418
+ | **ConfigChange** | `source`, `file_path` |
419
+ | **CwdChanged** | `old_cwd`, `new_cwd` |
420
+ | **FileChanged** | `file_path`, `event` |
421
+ | **InstructionsLoaded** | `file_path`, `load_reason` |
422
+ | **Elicitation** | `mcp_server_name`, `message`, `mode`, `url`, `elicitation_id`, `requested_schema` |
423
+ | **ElicitationResult** | `mcp_server_name`, `action`, `mode`, `elicitation_id`, `content` |
424
+ | **WorktreeCreate** | `name` |
425
+ | **WorktreeRemove** | `worktree_path` |
401
426
  | **SessionEnd** | `reason` |
402
427
 
403
428
  ### Hooks API
@@ -405,15 +430,35 @@ The framework supports all existing hook types with their respective input field
405
430
  **All hook types** inherit from `ClaudeHooks::Base` and share a common API, as well as hook specific APIs.
406
431
 
407
432
  - [📚 Common API Methods](docs/API/COMMON.md)
408
- - [🔔 Notification Hooks](docs/API/NOTIFICATION.md)
409
433
  - [🚀 Session Start Hooks](docs/API/SESSION_START.md)
434
+ - [⚙️ Setup Hooks](docs/API/SETUP.md)
410
435
  - [🖋️ User Prompt Submit Hooks](docs/API/USER_PROMPT_SUBMIT.md)
436
+ - [🔀 User Prompt Expansion Hooks](docs/API/USER_PROMPT_EXPANSION.md)
437
+ - [🔔 Notification Hooks](docs/API/NOTIFICATION.md)
438
+ - [💬 Message Display Hooks](docs/API/MESSAGE_DISPLAY.md)
411
439
  - [🛠️ Pre-Tool Use Hooks](docs/API/PRE_TOOL_USE.md)
412
440
  - [🔐 Permission Request Hooks](docs/API/PERMISSION_REQUEST.md)
441
+ - [🚫 Permission Denied Hooks](docs/API/PERMISSION_DENIED.md)
413
442
  - [🔧 Post-Tool Use Hooks](docs/API/POST_TOOL_USE.md)
414
- - [📝 Pre-Compact Hooks](docs/API/PRE_COMPACT.md)
443
+ - [📦 Post-Tool Batch Hooks](docs/API/POST_TOOL_BATCH.md)
444
+ - [❌ Post-Tool Use Failure Hooks](docs/API/POST_TOOL_USE_FAILURE.md)
415
445
  - [⏹️ Stop Hooks](docs/API/STOP.md)
446
+ - [💥 Stop Failure Hooks](docs/API/STOP_FAILURE.md)
447
+ - [▶️ Subagent Start Hooks](docs/API/SUBAGENT_START.md)
416
448
  - [⏹️ Subagent Stop Hooks](docs/API/SUBAGENT_STOP.md)
449
+ - [✅ Task Created Hooks](docs/API/TASK_CREATED.md)
450
+ - [✅ Task Completed Hooks](docs/API/TASK_COMPLETED.md)
451
+ - [💤 Teammate Idle Hooks](docs/API/TEAMMATE_IDLE.md)
452
+ - [📝 Pre-Compact Hooks](docs/API/PRE_COMPACT.md)
453
+ - [📄 Post-Compact Hooks](docs/API/POST_COMPACT.md)
454
+ - [🔩 Config Change Hooks](docs/API/CONFIG_CHANGE.md)
455
+ - [📂 Cwd Changed Hooks](docs/API/CWD_CHANGED.md)
456
+ - [📄 File Changed Hooks](docs/API/FILE_CHANGED.md)
457
+ - [📋 Instructions Loaded Hooks](docs/API/INSTRUCTIONS_LOADED.md)
458
+ - [💬 Elicitation Hooks](docs/API/ELICITATION.md)
459
+ - [💬 Elicitation Result Hooks](docs/API/ELICITATION_RESULT.md)
460
+ - [🌳 Worktree Create Hooks](docs/API/WORKTREE_CREATE.md)
461
+ - [🗑️ Worktree Remove Hooks](docs/API/WORKTREE_REMOVE.md)
417
462
  - [🔚 Session End Hooks](docs/API/SESSION_END.md)
418
463
 
419
464
  ### 📝 Logging
@@ -457,7 +502,7 @@ Logs are written to session-specific files in the configured log directory:
457
502
 
458
503
  Let's create a hook that will monitor tool usage and ask for permission before using dangerous tools.
459
504
 
460
- First, register an entrypoint in `~/.claude/settings.json`:
505
+ First, register your hook in `~/.claude/settings.json`:
461
506
 
462
507
  ```json
463
508
  "hooks": {
@@ -467,7 +512,7 @@ First, register an entrypoint in `~/.claude/settings.json`:
467
512
  "hooks": [
468
513
  {
469
514
  "type": "command",
470
- "command": "~/.claude/hooks/entrypoints/pre_tool_use.rb"
515
+ "command": "~/.claude/hooks/tool_monitor.rb"
471
516
  }
472
517
  ]
473
518
  }
@@ -475,43 +520,11 @@ First, register an entrypoint in `~/.claude/settings.json`:
475
520
  }
476
521
  ```
477
522
 
478
- Then, create your main entrypoint script and _don't forget to make it executable_:
479
- ```bash
480
- touch ~/.claude/hooks/entrypoints/pre_tool_use.rb
481
- chmod +x ~/.claude/hooks/entrypoints/pre_tool_use.rb
482
- ```
483
-
484
- ```ruby
485
- #!/usr/bin/env ruby
486
-
487
- require 'json'
488
- require_relative '../handlers/pre_tool_use/tool_monitor'
489
-
490
- begin
491
- # Read input from stdin
492
- input_data = JSON.parse(STDIN.read)
493
-
494
- tool_monitor = ToolMonitor.new(input_data)
495
- tool_monitor.call
496
-
497
- # You could also call any other handler here and then merge the outputs
498
-
499
- tool_monitor.output_and_exit
500
- rescue StandardError => e
501
- STDERR.puts JSON.generate({
502
- continue: false,
503
- stopReason: "Hook execution error: #{e.message}",
504
- suppressOutput: false
505
- })
506
- # Non-blocking error
507
- exit 1
508
- end
509
- ```
510
-
511
- Finally, create the handler that will be used to monitor tool usage.
523
+ Then create the hook script and make it executable:
512
524
 
513
525
  ```bash
514
- touch ~/.claude/hooks/handlers/pre_tool_use/tool_monitor.rb
526
+ touch ~/.claude/hooks/tool_monitor.rb
527
+ chmod +x ~/.claude/hooks/tool_monitor.rb
515
528
  ```
516
529
 
517
530
  ```ruby
@@ -527,17 +540,16 @@ class ToolMonitor < ClaudeHooks::PreToolUse
527
540
 
528
541
  if DANGEROUS_TOOLS.include?(tool_name)
529
542
  log "Dangerous tool detected: #{tool_name}", level: :warn
530
- # Use one of the ClaudeHooks::PreToolUse methods to modify the hook state and block the tool
531
543
  ask_for_permission!("The tool '#{tool_name}' can impact your system. Allow?")
532
544
  else
533
- # Use one of the ClaudeHooks::PreToolUse methods to modify the hook state and allow the tool
534
545
  approve_tool!("Safe tool usage")
535
546
  end
536
547
 
537
- # Accessor provided by ClaudeHooks::PreToolUse
538
548
  output
539
549
  end
540
550
  end
551
+
552
+ ClaudeHooks::CLI.run_hook(ToolMonitor, on_error: :block)
541
553
  ```
542
554
 
543
555
  ## 🔄 Hook Output
@@ -555,24 +567,24 @@ This method will return an output object based on the hook's type class (e.g: `C
555
567
 
556
568
  ### 🔄 Hook Output Merging
557
569
 
558
- Often, you will want to call multiple hooks from a same entrypoint.
559
- Each hook type's `output` provides a `merge` method that will try to intelligently merge multiple hook results.
560
- Merged outputs always inherit the **most restrictive behavior**.
570
+ When running multiple hooks for the same event, each hook type's `output` provides a `merge` method that intelligently combines results. Merged outputs always inherit the **most restrictive behavior**.
561
571
 
562
572
  ```ruby
573
+ #!/usr/bin/env ruby
563
574
 
564
575
  require 'json'
576
+ require 'claude_hooks'
565
577
  require_relative '../handlers/user_prompt_submit/hook1'
566
578
  require_relative '../handlers/user_prompt_submit/hook2'
567
579
  require_relative '../handlers/user_prompt_submit/hook3'
568
580
 
569
581
  begin
570
- # Read input from stdin
582
+ # Read input from stdin
571
583
  input_data = JSON.parse(STDIN.read)
572
584
 
573
585
  hook1 = Hook1.new(input_data)
574
- hook2 = Hook1.new(input_data)
575
- hook3 = Hook1.new(input_data)
586
+ hook2 = Hook2.new(input_data)
587
+ hook3 = Hook3.new(input_data)
576
588
 
577
589
  # Execute the multiple hooks
578
590
  hook1.call
@@ -594,6 +606,14 @@ begin
594
606
 
595
607
  # Automatically handles outputting to the right stream (STDOUT or STDERR) and uses the right exit code depending on hook state
596
608
  merged_output.output_and_exit
609
+ rescue StandardError => e
610
+ # This is exactly what CLI.run_hook does for you (non-blocking / fail-open):
611
+ STDERR.puts JSON.generate({
612
+ continue: false,
613
+ stopReason: "Hook execution error: #{e.message}",
614
+ suppressOutput: false
615
+ })
616
+ exit 1
597
617
  end
598
618
  ```
599
619
 
@@ -625,6 +645,13 @@ Claude Code hooks support multiple exit codes with different behaviors depending
625
645
  | SessionStart | Operation continues<br/><br />**`STDOUT` added as context to Claude** | Non-blocking error<br/><br />`STDERR` shown to user | N/A<br/><br />`STDERR` shown to user only |
626
646
  | SessionEnd | Operation continues<br/><br />Logged to debug only (`--debug`) | Non-blocking error<br/><br />Logged to debug only (`--debug`) | N/A<br/><br />Logged to debug only (`--debug`) |
627
647
 
648
+ > [!NOTE]
649
+ > The 20 events added in `1.2.0` follow the same families. Their per-event exit-code behavior is documented on each API page under [`docs/API/`](docs/API/):
650
+ > - **Blocking via top-level `decision`** (behave like `PreToolUse`/`Stop`): `UserPromptExpansion`, `PostToolBatch`, `ConfigChange`.
651
+ > - **Blocking via `exit 2` / `continue: false`** (no `decision` field): `TaskCreated`, `TaskCompleted`, `TeammateIdle`.
652
+ > - **JSON-API special** (always `exit 0`, decision in `hookSpecificOutput`): `PermissionDenied`, `Elicitation`, `ElicitationResult`, `WorktreeCreate` (bare-path stdout).
653
+ > - **Non-blocking / context-only** (exit code effectively ignored): `Setup`, `SubagentStart`, `PostToolUseFailure`, `StopFailure`, `PostCompact`, `CwdChanged`, `FileChanged`, `InstructionsLoaded`, `WorktreeRemove`, `MessageDisplay`.
654
+
628
655
 
629
656
  #### Manually outputing and exiting example with success
630
657
  For the operation to continue for a `UserPromptSubmit` hook, you would `STDOUT.puts` structured JSON data followed by `exit 0`:
@@ -709,12 +736,7 @@ class PluginFormatter < ClaudeHooks::PostToolUse
709
736
  end
710
737
  end
711
738
 
712
- if __FILE__ == $0
713
- input_data = JSON.parse(STDIN.read)
714
- hook = PluginFormatter.new(input_data)
715
- hook.call
716
- hook.output_and_exit
717
- end
739
+ ClaudeHooks::CLI.run_hook(PluginFormatter)
718
740
  ```
719
741
 
720
742
  **Environment variables available in plugins:**
@@ -722,7 +744,70 @@ end
722
744
  - `${CLAUDE_PROJECT_DIR}`: Project root directory (same as for project hooks)
723
745
  - All standard environment variables and configuration options work the same way
724
746
 
725
- See the [plugin components reference](https://docs.claude.com/en/docs/claude-code/plugins-reference#hooks) for more details on creating plugin hooks.
747
+ See the [plugin components reference](https://code.claude.com/docs/en/plugins-reference#hooks) for more details on creating plugin hooks.
748
+
749
+ ## 🛠️ MCP Tools Integration
750
+
751
+ [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) tools can be used with Claude Code hooks. When MCP servers are configured, their tools become available and can be intercepted by hooks just like built-in tools.
752
+
753
+ ### MCP Tool Naming Convention
754
+
755
+ MCP tools follow a specific naming pattern: `mcp__<server-name>__<tool-name>`
756
+
757
+ **Example MCP tool names:**
758
+ - `mcp__filesystem__read_file`
759
+ - `mcp__github__create_issue`
760
+ - `mcp__database__query`
761
+
762
+ ### Configuring Hooks for MCP Tools
763
+
764
+ You can use matchers to target specific MCP tools or all tools from a server:
765
+
766
+ ```json
767
+ {
768
+ "hooks": {
769
+ "PreToolUse": [
770
+ {
771
+ "matcher": "mcp__github__.*",
772
+ "hooks": [
773
+ {
774
+ "type": "command",
775
+ "command": "~/.claude/hooks/github_guard.rb"
776
+ }
777
+ ]
778
+ },
779
+ {
780
+ "matcher": "mcp__.*__create.*|mcp__.*__delete.*",
781
+ "hooks": [
782
+ {
783
+ "type": "command",
784
+ "command": "~/.claude/hooks/destructive_operation_guard.rb"
785
+ }
786
+ ]
787
+ }
788
+ ]
789
+ }
790
+ }
791
+ ```
792
+
793
+ ### Common MCP Tool Patterns
794
+
795
+ | Pattern | Matches |
796
+ |---------|---------|
797
+ | `mcp__*__*` | All MCP tools from all servers |
798
+ | `mcp__github__*` | All tools from the github server |
799
+ | `mcp__*__read*` | All read operations from any server |
800
+ | `mcp__.*__create.*\|mcp__.*__delete.*` | All create/delete operations |
801
+
802
+ ### Best Practices for MCP Tools
803
+
804
+ 1. **Use regex matchers** - MCP tool names are predictable, making regex patterns very effective
805
+ 2. **Guard destructive operations** - Always review create/delete/update operations
806
+ 3. **Server-specific rules** - Different MCP servers may need different security policies
807
+ 4. **Log MCP tool usage** - Track which external tools are being used
808
+ 5. **Test with your MCP servers** - Tool names vary by server implementation
809
+
810
+ See the [official MCP documentation](https://modelcontextprotocol.io/) for more information about MCP servers and tools.
726
811
 
727
812
  ## 🚨 Advices
728
813
 
@@ -732,124 +817,103 @@ See the [plugin components reference](https://docs.claude.com/en/docs/claude-cod
732
817
 
733
818
  ## ⚠️ Troubleshooting
734
819
 
735
- ### Make your entrypoint scripts executable
820
+ ### Make your hook scripts executable
736
821
 
737
822
  Don't forget to make the scripts called from `settings.json` executable:
738
823
 
739
824
  ```bash
740
- chmod +x ~/.claude/hooks/entrypoints/user_prompt_submit.rb
825
+ chmod +x ~/.claude/hooks/my_hook.rb
741
826
  ```
742
827
 
743
828
 
744
829
  ## 🧪 CLI Debugging
745
830
 
746
- The `ClaudeHooks::CLI` module provides utilities to simplify testing hooks in isolation. Instead of writing repetitive JSON parsing and error handling code, you can use the CLI test runner.
831
+ `ClaudeHooks::CLI` provides two helpers: `run_hook` (for production use) and `test_runner`/`run_with_sample_data` (for local testing with custom input).
747
832
 
748
- ### Basic Usage
833
+ ### CLI.run_hook
749
834
 
750
- Replace the traditional testing boilerplate:
835
+ `CLI.run_hook` is what you put at the bottom of every simple hook script. It reads JSON from STDIN, runs your hook, handles errors, and calls `output_and_exit` with the right exit code.
751
836
 
752
837
  ```ruby
753
- # Old way (15+ lines of repetitive code)
754
- if __FILE__ == $0
755
- begin
756
- require 'json'
757
- input_data = JSON.parse(STDIN.read)
758
- hook = MyHook.new(input_data)
759
- result = hook.call
760
- puts JSON.generate(result)
761
- rescue StandardError => e
762
- STDERR.puts "Error: #{e.message}"
763
- puts JSON.generate({
764
- continue: false,
765
- stopReason: "Error: #{e.message}",
766
- suppressOutput: false
767
- })
768
- exit 1
769
- end
770
- end
838
+ # Single hook (most common)
839
+ ClaudeHooks::CLI.run_hook(MyHook)
771
840
  ```
772
841
 
773
- With the simple CLI test runner:
842
+ It replaces the more verbose
774
843
 
775
844
  ```ruby
776
- # New way (1 line!)
777
- if __FILE__ == $0
778
- ClaudeHooks::CLI.test_runner(MyHook)
845
+ begin
846
+ # Read input from stdin
847
+ input_data = JSON.parse(STDIN.read)
848
+
849
+ hook = MyHook.new(input_data)
850
+ hook.call
851
+ hook.output_and_exit
852
+ rescue StandardError => e
853
+ # Non-blocking by default (fail-open): Claude continues as if the hook didn't run.
854
+ # Pass `on_error: :block` to CLI.run_hook to exit 2 (fail-closed) instead.
855
+ STDERR.puts JSON.generate({
856
+ continue: false,
857
+ stopReason: "Hook execution error: #{e.message}",
858
+ suppressOutput: false
859
+ })
860
+ exit 1
779
861
  end
780
862
  ```
781
863
 
782
- ### Customization with Blocks
864
+ #### on_error: fail-open vs fail-closed
783
865
 
784
- You can customize the input data for testing using blocks:
866
+ By default, if your hook raises an unexpected exception, `CLI.run_hook` exits 1 (non-blocking) — Claude continues as if the hook didn't run. This is **fail-open**.
785
867
 
786
- ```ruby
787
- if __FILE__ == $0
788
- ClaudeHooks::CLI.test_runner(MyHook) do |input_data|
789
- input_data['debug_mode'] = true
790
- input_data['custom_field'] = 'test_value'
791
- input_data['user_name'] = 'TestUser'
792
- end
793
- end
794
- ```
868
+ For security or policy hooks (`PreToolUse` guards, prompt filters, etc.) you almost certainly want **fail-closed** instead — a crash should block the action, not silently pass it through:
795
869
 
796
- ### Testing Methods
797
-
798
- #### 1. Test with STDIN (default)
799
870
  ```ruby
800
- ClaudeHooks::CLI.test_runner(MyHook)
801
- # Usage: echo '{"session_id":"test","prompt":"Hello"}' | ruby my_hook.rb
802
- ```
871
+ # Default: hook crash is non-blocking — Claude continues anyway (exit 1)
872
+ ClaudeHooks::CLI.run_hook(MyHook)
803
873
 
804
- #### 2. Test with default sample data instead of STDIN
805
- ```ruby
806
- ClaudeHooks::CLI.run_with_sample_data(MyHook, { 'prompt' => 'test prompt' })
807
- # Provides default values, no STDIN needed
808
- ```
874
+ # Fail-closed: hook crash blocks the action (exit 2)
875
+ ClaudeHooks::CLI.run_hook(MyHook, on_error: :block)
809
876
 
810
- #### 3. Test with Sample Data + Customization
811
- ```ruby
812
- ClaudeHooks::CLI.run_with_sample_data(MyHook) do |input_data|
813
- input_data['prompt'] = 'Custom test prompt'
814
- input_data['debug'] = true
877
+ # Also works with block form
878
+ ClaudeHooks::CLI.run_hook(on_error: :block) do |input_data|
879
+ # ...
815
880
  end
816
881
  ```
817
882
 
818
- ### Example Hook with CLI Testing
819
-
820
- ```ruby
821
- #!/usr/bin/env ruby
883
+ > [!WARNING]
884
+ > If you're writing a `PreToolUse` or `UserPromptSubmit` hook that enforces security policy, use `on_error: :block`. Without it, a Ruby exception (network timeout, nil reference, etc.) will silently allow the action through.
822
885
 
823
- require 'claude_hooks'
886
+ ### CLI.test_runner — local testing
824
887
 
825
- class MyTestHook < ClaudeHooks::UserPromptSubmit
826
- def call
827
- log "Debug mode: #{input_data['debug_mode']}"
828
- log "Processing: #{prompt}"
888
+ Use `test_runner` when running the script directly (outside of Claude Code) to inject custom input data:
829
889
 
830
- if input_data['debug_mode']
831
- log "All input keys: #{input_data.keys.join(', ')}"
832
- end
833
-
834
- output
890
+ ```ruby
891
+ # At the bottom of your hook file, guarded so it only runs directly:
892
+ if __FILE__ == $0
893
+ ClaudeHooks::CLI.test_runner(MyHook) do |input_data|
894
+ input_data['debug_mode'] = true
895
+ input_data['prompt'] = 'Test prompt'
835
896
  end
836
897
  end
837
898
 
838
- # Test runner with customization
899
+ # Or test with synthetic data (no STDIN needed):
839
900
  if __FILE__ == $0
840
- ClaudeHooks::CLI.test_runner(MyTestHook) do |input_data|
841
- input_data['debug_mode'] = true
842
- end
901
+ ClaudeHooks::CLI.run_with_sample_data(MyHook, { 'prompt' => 'test prompt' })
843
902
  end
844
903
  ```
845
904
 
905
+ Test with real STDIN:
906
+ ```bash
907
+ echo '{"session_id":"test","prompt":"Hello"}' | ruby my_hook.rb
908
+ ```
909
+
846
910
  ## 🐛 Debugging
847
911
 
848
- ### Test an individual entrypoint
912
+ ### Test a hook script directly
849
913
 
850
914
  ```bash
851
915
  # Test with sample data
852
- echo '{"session_id": "test", "transcript_path": "/tmp/transcript", "cwd": "/tmp", "hook_event_name": "UserPromptSubmit", "user_prompt": "Hello Claude"}' | CLAUDE_PROJECT_DIR=$(pwd) ruby ~/.claude/hooks/entrypoints/user_prompt_submit.rb
916
+ echo '{"session_id": "test", "transcript_path": "/tmp/transcript", "cwd": "/tmp", "hook_event_name": "UserPromptSubmit", "user_prompt": "Hello Claude"}' | CLAUDE_PROJECT_DIR=$(pwd) ruby ~/.claude/hooks/user_prompt_submit.rb
853
917
  ```
854
918
 
855
919
  ## 🧪 Development & Contributing