claude_hooks 1.2.0 → 1.3.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.
@@ -1,35 +1,18 @@
1
1
  #!/usr/bin/env ruby
2
2
 
3
- require 'json'
3
+ require 'claude_hooks'
4
4
  require_relative '../handlers/session_end/cleanup_handler'
5
5
  require_relative '../handlers/session_end/log_session_stats'
6
6
 
7
- begin
8
- # Read input from stdin
9
- input_data = JSON.parse(STDIN.read)
10
-
11
- # Initialize handlers
7
+ ClaudeHooks::CLI.run_hook do |input_data|
12
8
  cleanup_handler = CleanupHandler.new(input_data)
13
9
  log_handler = LogSessionStats.new(input_data)
14
10
 
15
- # Execute handlers
16
11
  cleanup_handler.call
17
12
  log_handler.call
18
13
 
19
- # Merge outputs using the SessionEnd output merger
20
- merged_output = ClaudeHooks::Output::SessionEnd.merge(
14
+ ClaudeHooks::Output::SessionEnd.merge(
21
15
  cleanup_handler.output,
22
16
  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
17
+ ).output_and_exit
18
+ end
@@ -1,40 +1,18 @@
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
-
6
3
  require 'claude_hooks'
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'
11
4
  require_relative '../handlers/user_prompt_submit/append_rules'
12
5
  require_relative '../handlers/user_prompt_submit/log_user_prompt'
13
6
 
14
- begin
15
- # Read input from stdin
16
- input_data = JSON.parse(STDIN.read)
17
-
18
- # Execute all hook scripts
7
+ ClaudeHooks::CLI.run_hook do |input_data|
19
8
  append_rules = AppendRules.new(input_data)
20
9
  append_rules.call
21
10
 
22
11
  log_user_prompt = LogUserPrompt.new(input_data)
23
12
  log_user_prompt.call
24
13
 
25
- merged_output = ClaudeHooks::Output::UserPromptSubmit.merge(
14
+ ClaudeHooks::Output::UserPromptSubmit.merge(
26
15
  append_rules.output,
27
16
  log_user_prompt.output
28
- )
29
-
30
- merged_output.output_and_exit
31
-
32
- rescue StandardError => e
33
- # Same simple error pattern
34
- STDERR.puts JSON.generate({
35
- continue: false,
36
- stopReason: "Hook execution error: #{e.message} #{e.backtrace.join("\n")}",
37
- suppressOutput: false
38
- })
39
- exit 2
40
- end
17
+ ).output_and_exit
18
+ end
@@ -238,16 +238,10 @@ class GithubGuard < ClaudeHooks::PreToolUse
238
238
  end
239
239
  end
240
240
 
241
- # When running this file directly (for debugging)
242
- if __FILE__ == $PROGRAM_NAME
243
- ClaudeHooks::CLI.run_with_sample_data(GithubGuard) do |data|
244
- data.merge!(
245
- 'session_id' => 'GithubGuardTest',
246
- 'transcript_path' => '',
247
- 'cwd' => Dir.pwd,
248
- 'hook_event_name' => 'PreToolUse',
249
- 'tool_name' => 'mcp__github__create_pull_request',
250
- 'tool_input' => { 'draft' => false },
251
- )
252
- end
253
- end
241
+ # Registered directly in settings.json under PreToolUse. Claude Code runs this file
242
+ # with the hook payload on STDIN. on_error: :block makes the guard fail-closed — a
243
+ # crash blocks the tool instead of silently allowing it.
244
+ #
245
+ # Debug it by piping sample JSON:
246
+ # echo '{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"git push --force"}}' | ruby github_guard.rb
247
+ ClaudeHooks::CLI.run_hook(GithubGuard, on_error: :block)
@@ -10,7 +10,7 @@
10
10
  "hooks": [
11
11
  {
12
12
  "type": "command",
13
- "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/entrypoints/pre_tool_use.rb"
13
+ "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/github_guard.rb"
14
14
  }
15
15
  ]
16
16
  }
