claude_hooks 1.0.2 → 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/auto-fix.md +7 -0
- data/.claude/settings.local.json +21 -0
- data/AGENTS.md +46 -0
- data/CHANGELOG.md +93 -0
- data/README.md +159 -21
- data/docs/1.1.0_UPGRADE_GUIDE.md +269 -0
- data/docs/API/COMMON.md +7 -0
- 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/NOTIFICATION.md +1 -0
- data/docs/API/PERMISSION_DENIED.md +37 -0
- data/docs/API/PERMISSION_REQUEST.md +199 -0
- data/docs/API/POST_COMPACT.md +30 -0
- data/docs/API/POST_TOOL_BATCH.md +39 -0
- data/docs/API/POST_TOOL_USE.md +1 -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 +9 -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 +2846 -18
- 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 +30 -1
- 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/notification.rb +5 -1
- data/lib/claude_hooks/output/base.rb +48 -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 +120 -0
- 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 +26 -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 +63 -0
- 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 +15 -1
- 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 +34 -1
- 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 +42 -0
- metadata +76 -2
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base'
|
|
4
|
+
|
|
5
|
+
module ClaudeHooks
|
|
6
|
+
# MessageDisplay runs while assistant message text is displayed on screen.
|
|
7
|
+
# Display-only: exit code and decision fields are ignored. The only effect is
|
|
8
|
+
# replacing the on-screen text via hookSpecificOutput.displayContent — the
|
|
9
|
+
# transcript and what Claude sees keep the original text.
|
|
10
|
+
class MessageDisplay < Base
|
|
11
|
+
def self.hook_type
|
|
12
|
+
'MessageDisplay'
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.input_fields
|
|
16
|
+
%w[turn_id message_id index final delta]
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# === INPUT DATA ACCESS ===
|
|
20
|
+
|
|
21
|
+
def turn_id
|
|
22
|
+
@input_data['turn_id'] || @input_data['turnId']
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def message_id
|
|
26
|
+
@input_data['message_id'] || @input_data['messageId']
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def index
|
|
30
|
+
@input_data['index']
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def final
|
|
34
|
+
@input_data.key?('final') ? @input_data['final'] : @input_data['isFinal']
|
|
35
|
+
end
|
|
36
|
+
alias_method :final?, :final
|
|
37
|
+
|
|
38
|
+
# The streamed assistant text delta for this event.
|
|
39
|
+
def delta
|
|
40
|
+
@input_data['delta']
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Some payloads expose the full text so far; kept as a convenience reader.
|
|
44
|
+
def message_text
|
|
45
|
+
@input_data['message_text'] || @input_data['messageText']
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# === OUTPUT DATA HELPERS ===
|
|
49
|
+
|
|
50
|
+
# Replace the text shown on screen (display-only).
|
|
51
|
+
def display_content!(content)
|
|
52
|
+
@output_data['hookSpecificOutput'] = {
|
|
53
|
+
'hookEventName' => hook_event_name,
|
|
54
|
+
'displayContent' => content
|
|
55
|
+
}
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -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
|
|
@@ -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)
|
|
@@ -119,6 +124,8 @@ module ClaudeHooks
|
|
|
119
124
|
UserPromptSubmit.new(data)
|
|
120
125
|
when 'PreToolUse'
|
|
121
126
|
PreToolUse.new(data)
|
|
127
|
+
when 'PermissionRequest'
|
|
128
|
+
PermissionRequest.new(data)
|
|
122
129
|
when 'PostToolUse'
|
|
123
130
|
PostToolUse.new(data)
|
|
124
131
|
when 'Stop'
|
|
@@ -127,12 +134,52 @@ module ClaudeHooks
|
|
|
127
134
|
SubagentStop.new(data)
|
|
128
135
|
when 'Notification'
|
|
129
136
|
Notification.new(data)
|
|
137
|
+
when 'MessageDisplay'
|
|
138
|
+
MessageDisplay.new(data)
|
|
130
139
|
when 'SessionStart'
|
|
131
140
|
SessionStart.new(data)
|
|
132
141
|
when 'SessionEnd'
|
|
133
142
|
SessionEnd.new(data)
|
|
134
143
|
when 'PreCompact'
|
|
135
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)
|
|
136
183
|
else
|
|
137
184
|
raise ArgumentError, "Unknown hook type: #{hook_type}"
|
|
138
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
|
|
@@ -0,0 +1,120 @@
|
|
|
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 behavior
|
|
11
|
+
hook_specific_output.dig('decision', 'behavior')
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Read nested decision.behavior with flat legacy fallback
|
|
15
|
+
def permission_decision
|
|
16
|
+
behavior || hook_specific_output['permissionDecision']
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Read decision.message with legacy permissionDecisionReason fallback
|
|
20
|
+
def permission_reason
|
|
21
|
+
hook_specific_output.dig('decision', 'message') ||
|
|
22
|
+
hook_specific_output['permissionDecisionReason'] || ''
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Read decision.updatedInput with flat legacy fallback
|
|
26
|
+
def updated_input
|
|
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
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# === SEMANTIC HELPERS ===
|
|
40
|
+
|
|
41
|
+
def allowed?
|
|
42
|
+
permission_decision == 'allow'
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def denied?
|
|
46
|
+
permission_decision == 'deny'
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def input_updated?
|
|
50
|
+
!updated_input.nil?
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# === EXIT CODE LOGIC ===
|
|
54
|
+
|
|
55
|
+
def exit_code
|
|
56
|
+
0
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# === OUTPUT STREAM LOGIC ===
|
|
60
|
+
|
|
61
|
+
def output_stream
|
|
62
|
+
:stdout
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# === MERGE HELPER ===
|
|
66
|
+
|
|
67
|
+
def self.merge(*outputs)
|
|
68
|
+
compacted_outputs = outputs.compact
|
|
69
|
+
return compacted_outputs.first if compacted_outputs.length == 1
|
|
70
|
+
return super(*outputs) if compacted_outputs.empty?
|
|
71
|
+
|
|
72
|
+
merged = super(*outputs)
|
|
73
|
+
merged_data = merged.data
|
|
74
|
+
|
|
75
|
+
# deny > allow (most restrictive wins)
|
|
76
|
+
behavior = 'allow'
|
|
77
|
+
messages = []
|
|
78
|
+
updated_inputs = []
|
|
79
|
+
interrupt = false
|
|
80
|
+
updated_permissions = nil
|
|
81
|
+
|
|
82
|
+
compacted_outputs.each do |output|
|
|
83
|
+
output_data = output.respond_to?(:data) ? output.data : output
|
|
84
|
+
|
|
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
|
|
89
|
+
|
|
90
|
+
behavior = 'deny' if current_behavior == 'deny'
|
|
91
|
+
|
|
92
|
+
msg = output_data.dig('hookSpecificOutput', 'decision', 'message') ||
|
|
93
|
+
output_data.dig('hookSpecificOutput', 'permissionDecisionReason')
|
|
94
|
+
messages << msg if msg && !msg.empty?
|
|
95
|
+
|
|
96
|
+
updated = output_data.dig('hookSpecificOutput', 'decision', 'updatedInput') ||
|
|
97
|
+
output_data.dig('hookSpecificOutput', 'updatedInput')
|
|
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?
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
merged_data['hookSpecificOutput'] ||= { 'hookEventName' => 'PermissionRequest' }
|
|
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
|
|
115
|
+
|
|
116
|
+
new(merged_data)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
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
|