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.
Files changed (49) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +137 -0
  3. data/README.md +287 -356
  4. data/claude_hooks.gemspec +2 -2
  5. data/docs/1.0.0_MIGRATION_GUIDE.md +228 -0
  6. data/docs/API/COMMON.md +83 -0
  7. data/docs/API/NOTIFICATION.md +32 -0
  8. data/docs/API/POST_TOOL_USE.md +45 -0
  9. data/docs/API/PRE_COMPACT.md +39 -0
  10. data/docs/API/PRE_TOOL_USE.md +110 -0
  11. data/docs/API/SESSION_END.md +100 -0
  12. data/docs/API/SESSION_START.md +40 -0
  13. data/docs/API/STOP.md +47 -0
  14. data/docs/API/SUBAGENT_STOP.md +47 -0
  15. data/docs/API/USER_PROMPT_SUBMIT.md +47 -0
  16. data/{WHY.md → docs/WHY.md} +15 -8
  17. data/docs/external/claude-hooks-reference.md +34 -0
  18. data/example_dotclaude/hooks/entrypoints/pre_tool_use.rb +25 -0
  19. data/example_dotclaude/hooks/entrypoints/session_end.rb +35 -0
  20. data/example_dotclaude/hooks/entrypoints/user_prompt_submit.rb +17 -12
  21. data/example_dotclaude/hooks/handlers/pre_tool_use/github_guard.rb +253 -0
  22. data/example_dotclaude/hooks/handlers/session_end/cleanup_handler.rb +55 -0
  23. data/example_dotclaude/hooks/handlers/session_end/log_session_stats.rb +64 -0
  24. data/example_dotclaude/hooks/handlers/user_prompt_submit/append_rules.rb +3 -2
  25. data/example_dotclaude/hooks/handlers/user_prompt_submit/log_user_prompt.rb +2 -2
  26. data/example_dotclaude/plugins/README.md +175 -0
  27. data/example_dotclaude/settings.json +22 -0
  28. data/lib/claude_hooks/base.rb +16 -24
  29. data/lib/claude_hooks/cli.rb +75 -1
  30. data/lib/claude_hooks/logger.rb +0 -1
  31. data/lib/claude_hooks/output/base.rb +152 -0
  32. data/lib/claude_hooks/output/notification.rb +22 -0
  33. data/lib/claude_hooks/output/post_tool_use.rb +76 -0
  34. data/lib/claude_hooks/output/pre_compact.rb +20 -0
  35. data/lib/claude_hooks/output/pre_tool_use.rb +94 -0
  36. data/lib/claude_hooks/output/session_end.rb +24 -0
  37. data/lib/claude_hooks/output/session_start.rb +49 -0
  38. data/lib/claude_hooks/output/stop.rb +83 -0
  39. data/lib/claude_hooks/output/subagent_stop.rb +14 -0
  40. data/lib/claude_hooks/output/user_prompt_submit.rb +78 -0
  41. data/lib/claude_hooks/post_tool_use.rb +6 -12
  42. data/lib/claude_hooks/pre_tool_use.rb +0 -37
  43. data/lib/claude_hooks/session_end.rb +43 -0
  44. data/lib/claude_hooks/session_start.rb +0 -23
  45. data/lib/claude_hooks/stop.rb +8 -25
  46. data/lib/claude_hooks/user_prompt_submit.rb +0 -26
  47. data/lib/claude_hooks/version.rb +1 -1
  48. data/lib/claude_hooks.rb +15 -0
  49. metadata +37 -8