@@ -3,191 +3,126 @@
3
3
  require 'json'
4
4
 
5
5
  module ClaudeHooks
6
- # CLI utility for testing hook handlers in isolation
7
- # This module provides a standardized way to run hooks directly from the command line
8
- # for testing and debugging purposes.
9
6
  module CLI
10
7
  class << self
11
- # Run a hook class directly from command line
12
- # Usage:
13
- # ClaudeHooks::CLI.run_hook(YourHookClass)
14
- # ClaudeHooks::CLI.run_hook(YourHookClass, custom_input_data)
15
- #
16
- # # With customization block:
17
- # ClaudeHooks::CLI.run_hook(YourHookClass) do |input_data|
18
- # input_data['debug_mode'] = true
19
- # end
20
- def run_hook(hook_class, input_data = nil, &block)
21
- # If no input data provided, read from STDIN
22
- input_data ||= read_stdin_input
23
-
24
- # Apply customization block if provided
8
+ # Run a hook script from the command line.
9
+ # Reads JSON from STDIN, calls hook.call, and exits with the correct code.
10
+ #
11
+ # on_error controls what happens when the hook raises an unexpected exception:
12
+ # :allow (default) — exit 1, non-blocking; Claude continues as if the hook didn't run.
13
+ # :block — exit 2, blocking; Claude stops and shows the error. Use this for
14
+ # security/policy hooks where a crash should never silently pass through.
15
+ #
16
+ # Usage patterns:
17
+ #
18
+ # 1. Single hook class:
19
+ # ClaudeHooks::CLI.run_hook(MyHook)
20
+ # ClaudeHooks::CLI.run_hook(MyHook, on_error: :block) # fail-closed
21
+ #
22
+ # 2. Multiple hooks with merging:
23
+ # ClaudeHooks::CLI.run_hook(on_error: :block) do |input_data|
24
+ # hook1 = Hook1.new(input_data)
25
+ # hook2 = Hook2.new(input_data)
26
+ # hook1.call
27
+ # hook2.call
28
+ # ClaudeHooks::Output::PreToolUse.merge(hook1.output, hook2.output).output_and_exit
29
+ # end
30
+ def run_hook(hook_class = nil, on_error: :allow, &block)
31
+ input_data = JSON.parse(STDIN.read)
32
+
25
33
  if block_given?
26
34
  yield(input_data)
35
+ elsif hook_class
36
+ hook = hook_class.new(input_data)
37
+ hook.call
38
+ hook.output_and_exit
39
+ else
40
+ raise ArgumentError, "Either provide a hook_class or a block"
27
41
  end
28
-
29
- # Create and execute the hook
30
- hook = hook_class.new(input_data)
31
- result = hook.call
32
-
33
- # Output the result as JSON (same format as production hooks)
34
- puts JSON.generate(result) if result
35
-
36
- result
42
+
43
+ rescue JSON::ParserError => e
44
+ handle_run_error("JSON parsing error: #{e.message}", on_error)
45
+
37
46
  rescue StandardError => e
38
- handle_error(e, hook_class)
47
+ handle_run_error("Hook execution error: #{e.message}", on_error, backtrace: e.backtrace)
39
48
  end
40
49
 
41
- # Create a test runner block for a hook class
42
- # This generates the common if __FILE__ == $0 block content
43
- #
44
- # Usage:
45
- # ClaudeHooks::CLI.test_runner(YourHookClass)
46
- #
47
- # # With customization block:
48
- # ClaudeHooks::CLI.test_runner(YourHookClass) do |input_data|
49
- # input_data['custom_field'] = 'test_value'
50
- # input_data['user_name'] = 'TestUser'
51
- # end
50
+ # @deprecated Use {run_hook} instead.
51
+ def entrypoint(hook_class = nil, on_error: :allow, &block)
52
+ warn "[ClaudeHooks] CLI.entrypoint is deprecated — use CLI.run_hook instead."
53
+ run_hook(hook_class, on_error: on_error, &block)
54
+ end
55
+
56
+ # Testing helpers — use these inside `if __FILE__ == $0` blocks, not in production.
57
+
58
+ # Run a hook with input read from STDIN, with optional block to mutate input_data before running.
52
59
  def test_runner(hook_class, &block)
