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.
@@ -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,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'stop'
4
+
5
+ module ClaudeHooks
6
+ class SubagentStop < Stop
7
+ def self.hook_type
8
+ 'SubagentStop'
9
+ end
10
+ end
11
+ 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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeHooks
4
+ VERSION = "0.1.1"
5
+ end
@@ -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