@@ -0,0 +1,64 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'claude_hooks'
4
+
5
+ class LogSessionStats < ClaudeHooks::SessionEnd
6
+ def call
7
+ log "Logging session statistics for session #{session_id}"
8
+
9
+ # Generate session statistics
10
+ stats = gather_session_stats
11
+
12
+ # Log detailed statistics
13
+ log <<~STATS
14
+ === Session Statistics ===
15
+ Session ID: #{session_id}
16
+ End Reason: #{reason}
17
+ Duration: #{stats[:duration]}
18
+ Working Directory: #{cwd}
19
+ Transcript Path: #{transcript_path}
20
+ ===========================
21
+ STATS
22
+
23
+ # Log session summary
24
+ log format_session_summary(stats)
25
+
26
+ output
27
+ end
28
+
29
+ private
30
+
31
+ def gather_session_stats
32
+ {
33
+ duration: calculate_session_duration,
34
+ end_time: Time.now,
35
+ transcript_size: get_transcript_size
36
+ }
37
+ end
38
+
39
+ def calculate_session_duration
40
+ # Example: could read session start time from transcript or file
41
+ "Unknown (would need session start time)"
42
+ end
43
+
44
+ def get_transcript_size
45
+ return 0 unless transcript_path && File.exist?(transcript_path)
46
+
47
+ File.size(transcript_path)
48
+ rescue StandardError => e
49
+ log "Error getting transcript size: #{e.message}", level: :warn
50
+ 0
51
+ end
52
+
53
+ def format_session_summary(stats)
54
+ "Session #{session_id} ended (#{reason}): #{stats[:transcript_size]} bytes transcript"
55
+ end
56
+ end
57
+
58
+ # CLI testing support
59
+ if __FILE__ == $0
60
+ ClaudeHooks::CLI.test_runner(LogSessionStats) do |input_data|
61
+ input_data['reason'] ||= 'other'
62
+ input_data['transcript_path'] ||= '/tmp/test_transcript'
63
+ end
64
+ end
@@ -2,7 +2,8 @@
2
2
 
3
3
  require 'claude_hooks'
4
4
 
5
- # Hook script that appends rules to user prompt
5
+ # Hook script that appends rules to user prompt as additional context.
6
+ # A great way to make sure specific context is added with each user prompt.
6
7
  class AppendRules < ClaudeHooks::UserPromptSubmit
7
8
 
8
9
  def call
@@ -18,7 +19,7 @@ class AppendRules < ClaudeHooks::UserPromptSubmit
18
19
  log "No rule content found", level: :warn
19
20
  end
20
21
 
21
- output_data
22
+ output
22
23
  end
23
24
 
24
25
  private
@@ -12,10 +12,10 @@ class LogUserPrompt < ClaudeHooks::UserPromptSubmit
12
12
 
13
13
  log <<~TEXT
14
14
  Prompt: #{current_prompt}