53
60
  input_data = read_stdin_input
54
-
55
- # Apply customization block if provided
56
- if block_given?
57
- yield(input_data)
58
- end
59
-
60
- run_hook(hook_class, input_data)
61
+ yield(input_data) if block_given?
62
+ run_hook_with_data(hook_class, input_data)
61
63
  end
62
64
 
63
- # Run hook with sample data (useful for development)
64
- # Usage:
65
- # ClaudeHooks::CLI.run_with_sample_data(YourHookClass)
66
- # ClaudeHooks::CLI.run_with_sample_data(YourHookClass, { 'prompt' => 'test prompt' })
67
- #
68
- # # With customization block:
69
- # ClaudeHooks::CLI.run_with_sample_data(YourHookClass) do |input_data|
70
- # input_data['prompt'] = 'Custom test prompt'
71
- # input_data['debug'] = true
72
- # end
65
+ # Run a hook with synthetic sample data (no STDIN needed).
73
66
  def run_with_sample_data(hook_class, sample_data = {}, &block)
74
- default_sample = {
67
+ input_data = {
75
68
  'session_id' => 'test-session',
76
69
  'transcript_path' => '/tmp/test_transcript.md',
77
70
  'cwd' => Dir.pwd,
78
71
  'hook_event_name' => hook_class.hook_type
79
- }
72
+ }.merge(sample_data)
80
73
 
81
- # Merge with hook-specific sample data
82
- merged_data = default_sample.merge(sample_data)
83
-
84
- # Apply customization block if provided
85
- if block_given?
86
- yield(merged_data)
87
- end
88
-
89
- run_hook(hook_class, merged_data)
74
+ yield(input_data) if block_given?
75
+ run_hook_with_data(hook_class, input_data)
90
76
  end
91
77
 
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
-
78
+ private
79
+
80
+ # Runs a hook with already-parsed input_data. Returns the result without exiting.
81
+ # Used internally by test_runner and run_with_sample_data.
82
+ def run_hook_with_data(hook_class, input_data)
83
+ hook = hook_class.new(input_data)
84
+ result = hook.call
85
+ puts JSON.generate(result) if result
86
+ result
149
87
  rescue StandardError => e
150
- STDERR.puts "Hook execution error: #{e.message}"
88
+ hook_name = hook_class.name || hook_class.to_s
89
+ STDERR.puts "Error in #{hook_name} hook: #{e.message}"
151
90
  STDERR.puts e.backtrace.join("\n") if e.backtrace
152
-
153
- error_response = {
91
+ response = JSON.generate({
154
92
  continue: false,
155
- stopReason: "Hook execution error: #{e.message}",
93
+ stopReason: "#{hook_name} execution error: #{e.message}",
156
94
  suppressOutput: false
157
- }
158
- response = JSON.generate(error_response)
95
+ })
159
96
  puts response
160
97
  STDERR.puts response
161
98
  exit 1
162
99
  end
163
100
 
164
- private
101
+ def handle_run_error(message, on_error, backtrace: nil)
102
+ if on_error == :block
103
+ # Exit 2: Claude Code shows stderr to the model as plain text (never
104
+ # parsed as JSON), so emit just the message and block.
105
+ STDERR.puts message
106
+ exit 2
107
+ else
108
+ # Exit 1: non-blocking. stderr's first line surfaces in the transcript.
109
+ STDERR.puts backtrace.join("\n") if backtrace
110
+ STDERR.puts JSON.generate({
111
+ continue: false,
112
+ stopReason: message,
113
+ suppressOutput: false
114
+ })
115
+ exit 1
116
+ end
117
+ end
165
118
 
166
119
  def read_stdin_input
167
120
  stdin_content = STDIN.read.strip
