claude_hooks 1.2.0 → 1.3.0

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c1803cc66c5f4c6e0d2ca706c4106654a2aa961e8a8e968d3198acab8ff95a7d
4
- data.tar.gz: 5dba30b7bce90d4a420cb1c83be1b9e2e2c3a1b0b1dfc42f47b0edd01e08941c
3
+ metadata.gz: ee18a384c04bcfff4fa734935277a938e14d2799646369e8db8e013c5a7b33ce
4
+ data.tar.gz: b784bc4cc4e52096b563c25a2c6d19175a38ac37e6ea72d110c32c3887566128
5
5
  SHA512:
6
- metadata.gz: f5b394c9005bea73bdb66db595e3783fc7cb2e67889dbf2923b945899bd8c907edf0df241a534ac4a2679c887a07538ec9e1d65d58d24f2b9313cded33e5ca18
7
- data.tar.gz: b9867904501454ef867f5be72a0c82e63de3d7bcfdfe6981c8cfbbf946fdd50cc2968671dfd341a1811736da51302c67e412e09302e295b61b30abca25790b23
6
+ metadata.gz: 25345ee635cc383661393b66bca8d82f476615e311fb07d3206630b4cc13b9972c62cb08efe2d0ba03ab053ce40f07bdac2dfa3b0d633377150474ba37743e5d
7
+ data.tar.gz: ed9511e85a7a74e9ea851e5ece032afcb6c860f48b52143da80fc999a81dce35374ff1df3fca1914d2c91963578c7f7561d94890bb6f39bf23e530599b2a107e
data/CHANGELOG.md CHANGED
@@ -5,6 +5,33 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.3.0] - 2026-08-15
9
+
10
+ ### Added
11
+
12
+ - **`DirectoryAdded` hook event** (`ClaudeHooks::DirectoryAdded` / `ClaudeHooks::Output::DirectoryAdded`): non-blocking hook that runs after a working directory is added mid-session via `/add-dir` or the SDK `register_repo_root` control request. Exposes `directory` and `source` readers plus a `system_message!` builder.
13
+
14
+ ## [1.2.1] - 2026-07-15
15
+
16
+ ### Added
17
+
18
+ - **`CLI.run_hook` — new primary entrypoint method** replacing `CLI.entrypoint`. Reads JSON from STDIN, runs the hook, and exits with the correct code and stream for the hook type.
19
+ - Accepts `on_error: :allow` (default, exit 1 — non-blocking) or `on_error: :block` (exit 2 — blocking). Use `:block` for security/policy hooks where a crash should never silently pass through.
20
+ - Supports a class form (`CLI.run_hook(MyHook)`) and a block form for multi-handler merging.
21
+
22
+ ### Changed
23
+
24
+ - **`CLI.entrypoint` deprecated** in favour of `CLI.run_hook`. It remains as a delegating alias with a deprecation warning and will be removed in a future minor version.
25
+ - **`CLI.run_hook` (old)** — the previous public `run_hook(hook_class, input_data)` helper used internally by `test_runner`/`run_with_sample_data` has been renamed to `run_hook_with_data` and made private. It was never intended as part of the public API.
26
+ - **Error output stream** — `CLI.run_hook` / `CLI.entrypoint` now write errors exclusively to stderr (previously the `:allow` path also wrote to stdout). On the `on_error: :block` (exit 2) path the message is written as **plain text**, not JSON, since Claude Code shows exit-2 stderr to the model verbatim and never parses it as JSON. The `:allow` (exit 1) path still writes the JSON error object to stderr.
27
+ - **README and examples** updated throughout to use `CLI.run_hook`.
28
+
29
+ ### Notes
30
+
31
+ - No hook class or output class changes — existing hook scripts continue to work without modification
32
+ - `CLI.entrypoint` still works; only a deprecation warning is emitted
33
+ - 3 new tests covering `on_error: :block` (hook error → exit 2, invalid JSON → exit 2) and the deprecation warning
34
+
8
35
  ## [1.2.0] - 2026-07-14
9
36
 
10
37
  ### Added
data/README.md CHANGED
@@ -50,10 +50,8 @@ Here's how to create a simple command hook with this DSL:
50
50
  1. **Create a simple hook script**
