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.
data/WHY.md ADDED
@@ -0,0 +1,65 @@
1
+ # Why is this DSL useful?
2
+
3
+ When creating fairly complex hook systems for Claude Code it is easy to end up either with:
4
+ - Fairly monolithic scripts that becomes hard to maintain and have to handle complex merging logic for the output.
5
+ - A lot of scripts set up in the `settings.json` that all use the same hook input / output logic that needs to be rewritten multiple times.
6
+
7
+ Furthermore, Claude's documentation on hooks is a bit hard to navigate and setting up hooks is not necessarily intuitive or straightforward having to play around with STDIN, STDOUT, STDERR, exit codes, etc.
8
+
9
+ This DSL enables the easy creation of composable, testable handlers that can be easily maintained and reused and is suited for both a monolithic approach (using entrypoints and handlers) or a modular one (using handlers only).
10
+
11
+ ```bash
12
+ # Monolithic approach (Recommended)
13
+ settings.json
14
+ └── [On PreToolUse Hook] -> entrypoints/pre_tool_use.rb
15
+ ├── calls handlers/pre_tool_use/danger_check.rb
16
+ ├── calls handlers/pre_tool_use/audit_logger.rb
17
+ └── calls handlers/pre_tool_use/permission_guard.rb
18
+
19
+ # Modular approach
20
+ settings.json
21
+ ├── [On PreToolUse Hook] -> handlers/pre_tool_use/danger_check.rb
22
+ ├── [On PreToolUse Hook] -> handlers/pre_tool_use/audit_logger.rb
23
+ └── [On PreToolUse Hook] -> handlers/pre_tool_use/permission_guard.rb
24
+ ```
25
+
26
+ ## For both approaches it will bring
27
+
28
+ 1. Normalized Input/Output handling - input parsing, validation, straightforward output helpers (block_tool!, add_context!).
29
+
30
+ 2. Hook-Specific APIs - 8 different hook types with tailored methods (e.g., ask_for_permission! vs block_tool!) and smart merge logic for combining outputs.
31
+
32
+ 3. Session-Based Logging - Dedicated logger to understand the flow of what happens in Claude Code and write it out to a `session-{session_id}.log` file.
33
+
34
+ 4. Configuration Management - Centralized config and helpers for use across the hook system.
35
+
36
+ 5. Testing Support - Standalone execution mode for individual hook testing and CLI testing with sample JSON input.
37
+
38
+
39
+ ## For a monolithic approach it will additionally bring
40
+
41
+ 1. Composable Hook Handlers
42
+ For instance, `entrypoints/user_prompt_submit.rb` orchestrates multiple handlers in one place:
43
+ ```ruby
44
+ # Add contextual rules
45
+ append_rules_result = AppendRules.new(input_data).call
46
+ # Audit logging
47
+ log_result = LogUserPrompt.new(input_data).call
48
+
49
+ # Merge outputs to Claude Code
50
+ puts ClaudeHooks::UserPromptSubmit.merge_outputs(append_rules_result, log_result)
51
+ ```
52
+
53
+ 2. Intelligent Output Merging
54
+ - Each hook handler can return different decisions (block, context additions, etc.)
55
+ - The framework intelligently merges conflicting decisions (e.g., any handler can block)
56
+ - Combines multiple contexts cleanly
57
+
58
+ 3. Individual Handler Testability
59
+ Each handler can run standalone for testing:
60
+ ```ruby
61
+ if __FILE__ == $0
62
+ hook = AppendRules.new(JSON.parse(STDIN.read))
63
+ puts hook.stringify_output
64
+ end
65
+ ```
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/claude_hooks/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "claude_hooks"
7
+ spec.version = ClaudeHooks::VERSION
8
+ spec.authors = ["Gabriel Dehan"]
9
+ spec.email = ["dehan.gabriel@gmail.com"]
10
+
11
+ spec.summary = "Ruby DSL for creating Claude Code hooks"
12
+ spec.description = "A Ruby DSL framework for creating Claude Code hooks with composable hook scripts that enable teams to easily implement logging, security checks, and workflow automation."
13
+ spec.homepage = "https://github.com/gabriel-dehan/claude_hooks"
14
+ spec.license = "MIT"
15
+ spec.required_ruby_version = ">= 2.7.0"
16
+
17
+ spec.metadata["allowed_push_host"] = "https://rubygems.org"
18
+ spec.metadata["homepage_uri"] = spec.homepage
19
+ spec.metadata["source_code_uri"] = spec.homepage
20
+ spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
21
+
22
+ # Specify which files should be added to the gem when it is released.
23
+ spec.files = Dir.chdir(__dir__) do
24
+ `git ls-files -z`.split("\x0").reject do |f|
25
+ (File.expand_path(f) == __FILE__) ||
26
+ f.start_with?(*%w[bin/ test/ spec/ features/ .git .github appveyor Gemfile])
27
+ end
28
+ end
29
+ spec.bindir = "exe"
30
+ spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
31
+ spec.require_paths = ["lib"]
32
+
33
+ # Runtime dependencies
34
+ spec.add_dependency "json", "~> 2.0"
35
+
36
+ # Development dependencies
37
+ spec.add_development_dependency "rake", "~> 13.0"
38
+ spec.add_development_dependency "rspec", "~> 3.0"
39
+ end
File without changes
@@ -0,0 +1,66 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'claude_hooks'
4
+
5
+ # Hook script that appends rules to user prompt
6
+ class AppendRules < ClaudeHooks::UserPromptSubmit
7
+
8
+ def call
9
+ log "Executing AppendRules hook"
10
+
11
+ # Read the rule content
12
+ rule_content = read_rule_content
13
+
14
+ if rule_content
15
+ add_additional_context!(rule_content)
16
+ log "Successfully added rule content as additional context (#{rule_content.length} characters)"
17
+ else
18
+ log "No rule content found", level: :warn
19
+ end
20
+
21
+ output_data
22
+ end
23
+
24
+ private
25
+
26
+ def read_rule_content
27
+ rule_file_path = path_for('rules/post-user-prompt.rule.md')
28
+
29
+ if File.exist?(rule_file_path)
30
+ content = File.read(rule_file_path).strip
31
+ return content unless content.empty?
32
+ end
33
+
34
+ log "Rule file not found or empty at: #{rule_file_path}", level: :warn
35
+ log "Base directory: #{base_dir}"
36
+ nil
37
+ end
38
+ end
39
+
40
+ # If this file is run directly (for testing), call the hook script
41
+ if __FILE__ == $0
42
+ begin
43
+ require 'json'
44
+
45
+ input_data = JSON.parse(STDIN.read)
46
+ hook = AppendRules.new(input_data)
47
+ hook.call
48
+ puts hook.stringify_output
49
+ rescue JSON::ParserError => e
50
+ STDERR.puts "Error parsing JSON: #{e.message}"
51
+ puts JSON.generate({
52
+ continue: false,
53
+ stopReason: "JSON parsing error in AppendRules: #{e.message}",
54
+ suppressOutput: false
55
+ })
56
+ exit 0
57
+ rescue StandardError => e
58
+ STDERR.puts "Error in AppendRules hook: #{e.message}, #{e.backtrace.join("\n")}"
59
+ puts JSON.generate({
60
+ continue: false,
61
+ stopReason: "AppendRules execution error: #{e.message}",
62
+ suppressOutput: false
63
+ })
64
+ exit 0
65
+ end
66
+ end
@@ -0,0 +1,50 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'fileutils'
4
+ require 'claude_hooks'
5
+
6
+ # Example hook module that logs user prompts to a file
7
+ class LogUserPrompt < ClaudeHooks::UserPromptSubmit
8
+
9
+ def call
10
+ log "Executing LogUserPrompt hook"
11
+
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
+ timestamp = Time.now.strftime('%Y-%m-%d %H:%M:%S')
17
+
18
+ log <<~TEXT
19
+ Prompt: #{current_prompt}
20
+ Logged user prompt to #{log_file_path}
21
+ TEXT
22
+
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)
31
+ end
32
+ end
33
+
34
+ # If this file is run directly (for testing), call the hook
35
+ if __FILE__ == $0
36
+ begin
37
+ require 'json'
38
+
39
+ hook = LogUserPrompt.new(JSON.parse(STDIN.read))
40
+ hook.call
41
+ rescue StandardError => e
42
+ STDERR.puts "Error in LogUserPrompt hook: #{e.message}, #{e.backtrace.join("\n")}"
43
+ puts JSON.generate({
44
+ continue: false,
45
+ stopReason: "LogUserPrompt execution error: #{e.message}",
46
+ suppressOutput: false
47
+ })
48
+ exit 0
49
+ end
50
+ end
@@ -0,0 +1,44 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'claude_hooks'
4
+ require 'json'
5
+ require_relative '../user_prompt_submit/append_rules'
6
+ require_relative '../user_prompt_submit/log_user_prompt'
7
+
8
+ begin
9
+ # Read input from stdin
10
+ input_data = JSON.parse(STDIN.read)
11
+
12
+ # Execute all hook scripts
13
+ append_rules = AppendRules.new(input_data)
14
+ appended_rules_result = append_rules.call
15
+
16
+ log_user_prompt = LogUserPrompt.new(input_data)
17
+ log_user_prompt_result = log_user_prompt.call
18
+
19
+ # Merge all hook results intelligently using the UserPromptSubmit class method
20
+ hook_output = ClaudeHooks::UserPromptSubmit.merge_outputs(appended_rules_result, log_user_prompt_result)
21
+
22
+ # Output final merged result to Claude Code
23
+ puts JSON.generate(hook_output)
24
+
25
+ exit 0
26
+ rescue JSON::ParserError => e
27
+ STDERR.puts "Error parsing JSON: #{e.message}"
28
+
29
+ puts JSON.generate({
30
+ continue: false,
31
+ stopReason: "Hook JSON parsing error: #{e.message}",
32
+ suppressOutput: false
33
+ })
34
+ exit 1
35
+ rescue StandardError => e
36
+ STDERR.puts "Error in UserPromptSubmit hook: #{e.message} #{e.backtrace.join("\n")}"
37
+
38
+ puts JSON.generate({
39
+ continue: false,
40
+ stopReason: "Hook execution error: #{e.message} #{e.backtrace.join("\n")}",
41
+ suppressOutput: false
42
+ })
43
+ exit 1
44
+ end
@@ -0,0 +1,66 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'claude_hooks'
4
+
5
+ # Hook script that appends rules to user prompt
6
+ class AppendRules < ClaudeHooks::UserPromptSubmit
7
+
8
+ def call
9
+ log "Executing AppendRules hook"
10
+
11
+ # Read the rule content
12
+ rule_content = read_rule_content
13
+
14
+ if rule_content
15
+ add_additional_context!(rule_content)
16
+ log "Successfully added rule content as additional context (#{rule_content.length} characters)"
17
+ else
18
+ log "No rule content found", level: :warn
19
+ end
20
+
21
+ output_data
22
+ end
23
+
24
+ private
25
+
26
+ def read_rule_content
27
+ rule_file_path = path_for('rules/post-user-prompt.rule.md')
28
+
29
+ if File.exist?(rule_file_path)
30
+ content = File.read(rule_file_path).strip
31
+ return content unless content.empty?
32
+ end
33
+
34
+ log "Rule file not found or empty at: #{rule_file_path}", level: :warn
35
+ log "Base directory: #{base_dir}"
36
+ nil
37
+ end
38
+ end
39
+
40
+ # If this file is run directly (for testing), call the hook script
41
+ if __FILE__ == $0
42
+ begin
43
+ require 'json'
44
+
45
+ input_data = JSON.parse(STDIN.read)
46
+ hook = AppendRules.new(input_data)
47
+ hook.call
48
+ puts hook.stringify_output
49
+ rescue JSON::ParserError => e
50
+ STDERR.puts "Error parsing JSON: #{e.message}"
51
+ puts JSON.generate({
52
+ continue: false,
53
+ stopReason: "JSON parsing error in AppendRules: #{e.message}",
54
+ suppressOutput: false
55
+ })
56
+ exit 0
57
+ rescue StandardError => e
58
+ STDERR.puts "Error in AppendRules hook: #{e.message}, #{e.backtrace.join("\n")}"
59
+ puts JSON.generate({
60
+ continue: false,
61
+ stopReason: "AppendRules execution error: #{e.message}",
62
+ suppressOutput: false
63
+ })
64
+ exit 0
65
+ end
66
+ end
@@ -0,0 +1,50 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'fileutils'
4
+ require 'claude_hooks'
5
+
6
+ # Example hook module that logs user prompts to a file
7
+ class LogUserPrompt < ClaudeHooks::UserPromptSubmit
8
+
9
+ def call
10
+ log "Executing LogUserPrompt hook"
11
+
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
+ timestamp = Time.now.strftime('%Y-%m-%d %H:%M:%S')
17
+
18
+ log <<~TEXT
19
+ Prompt: #{current_prompt}
20
+ Logged user prompt to #{log_file_path}
21
+ TEXT
22
+
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)
31
+ end
32
+ end
33
+
34
+ # If this file is run directly (for testing), call the hook
35
+ if __FILE__ == $0
36
+ begin
37
+ require 'json'
38
+
39
+ hook = LogUserPrompt.new(JSON.parse(STDIN.read))
40
+ hook.call
41
+ rescue StandardError => e
42
+ STDERR.puts "Error in LogUserPrompt hook: #{e.message}, #{e.backtrace.join("\n")}"
43
+ puts JSON.generate({
44
+ continue: false,
45
+ stopReason: "LogUserPrompt execution error: #{e.message}",
46
+ suppressOutput: false
47
+ })
48
+ exit 0
49
+ end
50
+ end
@@ -0,0 +1,23 @@
1
+ {
2
+ "includeCoAuthoredBy": false,
3
+ "permissions": {
4
+ "allow": []
5
+ },
6
+ "hooks": {
7
+ "UserPromptSubmit": [
8
+ {
9
+ "matcher": "",
10
+ "hooks": [
11
+ {
12
+ "type": "command",
13
+ "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/entrypoints/user_prompt_submit.rb"
14
+ }
15
+ ]
16
+ }
17
+ ]
18
+ },
19
+ "env": {
20
+ "CLAUDE_BASH_MAINTAIN_PROJECT_WORKING_DIR": "1",
21
+ "DISABLE_TELEMETRY": "1"
22
+ }
23
+ }
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require_relative 'configuration'
5
+ require_relative 'logger'
6
+
7
+ module ClaudeHooks
8
+ # Base class for Claude Code hook scripts
9
+ class Base
10
+ # Common input fields for all hook types
11
+ COMMON_INPUT_FIELDS = %w[session_id transcript_path cwd hook_event_name].freeze
12
+
13
+ # Override in subclasses to specify hook type
14
+ def self.hook_type
15
+ raise NotImplementedError, "Subclasses must define hook_type"
16
+ end
17
+
18
+ # Override in subclasses to specify hook-specific input fields
19
+ def self.input_fields
20
+ raise NotImplementedError, "Subclasses must define input_fields"
21
+ end
22
+
23
+ def hook_type
24
+ self.class.hook_type
25
+ end
26
+
27
+ attr_reader :config, :input_data, :output_data, :logger
28
+ def initialize(input_data = {})
29
+ @config = Configuration.config
30
+ @input_data = input_data
31
+ @output_data = {
32
+ 'continue' => true,
33
+ 'stopReason' => '',
34
+ 'suppressOutput' => false
35
+ }
36
+ @logger = Logger.new(session_id, self.class.name)
37
+
38
+ validate_input!
39
+ end
40
+
41
+ # Main execution method - override in subclasses
42
+ def call
43
+ raise NotImplementedError, "Subclasses must implement the call method"
44
+ end
45
+
46
+ def stringify_output
47
+ JSON.generate(@output_data)
48
+ end
49
+
50
+ # === COMMON INPUT DATA ACCESS ===
51
+
52
+ def session_id
53
+ @input_data['session_id'] || 'claude-default-session'
54
+ end
55
+
56
+ def transcript_path
57
+ @input_data['transcript_path']
58
+ end
59
+
60
+ def cwd
61
+ @input_data['cwd']
62
+ end
63
+
64
+ def hook_event_name
65
+ @input_data['hook_event_name'] || @input_data['hookEventName'] || hook_type
66
+ end
67
+
68
+ def read_transcript
69
+ unless transcript_path && File.exist?(transcript_path)
70
+ log "Transcript file not found at #{transcript_path}", level: :warn
71
+ return ''
72
+ end
73
+
74
+ begin
75
+ File.read(transcript_path)
76
+ rescue => e
77
+ log "Error reading transcript file at #{transcript_path}: #{e.message}", level: :error
78
+ ''
79
+ end
80
+ end
81
+ alias_method :transcript, :read_transcript
82
+
83
+ # === COMMON OUTPUT HELPERS ===
84
+
85
+ # Allow Claude to continue (default: true)
86
+ def allow_continue!
87
+ @output_data['continue'] = true
88
+ end
89
+
90
+ def prevent_continue!(reason)
91
+ @output_data['continue'] = false
92
+ @output_data['stopReason'] = reason
93
+ end
94
+
95
+ # Hide stdout from transcript mode (default: false)
96
+ def suppress_output!
97
+ @output_data['suppressOutput'] = true
98
+ end
99
+
100
+ def show_output!
101
+ @output_data['suppressOutput'] = false
102
+ end
103
+
104
+ def clear_specifics!
105
+ @output_data['hookSpecificOutput'] = nil
106
+ end
107
+
108
+ # === CONFIG AND UTILITY METHODS ===
109
+
110
+ def base_dir
111
+ Configuration.base_dir
112
+ end
113
+
114
+ def path_for(relative_path)
115
+ Configuration.path_for(relative_path)
116
+ end
117
+
118
+ # Supports both single messages and blocks for multiline logging
119
+ def log(message = nil, level: :info, &block)
120
+ @logger.log(message, level: level, &block)
121
+ end
122
+
123
+ protected
124
+
125
+ # === MERGE HELPER BASE ===
126
+
127
+ # Handles common merging logic for all hook types
128
+ # Subclasses should call super and add their specific logic
129
+ def self.merge_outputs(*outputs_data)
130
+ compacted_outputs_data = outputs_data.compact
131
+ return { 'continue' => true, 'stopReason' => '', 'suppressOutput' => false } if compacted_outputs_data.empty?
132
+
133
+ # Initialize merged result with defaults
134
+ merged = { 'continue' => true, 'stopReason' => '', 'suppressOutput' => false }
135
+
136
+ # Apply common merge logic
137
+ compacted_outputs_data.each do |output|
138
+ merged['continue'] = false if output['continue'] == false
139
+ merged['stopReason'] = [merged['stopReason'], output['stopReason']].compact.reject(&:empty?).join('; ')
140
+ merged['suppressOutput'] = true if output['suppressOutput'] == true
141
+ end
142
+
143
+ merged
144
+ end
145
+
146
+ private
147
+
148
+ def validate_input!
149
+ expected_fields = COMMON_INPUT_FIELDS + self.class.input_fields
150
+ missing_fields = expected_fields - @input_data.keys
151
+
152
+ unless missing_fields.empty?
153
+ log "Missing required input fields for #{hook_type}: #{missing_fields.join(', ')}", level: :warn
154
+ end
155
+ end
156
+ end
157
+ end