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,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
|
-
|
|
22
|
+
output
|
|
22
23
|
end
|
|
23
24
|
|
|
24
25
|
private
|
|
@@ -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": {
|
data/lib/claude_hooks/base.rb
CHANGED
|
@@ -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!
|
data/lib/claude_hooks/cli.rb
CHANGED
|
@@ -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
|
-
|
|
186
|
+
response = JSON.generate(error_response)
|
|
187
|
+
puts response
|
|
188
|
+
STDERR.puts response
|
|
115
189
|
exit 1
|
|
116
190
|
end
|
|
117
191
|
end
|
data/lib/claude_hooks/logger.rb
CHANGED
|
@@ -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
|