51
51
  ```ruby
52
52
  #!/usr/bin/env ruby
53
- require 'json'
54
53
  require 'claude_hooks'
55
54
 
56
- # Inherit from the right hook type class to get access to helper methods
57
55
  class AddContextAfterPrompt < ClaudeHooks::UserPromptSubmit
58
56
  def call
59
57
  log "User asked: #{prompt}"
@@ -62,18 +60,7 @@ class AddContextAfterPrompt < ClaudeHooks::UserPromptSubmit
62
60
  end
63
61
  end
64
62
 
65
- # Run the hook
66
- if __FILE__ == $0
67
- # Read Claude Code's input data from STDIN
68
- input_data = JSON.parse(STDIN.read)
69
-
70
- hook = AddContextAfterPrompt.new(input_data)
71
- hook.call
72
-
73
- # Handles output and exit code depending on the hook state.
74
- # In this case, uses exit code 0 (success) and prints output to STDOUT
75
- hook.output_and_exit
76
- end
63
+ ClaudeHooks::CLI.run_hook(AddContextAfterPrompt)
77
64
  ```
78
65
 
79
66
  3. ⚠️ **Make it executable**
@@ -102,7 +89,7 @@ echo '{"session_id":"test","prompt":"Hello!"}' | ./add_context_after_prompt.rb
102
89
  That's it! Your hook will now add context to every user prompt. 🎉
103
90
 
104
91
  > [!TIP]
105
- > 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.
106
93
 
107
94
  ## 📦 Installation
108
95
 
@@ -232,34 +219,47 @@ end
232
219
  ### Core Components
233
220
 
234
221
  1. **`ClaudeHooks::Base`** - Base class with common functionality (logging, config, validation)
235
- 2. **Hook Handler Classes** - Self-contained classes (`ClaudeHooks::UserPromptSubmit`, `ClaudeHooks::PreToolUse`, etc.)
236
- 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.
237
- 4. **Configuration** - Shared configuration management via `ClaudeHooks::Configuration`
238
- 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
239
227
 
240
- ### Recommended structure for your .claude/hooks/ directory
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:
241
231
 
242
232
  ```
243
233
  .claude/hooks/
244
- ├── entrypoints/ # Main entry points
245
- │   ├── notification.rb
246
- │   ├── pre_tool_use.rb
247
- │   ├── post_tool_use.rb
248
- │   ├── pre_compact.rb
249
- │   ├── session_start.rb
250
- │   ├── stop.rb
251
- │   └── subagent_stop.rb
252
- |
253
- └── handlers/ # Hook handlers for specific hook type
254
- ├── user_prompt_submit/
255
- │ ├── append_rules.rb
256
- │ └── log_user_prompt.rb
257
- ├── pre_tool_use/
258
- │ ├── github_guard.rb
259
- │ └── tool_monitor.rb
260
- └── ...
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)
261
238
  ```
262
239
 
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:
245
+
246
+ ```
247
+ .claude/hooks/
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
259
+ ```
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
+
263
263
  ## 🪝 Hook Types
264
264
 
265
265
  The framework supports the following hook types:
@@ -289,6 +289,7 @@ The framework supports the following hook types:
289
289
  | **[PostCompact](docs/API/POST_COMPACT.md)** | `ClaudeHooks::PostCompact` | Runs after transcript compaction completes |
290
290
  | **[ConfigChange](docs/API/CONFIG_CHANGE.md)** | `ClaudeHooks::ConfigChange` | Runs when Claude Code configuration changes; can block it |
291
291
  | **[CwdChanged](docs/API/CWD_CHANGED.md)** | `ClaudeHooks::CwdChanged` | Runs when the working directory changes |
292
+ | **[DirectoryAdded](docs/API/DIRECTORY_ADDED.md)** | `ClaudeHooks::DirectoryAdded` | Runs when a working directory is added mid-session |
292
293
  | **[FileChanged](docs/API/FILE_CHANGED.md)** | `ClaudeHooks::FileChanged` | Runs when a watched file is created, modified, or deleted |
293
294
  | **[InstructionsLoaded](docs/API/INSTRUCTIONS_LOADED.md)** | `ClaudeHooks::InstructionsLoaded` | Runs when a CLAUDE.md instructions file is loaded |
