claude_hooks 1.0.0 → 1.0.2
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 +49 -4
- data/README.md +84 -11
- data/docs/{OUTPUT_MIGRATION_GUIDE.md → 1.0.0_MIGRATION_GUIDE.md} +17 -17
- data/docs/API/SESSION_START.md +1 -1
- data/docs/WHY.md +15 -8
- data/docs/external/claude-hooks-reference.md +34 -0
- data/example_dotclaude/hooks/entrypoints/pre_tool_use.rb +25 -0
- data/example_dotclaude/hooks/handlers/pre_tool_use/github_guard.rb +253 -0
- data/example_dotclaude/hooks/handlers/user_prompt_submit/append_rules.rb +2 -1
- data/example_dotclaude/plugins/README.md +175 -0
- data/example_dotclaude/settings.json +11 -0
- data/lib/claude_hooks/output/post_tool_use.rb +14 -6
- data/lib/claude_hooks/output/pre_tool_use.rb +30 -33
- data/lib/claude_hooks/output/stop.rb +13 -6
- data/lib/claude_hooks/output/user_prompt_submit.rb +14 -7
- data/lib/claude_hooks/stop.rb +8 -0
- data/lib/claude_hooks/version.rb +1 -1
- metadata +7 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d82c5deaa5426bc2ca898300f7772fa58c56a2998fd00af4eb1a93de25d3336e
|
|
4
|
+
data.tar.gz: ffde711d996ad0f43f7f75a3002f5a1e74e84f28ed2197ddf9c53c8b982a5152
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8256eeed53e717873271ea94b9d85610e741ade7d6009b41a9df6eccc8ea58ec4af3b4ab8883d8dfba3d0ef849afdcae6939786d7cb7ad4d000dd7dc749a73ab
|
|
7
|
+
data.tar.gz: 306144f71bd099d70df514ee21eb9e63a11389562322927465b1aecff447c78eaafc602aa973db8a925bf0885c4e212fbcbfacb410c6d34c30a7b56eb9e51145
|
data/CHANGELOG.md
CHANGED
|
@@ -5,11 +5,56 @@ 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.0.2] - 2026-01-04
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- **Critical: Fixed hook output contract for JSON API** - Stop, PostToolUse, UserPromptSubmit, and PreToolUse hooks now correctly use stdout + exit 0 when outputting structured JSON
|
|
13
|
+
- Stop hooks now output to stdout with exit 0 instead of stderr with exit 2 (PR #15, fixes #11)
|
|
14
|
+
- PostToolUse hooks now use stdout + exit 0 for JSON API consistency
|
|
15
|
+
- UserPromptSubmit hooks now use stdout + exit 0 for JSON API consistency
|
|
16
|
+
- PreToolUse hooks now use exit 0 for all permission decisions (previously varied: 0 for allow, 1 for ask, 2 for deny)
|
|
17
|
+
- This aligns with Anthropic's official guidance: when using advanced JSON API with decision/reason fields, hooks must output to stdout with exit 0
|
|
18
|
+
- **Breaking behavior fixed**: Plugin hooks with `continue_with_instructions!` now work correctly
|
|
19
|
+
- Reference: https://github.com/anthropics/claude-code/issues/10875
|
|
20
|
+
- Reference: https://github.com/gabriel-dehan/claude_hooks/issues/11
|
|
21
|
+
|
|
22
|
+
### Notes
|
|
23
|
+
|
|
24
|
+
- All tests updated and passing (147 runs, 262 assertions, 0 failures)
|
|
25
|
+
- This fix ensures compatibility with Claude Code's plugin system
|
|
26
|
+
- The exit code 2 + stderr approach is only for simple text output without JSON structure
|
|
27
|
+
|
|
28
|
+
## [1.0.1] - 2025-10-13
|
|
29
|
+
|
|
30
|
+
### Documentation
|
|
31
|
+
|
|
32
|
+
- **Added comprehensive plugin hooks documentation** - Full guide on using the Claude Hooks DSL with Claude Code plugins
|
|
33
|
+
- New "Plugin Hooks Support" section in README with working examples
|
|
34
|
+
- Created `example_dotclaude/plugins/README.md` with complete plugin development guide
|
|
35
|
+
- Example plugin formatter implementation using the DSL
|
|
36
|
+
- Environment variables documentation for plugin development
|
|
37
|
+
- Best practices and testing instructions for plugin hooks
|
|
38
|
+
- **Updated SessionStart hook documentation** - Added the new `compact` matcher introduced in official Claude Hooks documentation
|
|
39
|
+
- Updated `docs/API/SESSION_START.md` to include `'compact'` as a valid source value
|
|
40
|
+
- Updated SessionStart hook type description to mention compact functionality
|
|
41
|
+
- **Synchronized with official documentation** - All documentation now matches the official Claude Hooks reference as of 2025-10-13
|
|
42
|
+
- Plugin hooks feature (Issue #9)
|
|
43
|
+
- SessionStart `compact` matcher (Issue #2)
|
|
44
|
+
|
|
45
|
+
### Notes
|
|
46
|
+
|
|
47
|
+
- No code changes required - the implementation already supports all documented features
|
|
48
|
+
- The DSL's dynamic implementation handles the new `compact` source automatically
|
|
49
|
+
- Plugin environment variables (`CLAUDE_PLUGIN_ROOT`) work seamlessly with existing configuration system
|
|
50
|
+
|
|
8
51
|
## [1.0.0] - 2025-08-27
|
|
9
52
|
|
|
10
53
|
> [!WARNING]
|
|
11
54
|
> These changes are not backward compatible (hence the version bump to 1.0.0), the API has changed too much.
|
|
12
55
|
|
|
56
|
+
Follow the [migration guide](docs/1.0.0_MIGRATION_GUIDE.md) to migrate to the new API.
|
|
57
|
+
|
|
13
58
|
### Changed
|
|
14
59
|
|
|
15
60
|
#### Revamped documentation
|
|
@@ -57,13 +102,13 @@ begin
|
|
|
57
102
|
input_data = JSON.parse(STDIN.read)
|
|
58
103
|
hook = MyHook.new(input_data)
|
|
59
104
|
result = hook.call
|
|
60
|
-
|
|
105
|
+
|
|
61
106
|
# Manual stream and exit code selection
|
|
62
107
|
if result['continue'] != false
|
|
63
108
|
if result.dig('hookSpecificOutput', 'permissionDecision') == 'deny'
|
|
64
109
|
STDERR.puts hook.stringify_output
|
|
65
110
|
exit 1
|
|
66
|
-
elsif result.dig('hookSpecificOutput', 'permissionDecision') == 'ask'
|
|
111
|
+
elsif result.dig('hookSpecificOutput', 'permissionDecision') == 'ask'
|
|
67
112
|
STDERR.puts hook.stringify_output
|
|
68
113
|
exit 2
|
|
69
114
|
else
|
|
@@ -89,9 +134,9 @@ begin
|
|
|
89
134
|
input_data = JSON.parse(STDIN.read)
|
|
90
135
|
hook = MyHook.new(input_data)
|
|
91
136
|
hook.call
|
|
92
|
-
|
|
137
|
+
|
|
93
138
|
# Handles everything automatically!
|
|
94
|
-
hook.output_and_exit
|
|
139
|
+
hook.output_and_exit
|
|
95
140
|
rescue StandardError => e
|
|
96
141
|
# Error handling...
|
|
97
142
|
end
|
data/README.md
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
# Ruby DSL for Claude Code hooks
|
|
2
2
|
|
|
3
|
+
> [!IMPORTANT]
|
|
4
|
+
> v1.0.0 just released and is introducing breaking changes. Please read the [CHANGELOG](CHANGELOG.md) for more information.
|
|
5
|
+
|
|
6
|
+
|
|
3
7
|
A Ruby DSL (Domain Specific Language) for creating Claude Code hooks. This will hopefully make creating and configuring new hooks way easier.
|
|
4
8
|
|
|
5
9
|
[**Why use this instead of writing bash, or simple ruby scripts?**](docs/WHY.md)
|
|
6
10
|
|
|
7
11
|
> You might also be interested in my other project, a [Claude Code statusline](https://github.com/gabriel-dehan/claude_monitor_statusline) that shows your Claude usage in realtime, inside Claude Code ✨.
|
|
8
12
|
|
|
13
|
+
|
|
9
14
|
## 📖 Table of Contents
|
|
10
15
|
|
|
11
16
|
- [Ruby DSL for Claude Code hooks](#ruby-dsl-for-claude-code-hooks)
|
|
@@ -18,6 +23,7 @@ A Ruby DSL (Domain Specific Language) for creating Claude Code hooks. This will
|
|
|
18
23
|
- [📚 API Reference](#-api-reference)
|
|
19
24
|
- [📝 Example: Tool usage monitor](#-example-tool-usage-monitor)
|
|
20
25
|
- [🔄 Hook Output](#-hook-output)
|
|
26
|
+
- [🔌 Plugin Hooks Support](#-plugin-hooks-support)
|
|
21
27
|
- [🚨 Advices](#-advices)
|
|
22
28
|
- [⚠️ Troubleshooting](#️-troubleshooting)
|
|
23
29
|
- [🧪 CLI Debugging](#-cli-debugging)
|
|
@@ -27,7 +33,7 @@ A Ruby DSL (Domain Specific Language) for creating Claude Code hooks. This will
|
|
|
27
33
|
## 🚀 Quick Start
|
|
28
34
|
|
|
29
35
|
> [!TIP]
|
|
30
|
-
>
|
|
36
|
+
> 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)
|
|
31
37
|
|
|
32
38
|
Here's how to create a simple hook:
|
|
33
39
|
|
|
@@ -58,7 +64,7 @@ if __FILE__ == $0
|
|
|
58
64
|
|
|
59
65
|
hook = AddContextAfterPrompt.new(input_data)
|
|
60
66
|
hook.call
|
|
61
|
-
|
|
67
|
+
|
|
62
68
|
# Handles output and exit code depending on the hook state.
|
|
63
69
|
# In this case, uses exit code 0 (success) and prints output to STDOUT
|
|
64
70
|
hook.output_and_exit
|
|
@@ -255,7 +261,7 @@ The framework supports the following hook types:
|
|
|
255
261
|
|
|
256
262
|
| Hook Type | Class | Description |
|
|
257
263
|
|-----------|-------|-------------|
|
|
258
|
-
| **[SessionStart](docs/API/SESSION_START.md)** | `ClaudeHooks::SessionStart` | Hooks that run when Claude Code starts a new session or
|
|
264
|
+
| **[SessionStart](docs/API/SESSION_START.md)** | `ClaudeHooks::SessionStart` | Hooks that run when Claude Code starts a new session, resumes, or compacts |
|
|
259
265
|
| **[UserPromptSubmit](docs/API/USER_PROMPT_SUBMIT.md)** | `ClaudeHooks::UserPromptSubmit` | Hooks that run before the user's prompt is processed |
|
|
260
266
|
| **[Notification](docs/API/NOTIFICATION.md)** | `ClaudeHooks::Notification` | Hooks that run when Claude Code sends notifications |
|
|
261
267
|
| **[PreToolUse](docs/API/PRE_TOOL_USE.md)** | `ClaudeHooks::PreToolUse` | Hooks that run before a tool is used |
|
|
@@ -269,7 +275,7 @@ The framework supports the following hook types:
|
|
|
269
275
|
|
|
270
276
|
### A very simplified view of how a hook works in Claude Code
|
|
271
277
|
|
|
272
|
-
Claude Code hooks in essence work in a very simple way:
|
|
278
|
+
Claude Code hooks in essence work in a very simple way:
|
|
273
279
|
- Claude Code passes data to the hook script through `STDIN`
|
|
274
280
|
- The hook uses the data to do its thing
|
|
275
281
|
- The hook outputs data to `STDOUT` or `STDERR` and then `exit`s with the proper code:
|
|
@@ -342,7 +348,7 @@ class AddContextAfterPrompt < ClaudeHooks::UserPromptSubmit
|
|
|
342
348
|
log "Prompt blocked: #{current_prompt} because of bad word"
|
|
343
349
|
end
|
|
344
350
|
|
|
345
|
-
# Return output if you need it
|
|
351
|
+
# Return output if you need it
|
|
346
352
|
output
|
|
347
353
|
end
|
|
348
354
|
end
|
|
@@ -355,7 +361,7 @@ if __FILE__ == $0
|
|
|
355
361
|
hook = AddContextAfterPrompt.new(input_data)
|
|
356
362
|
# Call the hook
|
|
357
363
|
hook.call
|
|
358
|
-
|
|
364
|
+
|
|
359
365
|
# Uses exit code 0 (success) and outputs to STDIN if the prompt wasn't blocked
|
|
360
366
|
# Uses exit code 2 (blocking error) and outputs to STDERR if the prompt was blocked
|
|
361
367
|
hook.output_and_exit
|
|
@@ -535,9 +541,9 @@ end
|
|
|
535
541
|
|
|
536
542
|
Hooks provide access to their output (which acts as the "state" of a hook) through the `output` method.
|
|
537
543
|
|
|
538
|
-
This method will return an output object based on the hook's type class (e.g: `ClaudeHooks::Output::UserPromptSubmit`) that provides helper methods:
|
|
544
|
+
This method will return an output object based on the hook's type class (e.g: `ClaudeHooks::Output::UserPromptSubmit`) that provides helper methods:
|
|
539
545
|
- to access output data
|
|
540
|
-
- for merging multiple outputs
|
|
546
|
+
- for merging multiple outputs
|
|
541
547
|
- for sending the right exit codes and output data back to Claude Code through the proper stream.
|
|
542
548
|
|
|
543
549
|
> [!TIP]
|
|
@@ -547,7 +553,7 @@ This method will return an output object based on the hook's type class (e.g: `C
|
|
|
547
553
|
### 🔄 Hook Output Merging
|
|
548
554
|
|
|
549
555
|
Often, you will want to call multiple hooks from a same entrypoint.
|
|
550
|
-
Each hook type's `output` provides a `merge` method that will try to intelligently merge multiple hook results.
|
|
556
|
+
Each hook type's `output` provides a `merge` method that will try to intelligently merge multiple hook results.
|
|
551
557
|
Merged outputs always inherit the **most restrictive behavior**.
|
|
552
558
|
|
|
553
559
|
```ruby
|
|
@@ -579,12 +585,12 @@ begin
|
|
|
579
585
|
# - additionalContext: concatenated
|
|
580
586
|
merged_output = ClaudeHooks::Output::UserPromptSubmit.merge(
|
|
581
587
|
hook1.output,
|
|
582
|
-
hook2.output,
|
|
588
|
+
hook2.output,
|
|
583
589
|
hook3.output
|
|
584
590
|
)
|
|
585
591
|
|
|
586
592
|
# Automatically handles outputting to the right stream (STDOUT or STDERR) and uses the right exit code depending on hook state
|
|
587
|
-
merged_output.output_and_exit
|
|
593
|
+
merged_output.output_and_exit
|
|
588
594
|
end
|
|
589
595
|
```
|
|
590
596
|
|
|
@@ -648,6 +654,73 @@ exit 2
|
|
|
648
654
|
> [!WARNING]
|
|
649
655
|
> You don't have to manually do this, just use `output_and_exit` to automatically handle this.
|
|
650
656
|
|
|
657
|
+
## 🔌 Plugin Hooks Support
|
|
658
|
+
|
|
659
|
+
This DSL works seamlessly with [Claude Code plugins](https://docs.claude.com/en/docs/claude-code/plugins)! When creating plugin hooks, you can use the exact same Ruby DSL and enjoy all the same benefits.
|
|
660
|
+
|
|
661
|
+
**How plugin hooks work:**
|
|
662
|
+
- Plugin hooks are defined in the plugin's `hooks/hooks.json` file
|
|
663
|
+
- They use the `${CLAUDE_PLUGIN_ROOT}` environment variable to reference plugin files
|
|
664
|
+
- Plugin hooks are automatically merged with user and project hooks when plugins are enabled
|
|
665
|
+
- Multiple hooks from different sources can respond to the same event
|
|
666
|
+
|
|
667
|
+
**Example plugin hook configuration (`hooks/hooks.json`):**
|
|
668
|
+
```json
|
|
669
|
+
{
|
|
670
|
+
"description": "Automatic code formatting",
|
|
671
|
+
"hooks": {
|
|
672
|
+
"PostToolUse": [
|
|
673
|
+
{
|
|
674
|
+
"matcher": "Write|Edit",
|
|
675
|
+
"hooks": [
|
|
676
|
+
{
|
|
677
|
+
"type": "command",
|
|
678
|
+
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/scripts/formatter.rb",
|
|
679
|
+
"timeout": 30
|
|
680
|
+
}
|
|
681
|
+
]
|
|
682
|
+
}
|
|
683
|
+
]
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
```
|
|
687
|
+
|
|
688
|
+
**Using this DSL in your plugin hooks (`hooks/scripts/formatter.rb`):**
|
|
689
|
+
```ruby
|
|
690
|
+
#!/usr/bin/env ruby
|
|
691
|
+
require 'claude_hooks'
|
|
692
|
+
|
|
693
|
+
class PluginFormatter < ClaudeHooks::PostToolUse
|
|
694
|
+
def call
|
|
695
|
+
log "Plugin executing from: #{ENV['CLAUDE_PLUGIN_ROOT']}"
|
|
696
|
+
|
|
697
|
+
if tool_name.match?(/Write|Edit/)
|
|
698
|
+
file_path = tool_input['file_path']
|
|
699
|
+
log "Formatting file: #{file_path}"
|
|
700
|
+
|
|
701
|
+
# Your formatting logic here
|
|
702
|
+
# Can use all the DSL helper methods!
|
|
703
|
+
end
|
|
704
|
+
|
|
705
|
+
output
|
|
706
|
+
end
|
|
707
|
+
end
|
|
708
|
+
|
|
709
|
+
if __FILE__ == $0
|
|
710
|
+
input_data = JSON.parse(STDIN.read)
|
|
711
|
+
hook = PluginFormatter.new(input_data)
|
|
712
|
+
hook.call
|
|
713
|
+
hook.output_and_exit
|
|
714
|
+
end
|
|
715
|
+
```
|
|
716
|
+
|
|
717
|
+
**Environment variables available in plugins:**
|
|
718
|
+
- `${CLAUDE_PLUGIN_ROOT}`: Absolute path to the plugin directory
|
|
719
|
+
- `${CLAUDE_PROJECT_DIR}`: Project root directory (same as for project hooks)
|
|
720
|
+
- All standard environment variables and configuration options work the same way
|
|
721
|
+
|
|
722
|
+
See the [plugin components reference](https://docs.claude.com/en/docs/claude-code/plugins-reference#hooks) for more details on creating plugin hooks.
|
|
723
|
+
|
|
651
724
|
## 🚨 Advices
|
|
652
725
|
|
|
653
726
|
1. **Logging**: Use `log()` method instead of `puts` to avoid interfering with Claude Code's expected output.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
# Migration Guide:
|
|
1
|
+
# Migration Guide: 1.0.0
|
|
2
2
|
|
|
3
|
-
This guide shows how to migrate from the old manual exit code patterns to the new simplified
|
|
3
|
+
This guide shows how to migrate from the old manual exit code patterns to the new simplified way introduced in v1.0.0.
|
|
4
4
|
|
|
5
5
|
## Problem: The Old Way (Before)
|
|
6
6
|
|
|
@@ -46,12 +46,12 @@ rescue StandardError => e
|
|
|
46
46
|
stopReason: "PreToolUser Hook execution error: #{e.message}",
|
|
47
47
|
suppressOutput: false
|
|
48
48
|
})
|
|
49
|
-
exit
|
|
49
|
+
exit 1 # Allow anyway to not block developers if there is an issue
|
|
50
50
|
end
|
|
51
51
|
```
|
|
52
52
|
|
|
53
53
|
**Problems with this approach:**
|
|
54
|
-
-
|
|
54
|
+
- A lot of lines of repetitive boilerplate
|
|
55
55
|
- Need to understand Claude Code's internal exit code semantics
|
|
56
56
|
- Manual stream selection (STDOUT vs STDERR)
|
|
57
57
|
- Error-prone - easy to get exit codes wrong
|
|
@@ -77,20 +77,21 @@ begin
|
|
|
77
77
|
|
|
78
78
|
rescue StandardError => e
|
|
79
79
|
# NEW: Simple error handling
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
80
|
+
STDERR.puts JSON.generate({
|
|
81
|
+
continue: false,
|
|
82
|
+
stopReason: "Hook execution error: #{e.message}",
|
|
83
|
+
suppressOutput: false
|
|
84
84
|
})
|
|
85
|
-
|
|
85
|
+
# Non-blocking error
|
|
86
|
+
exit 1
|
|
86
87
|
end
|
|
87
88
|
```
|
|
88
89
|
|
|
89
90
|
**Benefits:**
|
|
90
|
-
- **
|
|
91
|
+
- **More concise+** - 70% less boilerplate code
|
|
91
92
|
- **No Claude Code knowledge needed** - just use the gem's API
|
|
92
93
|
- **Automatic exit codes** based on hook-specific logic
|
|
93
|
-
- **Automatic stream selection** (STDOUT/STDERR)
|
|
94
|
+
- **Automatic stream selection** (STDOUT/STDERR). Note: `PreToolUse` always outputs to STDOUT.
|
|
94
95
|
- **Type-safe access** to hook-specific fields
|
|
95
96
|
|
|
96
97
|
## New Output Object API
|
|
@@ -143,7 +144,7 @@ output = hook.output
|
|
|
143
144
|
puts "Should continue? #{output.should_continue?}" # true if decision == 'block'
|
|
144
145
|
puts "Continue instructions: #{output.continue_instructions}"
|
|
145
146
|
|
|
146
|
-
hook.output_and_exit # exit
|
|
147
|
+
hook.output_and_exit # exit 2 for "continue", exit 0 for "stop"
|
|
147
148
|
```
|
|
148
149
|
|
|
149
150
|
## Merging Multiple Hooks
|
|
@@ -213,16 +214,15 @@ end
|
|
|
213
214
|
- `ClaudeHooks::Output::SubagentStop`
|
|
214
215
|
- `ClaudeHooks::Output::Notification`
|
|
215
216
|
- `ClaudeHooks::Output::SessionStart`
|
|
217
|
+
- `ClaudeHooks::Output::SessionEnd`
|
|
216
218
|
- `ClaudeHooks::Output::PreCompact`
|
|
217
219
|
|
|
218
220
|
Each handles the specific exit code logic and semantic helpers for its hook type.
|
|
219
221
|
|
|
220
222
|
## Migration Steps
|
|
221
223
|
|
|
222
|
-
1. **Update your hook handlers** to return `
|
|
223
|
-
2. **Replace manual exit logic** with `hook.output_and_exit` or `
|
|
224
|
+
1. **Update your hook handlers** to return ``output` instead of `output_data`
|
|
225
|
+
2. **Replace manual exit logic** with `hook.output_and_exit` or `merged_output.output_and_exit`
|
|
224
226
|
3. **Use semantic helpers** instead of digging through hash structures
|
|
225
|
-
4. **Use output object merging** instead of class methods
|
|
226
|
-
5. **Enjoy the simplified, cleaner code!**
|
|
227
|
+
4. **Use output object classes for merging** instead of hook class methods
|
|
227
228
|
|
|
228
|
-
The old patterns still work for backward compatibility, but the new output objects make everything much cleaner and less error-prone.
|
data/docs/API/SESSION_START.md
CHANGED
|
@@ -9,7 +9,7 @@ Input helpers to access the data provided by Claude Code through `STDIN`.
|
|
|
9
9
|
|
|
10
10
|
| Method | Description |
|
|
11
11
|
|--------|-------------|
|
|
12
|
-
| `source` | Get the session start source: `'startup'`, `'resume'`, or `'
|
|
12
|
+
| `source` | Get the session start source: `'startup'`, `'resume'`, `'clear'`, or `'compact'` |
|
|
13
13
|
|
|
14
14
|
## Hook State Helpers
|
|
15
15
|
Hook state methods are helpers to modify the hook's internal state (`output_data`) before yielding back to Claude Code.
|
data/docs/WHY.md
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
When creating fairly complex hook systems for Claude Code it is easy to end up either with:
|
|
4
4
|
- Fairly monolithic scripts that becomes hard to maintain and have to handle complex merging logic for the output.
|
|
5
5
|
- A lot of scripts set up in the `settings.json` that all use the same hook input / output logic that needs to be rewritten multiple times.
|
|
6
|
+
- Having to understand the very chaotic exit code and output steam logic of Claude Code and how to handle it.
|
|
6
7
|
|
|
7
8
|
Furthermore, Claude's documentation on hooks is a bit hard to navigate and setting up hooks is not necessarily intuitive or straightforward having to play around with STDIN, STDOUT, STDERR, exit codes, etc.
|
|
8
9
|
|
|
@@ -25,9 +26,9 @@ settings.json
|
|
|
25
26
|
|
|
26
27
|
## For both approaches it will bring
|
|
27
28
|
|
|
28
|
-
1. Normalized Input/Output handling - input parsing, validation, straightforward output helpers (block_tool
|
|
29
|
+
1. Normalized Input/Output handling - input parsing, validation, straightforward output helpers (`ask_for_permission!`, `approve_tool!`, `block_tool!`, `block_prompt!`, `add_context!`).
|
|
29
30
|
|
|
30
|
-
2. Hook-Specific APIs -
|
|
31
|
+
2. Hook-Specific APIs - all 9 hook types with tailored methods and output objects (with `output_and_exit`) and smart merge logic for combining outputs.
|
|
31
32
|
|
|
32
33
|
3. Session-Based Logging - Dedicated logger to understand the flow of what happens in Claude Code and write it out to a `session-{session_id}.log` file.
|
|
33
34
|
|
|
@@ -35,7 +36,6 @@ settings.json
|
|
|
35
36
|
|
|
36
37
|
5. Testing Support - Standalone execution mode for individual hook testing and CLI testing with sample JSON input.
|
|
37
38
|
|
|
38
|
-
|
|
39
39
|
## For a monolithic approach it will additionally bring
|
|
40
40
|
|
|
41
41
|
1. Composable Hook Handlers
|
|
@@ -43,11 +43,19 @@ For instance, `entrypoints/user_prompt_submit.rb` orchestrates multiple handlers
|
|
|
43
43
|
```ruby
|
|
44
44
|
# Add contextual rules
|
|
45
45
|
append_rules_result = AppendRules.new(input_data).call
|
|
46
|
+
append_rules_result.call
|
|
47
|
+
|
|
46
48
|
# Audit logging
|
|
47
49
|
log_result = LogUserPrompt.new(input_data).call
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
50
|
+
log_result.call
|
|
51
|
+
|
|
52
|
+
# Merge outputs and yield back to Claude Code
|
|
53
|
+
merged = ClaudeHooks::Output::UserPromptSubmit.merge(
|
|
54
|
+
append_rules.output,
|
|
55
|
+
log_handler.output
|
|
56
|
+
)
|
|
57
|
+
merged.output_and_exit
|
|
58
|
+
end
|
|
51
59
|
```
|
|
52
60
|
|
|
53
61
|
2. Intelligent Output Merging
|
|
@@ -59,7 +67,6 @@ puts ClaudeHooks::UserPromptSubmit.merge_outputs(append_rules_result, log_result
|
|
|
59
67
|
Each handler can run standalone for testing:
|
|
60
68
|
```ruby
|
|
61
69
|
if __FILE__ == $0
|
|
62
|
-
|
|
63
|
-
puts hook.stringify_output
|
|
70
|
+
ClaudeHooks::CLI.test_runner(AppendRules)
|
|
64
71
|
end
|
|
65
72
|
```
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
Documentation
|
|
2
|
+
|
|
3
|
+
Page
|
|
4
|
+
|
|
5
|
+
## Page Not Found
|
|
6
|
+
|
|
7
|
+
### The requested page could not be found.
|
|
8
|
+
|
|
9
|
+
Go back
|
|
10
|
+
|
|
11
|
+
Ask Docs
|
|
12
|
+

|
|
13
|
+
|
|
14
|
+
a.claude.ai
|
|
15
|
+
|
|
16
|
+
# a.claude.ai is blocked
|
|
17
|
+
|
|
18
|
+
**a.claude.ai** refused to connect.
|
|
19
|
+
|
|
20
|
+
ERR\_BLOCKED\_BY\_RESPONSE
|
|
21
|
+
|
|
22
|
+
**a.claude.ai** refused to connect.
|
|
23
|
+
|
|
24
|
+

|
|
25
|
+
|
|
26
|
+
Invalid domain for site key.
|
|
27
|
+
|
|
28
|
+
ERROR for site owner:
|
|
29
|
+
|
|
30
|
+
Invalid domain for site key
|
|
31
|
+
|
|
32
|
+
reCAPTCHA
|
|
33
|
+
|
|
34
|
+
[Privacy](https://www.google.com/intl/en/policies/privacy/) \- [Terms](https://www.google.com/intl/en/policies/terms/)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'claude_hooks'
|
|
5
|
+
require_relative '../handlers/pre_tool_use/github_guard'
|
|
6
|
+
|
|
7
|
+
begin
|
|
8
|
+
# Read Claude Code input from stdin
|
|
9
|
+
input_data = JSON.parse($stdin.read)
|
|
10
|
+
|
|
11
|
+
github_guard = GithubGuard.new(input_data)
|
|
12
|
+
github_guard.call
|
|
13
|
+
|
|
14
|
+
github_guard.output_and_exit
|
|
15
|
+
rescue StandardError => e
|
|
16
|
+
puts JSON.generate(
|
|
17
|
+
{
|
|
18
|
+
continue: false,
|
|
19
|
+
stopReason: "Error in PreToolUse hook, #{e.message}, #{e.backtrace.join("\n")}",
|
|
20
|
+
suppressOutput: false,
|
|
21
|
+
},
|
|
22
|
+
)
|
|
23
|
+
# Allow anyway, to not block developers if there is an issue with the hook
|
|
24
|
+
exit 1
|
|
25
|
+
end
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'claude_hooks'
|
|
5
|
+
require 'json'
|
|
6
|
+
require 'open3'
|
|
7
|
+
|
|
8
|
+
# GitHub Guard hook to prevent unauthorized or dangerous GitHub/Git actions
|
|
9
|
+
class GithubGuard < ClaudeHooks::PreToolUse
|
|
10
|
+
BLOCKED_TOOL_TIP = 'If they are sure they want to proceed, the user should run the command themselves using `!` (e.g. `!gh pr merge`, `!git push --force`, etc...)'
|
|
11
|
+
|
|
12
|
+
RULES = {
|
|
13
|
+
# MCP GitHub tools
|
|
14
|
+
mcp_tools: {
|
|
15
|
+
always_blocked: %w[
|
|
16
|
+
mcp__github__delete_repository
|
|
17
|
+
mcp__github__delete_file
|
|
18
|
+
],
|
|
19
|
+
owner_restricted_pr: %w[
|
|
20
|
+
mcp__github__merge_pull_request
|
|
21
|
+
mcp__github__update_pull_request
|
|
22
|
+
],
|
|
23
|
+
pr_draft_required: %w[
|
|
24
|
+
mcp__github__create_pull_request
|
|
25
|
+
],
|
|
26
|
+
},
|
|
27
|
+
|
|
28
|
+
# Bash command patterns (gh & github)
|
|
29
|
+
bash_patterns: {
|
|
30
|
+
always_blocked: [
|
|
31
|
+
/\Agh\s+pr\s+merge.*--rebase/,
|
|
32
|
+
/\Agh\s+repo\s+delete/,
|
|
33
|
+
/\Agh\s+secret\s+(set|delete)/,
|
|
34
|
+
/\Agit\s+push\s+.*--force/,
|
|
35
|
+
/\Agit\s+reset\s+--hard/,
|
|
36
|
+
/\Agit\s+reset\s+--hard\s+HEAD~[0-9]+/,
|
|
37
|
+
/\Agit\s+clean\s+-[fd]/,
|
|
38
|
+
/\Agit\s+reflog\s+expire/,
|
|
39
|
+
/\Agit\s+filter-branch/,
|
|
40
|
+
/\Agit\s+checkout\s+--\s+\./,
|
|
41
|
+
],
|
|
42
|
+
requires_permission: [
|
|
43
|
+
/\Agh\s+api/,
|
|
44
|
+
/\Agit\s+branch\s+(-D|-d|--delete)/,
|
|
45
|
+
/\Agit\s+rebase\s+(master|main)/,
|
|
46
|
+
/\Agit\s+commit\s+--amend/,
|
|
47
|
+
/\Agit\s+rebase\s+-i/,
|
|
48
|
+
],
|
|
49
|
+
owner_restricted_pr: [
|
|
50
|
+
'gh pr merge',
|
|
51
|
+
'gh pr edit',
|
|
52
|
+
'gh pr close',
|
|
53
|
+
'gh pr ready',
|
|
54
|
+
'gh pr lock',
|
|
55
|
+
],
|
|
56
|
+
pr_draft_required: [
|
|
57
|
+
'gh pr create',
|
|
58
|
+
],
|
|
59
|
+
},
|
|
60
|
+
}.freeze
|
|
61
|
+
|
|
62
|
+
CURRENT_USER = begin
|
|
63
|
+
github_user_data = Open3.capture2('gh api user').then { |data, _| JSON.parse(data) }
|
|
64
|
+
git_user, = Open3.capture2('git config user.name')
|
|
65
|
+
|
|
66
|
+
{
|
|
67
|
+
name: github_user_data['name'] || git_user.strip,
|
|
68
|
+
login: github_user_data['login'] || '',
|
|
69
|
+
}
|
|
70
|
+
rescue StandardError => e
|
|
71
|
+
log "Error fetching github user data, #{e.message}, make sure Github CLI is installed and you are logged in.",
|
|
72
|
+
:error
|
|
73
|
+
{ name: '', login: '' }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def call
|
|
77
|
+
log "Input data: #{input_data.inspect}"
|
|
78
|
+
|
|
79
|
+
case tool_name
|
|
80
|
+
when /\Amcp__github__/
|
|
81
|
+
log "Checking MCP tool: #{tool_name}"
|
|
82
|
+
validate_mcp_github_tool!
|
|
83
|
+
when 'Bash'
|
|
84
|
+
log "Checking tool: #{tool_name}(#{command})"
|
|
85
|
+
validate_bash_command!
|
|
86
|
+
else
|
|
87
|
+
approve_tool!("Tool #{tool_name} is allowed")
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
output_data
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
# Main validation methods
|
|
96
|
+
def validate_mcp_github_tool!
|
|
97
|
+
return block_with_tip!("#{tool_name} is dangerous and not allowed.") if always_blocked_mcp_tool?
|
|
98
|
+
return validate_pr_ownership!(tool_name, extract_pr_number_from_input) if owner_restricted_mcp_tool?
|
|
99
|
+
return validate_mcp_pr_draft_mode! if pr_draft_required_mcp_tool?
|
|
100
|
+
|
|
101
|
+
approve_tool!('Safe github MCP call')
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def validate_bash_command!
|
|
105
|
+
return block_with_tip!("Command blocked: #{command} - dangerous pattern.") if always_blocked_command?
|
|
106
|
+
return ask_for_permission!("Command requires permission: #{command}") if requires_permission_command?
|
|
107
|
+
return validate_pr_ownership!(command, extract_pr_number_from_command) if owner_restricted_pr_command?
|
|
108
|
+
return prevent_remote_branch_deletion! if remote_branch_deletion_command?
|
|
109
|
+
return validate_bash_pr_draft_mode! if pr_draft_required_command?
|
|
110
|
+
|
|
111
|
+
approve_tool!('Safe bash command')
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Helper validation methods
|
|
115
|
+
def validate_pr_ownership!(context, pr_number)
|
|
116
|
+
return ask_for_permission!("Could not determine PR number from #{context}") unless pr_number
|
|
117
|
+
|
|
118
|
+
pr_owner = get_pr_owner(pr_number)
|
|
119
|
+
return ask_for_permission!("Could not determine owner of PR ##{pr_number}") unless pr_owner
|
|
120
|
+
|
|
121
|
+
if user_owns_pr?(pr_owner)
|
|
122
|
+
approve_tool!("PR ##{pr_number} belongs to current user (#{pr_owner})")
|
|
123
|
+
else
|
|
124
|
+
block_with_tip!("Cannot execute '#{context}' - PR ##{pr_number} belongs to #{pr_owner}, you are #{CURRENT_USER[:login]}") # rubocop:disable Layout/LineLength
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def validate_mcp_pr_draft_mode!
|
|
129
|
+
if tool_input&.dig('draft') == true
|
|
130
|
+
approve_tool!('PR creation allowed (draft mode)')
|
|
131
|
+
else
|
|
132
|
+
block_with_tip!('PR creation must use draft: true. All PRs should be created as drafts.')
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def validate_bash_pr_draft_mode!
|
|
137
|
+
if command.include?('--draft')
|
|
138
|
+
approve_tool!('PR creation allowed (draft mode)')
|
|
139
|
+
else
|
|
140
|
+
block_with_tip!('gh pr create must use --draft flag. All PRs should be created as drafts.')
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def prevent_remote_branch_deletion!
|
|
145
|
+
branch = extract_branch_from_deletion
|
|
146
|
+
return ask_for_permission!('Could not determine branch name') unless branch
|
|
147
|
+
|
|
148
|
+
if remote_branch_deletion?
|
|
149
|
+
block_with_tip!("Cannot delete remote branch '#{branch}'")
|
|
150
|
+
else
|
|
151
|
+
approve_tool!('Branch deletion allowed')
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Rule matching methods
|
|
156
|
+
def always_blocked_mcp_tool?
|
|
157
|
+
RULES[:mcp_tools][:always_blocked].include?(tool_name)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def owner_restricted_mcp_tool?
|
|
161
|
+
RULES[:mcp_tools][:owner_restricted_pr].include?(tool_name)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def pr_draft_required_mcp_tool?
|
|
165
|
+
RULES[:mcp_tools][:pr_draft_required].include?(tool_name)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def always_blocked_command?
|
|
169
|
+
RULES[:bash_patterns][:always_blocked].any? { |pattern| command.match?(pattern) }
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def requires_permission_command?
|
|
173
|
+
RULES[:bash_patterns][:requires_permission].any? { |pattern| command.match?(pattern) }
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def owner_restricted_pr_command?
|
|
177
|
+
RULES[:bash_patterns][:owner_restricted_pr].any? { |cmd| command.start_with?(cmd) }
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def pr_draft_required_command?
|
|
181
|
+
RULES[:bash_patterns][:pr_draft_required].any? { |cmd| command.start_with?(cmd) }
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def remote_branch_deletion_command?
|
|
185
|
+
command.match?(/\Agit\s+push.*(--delete|-d|-D)/)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def remote_branch_deletion?
|
|
189
|
+
command.include?('origin') || command.include?('upstream')
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Utility methods
|
|
193
|
+
def user_owns_pr?(pr_owner)
|
|
194
|
+
pr_owner == CURRENT_USER[:login] || pr_owner == CURRENT_USER[:name]
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Extraction methods
|
|
198
|
+
def command
|
|
199
|
+
@command ||= tool_input&.dig('command') || ''
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def extract_pr_number_from_input
|
|
203
|
+
params = tool_input || {}
|
|
204
|
+
params['pullNumber'] || params['pull_number'] || params['number']
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def extract_pr_number_from_command
|
|
208
|
+
command.match(/\s+(\d+)/)&.[](1)&.to_i
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def extract_branch_from_deletion
|
|
212
|
+
command.match(/(?:--delete|-d|-D)\s+(\S+)/)&.[](1)
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def get_pr_owner(pr_number)
|
|
216
|
+
return nil unless pr_number
|
|
217
|
+
|
|
218
|
+
begin
|
|
219
|
+
# Try to get PR info using gh CLI
|
|
220
|
+
pr_info, status = Open3.capture2e("gh pr view #{pr_number} --json author")
|
|
221
|
+
|
|
222
|
+
if status.success?
|
|
223
|
+
data = JSON.parse(pr_info)
|
|
224
|
+
data.dig('author', 'login')
|
|
225
|
+
else
|
|
226
|
+
log "Could not fetch PR info: #{pr_info}", :warn
|
|
227
|
+
nil
|
|
228
|
+
end
|
|
229
|
+
rescue StandardError => e
|
|
230
|
+
log "Error fetching PR owner: #{e.message}", :error
|
|
231
|
+
nil
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# Utility methods
|
|
236
|
+
def block_with_tip!(message)
|
|
237
|
+
block_tool!("#{message}\n#{BLOCKED_TOOL_TIP}")
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# When running this file directly (for debugging)
|
|
242
|
+
if __FILE__ == $PROGRAM_NAME
|
|
243
|
+
ClaudeHooks::CLI.run_with_sample_data(GithubGuard) do |data|
|
|
244
|
+
data.merge!(
|
|
245
|
+
'session_id' => 'GithubGuardTest',
|
|
246
|
+
'transcript_path' => '',
|
|
247
|
+
'cwd' => Dir.pwd,
|
|
248
|
+
'hook_event_name' => 'PreToolUse',
|
|
249
|
+
'tool_name' => 'mcp__github__create_pull_request',
|
|
250
|
+
'tool_input' => { 'draft' => false },
|
|
251
|
+
)
|
|
252
|
+
end
|
|
253
|
+
end
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
require 'claude_hooks'
|
|
4
4
|
|
|
5
|
-
# Hook script that appends rules to user prompt
|
|
5
|
+
# Hook script that appends rules to user prompt as additional context.
|
|
6
|
+
# A great way to make sure specific context is added with each user prompt.
|
|
6
7
|
class AppendRules < ClaudeHooks::UserPromptSubmit
|
|
7
8
|
|
|
8
9
|
def call
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# Plugin Hooks Examples
|
|
2
|
+
|
|
3
|
+
This directory demonstrates how to use the Claude Hooks Ruby DSL when creating plugin hooks for [Claude Code plugins](https://docs.claude.com/en/docs/claude-code/plugins).
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
The Claude Hooks DSL works seamlessly with plugin development! When creating plugins, you can use the exact same Ruby DSL as you would for regular hooks. The only difference is that plugin hooks are referenced through `${CLAUDE_PLUGIN_ROOT}` in the plugin's `hooks/hooks.json` configuration file.
|
|
8
|
+
|
|
9
|
+
## Benefits of Using This DSL in Plugins
|
|
10
|
+
|
|
11
|
+
- ✨ **Same powerful abstractions** - All the helper methods, logging, and state management work identically
|
|
12
|
+
- 🔄 **Automatic output handling** - `output_and_exit` manages streams and exit codes correctly
|
|
13
|
+
- 📝 **Built-in logging** - Session-specific logs work out of the box
|
|
14
|
+
- 🛠️ **Configuration access** - Access both plugin and project configurations
|
|
15
|
+
- 🎯 **Type safety** - Strong typed hook classes for each event type
|
|
16
|
+
|
|
17
|
+
## Example Plugin Structure
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
my-formatter-plugin/
|
|
21
|
+
├── .claude-plugin/
|
|
22
|
+
│ └── plugin.json # Plugin metadata
|
|
23
|
+
├── hooks/
|
|
24
|
+
│ ├── hooks.json # Hook configuration (references scripts)
|
|
25
|
+
│ └── scripts/
|
|
26
|
+
│ └── formatter.rb # Hook script using ClaudeHooks DSL
|
|
27
|
+
└── README.md
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Example: Code Formatter Plugin
|
|
31
|
+
|
|
32
|
+
### Plugin Configuration (`hooks/hooks.json`)
|
|
33
|
+
|
|
34
|
+
```json
|
|
35
|
+
{
|
|
36
|
+
"description": "Automatic code formatting on file writes",
|
|
37
|
+
"hooks": {
|
|
38
|
+
"PostToolUse": [
|
|
39
|
+
{
|
|
40
|
+
"matcher": "Write|Edit",
|
|
41
|
+
"hooks": [
|
|
42
|
+
{
|
|
43
|
+
"type": "command",
|
|
44
|
+
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/scripts/formatter.rb",
|
|
45
|
+
"timeout": 30
|
|
46
|
+
}
|
|
47
|
+
]
|
|
48
|
+
}
|
|
49
|
+
]
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### Hook Script (`hooks/scripts/formatter.rb`)
|
|
55
|
+
|
|
56
|
+
```ruby
|
|
57
|
+
#!/usr/bin/env ruby
|
|
58
|
+
require 'claude_hooks'
|
|
59
|
+
|
|
60
|
+
class PluginFormatter < ClaudeHooks::PostToolUse
|
|
61
|
+
def call
|
|
62
|
+
log "Plugin formatter executing from: #{ENV['CLAUDE_PLUGIN_ROOT']}"
|
|
63
|
+
|
|
64
|
+
# Only format code files
|
|
65
|
+
file_path = tool_input['file_path']
|
|
66
|
+
return output unless should_format?(file_path)
|
|
67
|
+
|
|
68
|
+
log "Formatting file: #{file_path}"
|
|
69
|
+
|
|
70
|
+
# Perform formatting (example)
|
|
71
|
+
if format_file(file_path)
|
|
72
|
+
add_additional_context!("File formatted successfully: #{file_path}")
|
|
73
|
+
else
|
|
74
|
+
log "Failed to format #{file_path}", level: :warn
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
output
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def should_format?(file_path)
|
|
83
|
+
# Example: only format Ruby files
|
|
84
|
+
file_path.end_with?('.rb')
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def format_file(file_path)
|
|
88
|
+
# Your formatting logic here
|
|
89
|
+
# For example, using rubocop or prettier
|
|
90
|
+
return true # Simulated success
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
if __FILE__ == $0
|
|
95
|
+
input_data = JSON.parse(STDIN.read)
|
|
96
|
+
hook = PluginFormatter.new(input_data)
|
|
97
|
+
hook.call
|
|
98
|
+
hook.output_and_exit
|
|
99
|
+
end
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Environment Variables in Plugins
|
|
103
|
+
|
|
104
|
+
When your hook scripts run as part of a plugin, these environment variables are available:
|
|
105
|
+
|
|
106
|
+
| Variable | Description |
|
|
107
|
+
|----------|-------------|
|
|
108
|
+
| `CLAUDE_PLUGIN_ROOT` | Absolute path to the plugin directory |
|
|
109
|
+
| `CLAUDE_PROJECT_DIR` | Project root directory (where Claude Code was started) |
|
|
110
|
+
| `RUBY_CLAUDE_HOOKS_*` | All standard configuration environment variables |
|
|
111
|
+
|
|
112
|
+
### Accessing Plugin Root in Your Hooks
|
|
113
|
+
|
|
114
|
+
```ruby
|
|
115
|
+
class MyPluginHook < ClaudeHooks::PreToolUse
|
|
116
|
+
def call
|
|
117
|
+
# Access plugin root
|
|
118
|
+
plugin_root = ENV['CLAUDE_PLUGIN_ROOT']
|
|
119
|
+
log "Plugin root: #{plugin_root}"
|
|
120
|
+
|
|
121
|
+
# Load plugin-specific config files
|
|
122
|
+
plugin_config_path = File.join(plugin_root, 'config', 'settings.json')
|
|
123
|
+
|
|
124
|
+
# Your hook logic here
|
|
125
|
+
output
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## Best Practices for Plugin Hooks
|
|
131
|
+
|
|
132
|
+
1. **Keep hooks focused** - Each hook should have a single, well-defined purpose
|
|
133
|
+
2. **Use logging extensively** - Helps users debug plugin behavior
|
|
134
|
+
3. **Handle errors gracefully** - Don't crash on unexpected input
|
|
135
|
+
4. **Document environment requirements** - If your plugin needs external tools (rubocop, prettier, etc.)
|
|
136
|
+
5. **Test thoroughly** - Test with various file types and scenarios
|
|
137
|
+
|
|
138
|
+
## Testing Plugin Hooks Locally
|
|
139
|
+
|
|
140
|
+
You can test your plugin hooks before publishing:
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
# Test the hook script directly
|
|
144
|
+
echo '{"session_id":"test","transcript_path":"/tmp/test","cwd":"/tmp","hook_event_name":"PostToolUse","tool_name":"Write","tool_input":{"file_path":"test.rb"},"tool_response":{"success":true}}' | CLAUDE_PLUGIN_ROOT=/path/to/plugin ruby hooks/scripts/formatter.rb
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Or use the CLI test runner:
|
|
148
|
+
|
|
149
|
+
```ruby
|
|
150
|
+
#!/usr/bin/env ruby
|
|
151
|
+
require 'claude_hooks'
|
|
152
|
+
|
|
153
|
+
class PluginFormatter < ClaudeHooks::PostToolUse
|
|
154
|
+
# ... your implementation ...
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
if __FILE__ == $0
|
|
158
|
+
ClaudeHooks::CLI.run_with_sample_data(PluginFormatter) do |input_data|
|
|
159
|
+
input_data['tool_name'] = 'Write'
|
|
160
|
+
input_data['tool_input'] = { 'file_path' => 'test.rb' }
|
|
161
|
+
input_data['tool_response'] = { 'success' => true }
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
## Resources
|
|
167
|
+
|
|
168
|
+
- [Official Plugin Documentation](https://docs.claude.com/en/docs/claude-code/plugins)
|
|
169
|
+
- [Plugin Components Reference](https://docs.claude.com/en/docs/claude-code/plugins-reference#hooks)
|
|
170
|
+
- [Hooks API Reference](../../docs/API/)
|
|
171
|
+
- [Claude Hooks Main README](../../README.md)
|
|
172
|
+
|
|
173
|
+
## Contributing Examples
|
|
174
|
+
|
|
175
|
+
If you create a plugin using this DSL, consider contributing an example! Open a PR or issue on the [claude_hooks repository](https://github.com/gabriel-dehan/claude_hooks).
|
|
@@ -4,6 +4,17 @@
|
|
|
4
4
|
"allow": []
|
|
5
5
|
},
|
|
6
6
|
"hooks": {
|
|
7
|
+
"PreToolUse": [
|
|
8
|
+
{
|
|
9
|
+
"matcher": "",
|
|
10
|
+
"hooks": [
|
|
11
|
+
{
|
|
12
|
+
"type": "command",
|
|
13
|
+
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/entrypoints/pre_tool_use.rb"
|
|
14
|
+
}
|
|
15
|
+
]
|
|
16
|
+
}
|
|
17
|
+
],
|
|
7
18
|
"UserPromptSubmit": [
|
|
8
19
|
{
|
|
9
20
|
"matcher": "",
|
|
@@ -26,21 +26,29 @@ module ClaudeHooks
|
|
|
26
26
|
end
|
|
27
27
|
|
|
28
28
|
# === EXIT CODE LOGIC ===
|
|
29
|
-
|
|
29
|
+
#
|
|
30
|
+
# PostToolUse hooks use the advanced JSON API with exit code 0.
|
|
31
|
+
# Per Anthropic guidance: when using structured JSON with decision/reason fields,
|
|
32
|
+
# always output to stdout with exit 0 (not stderr with exit 2).
|
|
33
|
+
# Reference: https://github.com/anthropics/claude-code/issues/10875
|
|
30
34
|
def exit_code
|
|
31
|
-
return 2 unless continue?
|
|
32
|
-
return 2 if blocked?
|
|
33
|
-
|
|
34
35
|
0
|
|
35
36
|
end
|
|
36
37
|
|
|
38
|
+
# === OUTPUT STREAM LOGIC ===
|
|
39
|
+
#
|
|
40
|
+
# PostToolUse hooks always output to stdout when using the JSON API.
|
|
41
|
+
def output_stream
|
|
42
|
+
:stdout
|
|
43
|
+
end
|
|
44
|
+
|
|
37
45
|
# === MERGE HELPER ===
|
|
38
46
|
|
|
39
47
|
def self.merge(*outputs)
|
|
40
48
|
compacted_outputs = outputs.compact
|
|
41
49
|
return compacted_outputs.first if compacted_outputs.length == 1
|
|
42
50
|
return super(*outputs) if compacted_outputs.empty?
|
|
43
|
-
|
|
51
|
+
|
|
44
52
|
merged = super(*outputs)
|
|
45
53
|
merged_data = merged.data
|
|
46
54
|
contexts = []
|
|
@@ -65,4 +73,4 @@ module ClaudeHooks
|
|
|
65
73
|
end
|
|
66
74
|
end
|
|
67
75
|
end
|
|
68
|
-
end
|
|
76
|
+
end
|
|
@@ -24,29 +24,26 @@ module ClaudeHooks
|
|
|
24
24
|
def denied?
|
|
25
25
|
permission_decision == 'deny'
|
|
26
26
|
end
|
|
27
|
-
|
|
27
|
+
alias blocked? denied?
|
|
28
28
|
|
|
29
29
|
def should_ask_permission?
|
|
30
30
|
permission_decision == 'ask'
|
|
31
31
|
end
|
|
32
32
|
|
|
33
33
|
# === EXIT CODE LOGIC ===
|
|
34
|
-
|
|
35
|
-
#
|
|
34
|
+
#
|
|
35
|
+
# PreToolUse hooks use the advanced JSON API with exit code 0.
|
|
36
|
+
# Per Anthropic guidance: when using structured JSON with permissionDecision,
|
|
37
|
+
# always output to stdout with exit 0 (not stderr with exit 2).
|
|
38
|
+
# The permissionDecision field ('allow', 'deny', 'ask') controls behavior.
|
|
39
|
+
# Reference: https://github.com/anthropics/claude-code/issues/10875
|
|
36
40
|
def exit_code
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
case permission_decision
|
|
40
|
-
when 'deny'
|
|
41
|
-
2
|
|
42
|
-
when 'ask'
|
|
43
|
-
1
|
|
44
|
-
else # allow
|
|
45
|
-
0
|
|
46
|
-
end
|
|
41
|
+
0
|
|
47
42
|
end
|
|
48
43
|
|
|
49
|
-
#
|
|
44
|
+
# === OUTPUT STREAM LOGIC ===
|
|
45
|
+
#
|
|
46
|
+
# PreToolUse hooks always output to stdout when using the JSON API.
|
|
50
47
|
def output_stream
|
|
51
48
|
:stdout
|
|
52
49
|
end
|
|
@@ -57,7 +54,7 @@ module ClaudeHooks
|
|
|
57
54
|
compacted_outputs = outputs.compact
|
|
58
55
|
return compacted_outputs.first if compacted_outputs.length == 1
|
|
59
56
|
return super(*outputs) if compacted_outputs.empty?
|
|
60
|
-
|
|
57
|
+
|
|
61
58
|
merged = super(*outputs)
|
|
62
59
|
merged_data = merged.data
|
|
63
60
|
|
|
@@ -67,31 +64,31 @@ module ClaudeHooks
|
|
|
67
64
|
|
|
68
65
|
compacted_outputs.each do |output|
|
|
69
66
|
output_data = output.respond_to?(:data) ? output.data : output
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
reason = output_data.dig('hookSpecificOutput', 'permissionDecisionReason')
|
|
81
|
-
permission_reasons << reason if reason && !reason.empty?
|
|
67
|
+
|
|
68
|
+
next unless output_data.dig('hookSpecificOutput', 'permissionDecision')
|
|
69
|
+
|
|
70
|
+
current_decision = output_data['hookSpecificOutput']['permissionDecision']
|
|
71
|
+
case current_decision
|
|
72
|
+
when 'deny'
|
|
73
|
+
permission_decision = 'deny'
|
|
74
|
+
when 'ask'
|
|
75
|
+
permission_decision = 'ask' unless permission_decision == 'deny'
|
|
82
76
|
end
|
|
77
|
+
|
|
78
|
+
reason = output_data.dig('hookSpecificOutput', 'permissionDecisionReason')
|
|
79
|
+
permission_reasons << reason if reason && !reason.empty?
|
|
83
80
|
end
|
|
84
81
|
|
|
85
82
|
merged_data['hookSpecificOutput'] ||= { 'hookEventName' => 'PreToolUse' }
|
|
86
83
|
merged_data['hookSpecificOutput']['permissionDecision'] = permission_decision
|
|
87
|
-
if permission_reasons.any?
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
84
|
+
merged_data['hookSpecificOutput']['permissionDecisionReason'] = if permission_reasons.any?
|
|
85
|
+
permission_reasons.join('; ')
|
|
86
|
+
else
|
|
87
|
+
''
|
|
88
|
+
end
|
|
92
89
|
|
|
93
90
|
new(merged_data)
|
|
94
91
|
end
|
|
95
92
|
end
|
|
96
93
|
end
|
|
97
|
-
end
|
|
94
|
+
end
|
|
@@ -32,16 +32,23 @@ module ClaudeHooks
|
|
|
32
32
|
end
|
|
33
33
|
|
|
34
34
|
# === EXIT CODE LOGIC ===
|
|
35
|
-
|
|
35
|
+
#
|
|
36
|
+
# Stop hooks use the advanced JSON API with exit code 0.
|
|
37
|
+
# Per Anthropic guidance: when using structured JSON with decision/reason fields,
|
|
38
|
+
# always output to stdout with exit 0 (not stderr with exit 2).
|
|
39
|
+
# Reference: https://github.com/anthropics/claude-code/issues/10875
|
|
36
40
|
def exit_code
|
|
37
|
-
# For Stop hooks: we assume 'continue' means continue to stop
|
|
38
|
-
return 0 unless continue?
|
|
39
|
-
# For Stop hooks: decision 'block' means force continue (exit 2)
|
|
40
|
-
return 2 if should_continue?
|
|
41
|
-
|
|
42
41
|
0
|
|
43
42
|
end
|
|
44
43
|
|
|
44
|
+
# === OUTPUT STREAM LOGIC ===
|
|
45
|
+
#
|
|
46
|
+
# Stop hooks always output to stdout when using the JSON API.
|
|
47
|
+
# This follows the same pattern as PreToolUse hooks.
|
|
48
|
+
def output_stream
|
|
49
|
+
:stdout
|
|
50
|
+
end
|
|
51
|
+
|
|
45
52
|
# === MERGE HELPER ===
|
|
46
53
|
|
|
47
54
|
def self.merge(*outputs)
|
|
@@ -26,26 +26,33 @@ module ClaudeHooks
|
|
|
26
26
|
end
|
|
27
27
|
|
|
28
28
|
# === EXIT CODE LOGIC ===
|
|
29
|
-
|
|
29
|
+
#
|
|
30
|
+
# UserPromptSubmit hooks use the advanced JSON API with exit code 0.
|
|
31
|
+
# Per Anthropic guidance: when using structured JSON with decision/reason fields,
|
|
32
|
+
# always output to stdout with exit 0 (not stderr with exit 2).
|
|
33
|
+
# Reference: https://github.com/anthropics/claude-code/issues/10875
|
|
30
34
|
def exit_code
|
|
31
|
-
return 2 unless continue?
|
|
32
|
-
return 2 if blocked?
|
|
33
|
-
|
|
34
35
|
0
|
|
35
36
|
end
|
|
36
37
|
|
|
38
|
+
# === OUTPUT STREAM LOGIC ===
|
|
39
|
+
#
|
|
40
|
+
# UserPromptSubmit hooks always output to stdout when using the JSON API.
|
|
41
|
+
def output_stream
|
|
42
|
+
:stdout
|
|
43
|
+
end
|
|
44
|
+
|
|
37
45
|
# === MERGE HELPER ===
|
|
38
46
|
|
|
39
47
|
def self.merge(*outputs)
|
|
40
48
|
compacted_outputs = outputs.compact
|
|
41
49
|
return compacted_outputs.first if compacted_outputs.length == 1
|
|
42
50
|
return super(*outputs) if compacted_outputs.empty?
|
|
43
|
-
|
|
51
|
+
|
|
44
52
|
merged = super(*outputs)
|
|
45
53
|
merged_data = merged.data
|
|
46
54
|
|
|
47
55
|
contexts = []
|
|
48
|
-
reasons = []
|
|
49
56
|
|
|
50
57
|
compacted_outputs.each do |output|
|
|
51
58
|
output_data = output.respond_to?(:data) ? output.data : output
|
|
@@ -68,4 +75,4 @@ module ClaudeHooks
|
|
|
68
75
|
end
|
|
69
76
|
end
|
|
70
77
|
end
|
|
71
|
-
end
|
|
78
|
+
end
|
data/lib/claude_hooks/stop.rb
CHANGED
|
@@ -3,6 +3,14 @@
|
|
|
3
3
|
require_relative 'base'
|
|
4
4
|
|
|
5
5
|
module ClaudeHooks
|
|
6
|
+
# Stop hook for preventing Claude Code from stopping execution.
|
|
7
|
+
#
|
|
8
|
+
# When using continue_with_instructions!, this hook outputs JSON to stdout
|
|
9
|
+
# with exit code 0 (advanced JSON API approach).
|
|
10
|
+
#
|
|
11
|
+
# References:
|
|
12
|
+
# - https://github.com/anthropics/claude-code/issues/10875
|
|
13
|
+
# - https://github.com/gabriel-dehan/claude_hooks/issues/11
|
|
6
14
|
class Stop < Base
|
|
7
15
|
def self.hook_type
|
|
8
16
|
'Stop'
|
data/lib/claude_hooks/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: claude_hooks
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.0.
|
|
4
|
+
version: 1.0.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Gabriel Dehan
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date:
|
|
11
|
+
date: 2026-01-04 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: json
|
|
@@ -64,6 +64,7 @@ files:
|
|
|
64
64
|
- CHANGELOG.md
|
|
65
65
|
- README.md
|
|
66
66
|
- claude_hooks.gemspec
|
|
67
|
+
- docs/1.0.0_MIGRATION_GUIDE.md
|
|
67
68
|
- docs/API/COMMON.md
|
|
68
69
|
- docs/API/NOTIFICATION.md
|
|
69
70
|
- docs/API/POST_TOOL_USE.md
|
|
@@ -74,15 +75,18 @@ files:
|
|
|
74
75
|
- docs/API/STOP.md
|
|
75
76
|
- docs/API/SUBAGENT_STOP.md
|
|
76
77
|
- docs/API/USER_PROMPT_SUBMIT.md
|
|
77
|
-
- docs/OUTPUT_MIGRATION_GUIDE.md
|
|
78
78
|
- docs/WHY.md
|
|
79
|
+
- docs/external/claude-hooks-reference.md
|
|
79
80
|
- example_dotclaude/commands/.gitkeep
|
|
81
|
+
- example_dotclaude/hooks/entrypoints/pre_tool_use.rb
|
|
80
82
|
- example_dotclaude/hooks/entrypoints/session_end.rb
|
|
81
83
|
- example_dotclaude/hooks/entrypoints/user_prompt_submit.rb
|
|
84
|
+
- example_dotclaude/hooks/handlers/pre_tool_use/github_guard.rb
|
|
82
85
|
- example_dotclaude/hooks/handlers/session_end/cleanup_handler.rb
|
|
83
86
|
- example_dotclaude/hooks/handlers/session_end/log_session_stats.rb
|
|
84
87
|
- example_dotclaude/hooks/handlers/user_prompt_submit/append_rules.rb
|
|
85
88
|
- example_dotclaude/hooks/handlers/user_prompt_submit/log_user_prompt.rb
|
|
89
|
+
- example_dotclaude/plugins/README.md
|
|
86
90
|
- example_dotclaude/settings.json
|
|
87
91
|
- lib/claude_hooks.rb
|
|
88
92
|
- lib/claude_hooks/base.rb
|