claude_hooks 0.1.1
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 +7 -0
- data/CHANGELOG.md +31 -0
- data/README.md +777 -0
- data/WHY.md +65 -0
- data/claude_hooks.gemspec +39 -0
- data/example_dotclaude/commands/.gitkeep +0 -0
- data/example_dotclaude/hooks/entrypoints/user_prompt_submit/append_rules.rb +66 -0
- data/example_dotclaude/hooks/entrypoints/user_prompt_submit/log_user_prompt.rb +50 -0
- data/example_dotclaude/hooks/entrypoints/user_prompt_submit.rb +44 -0
- data/example_dotclaude/hooks/handlers/user_prompt_submit/append_rules.rb +66 -0
- data/example_dotclaude/hooks/handlers/user_prompt_submit/log_user_prompt.rb +50 -0
- data/example_dotclaude/settings.json +23 -0
- data/lib/claude_hooks/base.rb +157 -0
- data/lib/claude_hooks/configuration.rb +145 -0
- data/lib/claude_hooks/logger.rb +66 -0
- data/lib/claude_hooks/notification.rb +22 -0
- data/lib/claude_hooks/post_tool_use.rb +55 -0
- data/lib/claude_hooks/pre_compact.rb +42 -0
- data/lib/claude_hooks/pre_tool_use.rb +88 -0
- data/lib/claude_hooks/session_start.rb +58 -0
- data/lib/claude_hooks/stop.rb +61 -0
- data/lib/claude_hooks/subagent_stop.rb +11 -0
- data/lib/claude_hooks/user_prompt_submit.rb +73 -0
- data/lib/claude_hooks/version.rb +5 -0
- data/lib/claude_hooks.rb +18 -0
- metadata +115 -0
@@ -0,0 +1,145 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'json'
|
4
|
+
|
5
|
+
module ClaudeHooks
|
6
|
+
class Configuration
|
7
|
+
ENV_PREFIX = 'RUBY_CLAUDE_HOOKS_'
|
8
|
+
|
9
|
+
class << self
|
10
|
+
# Load the entire config as a hash (from ENV and optional config file)
|
11
|
+
def config
|
12
|
+
@config ||= load_config
|
13
|
+
end
|
14
|
+
|
15
|
+
# Unmemoize config
|
16
|
+
def reload!
|
17
|
+
@config = nil
|
18
|
+
@base_dir = nil
|
19
|
+
@config_file_path = nil
|
20
|
+
end
|
21
|
+
|
22
|
+
# Get the base directory from ENV or default
|
23
|
+
def base_dir
|
24
|
+
@base_dir ||= begin
|
25
|
+
env_base_dir = ENV["#{ENV_PREFIX}BASE_DIR"]
|
26
|
+
File.expand_path(env_base_dir || '~/.claude')
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
# Get the full path for a file/directory relative to base_dir
|
31
|
+
def path_for(relative_path)
|
32
|
+
File.join(base_dir, relative_path)
|
33
|
+
end
|
34
|
+
|
35
|
+
# Get the log directory path
|
36
|
+
def logs_directory
|
37
|
+
log_dir = get_config_value('LOG_DIR', 'logDirectory') || 'logs'
|
38
|
+
if log_dir.start_with?('/')
|
39
|
+
log_dir # Absolute path
|
40
|
+
else
|
41
|
+
path_for(log_dir) # Relative to base_dir
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
|
46
|
+
# Get any configuration value by key
|
47
|
+
# First checks ENV with prefix, then config file, then returns default
|
48
|
+
def get_config_value(env_key, config_key = nil, default = nil)
|
49
|
+
# Check environment variable first
|
50
|
+
env_value = ENV["#{ENV_PREFIX}#{env_key}"]
|
51
|
+
return env_value if env_value
|
52
|
+
|
53
|
+
# Check config file using provided key or converted env_key
|
54
|
+
file_key = config_key || env_key_to_config_key(env_key)
|
55
|
+
config_value = config.dig(file_key)
|
56
|
+
return config_value if config_value
|
57
|
+
|
58
|
+
# Return default
|
59
|
+
default
|
60
|
+
end
|
61
|
+
|
62
|
+
# Allow access to any config value using method_missing
|
63
|
+
def method_missing(method_name, *args, &block)
|
64
|
+
# Convert method name to ENV key format (e.g., my_custom_setting -> MY_CUSTOM_SETTING)
|
65
|
+
env_key = method_name.to_s.upcase
|
66
|
+
config_key = method_name.to_s
|
67
|
+
|
68
|
+
value = get_config_value(env_key, config_key)
|
69
|
+
return value unless value.nil?
|
70
|
+
|
71
|
+
super
|
72
|
+
end
|
73
|
+
|
74
|
+
def respond_to_missing?(method_name, include_private = false)
|
75
|
+
# Check if we have a config value for this method
|
76
|
+
env_key = method_name.to_s.upcase
|
77
|
+
config_key = method_name.to_s
|
78
|
+
|
79
|
+
!get_config_value(env_key, config_key).nil? || super
|
80
|
+
end
|
81
|
+
|
82
|
+
private
|
83
|
+
|
84
|
+
def config_file_path
|
85
|
+
@config_file_path ||= path_for('config/config.json')
|
86
|
+
end
|
87
|
+
|
88
|
+
def load_config
|
89
|
+
# Start with config file
|
90
|
+
file_config = load_config_file
|
91
|
+
|
92
|
+
# Merge with ENV variables
|
93
|
+
env_config = load_env_config
|
94
|
+
|
95
|
+
# ENV variables take precedence
|
96
|
+
file_config.merge(env_config)
|
97
|
+
end
|
98
|
+
|
99
|
+
def load_config_file
|
100
|
+
config_file = config_file_path
|
101
|
+
|
102
|
+
if File.exist?(config_file)
|
103
|
+
begin
|
104
|
+
JSON.parse(File.read(config_file))
|
105
|
+
rescue JSON::ParserError => e
|
106
|
+
warn "Warning: Error parsing config file #{config_file}: #{e.message}"
|
107
|
+
{}
|
108
|
+
end
|
109
|
+
else
|
110
|
+
# No config file is fine - we'll use ENV vars and defaults
|
111
|
+
{}
|
112
|
+
end
|
113
|
+
end
|
114
|
+
|
115
|
+
def load_env_config
|
116
|
+
env_config = {}
|
117
|
+
|
118
|
+
ENV.each do |key, value|
|
119
|
+
next unless key.start_with?(ENV_PREFIX)
|
120
|
+
|
121
|
+
# Remove prefix and convert to config key format
|
122
|
+
config_key = env_key_to_config_key(key.sub(ENV_PREFIX, ''))
|
123
|
+
env_config[config_key] = value
|
124
|
+
end
|
125
|
+
|
126
|
+
env_config
|
127
|
+
end
|
128
|
+
|
129
|
+
def env_key_to_config_key(env_key)
|
130
|
+
# Convert SCREAMING_SNAKE_CASE to camelCase
|
131
|
+
# BASE_DIR -> baseDir, LOG_DIR -> logDirectory (with special handling)
|
132
|
+
case env_key
|
133
|
+
when 'LOG_DIR'
|
134
|
+
'logDirectory'
|
135
|
+
when 'BASE_DIR'
|
136
|
+
'baseDir'
|
137
|
+
else
|
138
|
+
# Convert SCREAMING_SNAKE_CASE to camelCase
|
139
|
+
parts = env_key.downcase.split('_')
|
140
|
+
parts.first + parts[1..-1].map(&:capitalize).join
|
141
|
+
end
|
142
|
+
end
|
143
|
+
end
|
144
|
+
end
|
145
|
+
end
|
@@ -0,0 +1,66 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'fileutils'
|
4
|
+
require_relative 'configuration'
|
5
|
+
|
6
|
+
module ClaudeHooks
|
7
|
+
# Session-based logger for Claude Code hooks
|
8
|
+
# Provides both single-line and multiline block-based logging
|
9
|
+
class Logger
|
10
|
+
def initialize(session_id, source)
|
11
|
+
@session_id = session_id
|
12
|
+
@source = source
|
13
|
+
end
|
14
|
+
|
15
|
+
# Log to session-specific file
|
16
|
+
# Usage:
|
17
|
+
# log "Simple message"
|
18
|
+
# log "Debug info", level: :debug
|
19
|
+
def log(message, level: :info)
|
20
|
+
timestamp = Time.now.strftime('%Y-%m-%d %H:%M:%S')
|
21
|
+
class_prefix = "[#{timestamp}] [#{level.upcase}] [#{@source}]"
|
22
|
+
|
23
|
+
# For multiline strings, prepend a newline for better formatting
|
24
|
+
if message.include?("\n")
|
25
|
+
log_entry = "#{class_prefix}\n#{message}"
|
26
|
+
else
|
27
|
+
log_entry = "#{class_prefix} #{message}"
|
28
|
+
end
|
29
|
+
|
30
|
+
begin
|
31
|
+
write_to_session_log(log_entry)
|
32
|
+
rescue => e
|
33
|
+
# Fallback to STDERR if file logging fails
|
34
|
+
STDERR.puts log_entry
|
35
|
+
STDERR.puts "Warning: Failed to write to session log: #{e.message}"
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
private
|
40
|
+
|
41
|
+
# Sanitize session_id to be filesystem-safe
|
42
|
+
def safe_session_id
|
43
|
+
session = @session_id || 'unknown'
|
44
|
+
session.gsub(/[^a-zA-Z0-9\-_]/, '_')
|
45
|
+
end
|
46
|
+
|
47
|
+
# Write log entry to session-specific file
|
48
|
+
def write_to_session_log(log_entry)
|
49
|
+
log_file_path = File.join(
|
50
|
+
Configuration.logs_directory,
|
51
|
+
"hooks",
|
52
|
+
"session-#{safe_session_id}.log"
|
53
|
+
)
|
54
|
+
|
55
|
+
# Ensure log directory exists
|
56
|
+
log_dir = File.dirname(log_file_path)
|
57
|
+
FileUtils.mkdir_p(log_dir) unless Dir.exist?(log_dir)
|
58
|
+
|
59
|
+
# Write to file (thread-safe append mode)
|
60
|
+
File.open(log_file_path, 'a') do |file|
|
61
|
+
file.puts log_entry
|
62
|
+
file.flush # Ensure immediate write
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'base'
|
4
|
+
|
5
|
+
module ClaudeHooks
|
6
|
+
class Notification < Base
|
7
|
+
def self.hook_type
|
8
|
+
'Notification'
|
9
|
+
end
|
10
|
+
|
11
|
+
def self.input_fields
|
12
|
+
%w[message]
|
13
|
+
end
|
14
|
+
|
15
|
+
# === INPUT DATA ACCESS ===
|
16
|
+
|
17
|
+
def message
|
18
|
+
@input_data['message']
|
19
|
+
end
|
20
|
+
alias_method :notification_message, :message
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,55 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'base'
|
4
|
+
|
5
|
+
module ClaudeHooks
|
6
|
+
class PostToolUse < Base
|
7
|
+
def self.hook_type
|
8
|
+
'PostToolUse'
|
9
|
+
end
|
10
|
+
|
11
|
+
def self.input_fields
|
12
|
+
%w[tool_name tool_input tool_response]
|
13
|
+
end
|
14
|
+
|
15
|
+
# === INPUT DATA ACCESS ===
|
16
|
+
|
17
|
+
def tool_name
|
18
|
+
@input_data['tool_name']
|
19
|
+
end
|
20
|
+
|
21
|
+
def tool_input
|
22
|
+
@input_data['tool_input']
|
23
|
+
end
|
24
|
+
|
25
|
+
def tool_response
|
26
|
+
@input_data['tool_response']
|
27
|
+
end
|
28
|
+
|
29
|
+
# === OUTPUT DATA HELPERS ===
|
30
|
+
|
31
|
+
def block_tool!(reason = '')
|
32
|
+
@output_data['decision'] = 'block'
|
33
|
+
@output_data['reason'] = reason
|
34
|
+
end
|
35
|
+
|
36
|
+
def approve_tool!(reason = '')
|
37
|
+
@output_data['decision'] = nil
|
38
|
+
@output_data['reason'] = nil
|
39
|
+
end
|
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
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
@@ -0,0 +1,42 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'base'
|
4
|
+
require 'fileutils'
|
5
|
+
|
6
|
+
module ClaudeHooks
|
7
|
+
class PreCompact < Base
|
8
|
+
def self.hook_type
|
9
|
+
'PreCompact'
|
10
|
+
end
|
11
|
+
|
12
|
+
def self.input_fields
|
13
|
+
%w[trigger custom_instructions]
|
14
|
+
end
|
15
|
+
|
16
|
+
# === INPUT DATA ACCESS ===
|
17
|
+
|
18
|
+
def trigger
|
19
|
+
@input_data['trigger']
|
20
|
+
end
|
21
|
+
|
22
|
+
def custom_instructions
|
23
|
+
if trigger == 'manual'
|
24
|
+
@input_data['custom_instructions']
|
25
|
+
else
|
26
|
+
''
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
# === UTILITY HELPERS ===
|
31
|
+
|
32
|
+
def backup_transcript!(backup_file_path)
|
33
|
+
unless Dir.exist?(File.dirname(backup_file_path))
|
34
|
+
FileUtils.mkdir_p(File.dirname(backup_file_path))
|
35
|
+
end
|
36
|
+
|
37
|
+
transcript_content = read_transcript
|
38
|
+
File.write(backup_file_path, transcript_content)
|
39
|
+
log "Transcript backed up to: #{backup_file_path}"
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
@@ -0,0 +1,88 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'base'
|
4
|
+
|
5
|
+
module ClaudeHooks
|
6
|
+
class PreToolUse < Base
|
7
|
+
def self.hook_type
|
8
|
+
'PreToolUse'
|
9
|
+
end
|
10
|
+
|
11
|
+
def self.input_fields
|
12
|
+
%w[tool_name tool_input]
|
13
|
+
end
|
14
|
+
|
15
|
+
# === INPUT DATA ACCESS ===
|
16
|
+
|
17
|
+
def tool_name
|
18
|
+
@input_data['tool_name']
|
19
|
+
end
|
20
|
+
|
21
|
+
def tool_input
|
22
|
+
@input_data['tool_input']
|
23
|
+
end
|
24
|
+
|
25
|
+
# === OUTPUT DATA HELPERS ===
|
26
|
+
|
27
|
+
def approve_tool!(reason = '')
|
28
|
+
@output_data['hookSpecificOutput'] = {
|
29
|
+
'hookEventName' => hook_event_name,
|
30
|
+
'permissionDecision' => 'allow',
|
31
|
+
'permissionDecisionReason' => reason
|
32
|
+
}
|
33
|
+
end
|
34
|
+
|
35
|
+
def block_tool!(reason = '')
|
36
|
+
@output_data['hookSpecificOutput'] = {
|
37
|
+
'hookEventName' => hook_event_name,
|
38
|
+
'permissionDecision' => 'deny',
|
39
|
+
'permissionDecisionReason' => reason
|
40
|
+
}
|
41
|
+
end
|
42
|
+
|
43
|
+
def ask_for_permission!(reason = '')
|
44
|
+
@output_data['hookSpecificOutput'] = {
|
45
|
+
'hookEventName' => hook_event_name,
|
46
|
+
'permissionDecision' => 'ask',
|
47
|
+
'permissionDecisionReason' => reason
|
48
|
+
}
|
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
|
+
end
|
88
|
+
end
|
@@ -0,0 +1,58 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'base'
|
4
|
+
|
5
|
+
module ClaudeHooks
|
6
|
+
class SessionStart < Base
|
7
|
+
def self.hook_type
|
8
|
+
'SessionStart'
|
9
|
+
end
|
10
|
+
|
11
|
+
def self.input_fields
|
12
|
+
%w[source]
|
13
|
+
end
|
14
|
+
|
15
|
+
# === INPUT DATA ACCESS ===
|
16
|
+
|
17
|
+
def source
|
18
|
+
@input_data['source']
|
19
|
+
end
|
20
|
+
|
21
|
+
# === OUTPUT DATA HELPERS ===
|
22
|
+
|
23
|
+
def add_additional_context!(context)
|
24
|
+
@output_data['hookSpecificOutput'] = {
|
25
|
+
'hookEventName' => hook_event_name,
|
26
|
+
'additionalContext' => context
|
27
|
+
}
|
28
|
+
end
|
29
|
+
alias_method :add_context!, :add_additional_context!
|
30
|
+
|
31
|
+
def empty_additional_context!
|
32
|
+
@output_data['hookSpecificOutput'] = nil
|
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
|
+
end
|
58
|
+
end
|
@@ -0,0 +1,61 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'base'
|
4
|
+
|
5
|
+
module ClaudeHooks
|
6
|
+
class Stop < Base
|
7
|
+
def self.hook_type
|
8
|
+
'Stop'
|
9
|
+
end
|
10
|
+
|
11
|
+
def self.input_fields
|
12
|
+
%w[stop_hook_active]
|
13
|
+
end
|
14
|
+
|
15
|
+
# === INPUT DATA ACCESS ===
|
16
|
+
|
17
|
+
def stop_hook_active
|
18
|
+
@input_data['stop_hook_active']
|
19
|
+
end
|
20
|
+
|
21
|
+
# === OUTPUT DATA HELPERS ===
|
22
|
+
|
23
|
+
# Block Claude from stopping (force it to continue)
|
24
|
+
def continue_with_instructions!(instructions)
|
25
|
+
@output_data['decision'] = 'block'
|
26
|
+
@output_data['reason'] = instructions
|
27
|
+
end
|
28
|
+
alias_method :block!, :continue_with_instructions!
|
29
|
+
|
30
|
+
# Ensure Claude stops normally (default behavior)
|
31
|
+
def ensure_stopping!
|
32
|
+
@output_data.delete('decision')
|
33
|
+
@output_data.delete('reason')
|
34
|
+
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
|
+
end
|
61
|
+
end
|
@@ -0,0 +1,73 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'base'
|
4
|
+
|
5
|
+
module ClaudeHooks
|
6
|
+
class UserPromptSubmit < Base
|
7
|
+
def self.hook_type
|
8
|
+
'UserPromptSubmit'
|
9
|
+
end
|
10
|
+
|
11
|
+
def self.input_fields
|
12
|
+
%w[prompt]
|
13
|
+
end
|
14
|
+
|
15
|
+
# === INPUT DATA ACCESS ===
|
16
|
+
|
17
|
+
def prompt
|
18
|
+
@input_data['user_prompt'] || @input_data['prompt']
|
19
|
+
end
|
20
|
+
alias_method :user_prompt, :prompt
|
21
|
+
alias_method :current_prompt, :prompt
|
22
|
+
|
23
|
+
# === OUTPUT DATA HELPERS ===
|
24
|
+
|
25
|
+
def add_additional_context!(context)
|
26
|
+
@output_data['hookSpecificOutput'] = {
|
27
|
+
'hookEventName' => hook_event_name,
|
28
|
+
'additionalContext' => context
|
29
|
+
}
|
30
|
+
end
|
31
|
+
alias_method :add_context!, :add_additional_context!
|
32
|
+
|
33
|
+
def empty_additional_context!
|
34
|
+
@output_data['hookSpecificOutput'] = nil
|
35
|
+
end
|
36
|
+
|
37
|
+
def block_prompt!(reason = '')
|
38
|
+
@output_data['decision'] = 'block'
|
39
|
+
@output_data['reason'] = reason
|
40
|
+
end
|
41
|
+
|
42
|
+
def unblock_prompt!
|
43
|
+
@output_data['decision'] = nil
|
44
|
+
@output_data['reason'] = nil
|
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
|
+
end
|
73
|
+
end
|
data/lib/claude_hooks.rb
ADDED
@@ -0,0 +1,18 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "claude_hooks/version"
|
4
|
+
require_relative "claude_hooks/configuration"
|
5
|
+
require_relative "claude_hooks/logger"
|
6
|
+
require_relative "claude_hooks/base"
|
7
|
+
require_relative "claude_hooks/user_prompt_submit"
|
8
|
+
require_relative "claude_hooks/pre_tool_use"
|
9
|
+
require_relative "claude_hooks/post_tool_use"
|
10
|
+
require_relative "claude_hooks/notification"
|
11
|
+
require_relative "claude_hooks/stop"
|
12
|
+
require_relative "claude_hooks/subagent_stop"
|
13
|
+
require_relative "claude_hooks/pre_compact"
|
14
|
+
require_relative "claude_hooks/session_start"
|
15
|
+
|
16
|
+
module ClaudeHooks
|
17
|
+
class Error < StandardError; end
|
18
|
+
end
|