294
295
  | **[Elicitation](docs/API/ELICITATION.md)** | `ClaudeHooks::Elicitation` | Runs when an MCP server requests user input |
@@ -314,37 +315,11 @@ graph LR
314
315
  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
315
316
  ```
316
317
 
317
- 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.
318
+ 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.
318
319
 
319
- ### 🔄 Proposal: a more robust Claude Hook execution flow
320
+ ### Basic hook structure
320
321
 
321
- 1. An entrypoint for a hook is set in `~/.claude/settings.json`
322
- 2. Claude Code calls the entrypoint script (e.g., `hooks/entrypoints/pre_tool_use.rb`)
323
- 3. The entrypoint script reads STDIN and coordinates multiple **hook handlers**
324
- 4. Each **hook handler** executes and returns its output data
325
- 5. The entrypoint script combines/processes outputs from multiple **hook handlers**
326
- 6. And then returns final response to Claude Code with the correct exit code
327
-
328
- ```mermaid
329
- graph TD
330
- A[🔧 Hook Configuration<br/>settings.json] --> B
331
- B[🤖 Claude Code<br/><em>User submits prompt</em>] --> C[📋 Entrypoint<br />entrypoints/user_prompt_submit.rb]
332
-
333
- C --> D[📋 Entrypoint<br />Parses JSON from STDIN]
334
- D --> E[📋 Entrypoint<br />Calls hook handlers]
335
-
336
- E --> F[📝 Handler<br />AppendContextRules.call<br/><em>Returns output</em>]
337
- E --> G[📝 Handler<br />PromptGuard.call<br/><em>Returns output</em>]
338
-
339
- F --> J[📋 Entrypoint<br />Calls _ClaudeHooks::Output::UserPromptSubmit.merge_ to 🔀 merge outputs]
340
- G --> J
341
-
342
- J --> K[📋 Entrypoint<br />- Writes output to STDOUT or STDERR<br />- Uses correct exit code]
343
- K --> L[🤖 Yields back to Claude Code]
344
- L --> B
345
- ```
346
-
347
- ### Basic Hook Handler Structure
322
+ 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.
348
323
 
349
324
  ```ruby
350
325
  #!/usr/bin/env ruby
@@ -353,47 +328,53 @@ require 'claude_hooks'
353
328
 
354
329
  class AddContextAfterPrompt < ClaudeHooks::UserPromptSubmit
355
330
  def call
356
- # Access input data
357
- log do
358
- "--- INPUT DATA ---"
359
- "session_id: #{session_id}"
360
- "cwd: #{cwd}"
361
- "hook_event_name: #{hook_event_name}"
362
- "prompt: #{current_prompt}"
363
- "---"
364
- end
365
-
331
+ log "session_id: #{session_id}, prompt: #{prompt}"
366
332
  log "Full conversation transcript: #{read_transcript}"
367
333
 
368
- # Use a Hook state method to modify what's sent back to Claude Code
369
334
  add_additional_context!("Some custom context")
370
335
 
371
- # Control execution, for instance: block the prompt
372
- if current_prompt.include?("bad word")
336
+ if prompt.include?("bad word")
373
337
  block_prompt!("Hmm no no no!")
374
- log "Prompt blocked: #{current_prompt} because of bad word"
375
338
  end
376
339
 
377
- # Return output if you need it
378
340
  output
379
341
  end
380
342
  end
381
343
 