15
- Logged user prompt to #{log_file_path}
15
+ Logged user prompt (session: #{session_id})
16
16
  TEXT
17
17
 
18
- nil
18
+ nil # ignored output
19
19
  end
20
20
  end
21
21
 
@@ -0,0 +1,175 @@
1
+ # Plugin Hooks Examples
2
+
3
+ This directory demonstrates how to use the Claude Hooks Ruby DSL when creating plugin hooks for [Claude Code plugins](https://docs.claude.com/en/docs/claude-code/plugins).
4
+
5
+ ## Overview
6
+
7
+ The Claude Hooks DSL works seamlessly with plugin development! When creating plugins, you can use the exact same Ruby DSL as you would for regular hooks. The only difference is that plugin hooks are referenced through `${CLAUDE_PLUGIN_ROOT}` in the plugin's `hooks/hooks.json` configuration file.
8
+
9
+ ## Benefits of Using This DSL in Plugins
10
+
11
+ - ✨ **Same powerful abstractions** - All the helper methods, logging, and state management work identically
12
+ - 🔄 **Automatic output handling** - `output_and_exit` manages streams and exit codes correctly
13
+ - 📝 **Built-in logging** - Session-specific logs work out of the box
14
+ - 🛠️ **Configuration access** - Access both plugin and project configurations
15
+ - 🎯 **Type safety** - Strong typed hook classes for each event type
16
+
17
+ ## Example Plugin Structure
18
+
19
+ ```
20
+ my-formatter-plugin/
21
+ ├── .claude-plugin/
22
+ │ └── plugin.json # Plugin metadata
23
+ ├── hooks/
24
+ │ ├── hooks.json # Hook configuration (references scripts)
25
+ │ └── scripts/
26
+ │ └── formatter.rb # Hook script using ClaudeHooks DSL
27
+ └── README.md
28
+ ```
29
+
30
+ ## Example: Code Formatter Plugin
31
+
32
+ ### Plugin Configuration (`hooks/hooks.json`)
33
+
34
+ ```json
35
+ {
36
+ "description": "Automatic code formatting on file writes",
37
+ "hooks": {
38
+ "PostToolUse": [
39
+ {
40
+ "matcher": "Write|Edit",
41
+ "hooks": [
42
+ {
43
+ "type": "command",
44
+ "command": "${CLAUDE_PLUGIN_ROOT}/hooks/scripts/formatter.rb",
45
+ "timeout": 30
46
+ }
47
+ ]
48
+ }
49
+ ]
50
+ }
51
+ }
52
+ ```
53
+
54
+ ### Hook Script (`hooks/scripts/formatter.rb`)
55
+
56
+ ```ruby
57
+ #!/usr/bin/env ruby
58
+ require 'claude_hooks'
59
+
60
+ class PluginFormatter < ClaudeHooks::PostToolUse
61
+ def call
62
+ log "Plugin formatter executing from: #{ENV['CLAUDE_PLUGIN_ROOT']}"
63
+
64
+ # Only format code files
65
+ file_path = tool_input['file_path']
66
+ return output unless should_format?(file_path)
67
+
68
+ log "Formatting file: #{file_path}"
69
+
70
+ # Perform formatting (example)
71
+ if format_file(file_path)
72
+ add_additional_context!("File formatted successfully: #{file_path}")
73
+ else
74
+ log "Failed to format #{file_path}", level: :warn
75
+ end
76
+
77
+ output
78
+ end
79
+
80
+ private
81
+
82
+ def should_format?(file_path)
83
+ # Example: only format Ruby files
84
+ file_path.end_with?('.rb')
85
+ end
86
+
87
+ def format_file(file_path)
88
+ # Your formatting logic here
89
+ # For example, using rubocop or prettier
90
+ return true # Simulated success
91
+ end
92
+ end
93
+
94
+ if __FILE__ == $0
95
+ input_data = JSON.parse(STDIN.read)
96
+ hook = PluginFormatter.new(input_data)
97
+ hook.call
98
+ hook.output_and_exit
99
+ end
100
+ ```
101
+
102
+ ## Environment Variables in Plugins
103
+
104
+ When your hook scripts run as part of a plugin, these environment variables are available:
105
+
106
+ | Variable | Description |
107
+ |----------|-------------|
108
+ | `CLAUDE_PLUGIN_ROOT` | Absolute path to the plugin directory |
109
+ | `CLAUDE_PROJECT_DIR` | Project root directory (where Claude Code was started) |
110
+ | `RUBY_CLAUDE_HOOKS_*` | All standard configuration environment variables |
111
+
112
+ ### Accessing Plugin Root in Your Hooks
113
+
114
+ ```ruby
115
+ class MyPluginHook < ClaudeHooks::PreToolUse
116
+ def call
117
+ # Access plugin root
118
+ plugin_root = ENV['CLAUDE_PLUGIN_ROOT']
119
+ log "Plugin root: #{plugin_root}"
120
+
121
+ # Load plugin-specific config files
122
+ plugin_config_path = File.join(plugin_root, 'config', 'settings.json')
123
+
124
+ # Your hook logic here
125
+ output
126
+ end
127
+ end
128
+ ```
129
+
130
+ ## Best Practices for Plugin Hooks
131
+
132
+ 1. **Keep hooks focused** - Each hook should have a single, well-defined purpose
133
+ 2. **Use logging extensively** - Helps users debug plugin behavior
134
+ 3. **Handle errors gracefully** - Don't crash on unexpected input
135
+ 4. **Document environment requirements** - If your plugin needs external tools (rubocop, prettier, etc.)
136
+ 5. **Test thoroughly** - Test with various file types and scenarios
137
+
138
+ ## Testing Plugin Hooks Locally
139
+
140
+ You can test your plugin hooks before publishing:
141
+
142
+ ```bash
143
+ # Test the hook script directly
144
+ echo '{"session_id":"test","transcript_path":"/tmp/test","cwd":"/tmp","hook_event_name":"PostToolUse","tool_name":"Write","tool_input":{"file_path":"test.rb"},"tool_response":{"success":true}}' | CLAUDE_PLUGIN_ROOT=/path/to/plugin ruby hooks/scripts/formatter.rb
145
+ ```
146
+
147
+ Or use the CLI test runner:
148
+
149
+ ```ruby
150
+ #!/usr/bin/env ruby
151
+ require 'claude_hooks'
152
+
153
+ class PluginFormatter < ClaudeHooks::PostToolUse
154
+ # ... your implementation ...
155
+ end
156
+
157
+ if __FILE__ == $0
158
+ ClaudeHooks::CLI.run_with_sample_data(PluginFormatter) do |input_data|
159
+ input_data['tool_name'] = 'Write'
160
+ input_data['tool_input'] = { 'file_path' => 'test.rb' }
161
+ input_data['tool_response'] = { 'success' => true }
162
+ end
163
+ end
164
+ ```
165
+
166
+ ## Resources
167
+
168
+ - [Official Plugin Documentation](https://docs.claude.com/en/docs/claude-code/plugins)
169
+ - [Plugin Components Reference](https://docs.claude.com/en/docs/claude-code/plugins-reference#hooks)
170
+ - [Hooks API Reference](../../docs/API/)
171
+ - [Claude Hooks Main README](../../README.md)
172
+
173
+ ## Contributing Examples
174
+
175
+ If you create a plugin using this DSL, consider contributing an example! Open a PR or issue on the [claude_hooks repository](https://github.com/gabriel-dehan/claude_hooks).
@@ -4,6 +4,17 @@
4
4
  "allow": []
5
5
  },
6
6
  "hooks": {
7
+ "PreToolUse": [
8
+ {
9
+ "matcher": "",
10
+ "hooks": [
11
+ {
12
+ "type": "command",
13
+ "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/entrypoints/pre_tool_use.rb"
14
+ }
15
+ ]
16
+ }
17
+ ],
7
18
  "UserPromptSubmit": [
8
19
  {
9
20
  "matcher": "",
@@ -14,6 +25,17 @@
14
25
  }
15
26
  ]
16
27
  }
28
+ ],
29
+ "SessionEnd": [
30
+ {
31
+ "matcher": "",
32
+ "hooks": [
33
+ {
34
+ "type": "command",
35
+ "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/entrypoints/session_end.rb"
36
+ }
37
+ ]
38
+ }
17
39
  ]
18
40
  },
19
41
  "env": {
@@ -3,6 +3,7 @@
3
3
  require 'json'
4
4
  require_relative 'configuration'
5
5
  require_relative 'logger'
6
+ require_relative 'output/base'
6
7
 
7
8
  module ClaudeHooks
8
9
  # Base class for Claude Code hook scripts
@@ -24,7 +25,7 @@ module ClaudeHooks
24
25
  self.class.hook_type
25
26
  end
26
27
 
27
- attr_reader :config, :input_data, :output_data, :logger
28
+ attr_reader :config, :input_data, :output_data, :output, :logger
28
29
  def initialize(input_data = {})
29
30
  @config = Configuration
30
31
  @input_data = input_data
@@ -33,6 +34,7 @@ module ClaudeHooks
33
34
  'stopReason' => '',
34
35
  'suppressOutput' => false
35
36
  }
37
+ @output = ClaudeHooks::Output::Base.for_hook_type(hook_type, @output_data)
36
38
  @logger = Logger.new(session_id, self.class.name)
37
39
 
38
40
  validate_input!
@@ -47,6 +49,10 @@ module ClaudeHooks
47
49
  JSON.generate(@output_data)
48
50
  end
49
51
 
52
+ def output_and_exit
53
+ @output.output_and_exit
54
+ end
55
+
50
56
  # === COMMON INPUT DATA ACCESS ===
51
57
 
52
58
  def session_id
@@ -105,6 +111,15 @@ module ClaudeHooks
105
111
  @output_data['hookSpecificOutput'] = nil
106
112
  end
107
113
 
114
+ # System message shown to the user (not to Claude)
115
+ def system_message!(message)
116
+ @output_data['systemMessage'] = message
117
+ end
118
+
119
+ def clear_system_message!
120
+ @output_data.delete('systemMessage')
121
+ end
122
+
108
123
  # === CONFIG AND UTILITY METHODS ===
109
124
 
110
125
  def base_dir
@@ -136,29 +151,6 @@ module ClaudeHooks
136
151
  @logger.log(message, level: level, &block)
137
152
  end
138
153
 
139
- protected
140
-
141
- # === MERGE HELPER BASE ===
142
-
143
- # Handles common merging logic for all hook types
144
- # Subclasses should call super and add their specific logic
145
- def self.merge_outputs(*outputs_data)
146
- compacted_outputs_data = outputs_data.compact
147
- return { 'continue' => true, 'stopReason' => '', 'suppressOutput' => false } if compacted_outputs_data.empty?
148
-
149
- # Initialize merged result with defaults
150
- merged = { 'continue' => true, 'stopReason' => '', 'suppressOutput' => false }
151
-
152
- # Apply common merge logic
153
- compacted_outputs_data.each do |output|
154
- merged['continue'] = false if output['continue'] == false
155
- merged['stopReason'] = [merged['stopReason'], output['stopReason']].compact.reject(&:empty?).join('; ')
156
- merged['suppressOutput'] = true if output['suppressOutput'] == true
157
- end
158
-
159
- merged
160
- end
161
-
162
154
  private
163
155
 
164
156
  def validate_input!
@@ -89,6 +89,78 @@ module ClaudeHooks
89
89
  run_hook(hook_class, merged_data)
90
90
  end
91
91
 
92
+ # Simplified entrypoint helper for hook scripts
93
+ # This handles all the STDIN reading, JSON parsing, error handling, and output execution
94
+ #
95
+ # Usage patterns:
96
+ #
97
+ # 1. Block form - custom logic:
98
+ # ClaudeHooks::CLI.entrypoint do |input_data|
99
+ # hook = MyHook.new(input_data)
100
+ # hook.call
101
+ # hook.output_and_exit
102
+ # end
103
+ #
104
+ # 2. Simple form - single hook class:
105
+ # ClaudeHooks::CLI.entrypoint(MyHook)
106
+ #
107
+ # 3. Multiple hooks with merging:
108
+ # ClaudeHooks::CLI.entrypoint do |input_data|
109
+ # hook1 = Hook1.new(input_data)
110
+ # hook2 = Hook2.new(input_data)
111
+ # result1 = hook1.call
112
+ # result2 = hook2.call
113
+ #
114
+ # # Use the appropriate output class for merging
115
+ # merged = ClaudeHooks::Output::PreToolUse.merge(
116
+ # hook1.output,
117
+ # hook2.output
118
+ # )
119
+ # merged.output_and_exit
120
+ # end
121
+ def entrypoint(hook_class = nil, &block)
122
+ # Read and parse input from STDIN
123
+ input_data = JSON.parse(STDIN.read)
124
+
125
+ if block_given?
126
+ # Custom block form
127
+ yield(input_data)
128
+ elsif hook_class
129
+ # Simple single hook form
130
+ hook = hook_class.new(input_data)
131
+ hook.call
132
+ hook.output_and_exit
133
+ else
134
+ raise ArgumentError, "Either provide a hook_class or a block"
135
+ end
136
+
137
+ rescue JSON::ParserError => e
138
+ STDERR.puts "JSON parsing error: #{e.message}"
139
+ error_response = {
140
+ continue: false,
141
+ stopReason: "JSON parsing error: #{e.message}",
142
+ suppressOutput: false
143
+ }
144
+ response = JSON.generate(error_response)
145
+ puts response
146
+ STDERR.puts response
147
+ exit 1
148
+
149
+ rescue StandardError => e
150
+ STDERR.puts "Hook execution error: #{e.message}"
151
+ STDERR.puts e.backtrace.join("\n") if e.backtrace
152
+
153
+ error_response = {
154
+ continue: false,
155
+ stopReason: "Hook execution error: #{e.message}",
156
+ suppressOutput: false
157
+ }
158
+ response = JSON.generate(error_response)
159
+ puts response
160
+ STDERR.puts response
161
+ exit 1
162
+ end
163
+
92
164
  private
93
165
 
94
166
  def read_stdin_input
@@ -111,7 +183,9 @@ module ClaudeHooks
111
183
  suppressOutput: false
112
184
  }
113
185
 
114
- puts JSON.generate(error_response)
186
+ response = JSON.generate(error_response)
187
+ puts response
188
+ STDERR.puts response
115
189
  exit 1
116
190
  end
117
191
  end
@@ -5,7 +5,6 @@ require_relative 'configuration'
5
5
 
6
6
  module ClaudeHooks
7
7
  # Session-based logger for Claude Code hooks
8
- # Provides both single-line and multiline block-based logging
9
8
  class Logger
10
9
  def initialize(session_id, source)
11
10
  @session_id = session_id
@@ -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