claude_hooks 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/auto-fix.md +7 -0
  3. data/CHANGELOG.md +99 -4
  4. data/README.md +91 -15
  5. data/docs/{OUTPUT_MIGRATION_GUIDE.md → 1.0.0_MIGRATION_GUIDE.md} +17 -17
  6. data/docs/1.1.0_UPGRADE_GUIDE.md +269 -0
  7. data/docs/API/COMMON.md +1 -0
  8. data/docs/API/NOTIFICATION.md +1 -0
  9. data/docs/API/PERMISSION_REQUEST.md +196 -0
  10. data/docs/API/POST_TOOL_USE.md +1 -0
  11. data/docs/API/PRE_TOOL_USE.md +4 -0
  12. data/docs/API/SESSION_START.md +1 -1
  13. data/docs/WHY.md +15 -8
  14. data/docs/external/claude-hooks-reference.md +1310 -0
  15. data/example_dotclaude/hooks/entrypoints/pre_tool_use.rb +25 -0
  16. data/example_dotclaude/hooks/handlers/pre_tool_use/github_guard.rb +253 -0
  17. data/example_dotclaude/hooks/handlers/user_prompt_submit/append_rules.rb +2 -1
  18. data/example_dotclaude/plugins/README.md +175 -0
  19. data/example_dotclaude/settings.json +11 -0
  20. data/lib/claude_hooks/base.rb +5 -1
  21. data/lib/claude_hooks/notification.rb +5 -1
  22. data/lib/claude_hooks/output/base.rb +2 -0
  23. data/lib/claude_hooks/output/permission_request.rb +95 -0
  24. data/lib/claude_hooks/output/post_tool_use.rb +14 -6
  25. data/lib/claude_hooks/output/pre_tool_use.rb +47 -32
  26. data/lib/claude_hooks/output/stop.rb +13 -6
  27. data/lib/claude_hooks/output/user_prompt_submit.rb +14 -7
  28. data/lib/claude_hooks/permission_request.rb +56 -0
  29. data/lib/claude_hooks/post_tool_use.rb +5 -1
  30. data/lib/claude_hooks/pre_tool_use.rb +16 -1
  31. data/lib/claude_hooks/stop.rb +8 -0
  32. data/lib/claude_hooks/version.rb +1 -1
  33. data/lib/claude_hooks.rb +2 -0
  34. metadata +12 -3
@@ -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": "",
@@ -9,7 +9,7 @@ module ClaudeHooks
9
9
  # Base class for Claude Code hook scripts
10
10
  class Base
11
11
  # Common input fields for all hook types
12
- COMMON_INPUT_FIELDS = %w[session_id transcript_path cwd hook_event_name].freeze
12
+ COMMON_INPUT_FIELDS = %w[session_id transcript_path cwd hook_event_name permission_mode].freeze
13
13
 
14
14
  # Override in subclasses to specify hook type
15
15
  def self.hook_type
@@ -71,6 +71,10 @@ module ClaudeHooks
71
71
  @input_data['hook_event_name'] || @input_data['hookEventName'] || hook_type
72
72
  end
73
73
 
74
+ def permission_mode
75
+ @input_data['permission_mode'] || @input_data['permissionMode'] || 'default'
76
+ end
77
+
74
78
  def read_transcript
75
79
  unless transcript_path && File.exist?(transcript_path)
76
80
  log "Transcript file not found at #{transcript_path}", level: :warn
@@ -9,7 +9,7 @@ module ClaudeHooks
9
9
  end
10
10
 
11
11
  def self.input_fields
12
- %w[message]
12
+ %w[message notification_type]
13
13
  end
14
14
 
15
15
  # === INPUT DATA ACCESS ===
@@ -18,5 +18,9 @@ module ClaudeHooks
18
18
  @input_data['message']
19
19
  end
20
20
  alias_method :notification_message, :message
21
+
22
+ def notification_type
23
+ @input_data['notification_type'] || @input_data['notificationType']
24
+ end
21
25
  end
22
26
  end
@@ -119,6 +119,8 @@ module ClaudeHooks
119
119
  UserPromptSubmit.new(data)
120
120
  when 'PreToolUse'
121
121
  PreToolUse.new(data)
122
+ when 'PermissionRequest'
123
+ PermissionRequest.new(data)
122
124
  when 'PostToolUse'
123
125
  PostToolUse.new(data)
124
126
  when 'Stop'
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module ClaudeHooks
6
+ module Output
7
+ class PermissionRequest < Base
8
+ # === PERMISSION DECISION ACCESSORS ===
9
+
10
+ def permission_decision
11
+ hook_specific_output['permissionDecision']
12
+ end
13
+
14
+ def permission_reason
15
+ hook_specific_output['permissionDecisionReason'] || ''
16
+ end
17
+
18
+ def updated_input
19
+ hook_specific_output['updatedInput']
20
+ end
21
+
22
+ # === SEMANTIC HELPERS ===
23
+
24
+ def allowed?
25
+ permission_decision == 'allow'
26
+ end
27
+
28
+ def denied?
29
+ permission_decision == 'deny'
30
+ end
31
+
32
+ def input_updated?
33
+ !updated_input.nil?
34
+ end
35
+
36
+ # === EXIT CODE LOGIC ===
37
+ #
38
+ # PermissionRequest hooks use the advanced JSON API with exit code 0.
39
+ # Following the same pattern as PreToolUse (permission-based hooks).
40
+ def exit_code
41
+ 0
42
+ end
43
+
44
+ # === OUTPUT STREAM LOGIC ===
45
+ #
46
+ # PermissionRequest hooks always output to stdout when using the JSON API.
47
+ def output_stream
48
+ :stdout
49
+ end
50
+
51
+ # === MERGE HELPER ===
52
+
53
+ def self.merge(*outputs)
54
+ compacted_outputs = outputs.compact
55
+ return compacted_outputs.first if compacted_outputs.length == 1
56
+ return super(*outputs) if compacted_outputs.empty?
57
+
58
+ merged = super(*outputs)
59
+ merged_data = merged.data
60
+
61
+ # PermissionRequest specific merge: deny > allow (most restrictive wins)
62
+ permission_decision = 'allow'
63
+ permission_reasons = []
64
+ updated_inputs = []
65
+
66
+ compacted_outputs.each do |output|
67
+ output_data = output.respond_to?(:data) ? output.data : output
68
+
69
+ next unless output_data.dig('hookSpecificOutput', 'permissionDecision')
70
+
71
+ current_decision = output_data['hookSpecificOutput']['permissionDecision']
72
+ permission_decision = 'deny' if current_decision == 'deny'
73
+
74
+ reason = output_data.dig('hookSpecificOutput', 'permissionDecisionReason')
75
+ permission_reasons << reason if reason && !reason.empty?
76
+
77
+ updated = output_data.dig('hookSpecificOutput', 'updatedInput')
78
+ updated_inputs << updated if updated
79
+ end
80
+
81
+ merged_data['hookSpecificOutput'] ||= { 'hookEventName' => 'PermissionRequest' }
82
+ merged_data['hookSpecificOutput']['permissionDecision'] = permission_decision
83
+ merged_data['hookSpecificOutput']['permissionDecisionReason'] = if permission_reasons.any?
84
+ permission_reasons.join('; ')
85
+ else
86
+ ''
87
+ end
88
+ # Last updated input wins
89
+ merged_data['hookSpecificOutput']['updatedInput'] = updated_inputs.last if updated_inputs.any?
90
+
91
+ new(merged_data)
92
+ end
93
+ end
94
+ end
95
+ end
@@ -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