382
- # Use your handler (usually from an entrypoint file, but this is an example)
383
- if __FILE__ == $0
384
- # Read Claude Code's input data from STDIN
385
- input_data = JSON.parse(STDIN.read)
344
+ ClaudeHooks::CLI.run_hook(AddContextAfterPrompt)
345
+ ```
386
346
 
387
- hook = AddContextAfterPrompt.new(input_data)
388
- # Call the hook
389
- hook.call
347
+ ### Multi-handler flow
390
348
 
391
- # Uses exit code 0 (success) and outputs to STDIN if the prompt wasn't blocked
392
- # Uses exit code 2 (blocking error) and outputs to STDERR if the prompt was blocked
393
- hook.output_and_exit
394
- end
349
+ When multiple hook classes need to respond to the same event, use an entrypoint file to coordinate them:
350
+
351
+ 1. A hook is registered in `~/.claude/settings.json`
352
+ 2. Claude Code calls an entrypoint script
353
+ 3. The entrypoint instantiates each handler and calls them
354
+ 4. Outputs are merged with `Output.merge` (most restrictive behavior wins)
355
+ 5. The merged output is returned to Claude Code with the correct exit code
356
+
357
+ ```mermaid
358
+ graph TD
359
+ A[🔧 Hook Configuration<br/>settings.json] --> B
360
+ B[🤖 Claude Code<br/><em>User submits prompt</em>] --> C[📋 Entrypoint<br />entrypoints/user_prompt_submit.rb]
361
+
362
+ C --> D[📋 Entrypoint<br />Parses JSON from STDIN]
363
+ D --> E[📋 Entrypoint<br />Calls hook handlers]
364
+
365
+ E --> F[📝 Handler<br />AppendRules.call<br/><em>Returns output</em>]
366
+ E --> G[📝 Handler<br />LogUserPrompt.call<br/><em>Returns output</em>]
367
+
368
+ F --> J[📋 Entrypoint<br />Calls _ClaudeHooks::Output::UserPromptSubmit.merge_ to 🔀 merge outputs]
369
+ G --> J
370
+
371
+ J --> K[📋 Entrypoint<br />- Writes output to STDOUT or STDERR<br />- Uses correct exit code]
372
+ K --> L[🤖 Yields back to Claude Code]
373
+ L --> B
395
374
  ```
396
375
 
376
+ 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.
377
+
397
378
  ## 📚 API Reference
398
379
 
399
380
  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.
@@ -437,6 +418,7 @@ The framework supports all existing hook types with their respective input field
437
418
  | **PostCompact** | `trigger`, `compact_summary` |
438
419
  | **ConfigChange** | `source`, `file_path` |
439
420
  | **CwdChanged** | `old_cwd`, `new_cwd` |
421
+ | **DirectoryAdded** | `directory`, `source` |
440
422
  | **FileChanged** | `file_path`, `event` |
441
423
  | **InstructionsLoaded** | `file_path`, `load_reason` |
442
424
  | **Elicitation** | `mcp_server_name`, `message`, `mode`, `url`, `elicitation_id`, `requested_schema` |
@@ -522,7 +504,7 @@ Logs are written to session-specific files in the configured log directory:
522
504
 
523
505
  Let's create a hook that will monitor tool usage and ask for permission before using dangerous tools.
524
506
 
525
- First, register an entrypoint in `~/.claude/settings.json`:
507
+ First, register your hook in `~/.claude/settings.json`:
526
508
 
527
509
  ```json
528
510
  "hooks": {
@@ -532,7 +514,7 @@ First, register an entrypoint in `~/.claude/settings.json`:
532
514
  "hooks": [
533
515
  {
534
516
  "type": "command",
535
- "command": "~/.claude/hooks/entrypoints/pre_tool_use.rb"
517
+ "command": "~/.claude/hooks/tool_monitor.rb"
536
518
  }
537
519
  ]
538
520
  }
@@ -540,43 +522,11 @@ First, register an entrypoint in `~/.claude/settings.json`:
540
522
  }
541
523
  ```
542
524
 
543
- Then, create your main entrypoint script and _don't forget to make it executable_:
544
- ```bash
545
- touch ~/.claude/hooks/entrypoints/pre_tool_use.rb
546
- chmod +x ~/.claude/hooks/entrypoints/pre_tool_use.rb
547
- ```
548
-
549
- ```ruby
550
- #!/usr/bin/env ruby
551
-
552
- require 'json'
553
- require_relative '../handlers/pre_tool_use/tool_monitor'
554
-
555
- begin
556
- # Read input from stdin
557
- input_data = JSON.parse(STDIN.read)
558
-
559
- tool_monitor = ToolMonitor.new(input_data)
560
- tool_monitor.call
561
-
562
- # You could also call any other handler here and then merge the outputs
563
-
564
- tool_monitor.output_and_exit
565
- rescue StandardError => e
566
- STDERR.puts JSON.generate({
567
- continue: false,
568
- stopReason: "Hook execution error: #{e.message}",
569
- suppressOutput: false
570
- })
571
- # Non-blocking error
572
- exit 1
573
- end
574
- ```
575
-
576
- Finally, create the handler that will be used to monitor tool usage.
525
+ Then create the hook script and make it executable:
577
526
 
578
527
  ```bash
