claude_hooks 0.2.1 → 1.0.2
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 +137 -0
- data/README.md +287 -356
- data/claude_hooks.gemspec +2 -2
- data/docs/1.0.0_MIGRATION_GUIDE.md +228 -0
- 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/{WHY.md → docs/WHY.md} +15 -8
- data/docs/external/claude-hooks-reference.md +34 -0
- data/example_dotclaude/hooks/entrypoints/pre_tool_use.rb +25 -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/pre_tool_use/github_guard.rb +253 -0
- 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 +3 -2
- data/example_dotclaude/hooks/handlers/user_prompt_submit/log_user_prompt.rb +2 -2
- data/example_dotclaude/plugins/README.md +175 -0
- data/example_dotclaude/settings.json +22 -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 +76 -0
- data/lib/claude_hooks/output/pre_compact.rb +20 -0
- data/lib/claude_hooks/output/pre_tool_use.rb +94 -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 +83 -0
- data/lib/claude_hooks/output/subagent_stop.rb +14 -0
- data/lib/claude_hooks/output/user_prompt_submit.rb +78 -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 +8 -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 +37 -8
|
@@ -0,0 +1,76 @@
|
|
|
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
|
+
# 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
|
|
34
|
+
def exit_code
|
|
35
|
+
0
|
|
36
|
+
end
|
|
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
|
+
|
|
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
|
+
contexts = []
|
|
55
|
+
|
|
56
|
+
compacted_outputs.each do |output|
|
|
57
|
+
output_data = output.respond_to?(:data) ? output.data : output
|
|
58
|
+
merged_data['decision'] = 'block' if output_data['decision'] == 'block'
|
|
59
|
+
merged_data['reason'] = [merged_data['reason'], output_data['reason']].compact.reject(&:empty?).join('; ')
|
|
60
|
+
|
|
61
|
+
context = output_data.dig('hookSpecificOutput', 'additionalContext')
|
|
62
|
+
contexts << context if context && !context.empty?
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
unless contexts.empty?
|
|
66
|
+
merged_data['hookSpecificOutput'] = {
|
|
67
|
+
'hookEventName' => 'PostToolUse',
|
|
68
|
+
'additionalContext' => contexts.join("\n\n")
|
|
69
|
+
}
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
new(merged_data)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
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,94 @@
|
|
|
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 blocked? denied?
|
|
28
|
+
|
|
29
|
+
def should_ask_permission?
|
|
30
|
+
permission_decision == 'ask'
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# === EXIT CODE LOGIC ===
|
|
34
|
+
#
|
|
35
|
+
# PreToolUse hooks use the advanced JSON API with exit code 0.
|
|
36
|
+
# Per Anthropic guidance: when using structured JSON with permissionDecision,
|
|
37
|
+
# always output to stdout with exit 0 (not stderr with exit 2).
|
|
38
|
+
# The permissionDecision field ('allow', 'deny', 'ask') controls behavior.
|
|
39
|
+
# Reference: https://github.com/anthropics/claude-code/issues/10875
|
|
40
|
+
def exit_code
|
|
41
|
+
0
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# === OUTPUT STREAM LOGIC ===
|
|
45
|
+
#
|
|
46
|
+
# PreToolUse 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
|
+
# PreToolUse specific merge: deny > ask > allow (most restrictive wins)
|
|
62
|
+
permission_decision = 'allow'
|
|
63
|
+
permission_reasons = []
|
|
64
|
+
|
|
65
|
+
compacted_outputs.each do |output|
|
|
66
|
+
output_data = output.respond_to?(:data) ? output.data : output
|
|
67
|
+
|
|
68
|
+
next unless output_data.dig('hookSpecificOutput', 'permissionDecision')
|
|
69
|
+
|
|
70
|
+
current_decision = output_data['hookSpecificOutput']['permissionDecision']
|
|
71
|
+
case current_decision
|
|
72
|
+
when 'deny'
|
|
73
|
+
permission_decision = 'deny'
|
|
74
|
+
when 'ask'
|
|
75
|
+
permission_decision = 'ask' unless permission_decision == 'deny'
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
reason = output_data.dig('hookSpecificOutput', 'permissionDecisionReason')
|
|
79
|
+
permission_reasons << reason if reason && !reason.empty?
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
merged_data['hookSpecificOutput'] ||= { 'hookEventName' => 'PreToolUse' }
|
|
83
|
+
merged_data['hookSpecificOutput']['permissionDecision'] = permission_decision
|
|
84
|
+
merged_data['hookSpecificOutput']['permissionDecisionReason'] = if permission_reasons.any?
|
|
85
|
+
permission_reasons.join('; ')
|
|
86
|
+
else
|
|
87
|
+
''
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
new(merged_data)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
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,83 @@
|
|
|
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
|
+
# Stop hooks use the advanced JSON API with exit code 0.
|
|
37
|
+
# Per Anthropic guidance: when using structured JSON with decision/reason fields,
|
|
38
|
+
# always output to stdout with exit 0 (not stderr with exit 2).
|
|
39
|
+
# Reference: https://github.com/anthropics/claude-code/issues/10875
|
|
40
|
+
def exit_code
|
|
41
|
+
0
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# === OUTPUT STREAM LOGIC ===
|
|
45
|
+
#
|
|
46
|
+
# Stop hooks always output to stdout when using the JSON API.
|
|
47
|
+
# This follows the same pattern as PreToolUse hooks.
|
|
48
|
+
def output_stream
|
|
49
|
+
:stdout
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# === MERGE HELPER ===
|
|
53
|
+
|
|
54
|
+
def self.merge(*outputs)
|
|
55
|
+
compacted_outputs = outputs.compact
|
|
56
|
+
return compacted_outputs.first if compacted_outputs.length == 1
|
|
57
|
+
return super(*outputs) if compacted_outputs.empty?
|
|
58
|
+
|
|
59
|
+
merged = super(*outputs)
|
|
60
|
+
merged_data = merged.data
|
|
61
|
+
|
|
62
|
+
# A blocking reason is actually a "continue instructions"
|
|
63
|
+
blocking_reasons = []
|
|
64
|
+
|
|
65
|
+
compacted_outputs.each do |output|
|
|
66
|
+
output_data = output.respond_to?(:data) ? output.data : output
|
|
67
|
+
|
|
68
|
+
# Handle decision - if any hook says 'block', respect that
|
|
69
|
+
if output_data['decision'] == 'block'
|
|
70
|
+
merged_data['decision'] = 'block'
|
|
71
|
+
reason = output_data['reason']
|
|
72
|
+
blocking_reasons << reason if reason && !reason.empty?
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Combine all blocking reasons / continue instructions
|
|
77
|
+
merged_data['reason'] = blocking_reasons.join('; ') unless blocking_reasons.empty?
|
|
78
|
+
|
|
79
|
+
new(merged_data)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
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
|
+
# UserPromptSubmit 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
|
|
34
|
+
def exit_code
|
|
35
|
+
0
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# === OUTPUT STREAM LOGIC ===
|
|
39
|
+
#
|
|
40
|
+
# UserPromptSubmit hooks always output to stdout when using the JSON API.
|
|
41
|
+
def output_stream
|
|
42
|
+
:stdout
|
|
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
|
+
contexts = []
|
|
56
|
+
|
|
57
|
+
compacted_outputs.each do |output|
|
|
58
|
+
output_data = output.respond_to?(:data) ? output.data : output
|
|
59
|
+
|
|
60
|
+
merged_data['decision'] = 'block' if output_data['decision'] == 'block'
|
|
61
|
+
merged_data['reason'] = [merged_data['reason'], output_data['reason']].compact.reject(&:empty?).join('; ')
|
|
62
|
+
|
|
63
|
+
context = output_data.dig('hookSpecificOutput', 'additionalContext')
|
|
64
|
+
contexts << context if context && !context.empty?
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
unless contexts.empty?
|
|
68
|
+
merged_data['hookSpecificOutput'] = {
|
|
69
|
+
'hookEventName' => 'UserPromptSubmit',
|
|
70
|
+
'additionalContext' => contexts.join("\n\n")
|
|
71
|
+
}
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
new(merged_data)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
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
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base'
|
|
4
|
+
|
|
5
|
+
module ClaudeHooks
|
|
6
|
+
class SessionEnd < Base
|
|
7
|
+
def self.hook_type
|
|
8
|
+
'SessionEnd'
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def self.input_fields
|
|
12
|
+
%w[reason]
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# === INPUT DATA ACCESS ===
|
|
16
|
+
|
|
17
|
+
def reason
|
|
18
|
+
@input_data['reason']
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# === REASON HELPERS ===
|
|
22
|
+
|
|
23
|
+
# Check if session was cleared with /clear command
|
|
24
|
+
def cleared?
|
|
25
|
+
reason == 'clear'
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Check if user logged out
|
|
29
|
+
def logout?
|
|
30
|
+
reason == 'logout'
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Check if user exited while prompt input was visible
|
|
34
|
+
def prompt_input_exit?
|
|
35
|
+
reason == 'prompt_input_exit'
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Check if reason is unspecified or other
|
|
39
|
+
def other_reason?
|
|
40
|
+
!cleared? && !logout? && !prompt_input_exit?
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -31,28 +31,5 @@ module ClaudeHooks
|
|
|
31
31
|
def empty_additional_context!
|
|
32
32
|
@output_data['hookSpecificOutput'] = nil
|
|
33
33
|
end
|
|
34
|
-
|
|
35
|
-
# === MERGE HELPER ===
|
|
36
|
-
|
|
37
|
-
# Merge multiple SessionStart hook results intelligently
|
|
38
|
-
def self.merge_outputs(*outputs_data)
|
|
39
|
-
merged = super(*outputs_data)
|
|
40
|
-
contexts = []
|
|
41
|
-
|
|
42
|
-
outputs_data.compact.each do |output|
|
|
43
|
-
if output.dig('hookSpecificOutput', 'additionalContext')
|
|
44
|
-
contexts << output['hookSpecificOutput']['additionalContext']
|
|
45
|
-
end
|
|
46
|
-
end
|
|
47
|
-
|
|
48
|
-
unless contexts.empty?
|
|
49
|
-
merged['hookSpecificOutput'] = {
|
|
50
|
-
'hookEventName' => hook_type,
|
|
51
|
-
'additionalContext' => contexts.join("\n\n")
|
|
52
|
-
}
|
|
53
|
-
end
|
|
54
|
-
|
|
55
|
-
merged
|
|
56
|
-
end
|
|
57
34
|
end
|
|
58
35
|
end
|
data/lib/claude_hooks/stop.rb
CHANGED
|
@@ -3,6 +3,14 @@
|
|
|
3
3
|
require_relative 'base'
|
|
4
4
|
|
|
5
5
|
module ClaudeHooks
|
|
6
|
+
# Stop hook for preventing Claude Code from stopping execution.
|
|
7
|
+
#
|
|
8
|
+
# When using continue_with_instructions!, this hook outputs JSON to stdout
|
|
9
|
+
# with exit code 0 (advanced JSON API approach).
|
|
10
|
+
#
|
|
11
|
+
# References:
|
|
12
|
+
# - https://github.com/anthropics/claude-code/issues/10875
|
|
13
|
+
# - https://github.com/gabriel-dehan/claude_hooks/issues/11
|
|
6
14
|
class Stop < Base
|
|
7
15
|
def self.hook_type
|
|
8
16
|
'Stop'
|
|
@@ -32,30 +40,5 @@ module ClaudeHooks
|
|
|
32
40
|
@output_data.delete('decision')
|
|
33
41
|
@output_data.delete('reason')
|
|
34
42
|
end
|
|
35
|
-
|
|
36
|
-
# === MERGE HELPER ===
|
|
37
|
-
|
|
38
|
-
# Merge multiple Stop hook results intelligently
|
|
39
|
-
def self.merge_outputs(*outputs_data)
|
|
40
|
-
merged = super(*outputs_data)
|
|
41
|
-
|
|
42
|
-
# A blocking reason is actually a "continue instructions"
|
|
43
|
-
blocking_reasons = []
|
|
44
|
-
|
|
45
|
-
outputs_data.compact.each do |output|
|
|
46
|
-
# Handle decision - if any hook says 'block', respect that
|
|
47
|
-
if output['decision'] == 'block'
|
|
48
|
-
merged['decision'] = 'block'
|
|
49
|
-
blocking_reasons << output['reason'] if output['reason'] && !output['reason'].empty?
|
|
50
|
-
end
|
|
51
|
-
end
|
|
52
|
-
|
|
53
|
-
# Combine all blocking reasons / continue instructions
|
|
54
|
-
unless blocking_reasons.empty?
|
|
55
|
-
merged['reason'] = blocking_reasons.join('; ')
|
|
56
|
-
end
|
|
57
|
-
|
|
58
|
-
merged
|
|
59
|
-
end
|
|
60
43
|
end
|
|
61
44
|
end
|
|
@@ -43,31 +43,5 @@ module ClaudeHooks
|
|
|
43
43
|
@output_data['decision'] = nil
|
|
44
44
|
@output_data['reason'] = nil
|
|
45
45
|
end
|
|
46
|
-
|
|
47
|
-
# === MERGE HELPER ===
|
|
48
|
-
|
|
49
|
-
# Merge multiple UserPromptSubmit hook results intelligently
|
|
50
|
-
def self.merge_outputs(*outputs_data)
|
|
51
|
-
merged = super(*outputs_data)
|
|
52
|
-
contexts = []
|
|
53
|
-
|
|
54
|
-
outputs_data.compact.each do |output|
|
|
55
|
-
merged['decision'] = 'block' if output['decision'] == 'block'
|
|
56
|
-
merged['reason'] = [merged['reason'], output['reason']].compact.reject(&:empty?).join('; ')
|
|
57
|
-
|
|
58
|
-
if output.dig('hookSpecificOutput', 'additionalContext')
|
|
59
|
-
contexts << output['hookSpecificOutput']['additionalContext']
|
|
60
|
-
end
|
|
61
|
-
end
|
|
62
|
-
|
|
63
|
-
unless contexts.empty?
|
|
64
|
-
merged['hookSpecificOutput'] = {
|
|
65
|
-
'hookEventName' => hook_type,
|
|
66
|
-
'additionalContext' => contexts.join("\n\n")
|
|
67
|
-
}
|
|
68
|
-
end
|
|
69
|
-
|
|
70
|
-
merged
|
|
71
|
-
end
|
|
72
46
|
end
|
|
73
47
|
end
|
data/lib/claude_hooks/version.rb
CHANGED