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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bbd6349008b693bddb770b79ac5883a746c826a800ed7141f4de466bd88025e0
4
- data.tar.gz: b7a724ed1655ecb7c4af3393619e5dd800b0b59a08721c6d4b7279c4d31a185d
3
+ metadata.gz: d82c5deaa5426bc2ca898300f7772fa58c56a2998fd00af4eb1a93de25d3336e
4
+ data.tar.gz: ffde711d996ad0f43f7f75a3002f5a1e74e84f28ed2197ddf9c53c8b982a5152
5
5
  SHA512:
6
- metadata.gz: a1596119ed1ee346ed133a76a0991cc64f8be594905e96e6b056e6e5187be91ca43ffd4c28c3e822725b5ee4bdb0bdbc03aa0cea38c06a5813b94c3a381ff0c3
7
- data.tar.gz: 6063e35f39ffabd8eaf78e9deccd009e56f9103c86f69dedb6a2ed66b97cc9a8606d0aafb6ee2467d6fa29536031f4d0d671ff20e466c672009d16e2c2a962ea
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
- > An example is available in [`example_dotclaude/hooks/`](example_dotclaude/hooks/)
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 resumes |
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: Using Output Objects
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 output object system.
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 0 # Allow anyway to not block developers if there is an issue
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
- - 30+ lines of repetitive boilerplate
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
- error_output = ClaudeHooks::Output::PreToolUse.new({
81
- 'continue' => false,
82
- 'stopReason' => "Hook execution error: #{e.message}",
83
- 'suppressOutput' => false
80
+ STDERR.puts JSON.generate({
81
+ continue: false,
82
+ stopReason: "Hook execution error: #{e.message}",
83
+ suppressOutput: false
84
84
  })
85
- error_output.output_and_exit # Automatically uses STDERR and exit 1
85
+ # Non-blocking error
86
+ exit 1
86
87
  end
87
88
  ```
88
89
 
89
90
  **Benefits:**
90
- - **10 lines instead of 30+** - 70% less code
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 1 for "continue", exit 0 for "stop"
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 `output_data` (most already do)
223
- 2. **Replace manual exit logic** with `hook.output_and_exit` or `output.output_and_exit`
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.
@@ -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 `'clear'` |
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!, add_context!).
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 - 8 different hook types with tailored methods (e.g., ask_for_permission! vs block_tool!) and smart merge logic for combining outputs.
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
- # Merge outputs to Claude Code
50
- puts ClaudeHooks::UserPromptSubmit.merge_outputs(append_rules_result, log_result)
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
- hook = AppendRules.new(JSON.parse(STDIN.read))
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
+ ![Chat avatar](https://platform.claude.com/docs/images/book-icon-light.svg)
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
+ ![](<Base64-Image-Removed>)![](<Base64-Image-Removed>)
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
- alias_method :blocked?, :denied?
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
- # Priority: continue false > permission decision
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
- return 2 unless continue?
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
- # STDOUT always works for PreToolUse
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
- if output_data.dig('hookSpecificOutput', 'permissionDecision')
72
- current_decision = output_data['hookSpecificOutput']['permissionDecision']
73
- case current_decision
74
- when 'deny'
75
- permission_decision = 'deny'
76
- when 'ask'
77
- permission_decision = 'ask' unless permission_decision == 'deny'
78
- end
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
- merged_data['hookSpecificOutput']['permissionDecisionReason'] = permission_reasons.join('; ')
89
- else
90
- merged_data['hookSpecificOutput']['permissionDecisionReason'] = ''
91
- end
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
@@ -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'
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ClaudeHooks
4
- VERSION = "1.0.0"
4
+ VERSION = "1.0.2"
5
5
  end
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.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: 2025-08-29 00:00:00.000000000 Z
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