579
- touch ~/.claude/hooks/handlers/pre_tool_use/tool_monitor.rb
528
+ touch ~/.claude/hooks/tool_monitor.rb
529
+ chmod +x ~/.claude/hooks/tool_monitor.rb
580
530
  ```
581
531
 
582
532
  ```ruby
@@ -592,17 +542,16 @@ class ToolMonitor < ClaudeHooks::PreToolUse
592
542
 
593
543
  if DANGEROUS_TOOLS.include?(tool_name)
594
544
  log "Dangerous tool detected: #{tool_name}", level: :warn
595
- # Use one of the ClaudeHooks::PreToolUse methods to modify the hook state and block the tool
596
545
  ask_for_permission!("The tool '#{tool_name}' can impact your system. Allow?")
597
546
  else
598
- # Use one of the ClaudeHooks::PreToolUse methods to modify the hook state and allow the tool
599
547
  approve_tool!("Safe tool usage")
600
548
  end
601
549
 
602
- # Accessor provided by ClaudeHooks::PreToolUse
603
550
  output
604
551
  end
605
552
  end
553
+
554
+ ClaudeHooks::CLI.run_hook(ToolMonitor, on_error: :block)
606
555
  ```
607
556
 
608
557
  ## 🔄 Hook Output
@@ -620,24 +569,24 @@ This method will return an output object based on the hook's type class (e.g: `C
620
569
 
621
570
  ### 🔄 Hook Output Merging
622
571
 
623
- Often, you will want to call multiple hooks from a same entrypoint.
624
- Each hook type's `output` provides a `merge` method that will try to intelligently merge multiple hook results.
625
- Merged outputs always inherit the **most restrictive behavior**.
572
+ 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**.
626
573
 
627
574
  ```ruby
575
+ #!/usr/bin/env ruby
628
576
 
629
577
  require 'json'
578
+ require 'claude_hooks'
630
579
  require_relative '../handlers/user_prompt_submit/hook1'
631
580
  require_relative '../handlers/user_prompt_submit/hook2'
632
581
  require_relative '../handlers/user_prompt_submit/hook3'
633
582
 
634
583
  begin
635
- # Read input from stdin
584
+ # Read input from stdin
636
585
  input_data = JSON.parse(STDIN.read)
637
586
 
638
587
  hook1 = Hook1.new(input_data)
639
- hook2 = Hook1.new(input_data)
640
- hook3 = Hook1.new(input_data)
588
+ hook2 = Hook2.new(input_data)
589
+ hook3 = Hook3.new(input_data)
641
590
 
642
591
  # Execute the multiple hooks
643
592
  hook1.call
@@ -659,6 +608,14 @@ begin
659
608
 
660
609
  # Automatically handles outputting to the right stream (STDOUT or STDERR) and uses the right exit code depending on hook state
661
610
  merged_output.output_and_exit
611
+ rescue StandardError => e
612
+ # This is exactly what CLI.run_hook does for you (non-blocking / fail-open):
613
+ STDERR.puts JSON.generate({
614
+ continue: false,
615
+ stopReason: "Hook execution error: #{e.message}",
616
+ suppressOutput: false
617
+ })
618
+ exit 1
662
619
  end
663
620
  ```
664
621
 
@@ -695,7 +652,7 @@ Claude Code hooks support multiple exit codes with different behaviors depending
695
652
  > - **Blocking via top-level `decision`** (behave like `PreToolUse`/`Stop`): `UserPromptExpansion`, `PostToolBatch`, `ConfigChange`.
696
653
  > - **Blocking via `exit 2` / `continue: false`** (no `decision` field): `TaskCreated`, `TaskCompleted`, `TeammateIdle`.
697
654
  > - **JSON-API special** (always `exit 0`, decision in `hookSpecificOutput`): `PermissionDenied`, `Elicitation`, `ElicitationResult`, `WorktreeCreate` (bare-path stdout).