168
121
  return {} if stdin_content.empty?
169
-
170
122
  JSON.parse(stdin_content)
171
123
  rescue JSON::ParserError => e
172
124
  raise "Invalid JSON input: #{e.message}"
173
125
  end
174
-
175
- def handle_error(error, hook_class)
176
- STDERR.puts "Error in #{hook_class.name} hook: #{error.message}"
177
- STDERR.puts error.backtrace.join("\n") if error.backtrace
178
-
179
- # Output error response in Claude Code format
180
- error_response = {
181
- continue: false,
182
- stopReason: "#{hook_class.name} execution error: #{error.message}",
183
- suppressOutput: false
184
- }
185
-
186
- response = JSON.generate(error_response)
187
- puts response
188
- STDERR.puts response
189
- exit 1
190
- end
191
126
  end
192
127
  end
193
- end
128
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module ClaudeHooks
6
+ class DirectoryAdded < Base
7
+ def self.hook_type
8
+ 'DirectoryAdded'
9
+ end
10
+
11
+ def self.input_fields
12
+ %w[directory source]
13
+ end
14
+
15
+ def directory
16
+ @input_data['directory']
17
+ end
18
+
19
+ # Values: slash_command | register_repo_root
20
+ def source
21
+ @input_data['source']
22
+ end
23
+ end
24
+ end
@@ -180,6 +180,8 @@ module ClaudeHooks
180
180
  ElicitationResult.new(data)
181
181
  when 'WorktreeCreate'
182
182
  WorktreeCreate.new(data)
183
+ when 'DirectoryAdded'
184
+ DirectoryAdded.new(data)
183
185
  else
184
186
  raise ArgumentError, "Unknown hook type: #{hook_type}"
185
187
  end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module ClaudeHooks
6
+ module Output
7
+ # DirectoryAdded is non-blocking — the directory is already added.
8
+ # Only `systemMessage` is consumed (as Claude context for `slash_command`,
9
+ # debug log for `register_repo_root`).
10
+ class DirectoryAdded < Base
11
+ def exit_code
12
+ 0
13
+ end
14
+
15
+ def self.merge(*outputs)
16
+ merged = super(*outputs)
17
+ new(merged.data)
18
+ end
19
+ end
20
+ end
21
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ClaudeHooks
4
- VERSION = "1.2.0"
4
+ VERSION = "1.3.0"
5
5
  end
data/lib/claude_hooks.rb CHANGED
@@ -37,6 +37,7 @@ require_relative "claude_hooks/permission_denied"
37
37
  require_relative "claude_hooks/elicitation"
38
38
  require_relative "claude_hooks/elicitation_result"
39
39
  require_relative "claude_hooks/worktree_create"
40
+ require_relative "claude_hooks/directory_added"
40
41
 
41
42
  # Output classes
42
43
  require_relative "claude_hooks/output/base"
@@ -70,6 +71,7 @@ require_relative "claude_hooks/output/permission_denied"
70
71
  require_relative "claude_hooks/output/elicitation"
71
72
  require_relative "claude_hooks/output/elicitation_result"
72
73
  require_relative "claude_hooks/output/worktree_create"
74
+ require_relative "claude_hooks/output/directory_added"
73
75
 
74
76
  module ClaudeHooks
75
77
  class Error < StandardError; end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: claude_hooks
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.0
4
+ version: 1.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Gabriel Dehan
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-07-14 00:00:00.000000000 Z
11
+ date: 2026-08-15 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: json
@@ -64,7 +64,6 @@ files:
64
64
  - ".agents/skills/ci-monitoring/SKILL.md"
65
65
  - ".agents/skills/run-tests/SKILL.md"
66
66
  - ".claude/auto-fix.md"
67
- - ".claude/settings.local.json"
68
67
  - AGENTS.md
69
68
  - CHANGELOG.md
70
69
  - README.md
@@ -74,6 +73,7 @@ files:
74
73
  - docs/API/COMMON.md
