claude_hooks 0.2.1 → 1.0.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/CHANGELOG.md +92 -0
- data/README.md +215 -357
- data/claude_hooks.gemspec +2 -2
- data/docs/API/COMMON.md +83 -0
- data/docs/API/NOTIFICATION.md +32 -0
- data/docs/API/POST_TOOL_USE.md +45 -0
- data/docs/API/PRE_COMPACT.md +39 -0
- data/docs/API/PRE_TOOL_USE.md +110 -0
- data/docs/API/SESSION_END.md +100 -0
- data/docs/API/SESSION_START.md +40 -0
- data/docs/API/STOP.md +47 -0
- data/docs/API/SUBAGENT_STOP.md +47 -0
- data/docs/API/USER_PROMPT_SUBMIT.md +47 -0
- data/docs/OUTPUT_MIGRATION_GUIDE.md +228 -0
- data/example_dotclaude/hooks/entrypoints/session_end.rb +35 -0
- data/example_dotclaude/hooks/entrypoints/user_prompt_submit.rb +17 -12
- data/example_dotclaude/hooks/handlers/session_end/cleanup_handler.rb +55 -0
- data/example_dotclaude/hooks/handlers/session_end/log_session_stats.rb +64 -0
- data/example_dotclaude/hooks/handlers/user_prompt_submit/append_rules.rb +1 -1
- data/example_dotclaude/hooks/handlers/user_prompt_submit/log_user_prompt.rb +2 -2
- data/example_dotclaude/settings.json +11 -0
- data/lib/claude_hooks/base.rb +16 -24
- data/lib/claude_hooks/cli.rb +75 -1
- data/lib/claude_hooks/logger.rb +0 -1
- data/lib/claude_hooks/output/base.rb +152 -0
- data/lib/claude_hooks/output/notification.rb +22 -0
- data/lib/claude_hooks/output/post_tool_use.rb +68 -0
- data/lib/claude_hooks/output/pre_compact.rb +20 -0
- data/lib/claude_hooks/output/pre_tool_use.rb +97 -0
- data/lib/claude_hooks/output/session_end.rb +24 -0
- data/lib/claude_hooks/output/session_start.rb +49 -0
- data/lib/claude_hooks/output/stop.rb +76 -0
- data/lib/claude_hooks/output/subagent_stop.rb +14 -0
- data/lib/claude_hooks/output/user_prompt_submit.rb +71 -0
- data/lib/claude_hooks/post_tool_use.rb +6 -12
- data/lib/claude_hooks/pre_tool_use.rb +0 -37
- data/lib/claude_hooks/session_end.rb +43 -0
- data/lib/claude_hooks/session_start.rb +0 -23
- data/lib/claude_hooks/stop.rb +0 -25
- data/lib/claude_hooks/user_prompt_submit.rb +0 -26
- data/lib/claude_hooks/version.rb +1 -1
- data/lib/claude_hooks.rb +15 -0
- metadata +33 -8
- /data/{WHY.md → docs/WHY.md} +0 -0
@@ -0,0 +1,152 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'json'
|
4
|
+
|
5
|
+
module ClaudeHooks
|
6
|
+
module Output
|
7
|
+
# Base class for all Claude Code hook output handlers
|
8
|
+
# Handles common functionality like continue/stop logic, output streams, and exit codes
|
9
|
+
class Base
|
10
|
+
attr_reader :data
|
11
|
+
|
12
|
+
def initialize(data)
|
13
|
+
@data = data || {}
|
14
|
+
end
|
15
|
+
|
16
|
+
# === COMMON FIELD ACCESSORS ===
|
17
|
+
|
18
|
+
# Check if Claude should continue processing
|
19
|
+
def continue?
|
20
|
+
@data['continue'] != false
|
21
|
+
end
|
22
|
+
|
23
|
+
# Get the stop reason if continue is false
|
24
|
+
def stop_reason
|
25
|
+
@data['stopReason'] || ''
|
26
|
+
end
|
27
|
+
|
28
|
+
# Check if output should be suppressed from transcript
|
29
|
+
def suppress_output?
|
30
|
+
@data['suppressOutput'] == true
|
31
|
+
end
|
32
|
+
|
33
|
+
# Get the system message (if any)
|
34
|
+
def system_message
|
35
|
+
@data['systemMessage'] || ''
|
36
|
+
end
|
37
|
+
|
38
|
+
# Get the hook-specific output data
|
39
|
+
def hook_specific_output
|
40
|
+
@data['hookSpecificOutput'] || {}
|
41
|
+
end
|
42
|
+
|
43
|
+
# === JSON SERIALIZATION ===
|
44
|
+
|
45
|
+
# Convert to JSON string (same as existing stringify_output)
|
46
|
+
def to_json(*args)
|
47
|
+
JSON.generate(@data, *args)
|
48
|
+
end
|
49
|
+
alias_method :stringify, :to_json
|
50
|
+
|
51
|
+
# === EXECUTION CONTROL ===
|
52
|
+
|
53
|
+
# Main execution method - handles output and exits with correct code
|
54
|
+
def output_and_exit
|
55
|
+
stream = output_stream
|
56
|
+
code = exit_code
|
57
|
+
|
58
|
+
case stream
|
59
|
+
when :stdout
|
60
|
+
$stdout.puts to_json
|
61
|
+
when :stderr
|
62
|
+
$stderr.puts to_json
|
63
|
+
else
|
64
|
+
raise "Unknown output stream: #{stream}"
|
65
|
+
end
|
66
|
+
|
67
|
+
exit code
|
68
|
+
end
|
69
|
+
|
70
|
+
# === ABSTRACT METHODS ===
|
71
|
+
# These must be implemented by subclasses
|
72
|
+
|
73
|
+
# Determine the exit code based on hook-specific logic
|
74
|
+
def exit_code
|
75
|
+
raise NotImplementedError, "Subclasses must implement exit_code"
|
76
|
+
end
|
77
|
+
|
78
|
+
# Determine the output stream (:stdout or :stderr)
|
79
|
+
def output_stream
|
80
|
+
default_output_stream
|
81
|
+
end
|
82
|
+
|
83
|
+
# === MERGE HELPER ===
|
84
|
+
|
85
|
+
# Base merge method - handles common fields like continue, stopReason, suppressOutput
|
86
|
+
# Subclasses should call super and add their specific logic
|
87
|
+
def self.merge(*outputs)
|
88
|
+
compacted_outputs = outputs.compact
|
89
|
+
|
90
|
+
merged_data = {
|
91
|
+
'continue' => true,
|
92
|
+
'stopReason' => '',
|
93
|
+
'suppressOutput' => false,
|
94
|
+
'systemMessage' => ''
|
95
|
+
}
|
96
|
+
|
97
|
+
return compacted_outputs.first if compacted_outputs.length == 1
|
98
|
+
return self.new(merged_data) if compacted_outputs.empty?
|
99
|
+
|
100
|
+
# Apply base merge logic
|
101
|
+
compacted_outputs.each do |output|
|
102
|
+
output_data = output.respond_to?(:data) ? output.data : output
|
103
|
+
|
104
|
+
merged_data['continue'] = false if output_data['continue'] == false
|
105
|
+
merged_data['suppressOutput'] = true if output_data['suppressOutput'] == true
|
106
|
+
merged_data['stopReason'] = [merged_data['stopReason'], output_data['stopReason']].compact.reject(&:empty?).join('; ')
|
107
|
+
merged_data['systemMessage'] = [merged_data['systemMessage'], output_data['systemMessage']].compact.reject(&:empty?).join('; ')
|
108
|
+
end
|
109
|
+
|
110
|
+
self.new(merged_data)
|
111
|
+
end
|
112
|
+
|
113
|
+
# === FACTORY ===
|
114
|
+
|
115
|
+
# Factory method to create the correct output class for a given hook type
|
116
|
+
def self.for_hook_type(hook_type, data)
|
117
|
+
case hook_type
|
118
|
+
when 'UserPromptSubmit'
|
119
|
+
UserPromptSubmit.new(data)
|
120
|
+
when 'PreToolUse'
|
121
|
+
PreToolUse.new(data)
|
122
|
+
when 'PostToolUse'
|
123
|
+
PostToolUse.new(data)
|
124
|
+
when 'Stop'
|
125
|
+
Stop.new(data)
|
126
|
+
when 'SubagentStop'
|
127
|
+
SubagentStop.new(data)
|
128
|
+
when 'Notification'
|
129
|
+
Notification.new(data)
|
130
|
+
when 'SessionStart'
|
131
|
+
SessionStart.new(data)
|
132
|
+
when 'SessionEnd'
|
133
|
+
SessionEnd.new(data)
|
134
|
+
when 'PreCompact'
|
135
|
+
PreCompact.new(data)
|
136
|
+
else
|
137
|
+
raise ArgumentError, "Unknown hook type: #{hook_type}"
|
138
|
+
end
|
139
|
+
end
|
140
|
+
|
141
|
+
protected
|
142
|
+
|
143
|
+
def default_exit_code
|
144
|
+
continue? ? 0 : 2
|
145
|
+
end
|
146
|
+
|
147
|
+
def default_output_stream
|
148
|
+
exit_code == 2 ? :stderr : :stdout
|
149
|
+
end
|
150
|
+
end
|
151
|
+
end
|
152
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'base'
|
4
|
+
|
5
|
+
module ClaudeHooks
|
6
|
+
module Output
|
7
|
+
class Notification < Base
|
8
|
+
# === EXIT CODE LOGIC ===
|
9
|
+
|
10
|
+
def exit_code
|
11
|
+
default_exit_code
|
12
|
+
end
|
13
|
+
|
14
|
+
# === MERGE HELPER ===
|
15
|
+
|
16
|
+
def self.merge(*outputs)
|
17
|
+
merged = super(*outputs)
|
18
|
+
new(merged.data)
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,68 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'base'
|
4
|
+
|
5
|
+
module ClaudeHooks
|
6
|
+
module Output
|
7
|
+
class PostToolUse < Base
|
8
|
+
# === DECISION ACCESSORS ===
|
9
|
+
|
10
|
+
def decision
|
11
|
+
@data['decision']
|
12
|
+
end
|
13
|
+
|
14
|
+
def reason
|
15
|
+
@data['reason'] || ''
|
16
|
+
end
|
17
|
+
|
18
|
+
def additional_context
|
19
|
+
hook_specific_output['additionalContext'] || ''
|
20
|
+
end
|
21
|
+
|
22
|
+
# === SEMANTIC HELPERS ===
|
23
|
+
|
24
|
+
def blocked?
|
25
|
+
decision == 'block'
|
26
|
+
end
|
27
|
+
|
28
|
+
# === EXIT CODE LOGIC ===
|
29
|
+
|
30
|
+
def exit_code
|
31
|
+
return 2 unless continue?
|
32
|
+
return 2 if blocked?
|
33
|
+
|
34
|
+
0
|
35
|
+
end
|
36
|
+
|
37
|
+
# === MERGE HELPER ===
|
38
|
+
|
39
|
+
def self.merge(*outputs)
|
40
|
+
compacted_outputs = outputs.compact
|
41
|
+
return compacted_outputs.first if compacted_outputs.length == 1
|
42
|
+
return super(*outputs) if compacted_outputs.empty?
|
43
|
+
|
44
|
+
merged = super(*outputs)
|
45
|
+
merged_data = merged.data
|
46
|
+
contexts = []
|
47
|
+
|
48
|
+
compacted_outputs.each do |output|
|
49
|
+
output_data = output.respond_to?(:data) ? output.data : output
|
50
|
+
merged_data['decision'] = 'block' if output_data['decision'] == 'block'
|
51
|
+
merged_data['reason'] = [merged_data['reason'], output_data['reason']].compact.reject(&:empty?).join('; ')
|
52
|
+
|
53
|
+
context = output_data.dig('hookSpecificOutput', 'additionalContext')
|
54
|
+
contexts << context if context && !context.empty?
|
55
|
+
end
|
56
|
+
|
57
|
+
unless contexts.empty?
|
58
|
+
merged_data['hookSpecificOutput'] = {
|
59
|
+
'hookEventName' => 'PostToolUse',
|
60
|
+
'additionalContext' => contexts.join("\n\n")
|
61
|
+
}
|
62
|
+
end
|
63
|
+
|
64
|
+
new(merged_data)
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'base'
|
4
|
+
|
5
|
+
module ClaudeHooks
|
6
|
+
module Output
|
7
|
+
class PreCompact < Base
|
8
|
+
def exit_code
|
9
|
+
default_exit_code
|
10
|
+
end
|
11
|
+
|
12
|
+
# === MERGE HELPER ===
|
13
|
+
|
14
|
+
def self.merge(*outputs)
|
15
|
+
merged = super(*outputs)
|
16
|
+
new(merged.data)
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
@@ -0,0 +1,97 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'base'
|
4
|
+
|
5
|
+
module ClaudeHooks
|
6
|
+
module Output
|
7
|
+
class PreToolUse < 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
|
+
# === SEMANTIC HELPERS ===
|
19
|
+
|
20
|
+
def allowed?
|
21
|
+
permission_decision == 'allow'
|
22
|
+
end
|
23
|
+
|
24
|
+
def denied?
|
25
|
+
permission_decision == 'deny'
|
26
|
+
end
|
27
|
+
alias_method :blocked?, :denied?
|
28
|
+
|
29
|
+
def should_ask_permission?
|
30
|
+
permission_decision == 'ask'
|
31
|
+
end
|
32
|
+
|
33
|
+
# === EXIT CODE LOGIC ===
|
34
|
+
|
35
|
+
# Priority: continue false > permission decision
|
36
|
+
def exit_code
|
37
|
+
return 2 unless continue?
|
38
|
+
|
39
|
+
case permission_decision
|
40
|
+
when 'deny'
|
41
|
+
2
|
42
|
+
when 'ask'
|
43
|
+
1
|
44
|
+
else # allow
|
45
|
+
0
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
# STDOUT always works for PreToolUse
|
50
|
+
def output_stream
|
51
|
+
:stdout
|
52
|
+
end
|
53
|
+
|
54
|
+
# === MERGE HELPER ===
|
55
|
+
|
56
|
+
def self.merge(*outputs)
|
57
|
+
compacted_outputs = outputs.compact
|
58
|
+
return compacted_outputs.first if compacted_outputs.length == 1
|
59
|
+
return super(*outputs) if compacted_outputs.empty?
|
60
|
+
|
61
|
+
merged = super(*outputs)
|
62
|
+
merged_data = merged.data
|
63
|
+
|
64
|
+
# PreToolUse specific merge: deny > ask > allow (most restrictive wins)
|
65
|
+
permission_decision = 'allow'
|
66
|
+
permission_reasons = []
|
67
|
+
|
68
|
+
compacted_outputs.each do |output|
|
69
|
+
output_data = output.respond_to?(:data) ? output.data : output
|
70
|
+
|
71
|
+
if output_data.dig('hookSpecificOutput', 'permissionDecision')
|
72
|
+
current_decision = output_data['hookSpecificOutput']['permissionDecision']
|
73
|
+
case current_decision
|
74
|
+
when 'deny'
|
75
|
+
permission_decision = 'deny'
|
76
|
+
when 'ask'
|
77
|
+
permission_decision = 'ask' unless permission_decision == 'deny'
|
78
|
+
end
|
79
|
+
|
80
|
+
reason = output_data.dig('hookSpecificOutput', 'permissionDecisionReason')
|
81
|
+
permission_reasons << reason if reason && !reason.empty?
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
merged_data['hookSpecificOutput'] ||= { 'hookEventName' => 'PreToolUse' }
|
86
|
+
merged_data['hookSpecificOutput']['permissionDecision'] = permission_decision
|
87
|
+
if permission_reasons.any?
|
88
|
+
merged_data['hookSpecificOutput']['permissionDecisionReason'] = permission_reasons.join('; ')
|
89
|
+
else
|
90
|
+
merged_data['hookSpecificOutput']['permissionDecisionReason'] = ''
|
91
|
+
end
|
92
|
+
|
93
|
+
new(merged_data)
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|
97
|
+
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'base'
|
4
|
+
|
5
|
+
module ClaudeHooks
|
6
|
+
module Output
|
7
|
+
# Note: SessionEnd hooks cannot block session termination - they're for cleanup only
|
8
|
+
class SessionEnd < Base
|
9
|
+
# === EXIT CODE LOGIC ===
|
10
|
+
|
11
|
+
# SessionEnd hooks always return 0 - they're for cleanup only
|
12
|
+
def exit_code
|
13
|
+
0
|
14
|
+
end
|
15
|
+
|
16
|
+
# === MERGE HELPER ===
|
17
|
+
|
18
|
+
def self.merge(*outputs)
|
19
|
+
merged = super(*outputs)
|
20
|
+
new(merged.data)
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,49 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'base'
|
4
|
+
|
5
|
+
module ClaudeHooks
|
6
|
+
module Output
|
7
|
+
class SessionStart < Base
|
8
|
+
# === CONTEXT ACCESSORS ===
|
9
|
+
|
10
|
+
def additional_context
|
11
|
+
hook_specific_output['additionalContext'] || ''
|
12
|
+
end
|
13
|
+
|
14
|
+
# === EXIT CODE LOGIC ===
|
15
|
+
|
16
|
+
def exit_code
|
17
|
+
default_exit_code
|
18
|
+
end
|
19
|
+
|
20
|
+
# === MERGE HELPER ===
|
21
|
+
|
22
|
+
def self.merge(*outputs)
|
23
|
+
compacted_outputs = outputs.compact
|
24
|
+
return compacted_outputs.first if compacted_outputs.length == 1
|
25
|
+
return super(*outputs) if compacted_outputs.empty?
|
26
|
+
|
27
|
+
merged = super(*outputs)
|
28
|
+
merged_data = merged.data
|
29
|
+
contexts = []
|
30
|
+
|
31
|
+
compacted_outputs.each do |output|
|
32
|
+
output_data = output.respond_to?(:data) ? output.data : output
|
33
|
+
context = output_data.dig('hookSpecificOutput', 'additionalContext')
|
34
|
+
contexts << context if context && !context.empty?
|
35
|
+
end
|
36
|
+
|
37
|
+
# Set merged additional context
|
38
|
+
unless contexts.empty?
|
39
|
+
merged_data['hookSpecificOutput'] = {
|
40
|
+
'hookEventName' => 'SessionStart',
|
41
|
+
'additionalContext' => contexts.join("\n\n")
|
42
|
+
}
|
43
|
+
end
|
44
|
+
|
45
|
+
new(merged_data)
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
@@ -0,0 +1,76 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'base'
|
4
|
+
|
5
|
+
module ClaudeHooks
|
6
|
+
module Output
|
7
|
+
# Note: In Stop hooks, 'decision: block' actually means "force Claude to continue"
|
8
|
+
# This is counterintuitive but matches Claude Code's expected behavior
|
9
|
+
class Stop < Base
|
10
|
+
# === DECISION ACCESSORS ===
|
11
|
+
|
12
|
+
def decision
|
13
|
+
@data['decision']
|
14
|
+
end
|
15
|
+
|
16
|
+
def reason
|
17
|
+
@data['reason'] || ''
|
18
|
+
end
|
19
|
+
alias_method :continue_instructions, :reason
|
20
|
+
|
21
|
+
# === SEMANTIC HELPERS ===
|
22
|
+
|
23
|
+
# Check if Claude should be forced to continue (decision == 'block')
|
24
|
+
# Note: 'block' in Stop hooks means "block the stopping", i.e., continue
|
25
|
+
def should_continue?
|
26
|
+
decision == 'block'
|
27
|
+
end
|
28
|
+
|
29
|
+
# Check if Claude should stop normally (decision != 'block')
|
30
|
+
def should_stop?
|
31
|
+
decision != 'block'
|
32
|
+
end
|
33
|
+
|
34
|
+
# === EXIT CODE LOGIC ===
|
35
|
+
|
36
|
+
def exit_code
|
37
|
+
# For Stop hooks: we assume 'continue' means continue to stop
|
38
|
+
return 0 unless continue?
|
39
|
+
# For Stop hooks: decision 'block' means force continue (exit 2)
|
40
|
+
return 2 if should_continue?
|
41
|
+
|
42
|
+
0
|
43
|
+
end
|
44
|
+
|
45
|
+
# === MERGE HELPER ===
|
46
|
+
|
47
|
+
def self.merge(*outputs)
|
48
|
+
compacted_outputs = outputs.compact
|
49
|
+
return compacted_outputs.first if compacted_outputs.length == 1
|
50
|
+
return super(*outputs) if compacted_outputs.empty?
|
51
|
+
|
52
|
+
merged = super(*outputs)
|
53
|
+
merged_data = merged.data
|
54
|
+
|
55
|
+
# A blocking reason is actually a "continue instructions"
|
56
|
+
blocking_reasons = []
|
57
|
+
|
58
|
+
compacted_outputs.each do |output|
|
59
|
+
output_data = output.respond_to?(:data) ? output.data : output
|
60
|
+
|
61
|
+
# Handle decision - if any hook says 'block', respect that
|
62
|
+
if output_data['decision'] == 'block'
|
63
|
+
merged_data['decision'] = 'block'
|
64
|
+
reason = output_data['reason']
|
65
|
+
blocking_reasons << reason if reason && !reason.empty?
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
# Combine all blocking reasons / continue instructions
|
70
|
+
merged_data['reason'] = blocking_reasons.join('; ') unless blocking_reasons.empty?
|
71
|
+
|
72
|
+
new(merged_data)
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
@@ -0,0 +1,71 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'base'
|
4
|
+
|
5
|
+
module ClaudeHooks
|
6
|
+
module Output
|
7
|
+
class UserPromptSubmit < Base
|
8
|
+
# === DECISION ACCESSORS ===
|
9
|
+
|
10
|
+
def decision
|
11
|
+
@data['decision']
|
12
|
+
end
|
13
|
+
|
14
|
+
def reason
|
15
|
+
@data['reason'] || ''
|
16
|
+
end
|
17
|
+
|
18
|
+
def additional_context
|
19
|
+
hook_specific_output['additionalContext'] || ''
|
20
|
+
end
|
21
|
+
|
22
|
+
# === SEMANTIC HELPERS ===
|
23
|
+
|
24
|
+
def blocked?
|
25
|
+
decision == 'block'
|
26
|
+
end
|
27
|
+
|
28
|
+
# === EXIT CODE LOGIC ===
|
29
|
+
|
30
|
+
def exit_code
|
31
|
+
return 2 unless continue?
|
32
|
+
return 2 if blocked?
|
33
|
+
|
34
|
+
0
|
35
|
+
end
|
36
|
+
|
37
|
+
# === MERGE HELPER ===
|
38
|
+
|
39
|
+
def self.merge(*outputs)
|
40
|
+
compacted_outputs = outputs.compact
|
41
|
+
return compacted_outputs.first if compacted_outputs.length == 1
|
42
|
+
return super(*outputs) if compacted_outputs.empty?
|
43
|
+
|
44
|
+
merged = super(*outputs)
|
45
|
+
merged_data = merged.data
|
46
|
+
|
47
|
+
contexts = []
|
48
|
+
reasons = []
|
49
|
+
|
50
|
+
compacted_outputs.each do |output|
|
51
|
+
output_data = output.respond_to?(:data) ? output.data : output
|
52
|
+
|
53
|
+
merged_data['decision'] = 'block' if output_data['decision'] == 'block'
|
54
|
+
merged_data['reason'] = [merged_data['reason'], output_data['reason']].compact.reject(&:empty?).join('; ')
|
55
|
+
|
56
|
+
context = output_data.dig('hookSpecificOutput', 'additionalContext')
|
57
|
+
contexts << context if context && !context.empty?
|
58
|
+
end
|
59
|
+
|
60
|
+
unless contexts.empty?
|
61
|
+
merged_data['hookSpecificOutput'] = {
|
62
|
+
'hookEventName' => 'UserPromptSubmit',
|
63
|
+
'additionalContext' => contexts.join("\n\n")
|
64
|
+
}
|
65
|
+
end
|
66
|
+
|
67
|
+
new(merged_data)
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
@@ -38,18 +38,12 @@ module ClaudeHooks
|
|
38
38
|
@output_data['reason'] = nil
|
39
39
|
end
|
40
40
|
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
outputs_data.compact.each do |output|
|
48
|
-
merged['decision'] = 'block' if output['decision'] == 'block'
|
49
|
-
merged['reason'] = [merged['reason'], output['reason']].compact.reject(&:empty?).join('; ')
|
50
|
-
end
|
51
|
-
|
52
|
-
merged
|
41
|
+
def add_additional_context!(context)
|
42
|
+
@output_data['hookSpecificOutput'] = {
|
43
|
+
'hookEventName' => hook_event_name,
|
44
|
+
'additionalContext' => context
|
45
|
+
}
|
53
46
|
end
|
47
|
+
alias_method :add_context!, :add_additional_context!
|
54
48
|
end
|
55
49
|
end
|
@@ -47,42 +47,5 @@ module ClaudeHooks
|
|
47
47
|
'permissionDecisionReason' => reason
|
48
48
|
}
|
49
49
|
end
|
50
|
-
|
51
|
-
# === MERGE HELPER ===
|
52
|
-
|
53
|
-
# Merge multiple PreToolUse hook results intelligently
|
54
|
-
def self.merge_outputs(*outputs_data)
|
55
|
-
merged = super(*outputs_data)
|
56
|
-
|
57
|
-
# For PreToolUse: deny > ask > allow (most restrictive wins)
|
58
|
-
permission_decision = 'allow'
|
59
|
-
permission_reasons = []
|
60
|
-
|
61
|
-
outputs_data.compact.each do |output|
|
62
|
-
if output.dig('hookSpecificOutput', 'permissionDecision')
|
63
|
-
current_decision = output['hookSpecificOutput']['permissionDecision']
|
64
|
-
case current_decision
|
65
|
-
when 'deny'
|
66
|
-
permission_decision = 'deny'
|
67
|
-
when 'ask'
|
68
|
-
permission_decision = 'ask' unless permission_decision == 'deny'
|
69
|
-
end
|
70
|
-
|
71
|
-
if output['hookSpecificOutput']['permissionDecisionReason']
|
72
|
-
permission_reasons << output['hookSpecificOutput']['permissionDecisionReason']
|
73
|
-
end
|
74
|
-
end
|
75
|
-
end
|
76
|
-
|
77
|
-
unless permission_reasons.empty?
|
78
|
-
merged['hookSpecificOutput'] = {
|
79
|
-
'hookEventName' => hook_type,
|
80
|
-
'permissionDecision' => permission_decision,
|
81
|
-
'permissionDecisionReason' => permission_reasons.join('; ')
|
82
|
-
}
|
83
|
-
end
|
84
|
-
|
85
|
-
merged
|
86
|
-
end
|
87
50
|
end
|
88
51
|
end
|