698
- > - **Non-blocking / context-only** (exit code effectively ignored): `Setup`, `SubagentStart`, `PostToolUseFailure`, `StopFailure`, `PostCompact`, `CwdChanged`, `FileChanged`, `InstructionsLoaded`, `WorktreeRemove`, `MessageDisplay`.
655
+ > - **Non-blocking / context-only** (exit code effectively ignored): `Setup`, `SubagentStart`, `PostToolUseFailure`, `StopFailure`, `PostCompact`, `CwdChanged`, `DirectoryAdded`, `FileChanged`, `InstructionsLoaded`, `WorktreeRemove`, `MessageDisplay`.
699
656
 
700
657
 
701
658
  #### Manually outputing and exiting example with success
@@ -781,12 +738,7 @@ class PluginFormatter < ClaudeHooks::PostToolUse
781
738
  end
782
739
  end
783
740
 
784
- if __FILE__ == $0
785
- input_data = JSON.parse(STDIN.read)
786
- hook = PluginFormatter.new(input_data)
787
- hook.call
788
- hook.output_and_exit
789
- end
741
+ ClaudeHooks::CLI.run_hook(PluginFormatter)
790
742
  ```
791
743
 
792
744
  **Environment variables available in plugins:**
@@ -822,7 +774,7 @@ You can use matchers to target specific MCP tools or all tools from a server:
822
774
  "hooks": [
823
775
  {
824
776
  "type": "command",
825
- "command": "~/.claude/hooks/entrypoints/github_guard.rb"
777
+ "command": "~/.claude/hooks/github_guard.rb"
826
778
  }
827
779
  ]
828
780
  },
@@ -831,7 +783,7 @@ You can use matchers to target specific MCP tools or all tools from a server:
831
783
  "hooks": [
832
784
  {
833
785
  "type": "command",
834
- "command": "~/.claude/hooks/entrypoints/destructive_operation_guard.rb"
786
+ "command": "~/.claude/hooks/destructive_operation_guard.rb"
835
787
  }
836
788
  ]
837
789
  }
@@ -867,124 +819,103 @@ See the [official MCP documentation](https://modelcontextprotocol.io/) for more
867
819
 
868
820
  ## ⚠️ Troubleshooting
869
821
 
870
- ### Make your entrypoint scripts executable
822
+ ### Make your hook scripts executable
871
823
 
872
824
  Don't forget to make the scripts called from `settings.json` executable:
873
825
 
874
826
  ```bash
875
- chmod +x ~/.claude/hooks/entrypoints/user_prompt_submit.rb
827
+ chmod +x ~/.claude/hooks/my_hook.rb
876
828
  ```
877
829
 
878
830
 
879
831
  ## 🧪 CLI Debugging
880
832
 
881
- 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.
833
+ `ClaudeHooks::CLI` provides two helpers: `run_hook` (for production use) and `test_runner`/`run_with_sample_data` (for local testing with custom input).
882
834
 
883
- ### Basic Usage
835
+ ### CLI.run_hook
884
836
 
885
- Replace the traditional testing boilerplate:
837
+ `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.
886
838
 
887
839
  ```ruby
888
- # Old way (15+ lines of repetitive code)
889
- if __FILE__ == $0
890
- begin
891
- require 'json'
892
- input_data = JSON.parse(STDIN.read)
893
- hook = MyHook.new(input_data)
894
- result = hook.call
895
- puts JSON.generate(result)
896
- rescue StandardError => e
897
- STDERR.puts "Error: #{e.message}"
898
- puts JSON.generate({
899
- continue: false,
900
- stopReason: "Error: #{e.message}",
901
- suppressOutput: false
902
- })
903
- exit 1
904
- end
905
- end
840
+ # Single hook (most common)
841
+ ClaudeHooks::CLI.run_hook(MyHook)
906
842
  ```
907
843
 
908
- With the simple CLI test runner:
844
+ It replaces the more verbose
909
845
 
910
846
  ```ruby
911
- # New way (1 line!)
912
- if __FILE__ == $0
913
- ClaudeHooks::CLI.test_runner(MyHook)
847
+ begin
848
+ # Read input from stdin
849
+ input_data = JSON.parse(STDIN.read)
850
+
851
+ hook = MyHook.new(input_data)
852
+ hook.call
853
+ hook.output_and_exit
854
+ rescue StandardError => e
855
+ # Non-blocking by default (fail-open): Claude continues as if the hook didn't run.
856
+ # Pass `on_error: :block` to CLI.run_hook to exit 2 (fail-closed) instead.
857
+ STDERR.puts JSON.generate({
858
+ continue: false,
859
+ stopReason: "Hook execution error: #{e.message}",
860
+ suppressOutput: false
861
+ })
862
+ exit 1
914
863
  end
915
864
  ```
