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 +4 -4
- data/CHANGELOG.md +27 -0
- data/README.md +152 -221
- data/docs/1.0.0_MIGRATION_GUIDE.md +6 -7
- data/docs/API/DIRECTORY_ADDED.md +34 -0
- data/docs/external/claude-hooks-reference.md +271 -107
- data/example_dotclaude/hooks/entrypoints/session_end.rb +5 -22
- data/example_dotclaude/hooks/entrypoints/user_prompt_submit.rb +4 -26
- data/example_dotclaude/hooks/{handlers/pre_tool_use/github_guard.rb → github_guard.rb} +7 -13
- data/example_dotclaude/settings.json +1 -1
- data/lib/claude_hooks/cli.rb +84 -149
- data/lib/claude_hooks/directory_added.rb +24 -0
- data/lib/claude_hooks/output/base.rb +2 -0
- data/lib/claude_hooks/output/directory_added.rb +21 -0
- data/lib/claude_hooks/version.rb +1 -1
- data/lib/claude_hooks.rb +2 -0
- metadata +6 -5
- data/.claude/settings.local.json +0 -21
- data/example_dotclaude/hooks/entrypoints/pre_tool_use.rb +0 -25
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ee18a384c04bcfff4fa734935277a938e14d2799646369e8db8e013c5a7b33ce
|
|
4
|
+
data.tar.gz: b784bc4cc4e52096b563c25a2c6d19175a38ac37e6ea72d110c32c3887566128
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
-
>
|
|
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
|
|
236
|
-
3. **Output Classes
|
|
237
|
-
4.
|
|
238
|
-
5. **
|
|
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
|
-
###
|
|
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
|
-
├──
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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 `
|
|
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
|
-
###
|
|
320
|
+
### Basic hook structure
|
|
320
321
|
|
|
321
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
383
|
-
|
|
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
|
-
|
|
388
|
-
# Call the hook
|
|
389
|
-
hook.call
|
|
347
|
+
### Multi-handler flow
|
|
390
348
|
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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
|
|
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/
|
|
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
|
|
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/
|
|
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
|
-
|
|
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 =
|
|
640
|
-
hook3 =
|
|
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
|
-
|
|
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/
|
|
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/
|
|
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
|
|
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/
|
|
827
|
+
chmod +x ~/.claude/hooks/my_hook.rb
|
|
876
828
|
```
|
|
877
829
|
|
|
878
830
|
|
|
879
831
|
## 🧪 CLI Debugging
|
|
880
832
|
|
|
881
|
-
|
|
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
|
-
###
|
|
835
|
+
### CLI.run_hook
|
|
884
836
|
|
|
885
|
-
|
|
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
|
-
#
|
|
889
|
-
|
|
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
|
-
|
|
844
|
+
It replaces the more verbose
|
|
909
845
|
|
|
910
846
|
```ruby
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
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
|
-
|
|
866
|
+
#### on_error: fail-open vs fail-closed
|
|
918
867
|
|
|
919
|
-
|
|
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
|
-
|
|
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
|
-
|
|
936
|
-
|
|
937
|
-
```
|
|
873
|
+
# Default: hook crash is non-blocking — Claude continues anyway (exit 1)
|
|
874
|
+
ClaudeHooks::CLI.run_hook(MyHook)
|
|
938
875
|
|
|
939
|
-
|
|
940
|
-
|
|
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
|
-
|
|
946
|
-
|
|
947
|
-
|
|
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
|
-
|
|
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
|
-
|
|
961
|
-
def call
|
|
962
|
-
log "Debug mode: #{input_data['debug_mode']}"
|
|
963
|
-
log "Processing: #{prompt}"
|
|
888
|
+
### CLI.test_runner — local testing
|
|
964
889
|
|
|
965
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
901
|
+
# Or test with synthetic data (no STDIN needed):
|
|
974
902
|
if __FILE__ == $0
|
|
975
|
-
ClaudeHooks::CLI.
|
|
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
|
|
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/
|
|
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
|