75
74
  - docs/API/CONFIG_CHANGE.md
76
75
  - docs/API/CWD_CHANGED.md
76
+ - docs/API/DIRECTORY_ADDED.md
77
77
  - docs/API/ELICITATION.md
78
78
  - docs/API/ELICITATION_RESULT.md
79
79
  - docs/API/FILE_CHANGED.md
@@ -110,10 +110,9 @@ files:
110
110
  - docs/mitts/setup.md
111
111
  - docs/mitts/task.md
112
112
  - example_dotclaude/commands/.gitkeep
113
- - example_dotclaude/hooks/entrypoints/pre_tool_use.rb
114
113
  - example_dotclaude/hooks/entrypoints/session_end.rb
115
114
  - example_dotclaude/hooks/entrypoints/user_prompt_submit.rb
116
- - example_dotclaude/hooks/handlers/pre_tool_use/github_guard.rb
115
+ - example_dotclaude/hooks/github_guard.rb
117
116
  - example_dotclaude/hooks/handlers/session_end/cleanup_handler.rb
118
117
  - example_dotclaude/hooks/handlers/session_end/log_session_stats.rb
119
118
  - example_dotclaude/hooks/handlers/user_prompt_submit/append_rules.rb
@@ -126,6 +125,7 @@ files:
126
125
  - lib/claude_hooks/config_change.rb
127
126
  - lib/claude_hooks/configuration.rb
128
127
  - lib/claude_hooks/cwd_changed.rb
128
+ - lib/claude_hooks/directory_added.rb
129
129
  - lib/claude_hooks/elicitation.rb
130
130
  - lib/claude_hooks/elicitation_result.rb
131
131
  - lib/claude_hooks/file_changed.rb
@@ -136,6 +136,7 @@ files:
136
136
  - lib/claude_hooks/output/base.rb
137
137
  - lib/claude_hooks/output/config_change.rb
138
138
  - lib/claude_hooks/output/cwd_changed.rb
139
+ - lib/claude_hooks/output/directory_added.rb
139
140
  - lib/claude_hooks/output/elicitation.rb
140
141
  - lib/claude_hooks/output/elicitation_result.rb
141
142
  - lib/claude_hooks/output/file_changed.rb
@@ -1,21 +0,0 @@
1
- {
2
- "env": {
3
- "CLAUDE_CODE_SUBAGENT_MODEL": "sonnet"
4
- },
5
- "permissions": {
6
- "allow": [
7
- "Read(//Users/gdehan/**)",
8
- "Read(//Users/gdehan/.config/**)",
9
- "Bash(herdr --help)",
10
- "Bash(herdr config *)",
11
- "Bash(herdr api *)",
12
- "Bash(python3 -m json.tool)",
13
- "Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\(list\\(d.keys\\(\\)\\)\\)\")",
14
- "Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\(json.dumps\\(list\\(d.keys\\(\\)\\), indent=2\\)\\)\")",
15
- "Bash(python3 -c ' *)",
16
- "Bash(herdr server *)",
17
- "WebFetch(domain:opencode.ai)",
18
- "Bash(ruby *)"
19
- ]
20
- }
21
- }
@@ -1,25 +0,0 @@
1
- #!/usr/bin/env ruby
2
- # frozen_string_literal: true
3
-
4
- require 'claude_hooks'
5
- require_relative '../handlers/pre_tool_use/github_guard'
6
-
7
- begin
8
- # Read Claude Code input from stdin
9
- input_data = JSON.parse($stdin.read)
10
-
11
- github_guard = GithubGuard.new(input_data)
12
- github_guard.call
13
-
14
- github_guard.output_and_exit
15
- rescue StandardError => e
16
- puts JSON.generate(
17
- {
18
- continue: false,
19
- stopReason: "Error in PreToolUse hook, #{e.message}, #{e.backtrace.join("\n")}",
20
- suppressOutput: false,
21
- },
22
- )
23
- # Allow anyway, to not block developers if there is an issue with the hook
24
- exit 1
25
- end