916
865
 
917
- ### Customization with Blocks
866
+ #### on_error: fail-open vs fail-closed
918
867
 
919
- You can customize the input data for testing using blocks:
868
+ 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**.
920
869
 
921
- ```ruby
922
- if __FILE__ == $0
923
- ClaudeHooks::CLI.test_runner(MyHook) do |input_data|
924
- input_data['debug_mode'] = true
925
- input_data['custom_field'] = 'test_value'
926
- input_data['user_name'] = 'TestUser'
927
- end
928
- end
929
- ```
930
-
931
- ### Testing Methods
870
+ 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:
932
871
 
933
- #### 1. Test with STDIN (default)
934
872
  ```ruby
935
- ClaudeHooks::CLI.test_runner(MyHook)
936
- # Usage: echo '{"session_id":"test","prompt":"Hello"}' | ruby my_hook.rb
937
- ```
873
+ # Default: hook crash is non-blocking — Claude continues anyway (exit 1)
874
+ ClaudeHooks::CLI.run_hook(MyHook)
938
875
 
939
- #### 2. Test with default sample data instead of STDIN
940
- ```ruby
941
- ClaudeHooks::CLI.run_with_sample_data(MyHook, { 'prompt' => 'test prompt' })
942
- # Provides default values, no STDIN needed
943
- ```
876
+ # Fail-closed: hook crash blocks the action (exit 2)
877
+ ClaudeHooks::CLI.run_hook(MyHook, on_error: :block)
944
878
 
945
- #### 3. Test with Sample Data + Customization
946
- ```ruby
947
- ClaudeHooks::CLI.run_with_sample_data(MyHook) do |input_data|
948
- input_data['prompt'] = 'Custom test prompt'
949
- input_data['debug'] = true
879
+ # Also works with block form
880
+ ClaudeHooks::CLI.run_hook(on_error: :block) do |input_data|
881
+ # ...
950
882
  end
951
883
  ```
952
884
 
953
- ### Example Hook with CLI Testing
954
-
955
- ```ruby
956
- #!/usr/bin/env ruby
957
-
958
- require 'claude_hooks'
885
+ > [!WARNING]
886
+ > 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.
959
887
 
960
- class MyTestHook < ClaudeHooks::UserPromptSubmit
961
- def call
962
- log "Debug mode: #{input_data['debug_mode']}"
963
- log "Processing: #{prompt}"
888
+ ### CLI.test_runner local testing
964
889
 
965
- if input_data['debug_mode']
966
- log "All input keys: #{input_data.keys.join(', ')}"
967
- end
890
+ Use `test_runner` when running the script directly (outside of Claude Code) to inject custom input data:
968
891
 
969
- output
892
+ ```ruby
893
+ # At the bottom of your hook file, guarded so it only runs directly:
894
+ if __FILE__ == $0
895
+ ClaudeHooks::CLI.test_runner(MyHook) do |input_data|
896
+ input_data['debug_mode'] = true
897
+ input_data['prompt'] = 'Test prompt'
970
898
  end
971
899
  end
972
900
 
973
- # Test runner with customization
901
+ # Or test with synthetic data (no STDIN needed):
974
902
  if __FILE__ == $0
975
- ClaudeHooks::CLI.test_runner(MyTestHook) do |input_data|
976
- input_data['debug_mode'] = true
977
- end
903
+ ClaudeHooks::CLI.run_with_sample_data(MyHook, { 'prompt' => 'test prompt' })
978
904
  end
979
905
  ```
980
906
 
907
+ Test with real STDIN:
908
+ ```bash
909
+ echo '{"session_id":"test","prompt":"Hello"}' | ruby my_hook.rb
910
+ ```
911
+
981
912
  ## 🐛 Debugging
982
913
 
983
- ### Test an individual entrypoint
914
+ ### Test a hook script directly
984
915
 
985
916
  ```bash
986
917
  # Test with sample data
987
- 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
918
+ 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
988
919
  ```
989
920
 
990
921
  ## 🧪 Development & Contributing