claude_hooks 1.1.0 → 1.2.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/.agents/skills/ci-monitoring/SKILL.md +103 -0
- data/.agents/skills/run-tests/SKILL.md +45 -0
- data/.claude/settings.local.json +21 -0
- data/AGENTS.md +46 -0
- data/CHANGELOG.md +43 -0
- data/README.md +156 -21
- data/docs/API/COMMON.md +7 -1
- data/docs/API/CONFIG_CHANGE.md +39 -0
- data/docs/API/CWD_CHANGED.md +34 -0
- data/docs/API/ELICITATION.md +44 -0
- data/docs/API/ELICITATION_RESULT.md +43 -0
- data/docs/API/FILE_CHANGED.md +37 -0
- data/docs/API/INSTRUCTIONS_LOADED.md +30 -0
- data/docs/API/MESSAGE_DISPLAY.md +38 -0
- data/docs/API/PERMISSION_DENIED.md +37 -0
- data/docs/API/PERMISSION_REQUEST.md +11 -8
- data/docs/API/POST_COMPACT.md +30 -0
- data/docs/API/POST_TOOL_BATCH.md +39 -0
- data/docs/API/POST_TOOL_USE_FAILURE.md +31 -0
- data/docs/API/PRE_COMPACT.md +10 -2
- data/docs/API/PRE_TOOL_USE.md +5 -1
- data/docs/API/SETUP.md +37 -0
- data/docs/API/STOP_FAILURE.md +19 -0
- data/docs/API/SUBAGENT_START.md +40 -0
- data/docs/API/TASK_COMPLETED.md +43 -0
- data/docs/API/TASK_CREATED.md +43 -0
- data/docs/API/TEAMMATE_IDLE.md +40 -0
- data/docs/API/USER_PROMPT_EXPANSION.md +43 -0
- data/docs/API/WORKTREE_CREATE.md +39 -0
- data/docs/API/WORKTREE_REMOVE.md +33 -0
- data/docs/PROMPT_BASED_HOOKS.md +386 -0
- data/docs/external/claude-hooks-reference.md +2406 -854
- data/docs/mitts/ideabox.md +12 -0
- data/docs/mitts/prd.md +417 -0
- data/docs/mitts/setup.md +303 -0
- data/docs/mitts/task.md +44 -0
- data/lib/claude_hooks/base.rb +25 -0
- data/lib/claude_hooks/config_change.rb +28 -0
- data/lib/claude_hooks/cwd_changed.rb +28 -0
- data/lib/claude_hooks/elicitation.rb +61 -0
- data/lib/claude_hooks/elicitation_result.rb +57 -0
- data/lib/claude_hooks/file_changed.rb +41 -0
- data/lib/claude_hooks/instructions_loaded.rb +23 -0
- data/lib/claude_hooks/message_display.rb +58 -0
- data/lib/claude_hooks/output/base.rb +46 -1
- data/lib/claude_hooks/output/config_change.rb +47 -0
- data/lib/claude_hooks/output/cwd_changed.rb +42 -0
- data/lib/claude_hooks/output/elicitation.rb +67 -0
- data/lib/claude_hooks/output/elicitation_result.rb +63 -0
- data/lib/claude_hooks/output/file_changed.rb +42 -0
- data/lib/claude_hooks/output/instructions_loaded.rb +19 -0
- data/lib/claude_hooks/output/message_display.rb +49 -0
- data/lib/claude_hooks/output/permission_denied.rb +42 -0
- data/lib/claude_hooks/output/permission_request.rb +50 -25
- data/lib/claude_hooks/output/post_compact.rb +18 -0
- data/lib/claude_hooks/output/post_tool_batch.rb +51 -0
- data/lib/claude_hooks/output/post_tool_use.rb +12 -0
- data/lib/claude_hooks/output/post_tool_use_failure.rb +42 -0
- data/lib/claude_hooks/output/pre_compact.rb +34 -3
- data/lib/claude_hooks/output/pre_tool_use.rb +8 -2
- data/lib/claude_hooks/output/session_start.rb +30 -8
- data/lib/claude_hooks/output/setup.rb +42 -0
- data/lib/claude_hooks/output/stop.rb +15 -0
- data/lib/claude_hooks/output/stop_failure.rb +19 -0
- data/lib/claude_hooks/output/subagent_start.rb +42 -0
- data/lib/claude_hooks/output/task_completed.rb +18 -0
- data/lib/claude_hooks/output/task_created.rb +18 -0
- data/lib/claude_hooks/output/teammate_idle.rb +18 -0
- data/lib/claude_hooks/output/user_prompt_expansion.rb +51 -0
- data/lib/claude_hooks/output/worktree_create.rb +60 -0
- data/lib/claude_hooks/output/worktree_remove.rb +19 -0
- data/lib/claude_hooks/permission_denied.rb +41 -0
- data/lib/claude_hooks/permission_request.rb +16 -9
- data/lib/claude_hooks/post_compact.rb +23 -0
- data/lib/claude_hooks/post_tool_batch.rb +59 -0
- data/lib/claude_hooks/post_tool_use.rb +10 -0
- data/lib/claude_hooks/post_tool_use_failure.rb +45 -0
- data/lib/claude_hooks/pre_compact.rb +7 -0
- data/lib/claude_hooks/pre_tool_use.rb +20 -2
- data/lib/claude_hooks/session_start.rb +28 -0
- data/lib/claude_hooks/setup.rb +25 -0
- data/lib/claude_hooks/stop.rb +17 -0
- data/lib/claude_hooks/stop_failure.rb +27 -0
- data/lib/claude_hooks/subagent_start.rb +23 -0
- data/lib/claude_hooks/subagent_stop.rb +5 -1
- data/lib/claude_hooks/task_completed.rb +35 -0
- data/lib/claude_hooks/task_created.rb +35 -0
- data/lib/claude_hooks/teammate_idle.rb +23 -0
- data/lib/claude_hooks/user_prompt_expansion.rb +45 -0
- data/lib/claude_hooks/version.rb +1 -1
- data/lib/claude_hooks/worktree_create.rb +27 -0
- data/lib/claude_hooks/worktree_remove.rb +19 -0
- data/lib/claude_hooks.rb +40 -0
- metadata +71 -2
|
@@ -40,6 +40,10 @@ module ClaudeHooks
|
|
|
40
40
|
@data['hookSpecificOutput'] || {}
|
|
41
41
|
end
|
|
42
42
|
|
|
43
|
+
def terminal_sequence
|
|
44
|
+
@data['terminalSequence']
|
|
45
|
+
end
|
|
46
|
+
|
|
43
47
|
# === JSON SERIALIZATION ===
|
|
44
48
|
|
|
45
49
|
# Convert to JSON string (same as existing stringify_output)
|
|
@@ -100,11 +104,12 @@ module ClaudeHooks
|
|
|
100
104
|
# Apply base merge logic
|
|
101
105
|
compacted_outputs.each do |output|
|
|
102
106
|
output_data = output.respond_to?(:data) ? output.data : output
|
|
103
|
-
|
|
107
|
+
|
|
104
108
|
merged_data['continue'] = false if output_data['continue'] == false
|
|
105
109
|
merged_data['suppressOutput'] = true if output_data['suppressOutput'] == true
|
|
106
110
|
merged_data['stopReason'] = [merged_data['stopReason'], output_data['stopReason']].compact.reject(&:empty?).join('; ')
|
|
107
111
|
merged_data['systemMessage'] = [merged_data['systemMessage'], output_data['systemMessage']].compact.reject(&:empty?).join('; ')
|
|
112
|
+
merged_data['terminalSequence'] = output_data['terminalSequence'] unless output_data['terminalSequence'].nil?
|
|
108
113
|
end
|
|
109
114
|
|
|
110
115
|
self.new(merged_data)
|
|
@@ -129,12 +134,52 @@ module ClaudeHooks
|
|
|
129
134
|
SubagentStop.new(data)
|
|
130
135
|
when 'Notification'
|
|
131
136
|
Notification.new(data)
|
|
137
|
+
when 'MessageDisplay'
|
|
138
|
+
MessageDisplay.new(data)
|
|
132
139
|
when 'SessionStart'
|
|
133
140
|
SessionStart.new(data)
|
|
134
141
|
when 'SessionEnd'
|
|
135
142
|
SessionEnd.new(data)
|
|
136
143
|
when 'PreCompact'
|
|
137
144
|
PreCompact.new(data)
|
|
145
|
+
when 'Setup'
|
|
146
|
+
Setup.new(data)
|
|
147
|
+
when 'SubagentStart'
|
|
148
|
+
SubagentStart.new(data)
|
|
149
|
+
when 'UserPromptExpansion'
|
|
150
|
+
UserPromptExpansion.new(data)
|
|
151
|
+
when 'PostToolBatch'
|
|
152
|
+
PostToolBatch.new(data)
|
|
153
|
+
when 'ConfigChange'
|
|
154
|
+
ConfigChange.new(data)
|
|
155
|
+
when 'TaskCreated'
|
|
156
|
+
TaskCreated.new(data)
|
|
157
|
+
when 'TaskCompleted'
|
|
158
|
+
TaskCompleted.new(data)
|
|
159
|
+
when 'TeammateIdle'
|
|
160
|
+
TeammateIdle.new(data)
|
|
161
|
+
when 'PostToolUseFailure'
|
|
162
|
+
PostToolUseFailure.new(data)
|
|
163
|
+
when 'StopFailure'
|
|
164
|
+
StopFailure.new(data)
|
|
165
|
+
when 'PostCompact'
|
|
166
|
+
PostCompact.new(data)
|
|
167
|
+
when 'CwdChanged'
|
|
168
|
+
CwdChanged.new(data)
|
|
169
|
+
when 'FileChanged'
|
|
170
|
+
FileChanged.new(data)
|
|
171
|
+
when 'InstructionsLoaded'
|
|
172
|
+
InstructionsLoaded.new(data)
|
|
173
|
+
when 'WorktreeRemove'
|
|
174
|
+
WorktreeRemove.new(data)
|
|
175
|
+
when 'PermissionDenied'
|
|
176
|
+
PermissionDenied.new(data)
|
|
177
|
+
when 'Elicitation'
|
|
178
|
+
Elicitation.new(data)
|
|
179
|
+
when 'ElicitationResult'
|
|
180
|
+
ElicitationResult.new(data)
|
|
181
|
+
when 'WorktreeCreate'
|
|
182
|
+
WorktreeCreate.new(data)
|
|
138
183
|
else
|
|
139
184
|
raise ArgumentError, "Unknown hook type: #{hook_type}"
|
|
140
185
|
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base'
|
|
4
|
+
|
|
5
|
+
module ClaudeHooks
|
|
6
|
+
module Output
|
|
7
|
+
class ConfigChange < Base
|
|
8
|
+
def decision
|
|
9
|
+
@data['decision']
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def reason
|
|
13
|
+
@data['reason'] || ''
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def blocked?
|
|
17
|
+
decision == 'block'
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def exit_code
|
|
21
|
+
default_exit_code
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.merge(*outputs)
|
|
25
|
+
compacted_outputs = outputs.compact
|
|
26
|
+
return compacted_outputs.first if compacted_outputs.length == 1
|
|
27
|
+
return super(*outputs) if compacted_outputs.empty?
|
|
28
|
+
|
|
29
|
+
merged = super(*outputs)
|
|
30
|
+
merged_data = merged.data
|
|
31
|
+
reasons = []
|
|
32
|
+
|
|
33
|
+
compacted_outputs.each do |output|
|
|
34
|
+
output_data = output.respond_to?(:data) ? output.data : output
|
|
35
|
+
if output_data['decision'] == 'block'
|
|
36
|
+
merged_data['decision'] = 'block'
|
|
37
|
+
r = output_data['reason']
|
|
38
|
+
reasons << r if r && !r.empty?
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
merged_data['reason'] = reasons.join('; ') if reasons.any?
|
|
43
|
+
new(merged_data)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base'
|
|
4
|
+
|
|
5
|
+
module ClaudeHooks
|
|
6
|
+
module Output
|
|
7
|
+
class CwdChanged < Base
|
|
8
|
+
def watch_paths
|
|
9
|
+
hook_specific_output['watchPaths'] || []
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def exit_code
|
|
13
|
+
default_exit_code
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.merge(*outputs)
|
|
17
|
+
compacted_outputs = outputs.compact
|
|
18
|
+
return compacted_outputs.first if compacted_outputs.length == 1
|
|
19
|
+
return super(*outputs) if compacted_outputs.empty?
|
|
20
|
+
|
|
21
|
+
merged = super(*outputs)
|
|
22
|
+
merged_data = merged.data
|
|
23
|
+
all_paths = []
|
|
24
|
+
|
|
25
|
+
compacted_outputs.each do |output|
|
|
26
|
+
output_data = output.respond_to?(:data) ? output.data : output
|
|
27
|
+
paths = output_data.dig('hookSpecificOutput', 'watchPaths')
|
|
28
|
+
all_paths.concat(paths) if paths&.any?
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
unless all_paths.empty?
|
|
32
|
+
merged_data['hookSpecificOutput'] = {
|
|
33
|
+
'hookEventName' => 'CwdChanged',
|
|
34
|
+
'watchPaths' => all_paths.uniq
|
|
35
|
+
}
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
new(merged_data)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base'
|
|
4
|
+
|
|
5
|
+
module ClaudeHooks
|
|
6
|
+
module Output
|
|
7
|
+
class Elicitation < Base
|
|
8
|
+
def action
|
|
9
|
+
hook_specific_output['action']
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def content
|
|
13
|
+
hook_specific_output['content'] || {}
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def accepted?
|
|
17
|
+
action == 'accept'
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def declined?
|
|
21
|
+
action == 'decline'
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def cancelled?
|
|
25
|
+
action == 'cancel'
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def exit_code
|
|
29
|
+
0
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def output_stream
|
|
33
|
+
:stdout
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.merge(*outputs)
|
|
37
|
+
compacted_outputs = outputs.compact
|
|
38
|
+
return compacted_outputs.first if compacted_outputs.length == 1
|
|
39
|
+
return super(*outputs) if compacted_outputs.empty?
|
|
40
|
+
|
|
41
|
+
# Last non-nil action wins
|
|
42
|
+
merged = super(*outputs)
|
|
43
|
+
merged_data = merged.data
|
|
44
|
+
last_action = nil
|
|
45
|
+
last_content = nil
|
|
46
|
+
|
|
47
|
+
compacted_outputs.each do |output|
|
|
48
|
+
output_data = output.respond_to?(:data) ? output.data : output
|
|
49
|
+
a = output_data.dig('hookSpecificOutput', 'action')
|
|
50
|
+
next unless a
|
|
51
|
+
last_action = a
|
|
52
|
+
last_content = output_data.dig('hookSpecificOutput', 'content')
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
if last_action
|
|
56
|
+
merged_data['hookSpecificOutput'] = {
|
|
57
|
+
'hookEventName' => 'Elicitation',
|
|
58
|
+
'action' => last_action
|
|
59
|
+
}
|
|
60
|
+
merged_data['hookSpecificOutput']['content'] = last_content if last_content
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
new(merged_data)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base'
|
|
4
|
+
|
|
5
|
+
module ClaudeHooks
|
|
6
|
+
module Output
|
|
7
|
+
class ElicitationResult < Base
|
|
8
|
+
def action
|
|
9
|
+
hook_specific_output['action']
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def content
|
|
13
|
+
hook_specific_output['content'] || {}
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def accepted?
|
|
17
|
+
action == 'accept'
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def declined?
|
|
21
|
+
action == 'decline'
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def cancelled?
|
|
25
|
+
action == 'cancel'
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def exit_code
|
|
29
|
+
0
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def output_stream
|
|
33
|
+
:stdout
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.merge(*outputs)
|
|
37
|
+
compacted_outputs = outputs.compact
|
|
38
|
+
return compacted_outputs.first if compacted_outputs.length == 1
|
|
39
|
+
return super(*outputs) if compacted_outputs.empty?
|
|
40
|
+
|
|
41
|
+
merged = super(*outputs)
|
|
42
|
+
merged_data = merged.data
|
|
43
|
+
last_action = nil
|
|
44
|
+
last_content = nil
|
|
45
|
+
|
|
46
|
+
compacted_outputs.each do |output|
|
|
47
|
+
output_data = output.respond_to?(:data) ? output.data : output
|
|
48
|
+
a = output_data.dig('hookSpecificOutput', 'action')
|
|
49
|
+
next unless a
|
|
50
|
+
last_action = a
|
|
51
|
+
last_content = output_data.dig('hookSpecificOutput', 'content')
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
if last_action
|
|
55
|
+
merged_data['hookSpecificOutput'] = { 'hookEventName' => 'ElicitationResult', 'action' => last_action }
|
|
56
|
+
merged_data['hookSpecificOutput']['content'] = last_content if last_content
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
new(merged_data)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base'
|
|
4
|
+
|
|
5
|
+
module ClaudeHooks
|
|
6
|
+
module Output
|
|
7
|
+
class FileChanged < Base
|
|
8
|
+
def watch_paths
|
|
9
|
+
hook_specific_output['watchPaths'] || []
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def exit_code
|
|
13
|
+
default_exit_code
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.merge(*outputs)
|
|
17
|
+
compacted_outputs = outputs.compact
|
|
18
|
+
return compacted_outputs.first if compacted_outputs.length == 1
|
|
19
|
+
return super(*outputs) if compacted_outputs.empty?
|
|
20
|
+
|
|
21
|
+
merged = super(*outputs)
|
|
22
|
+
merged_data = merged.data
|
|
23
|
+
all_paths = []
|
|
24
|
+
|
|
25
|
+
compacted_outputs.each do |output|
|
|
26
|
+
output_data = output.respond_to?(:data) ? output.data : output
|
|
27
|
+
paths = output_data.dig('hookSpecificOutput', 'watchPaths')
|
|
28
|
+
all_paths.concat(paths) if paths&.any?
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
unless all_paths.empty?
|
|
32
|
+
merged_data['hookSpecificOutput'] = {
|
|
33
|
+
'hookEventName' => 'FileChanged',
|
|
34
|
+
'watchPaths' => all_paths.uniq
|
|
35
|
+
}
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
new(merged_data)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base'
|
|
4
|
+
|
|
5
|
+
module ClaudeHooks
|
|
6
|
+
module Output
|
|
7
|
+
# InstructionsLoaded is non-blocking — exit code is ignored by Claude Code.
|
|
8
|
+
class InstructionsLoaded < Base
|
|
9
|
+
def exit_code
|
|
10
|
+
0
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def self.merge(*outputs)
|
|
14
|
+
merged = super(*outputs)
|
|
15
|
+
new(merged.data)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base'
|
|
4
|
+
|
|
5
|
+
module ClaudeHooks
|
|
6
|
+
module Output
|
|
7
|
+
# MessageDisplay is display-only — exit code and decision fields are ignored.
|
|
8
|
+
# Only hookSpecificOutput.displayContent has an effect (replaces on-screen text).
|
|
9
|
+
class MessageDisplay < Base
|
|
10
|
+
def display_content
|
|
11
|
+
hook_specific_output['displayContent']
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def exit_code
|
|
15
|
+
0
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def output_stream
|
|
19
|
+
:stdout
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Last non-nil displayContent wins.
|
|
23
|
+
def self.merge(*outputs)
|
|
24
|
+
compacted_outputs = outputs.compact
|
|
25
|
+
return compacted_outputs.first if compacted_outputs.length == 1
|
|
26
|
+
return super(*outputs) if compacted_outputs.empty?
|
|
27
|
+
|
|
28
|
+
merged = super(*outputs)
|
|
29
|
+
merged_data = merged.data
|
|
30
|
+
last_content = nil
|
|
31
|
+
|
|
32
|
+
compacted_outputs.each do |output|
|
|
33
|
+
output_data = output.respond_to?(:data) ? output.data : output
|
|
34
|
+
content = output_data.dig('hookSpecificOutput', 'displayContent')
|
|
35
|
+
last_content = content unless content.nil?
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
unless last_content.nil?
|
|
39
|
+
merged_data['hookSpecificOutput'] = {
|
|
40
|
+
'hookEventName' => 'MessageDisplay',
|
|
41
|
+
'displayContent' => last_content
|
|
42
|
+
}
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
new(merged_data)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base'
|
|
4
|
+
|
|
5
|
+
module ClaudeHooks
|
|
6
|
+
module Output
|
|
7
|
+
class PermissionDenied < Base
|
|
8
|
+
def retry?
|
|
9
|
+
hook_specific_output['retry'] == true
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Exit code is ignored by Claude Code for PermissionDenied.
|
|
13
|
+
def exit_code
|
|
14
|
+
0
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def output_stream
|
|
18
|
+
:stdout
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.merge(*outputs)
|
|
22
|
+
compacted_outputs = outputs.compact
|
|
23
|
+
return compacted_outputs.first if compacted_outputs.length == 1
|
|
24
|
+
return super(*outputs) if compacted_outputs.empty?
|
|
25
|
+
|
|
26
|
+
merged = super(*outputs)
|
|
27
|
+
merged_data = merged.data
|
|
28
|
+
|
|
29
|
+
# If any hook says retry, retry
|
|
30
|
+
should_retry = compacted_outputs.any? do |output|
|
|
31
|
+
output_data = output.respond_to?(:data) ? output.data : output
|
|
32
|
+
output_data.dig('hookSpecificOutput', 'retry') == true
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
merged_data['hookSpecificOutput'] ||= { 'hookEventName' => 'PermissionDenied' }
|
|
36
|
+
merged_data['hookSpecificOutput']['retry'] = should_retry
|
|
37
|
+
|
|
38
|
+
new(merged_data)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -7,16 +7,33 @@ module ClaudeHooks
|
|
|
7
7
|
class PermissionRequest < Base
|
|
8
8
|
# === PERMISSION DECISION ACCESSORS ===
|
|
9
9
|
|
|
10
|
+
def behavior
|
|
11
|
+
hook_specific_output.dig('decision', 'behavior')
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Read nested decision.behavior with flat legacy fallback
|
|
10
15
|
def permission_decision
|
|
11
|
-
hook_specific_output['permissionDecision']
|
|
16
|
+
behavior || hook_specific_output['permissionDecision']
|
|
12
17
|
end
|
|
13
18
|
|
|
19
|
+
# Read decision.message with legacy permissionDecisionReason fallback
|
|
14
20
|
def permission_reason
|
|
15
|
-
hook_specific_output
|
|
21
|
+
hook_specific_output.dig('decision', 'message') ||
|
|
22
|
+
hook_specific_output['permissionDecisionReason'] || ''
|
|
16
23
|
end
|
|
17
24
|
|
|
25
|
+
# Read decision.updatedInput with flat legacy fallback
|
|
18
26
|
def updated_input
|
|
19
|
-
hook_specific_output
|
|
27
|
+
hook_specific_output.dig('decision', 'updatedInput') ||
|
|
28
|
+
hook_specific_output['updatedInput']
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def updated_permissions
|
|
32
|
+
hook_specific_output.dig('decision', 'updatedPermissions')
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def interrupt?
|
|
36
|
+
hook_specific_output.dig('decision', 'interrupt') == true
|
|
20
37
|
end
|
|
21
38
|
|
|
22
39
|
# === SEMANTIC HELPERS ===
|
|
@@ -34,16 +51,13 @@ module ClaudeHooks
|
|
|
34
51
|
end
|
|
35
52
|
|
|
36
53
|
# === 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).
|
|
54
|
+
|
|
40
55
|
def exit_code
|
|
41
56
|
0
|
|
42
57
|
end
|
|
43
58
|
|
|
44
59
|
# === OUTPUT STREAM LOGIC ===
|
|
45
|
-
|
|
46
|
-
# PermissionRequest hooks always output to stdout when using the JSON API.
|
|
60
|
+
|
|
47
61
|
def output_stream
|
|
48
62
|
:stdout
|
|
49
63
|
end
|
|
@@ -58,35 +72,46 @@ module ClaudeHooks
|
|
|
58
72
|
merged = super(*outputs)
|
|
59
73
|
merged_data = merged.data
|
|
60
74
|
|
|
61
|
-
#
|
|
62
|
-
|
|
63
|
-
|
|
75
|
+
# deny > allow (most restrictive wins)
|
|
76
|
+
behavior = 'allow'
|
|
77
|
+
messages = []
|
|
64
78
|
updated_inputs = []
|
|
79
|
+
interrupt = false
|
|
80
|
+
updated_permissions = nil
|
|
65
81
|
|
|
66
82
|
compacted_outputs.each do |output|
|
|
67
83
|
output_data = output.respond_to?(:data) ? output.data : output
|
|
68
84
|
|
|
69
|
-
|
|
85
|
+
# Support both nested and legacy flat shapes
|
|
86
|
+
current_behavior = output_data.dig('hookSpecificOutput', 'decision', 'behavior') ||
|
|
87
|
+
output_data.dig('hookSpecificOutput', 'permissionDecision')
|
|
88
|
+
next unless current_behavior
|
|
70
89
|
|
|
71
|
-
|
|
72
|
-
permission_decision = 'deny' if current_decision == 'deny'
|
|
90
|
+
behavior = 'deny' if current_behavior == 'deny'
|
|
73
91
|
|
|
74
|
-
|
|
75
|
-
|
|
92
|
+
msg = output_data.dig('hookSpecificOutput', 'decision', 'message') ||
|
|
93
|
+
output_data.dig('hookSpecificOutput', 'permissionDecisionReason')
|
|
94
|
+
messages << msg if msg && !msg.empty?
|
|
76
95
|
|
|
77
|
-
updated = output_data.dig('hookSpecificOutput', 'updatedInput')
|
|
96
|
+
updated = output_data.dig('hookSpecificOutput', 'decision', 'updatedInput') ||
|
|
97
|
+
output_data.dig('hookSpecificOutput', 'updatedInput')
|
|
78
98
|
updated_inputs << updated if updated
|
|
99
|
+
|
|
100
|
+
# Propagate interrupt (OR logic — if any output has it, merged should too)
|
|
101
|
+
interrupt = true if output_data.dig('hookSpecificOutput', 'decision', 'interrupt') == true
|
|
102
|
+
|
|
103
|
+
# Propagate updatedPermissions (last writer wins)
|
|
104
|
+
perms = output_data.dig('hookSpecificOutput', 'decision', 'updatedPermissions')
|
|
105
|
+
updated_permissions = perms unless perms.nil?
|
|
79
106
|
end
|
|
80
107
|
|
|
81
108
|
merged_data['hookSpecificOutput'] ||= { 'hookEventName' => 'PermissionRequest' }
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
# Last updated input wins
|
|
89
|
-
merged_data['hookSpecificOutput']['updatedInput'] = updated_inputs.last if updated_inputs.any?
|
|
109
|
+
decision = { 'behavior' => behavior }
|
|
110
|
+
decision['message'] = messages.join('; ') if messages.any?
|
|
111
|
+
decision['updatedInput'] = updated_inputs.last if updated_inputs.any?
|
|
112
|
+
decision['interrupt'] = interrupt
|
|
113
|
+
decision['updatedPermissions'] = updated_permissions unless updated_permissions.nil?
|
|
114
|
+
merged_data['hookSpecificOutput']['decision'] = decision
|
|
90
115
|
|
|
91
116
|
new(merged_data)
|
|
92
117
|
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base'
|
|
4
|
+
|
|
5
|
+
module ClaudeHooks
|
|
6
|
+
module Output
|
|
7
|
+
class PostCompact < Base
|
|
8
|
+
def exit_code
|
|
9
|
+
default_exit_code
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def self.merge(*outputs)
|
|
13
|
+
merged = super(*outputs)
|
|
14
|
+
new(merged.data)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base'
|
|
4
|
+
|
|
5
|
+
module ClaudeHooks
|
|
6
|
+
module Output
|
|
7
|
+
class PostToolBatch < Base
|
|
8
|
+
def decision
|
|
9
|
+
@data['decision']
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def reason
|
|
13
|
+
@data['reason'] || ''
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def blocked?
|
|
17
|
+
decision == 'block'
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def additional_context
|
|
21
|
+
hook_specific_output['additionalContext'] || ''
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def exit_code
|
|
25
|
+
default_exit_code
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.merge(*outputs)
|
|
29
|
+
compacted_outputs = outputs.compact
|
|
30
|
+
return compacted_outputs.first if compacted_outputs.length == 1
|
|
31
|
+
return super(*outputs) if compacted_outputs.empty?
|
|
32
|
+
|
|
33
|
+
merged = super(*outputs)
|
|
34
|
+
merged_data = merged.data
|
|
35
|
+
reasons = []
|
|
36
|
+
|
|
37
|
+
compacted_outputs.each do |output|
|
|
38
|
+
output_data = output.respond_to?(:data) ? output.data : output
|
|
39
|
+
if output_data['decision'] == 'block'
|
|
40
|
+
merged_data['decision'] = 'block'
|
|
41
|
+
r = output_data['reason']
|
|
42
|
+
reasons << r if r && !r.empty?
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
merged_data['reason'] = reasons.join('; ') if reasons.any?
|
|
47
|
+
new(merged_data)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -19,12 +19,24 @@ module ClaudeHooks
|
|
|
19
19
|
hook_specific_output['additionalContext'] || ''
|
|
20
20
|
end
|
|
21
21
|
|
|
22
|
+
def updated_tool_output
|
|
23
|
+
hook_specific_output['updatedToolOutput']
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def updated_mcp_tool_output
|
|
27
|
+
hook_specific_output['updatedMCPToolOutput']
|
|
28
|
+
end
|
|
29
|
+
|
|
22
30
|
# === SEMANTIC HELPERS ===
|
|
23
31
|
|
|
24
32
|
def blocked?
|
|
25
33
|
decision == 'block'
|
|
26
34
|
end
|
|
27
35
|
|
|
36
|
+
def output_updated?
|
|
37
|
+
!updated_tool_output.nil? || !updated_mcp_tool_output.nil?
|
|
38
|
+
end
|
|
39
|
+
|
|
28
40
|
# === EXIT CODE LOGIC ===
|
|
29
41
|
#
|
|
30
42
|
# PostToolUse hooks use the advanced JSON API with exit code 0.
|