claude_hooks 0.2.0 → 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 +98 -1
- data/README.md +221 -362
- 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 +11 -9
- data/example_dotclaude/hooks/handlers/user_prompt_submit/log_user_prompt.rb +2 -14
- 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/configuration.rb +9 -9
- 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,228 @@
|
|
1
|
+
# Migration Guide: Using Output Objects
|
2
|
+
|
3
|
+
This guide shows how to migrate from the old manual exit code patterns to the new simplified output object system.
|
4
|
+
|
5
|
+
## Problem: The Old Way (Before)
|
6
|
+
|
7
|
+
```ruby
|
8
|
+
#!/usr/bin/env ruby
|
9
|
+
|
10
|
+
require 'claude_hooks'
|
11
|
+
require_relative '../handlers/pre_tool_use/github_guard'
|
12
|
+
|
13
|
+
begin
|
14
|
+
# Read Claude Code input from stdin
|
15
|
+
input_data = JSON.parse(STDIN.read)
|
16
|
+
|
17
|
+
github_guard = GithubGuard.new(input_data)
|
18
|
+
guard_output = github_guard.call
|
19
|
+
|
20
|
+
# MANUAL: Extract hook-specific decision
|
21
|
+
hook_specific_decision = guard_output['hookSpecificOutput']['permissionDecision']
|
22
|
+
is_allowed_to_continue = guard_output['continue'] != false
|
23
|
+
final_output = github_guard.stringify_output
|
24
|
+
|
25
|
+
# MANUAL: Complex exit logic that you need to understand and get right
|
26
|
+
if is_allowed_to_continue
|
27
|
+
case hook_specific_decision
|
28
|
+
when 'block'
|
29
|
+
STDERR.puts final_output
|
30
|
+
exit 1
|
31
|
+
when 'ask'
|
32
|
+
STDERR.puts final_output
|
33
|
+
exit 2
|
34
|
+
else
|
35
|
+
puts final_output
|
36
|
+
exit 0
|
37
|
+
end
|
38
|
+
else
|
39
|
+
STDERR.puts final_output
|
40
|
+
exit 1
|
41
|
+
end
|
42
|
+
rescue StandardError => e
|
43
|
+
# MANUAL: Error handling with manual JSON generation and stream selection
|
44
|
+
puts JSON.generate({
|
45
|
+
continue: false,
|
46
|
+
stopReason: "PreToolUser Hook execution error: #{e.message}",
|
47
|
+
suppressOutput: false
|
48
|
+
})
|
49
|
+
exit 0 # Allow anyway to not block developers if there is an issue
|
50
|
+
end
|
51
|
+
```
|
52
|
+
|
53
|
+
**Problems with this approach:**
|
54
|
+
- 30+ lines of repetitive boilerplate
|
55
|
+
- Need to understand Claude Code's internal exit code semantics
|
56
|
+
- Manual stream selection (STDOUT vs STDERR)
|
57
|
+
- Error-prone - easy to get exit codes wrong
|
58
|
+
- Need to manually extract hook-specific data from nested hashes
|
59
|
+
|
60
|
+
## Solution: The New Way (After)
|
61
|
+
|
62
|
+
```ruby
|
63
|
+
#!/usr/bin/env ruby
|
64
|
+
|
65
|
+
require 'claude_hooks'
|
66
|
+
require_relative '../handlers/pre_tool_use/github_guard'
|
67
|
+
|
68
|
+
begin
|
69
|
+
# Read Claude Code input from stdin
|
70
|
+
input_data = JSON.parse(STDIN.read)
|
71
|
+
|
72
|
+
github_guard = GithubGuard.new(input_data)
|
73
|
+
github_guard.call
|
74
|
+
|
75
|
+
# NEW: One line handles everything!
|
76
|
+
github_guard.output_and_exit
|
77
|
+
|
78
|
+
rescue StandardError => e
|
79
|
+
# NEW: Simple error handling
|
80
|
+
error_output = ClaudeHooks::Output::PreToolUse.new({
|
81
|
+
'continue' => false,
|
82
|
+
'stopReason' => "Hook execution error: #{e.message}",
|
83
|
+
'suppressOutput' => false
|
84
|
+
})
|
85
|
+
error_output.output_and_exit # Automatically uses STDERR and exit 1
|
86
|
+
end
|
87
|
+
```
|
88
|
+
|
89
|
+
**Benefits:**
|
90
|
+
- **10 lines instead of 30+** - 70% less code
|
91
|
+
- **No Claude Code knowledge needed** - just use the gem's API
|
92
|
+
- **Automatic exit codes** based on hook-specific logic
|
93
|
+
- **Automatic stream selection** (STDOUT/STDERR)
|
94
|
+
- **Type-safe access** to hook-specific fields
|
95
|
+
|
96
|
+
## New Output Object API
|
97
|
+
|
98
|
+
### PreToolUse Hooks
|
99
|
+
|
100
|
+
```ruby
|
101
|
+
hook = GithubGuard.new(input_data)
|
102
|
+
hook.call
|
103
|
+
output = hook.output
|
104
|
+
|
105
|
+
# Clean semantic access
|
106
|
+
puts "Permission: #{output.permission_decision}" # 'allow', 'deny', or 'ask'
|
107
|
+
puts "Reason: #{output.permission_reason}"
|
108
|
+
puts "Allowed? #{output.allowed?}"
|
109
|
+
puts "Should ask? #{output.should_ask_permission?}"
|
110
|
+
|
111
|
+
# Automatic exit logic
|
112
|
+
puts "Exit code: #{output.exit_code}" # 0, 1, or 2 based on permission
|
113
|
+
puts "Stream: #{output.output_stream}" # :stdout or :stderr
|
114
|
+
|
115
|
+
# One call handles everything
|
116
|
+
hook.output_and_exit # Prints JSON to correct stream and exits with correct code
|
117
|
+
```
|
118
|
+
|
119
|
+
### UserPromptSubmit Hooks
|
120
|
+
|
121
|
+
```ruby
|
122
|
+
hook = MyPromptHook.new(input_data)
|
123
|
+
hook.call
|
124
|
+
output = hook.output
|
125
|
+
|
126
|
+
# Clean semantic access
|
127
|
+
puts "Blocked? #{output.blocked?}"
|
128
|
+
puts "Reason: #{output.reason}"
|
129
|
+
puts "Context: #{output.additional_context}"
|
130
|
+
|
131
|
+
# Automatic execution
|
132
|
+
hook.output_and_exit # Handles all the exit logic
|
133
|
+
```
|
134
|
+
|
135
|
+
### Stop Hooks (Special Logic)
|
136
|
+
|
137
|
+
```ruby
|
138
|
+
hook = MyStopHook.new(input_data)
|
139
|
+
hook.call
|
140
|
+
output = hook.output
|
141
|
+
|
142
|
+
# Note: Stop hooks have inverted logic - 'decision: block' means "continue working"
|
143
|
+
puts "Should continue? #{output.should_continue?}" # true if decision == 'block'
|
144
|
+
puts "Continue instructions: #{output.continue_instructions}"
|
145
|
+
|
146
|
+
hook.output_and_exit # exit 1 for "continue", exit 0 for "stop"
|
147
|
+
```
|
148
|
+
|
149
|
+
## Merging Multiple Hooks
|
150
|
+
|
151
|
+
```ruby
|
152
|
+
# OLD WAY: Manual merging with class methods
|
153
|
+
result1 = hook1.call
|
154
|
+
result2 = hook2.call
|
155
|
+
merged_hash = ClaudeHooks::PreToolUse.merge_outputs(result1, result2)
|
156
|
+
# ... then manual exit logic
|
157
|
+
|
158
|
+
# NEW WAY: Clean object-based merging
|
159
|
+
hook1.call
|
160
|
+
hook2.call
|
161
|
+
merged_output = ClaudeHooks::Output::PreToolUse.merge(
|
162
|
+
hook1.output,
|
163
|
+
hook2.output
|
164
|
+
)
|
165
|
+
# /!\ Called on the merged output
|
166
|
+
merged_output.output_and_exit # Handles everything automatically
|
167
|
+
```
|
168
|
+
|
169
|
+
## Simplified Entrypoint Pattern
|
170
|
+
|
171
|
+
### For Single Hooks
|
172
|
+
```ruby
|
173
|
+
#!/usr/bin/env ruby
|
174
|
+
require 'claude_hooks'
|
175
|
+
require_relative '../handlers/my_hook'
|
176
|
+
|
177
|
+
# Super simple - just 3 lines!
|
178
|
+
ClaudeHooks::CLI.entrypoint do |input_data|
|
179
|
+
hook = MyHook.new(input_data)
|
180
|
+
hook.call
|
181
|
+
hook.output_and_exit
|
182
|
+
end
|
183
|
+
```
|
184
|
+
|
185
|
+
### For Multiple Hooks with Merging
|
186
|
+
```ruby
|
187
|
+
#!/usr/bin/env ruby
|
188
|
+
require 'claude_hooks'
|
189
|
+
require_relative '../handlers/hook1'
|
190
|
+
require_relative '../handlers/hook2'
|
191
|
+
|
192
|
+
ClaudeHooks::CLI.entrypoint do |input_data|
|
193
|
+
hook1 = Hook1.new(input_data)
|
194
|
+
hook2 = Hook2.new(input_data)
|
195
|
+
hook1.call
|
196
|
+
hook2.call
|
197
|
+
|
198
|
+
merged = ClaudeHooks::Output::PreToolUse.merge(
|
199
|
+
hook1.output,
|
200
|
+
hook2.output
|
201
|
+
)
|
202
|
+
# /!\ Called on the merged output
|
203
|
+
merged.output_and_exit
|
204
|
+
end
|
205
|
+
```
|
206
|
+
|
207
|
+
## All Supported Hook Types
|
208
|
+
|
209
|
+
- `ClaudeHooks::Output::UserPromptSubmit`
|
210
|
+
- `ClaudeHooks::Output::PreToolUse`
|
211
|
+
- `ClaudeHooks::Output::PostToolUse`
|
212
|
+
- `ClaudeHooks::Output::Stop`
|
213
|
+
- `ClaudeHooks::Output::SubagentStop`
|
214
|
+
- `ClaudeHooks::Output::Notification`
|
215
|
+
- `ClaudeHooks::Output::SessionStart`
|
216
|
+
- `ClaudeHooks::Output::PreCompact`
|
217
|
+
|
218
|
+
Each handles the specific exit code logic and semantic helpers for its hook type.
|
219
|
+
|
220
|
+
## Migration Steps
|
221
|
+
|
222
|
+
1. **Update your hook handlers** to return `output_data` (most already do)
|
223
|
+
2. **Replace manual exit logic** with `hook.output_and_exit` or `output.output_and_exit`
|
224
|
+
3. **Use semantic helpers** instead of digging through hash structures
|
225
|
+
4. **Use output object merging** instead of class methods
|
226
|
+
5. **Enjoy the simplified, cleaner code!**
|
227
|
+
|
228
|
+
The old patterns still work for backward compatibility, but the new output objects make everything much cleaner and less error-prone.
|
@@ -0,0 +1,35 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require 'json'
|
4
|
+
require_relative '../handlers/session_end/cleanup_handler'
|
5
|
+
require_relative '../handlers/session_end/log_session_stats'
|
6
|
+
|
7
|
+
begin
|
8
|
+
# Read input from stdin
|
9
|
+
input_data = JSON.parse(STDIN.read)
|
10
|
+
|
11
|
+
# Initialize handlers
|
12
|
+
cleanup_handler = CleanupHandler.new(input_data)
|
13
|
+
log_handler = LogSessionStats.new(input_data)
|
14
|
+
|
15
|
+
# Execute handlers
|
16
|
+
cleanup_handler.call
|
17
|
+
log_handler.call
|
18
|
+
|
19
|
+
# Merge outputs using the SessionEnd output merger
|
20
|
+
merged_output = ClaudeHooks::Output::SessionEnd.merge(
|
21
|
+
cleanup_handler.output,
|
22
|
+
log_handler.output
|
23
|
+
)
|
24
|
+
|
25
|
+
# Output result and exit with appropriate code
|
26
|
+
merged_output.output_and_exit
|
27
|
+
|
28
|
+
rescue StandardError => e
|
29
|
+
STDERR.puts JSON.generate({
|
30
|
+
continue: false,
|
31
|
+
stopReason: "Hook execution error: #{e.message}",
|
32
|
+
suppressOutput: false
|
33
|
+
})
|
34
|
+
exit 2
|
35
|
+
end
|
@@ -1,7 +1,13 @@
|
|
1
1
|
#!/usr/bin/env ruby
|
2
2
|
|
3
|
+
# Example of the NEW simplified entrypoint pattern using output objects
|
4
|
+
# Compare this to the existing user_prompt_submit.rb to see the difference!
|
5
|
+
|
3
6
|
require 'claude_hooks'
|
4
7
|
require 'json'
|
8
|
+
# Require the output classes
|
9
|
+
require_relative '../../../lib/claude_hooks/output/base'
|
10
|
+
require_relative '../../../lib/claude_hooks/output/user_prompt_submit'
|
5
11
|
require_relative '../handlers/user_prompt_submit/append_rules'
|
6
12
|
require_relative '../handlers/user_prompt_submit/log_user_prompt'
|
7
13
|
|
@@ -11,25 +17,24 @@ begin
|
|
11
17
|
|
12
18
|
# Execute all hook scripts
|
13
19
|
append_rules = AppendRules.new(input_data)
|
14
|
-
|
20
|
+
append_rules.call
|
15
21
|
|
16
22
|
log_user_prompt = LogUserPrompt.new(input_data)
|
17
|
-
|
23
|
+
log_user_prompt.call
|
18
24
|
|
19
|
-
|
20
|
-
|
25
|
+
merged_output = ClaudeHooks::Output::UserPromptSubmit.merge(
|
26
|
+
append_rules.output,
|
27
|
+
log_user_prompt.output
|
28
|
+
)
|
21
29
|
|
22
|
-
|
23
|
-
puts JSON.generate(hook_output)
|
30
|
+
merged_output.output_and_exit
|
24
31
|
|
25
|
-
exit 0 # Don't forget to exit with the right exit code (0, 1, 2)
|
26
32
|
rescue StandardError => e
|
27
|
-
|
28
|
-
|
29
|
-
puts JSON.generate({
|
33
|
+
# Same simple error pattern
|
34
|
+
STDERR.puts JSON.generate({
|
30
35
|
continue: false,
|
31
36
|
stopReason: "Hook execution error: #{e.message} #{e.backtrace.join("\n")}",
|
32
37
|
suppressOutput: false
|
33
38
|
})
|
34
|
-
exit
|
35
|
-
end
|
39
|
+
exit 2
|
40
|
+
end
|
@@ -0,0 +1,55 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require_relative '../../../../lib/claude_hooks'
|
4
|
+
|
5
|
+
class CleanupHandler < ClaudeHooks::SessionEnd
|
6
|
+
def call
|
7
|
+
log "Session ended with reason: #{reason}"
|
8
|
+
|
9
|
+
case reason
|
10
|
+
when 'clear'
|
11
|
+
log "Performing cleanup after /clear command"
|
12
|
+
cleanup_temp_files
|
13
|
+
when 'logout'
|
14
|
+
log "Performing cleanup after logout"
|
15
|
+
save_session_state
|
16
|
+
when 'prompt_input_exit'
|
17
|
+
log "User exited during prompt input"
|
18
|
+
save_partial_state
|
19
|
+
else
|
20
|
+
log "General session cleanup"
|
21
|
+
general_cleanup
|
22
|
+
end
|
23
|
+
|
24
|
+
output
|
25
|
+
end
|
26
|
+
|
27
|
+
private
|
28
|
+
|
29
|
+
def cleanup_temp_files
|
30
|
+
log "Cleaning up temporary files for session #{session_id}"
|
31
|
+
# Example cleanup logic
|
32
|
+
end
|
33
|
+
|
34
|
+
def save_session_state
|
35
|
+
log "Saving session state for session #{session_id}"
|
36
|
+
# Example state saving logic
|
37
|
+
end
|
38
|
+
|
39
|
+
def save_partial_state
|
40
|
+
log "Saving partial state for session #{session_id}"
|
41
|
+
# Example partial state saving logic
|
42
|
+
end
|
43
|
+
|
44
|
+
def general_cleanup
|
45
|
+
log "Performing general cleanup for session #{session_id}"
|
46
|
+
# Example general cleanup logic
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
# CLI testing support
|
51
|
+
if __FILE__ == $0
|
52
|
+
ClaudeHooks::CLI.test_runner(CleanupHandler) do |input_data|
|
53
|
+
input_data['reason'] ||= 'other'
|
54
|
+
end
|
55
|
+
end
|
@@ -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
|
@@ -8,23 +8,24 @@ class AppendRules < ClaudeHooks::UserPromptSubmit
|
|
8
8
|
def call
|
9
9
|
log "Executing AppendRules hook"
|
10
10
|
|
11
|
-
# Read the
|
12
|
-
|
11
|
+
# Read the rules
|
12
|
+
rules = read_rules
|
13
13
|
|
14
|
-
if
|
15
|
-
add_additional_context!(
|
16
|
-
log "Successfully added
|
14
|
+
if rules
|
15
|
+
add_additional_context!(rules)
|
16
|
+
log "Successfully added rules as additional context (#{rules.length} characters)"
|
17
17
|
else
|
18
18
|
log "No rule content found", level: :warn
|
19
19
|
end
|
20
20
|
|
21
|
-
|
21
|
+
output
|
22
22
|
end
|
23
23
|
|
24
24
|
private
|
25
25
|
|
26
|
-
def
|
27
|
-
|
26
|
+
def read_rules
|
27
|
+
# If we were in the project directory, we would use project_path_for instead
|
28
|
+
rule_file_path = home_path_for('rules/post-user-prompt.rule.md')
|
28
29
|
|
29
30
|
if File.exist?(rule_file_path)
|
30
31
|
content = File.read(rule_file_path).strip
|
@@ -32,7 +33,8 @@ class AppendRules < ClaudeHooks::UserPromptSubmit
|
|
32
33
|
end
|
33
34
|
|
34
35
|
log "Rule file not found or empty at: #{rule_file_path}", level: :warn
|
35
|
-
|
36
|
+
# If we were in the project directory, we would use project_claude_dir instead
|
37
|
+
log "Base directory: #{home_claude_dir}"
|
36
38
|
nil
|
37
39
|
end
|
38
40
|
end
|
@@ -1,6 +1,5 @@
|
|
1
1
|
#!/usr/bin/env ruby
|
2
2
|
|
3
|
-
require 'fileutils'
|
4
3
|
require 'claude_hooks'
|
5
4
|
|
6
5
|
# Example hook module that logs user prompts to a file
|
@@ -9,25 +8,14 @@ class LogUserPrompt < ClaudeHooks::UserPromptSubmit
|
|
9
8
|
def call
|
10
9
|
log "Executing LogUserPrompt hook"
|
11
10
|
|
12
|
-
# Log the prompt to a file (just as an example)
|
13
|
-
log_file_path = path_for('logs/user_prompts.log')
|
14
|
-
ensure_log_directory_exists
|
15
|
-
|
16
11
|
timestamp = Time.now.strftime('%Y-%m-%d %H:%M:%S')
|
17
12
|
|
18
13
|
log <<~TEXT
|
19
14
|
Prompt: #{current_prompt}
|
20
|
-
Logged user prompt
|
15
|
+
Logged user prompt (session: #{session_id})
|
21
16
|
TEXT
|
22
17
|
|
23
|
-
nil
|
24
|
-
end
|
25
|
-
|
26
|
-
private
|
27
|
-
|
28
|
-
def ensure_log_directory_exists
|
29
|
-
log_dir = path_for('logs')
|
30
|
-
FileUtils.mkdir_p(log_dir) unless Dir.exist?(log_dir)
|
18
|
+
nil # ignored output
|
31
19
|
end
|
32
20
|
end
|
33
21
|
|
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
|