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.
- checksums.yaml +4 -4
- data/.claude/auto-fix.md +7 -0
- data/CHANGELOG.md +99 -4
- data/README.md +91 -15
- data/docs/{OUTPUT_MIGRATION_GUIDE.md → 1.0.0_MIGRATION_GUIDE.md} +17 -17
- data/docs/1.1.0_UPGRADE_GUIDE.md +269 -0
- data/docs/API/COMMON.md +1 -0
- data/docs/API/NOTIFICATION.md +1 -0
- data/docs/API/PERMISSION_REQUEST.md +196 -0
- data/docs/API/POST_TOOL_USE.md +1 -0
- data/docs/API/PRE_TOOL_USE.md +4 -0
- data/docs/API/SESSION_START.md +1 -1
- data/docs/WHY.md +15 -8
- data/docs/external/claude-hooks-reference.md +1310 -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/base.rb +5 -1
- data/lib/claude_hooks/notification.rb +5 -1
- data/lib/claude_hooks/output/base.rb +2 -0
- data/lib/claude_hooks/output/permission_request.rb +95 -0
- data/lib/claude_hooks/output/post_tool_use.rb +14 -6
- data/lib/claude_hooks/output/pre_tool_use.rb +47 -32
- data/lib/claude_hooks/output/stop.rb +13 -6
- data/lib/claude_hooks/output/user_prompt_submit.rb +14 -7
- data/lib/claude_hooks/permission_request.rb +56 -0
- data/lib/claude_hooks/post_tool_use.rb +5 -1
- data/lib/claude_hooks/pre_tool_use.rb +16 -1
- data/lib/claude_hooks/stop.rb +8 -0
- data/lib/claude_hooks/version.rb +1 -1
- data/lib/claude_hooks.rb +2 -0
- 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": "",
|
data/lib/claude_hooks/base.rb
CHANGED
|
@@ -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
|
|
@@ -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
|