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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +92 -0
  3. data/README.md +215 -357
  4. data/claude_hooks.gemspec +2 -2
  5. data/docs/API/COMMON.md +83 -0
  6. data/docs/API/NOTIFICATION.md +32 -0
  7. data/docs/API/POST_TOOL_USE.md +45 -0
  8. data/docs/API/PRE_COMPACT.md +39 -0
  9. data/docs/API/PRE_TOOL_USE.md +110 -0
  10. data/docs/API/SESSION_END.md +100 -0
  11. data/docs/API/SESSION_START.md +40 -0
  12. data/docs/API/STOP.md +47 -0
  13. data/docs/API/SUBAGENT_STOP.md +47 -0
  14. data/docs/API/USER_PROMPT_SUBMIT.md +47 -0
  15. data/docs/OUTPUT_MIGRATION_GUIDE.md +228 -0
  16. data/example_dotclaude/hooks/entrypoints/session_end.rb +35 -0
  17. data/example_dotclaude/hooks/entrypoints/user_prompt_submit.rb +17 -12
  18. data/example_dotclaude/hooks/handlers/session_end/cleanup_handler.rb +55 -0
  19. data/example_dotclaude/hooks/handlers/session_end/log_session_stats.rb +64 -0
  20. data/example_dotclaude/hooks/handlers/user_prompt_submit/append_rules.rb +1 -1
  21. data/example_dotclaude/hooks/handlers/user_prompt_submit/log_user_prompt.rb +2 -2
  22. data/example_dotclaude/settings.json +11 -0
  23. data/lib/claude_hooks/base.rb +16 -24
  24. data/lib/claude_hooks/cli.rb +75 -1
  25. data/lib/claude_hooks/logger.rb +0 -1
  26. data/lib/claude_hooks/output/base.rb +152 -0
  27. data/lib/claude_hooks/output/notification.rb +22 -0
  28. data/lib/claude_hooks/output/post_tool_use.rb +68 -0
  29. data/lib/claude_hooks/output/pre_compact.rb +20 -0
  30. data/lib/claude_hooks/output/pre_tool_use.rb +97 -0
  31. data/lib/claude_hooks/output/session_end.rb +24 -0
  32. data/lib/claude_hooks/output/session_start.rb +49 -0
  33. data/lib/claude_hooks/output/stop.rb +76 -0
  34. data/lib/claude_hooks/output/subagent_stop.rb +14 -0
  35. data/lib/claude_hooks/output/user_prompt_submit.rb +71 -0
  36. data/lib/claude_hooks/post_tool_use.rb +6 -12
  37. data/lib/claude_hooks/pre_tool_use.rb +0 -37
  38. data/lib/claude_hooks/session_end.rb +43 -0
  39. data/lib/claude_hooks/session_start.rb +0 -23
  40. data/lib/claude_hooks/stop.rb +0 -25
  41. data/lib/claude_hooks/user_prompt_submit.rb +0 -26
  42. data/lib/claude_hooks/version.rb +1 -1
  43. data/lib/claude_hooks.rb +15 -0
  44. metadata +33 -8
  45. /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,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'stop'
4
+
5
+ module ClaudeHooks
6
+ module Output
7
+ class SubagentStop < Stop
8
+ def self.merge(*outputs)
9
+ merged = super(*outputs)
10
+ new(merged.data)
11
+ end
12
+ end
13
+ end
14
+ 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
- # === MERGE HELPER ===
42
-
43
- # Merge multiple PostToolUse hook results intelligently
44
- def self.merge_outputs(*outputs_data)
45
- merged = super(*outputs_data)
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