claude_hooks 0.1.1 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8eb1adbb7a091ea436fa56940baf40407eb2d0f148760d7f1c470d4577794744
4
- data.tar.gz: cf79657245bd18e98ba407d49d49b4fe539fd7feb4be4daa46c4cee00e5ea1a7
3
+ metadata.gz: 518b315ef2a184008b91029cdb9735b77ceee67490618d9581be16ff0c42cdb2
4
+ data.tar.gz: c469281a8f36dce611c1aad3f60611d5951219d1718846739d711fc310219c6f
5
5
  SHA512:
6
- metadata.gz: c0303fbf858d8869ff0a326ce5031193f684cd6ca76d1fce9657b52439f1a789b06cbdcdf161663478b65e35455f5d9e756bd595eeb1e065d47be6e8936782e1
7
- data.tar.gz: 8a134f14432693883b43576f7c66b9e9e68acd4f0c97e10be1ad91893a2e8f1d25cc30445d41afd087a923272105e32bb0df110f4834d3930c275d4afc263181
6
+ metadata.gz: c500ced26412c6666d19cbef211afe43b89dcaa1b6282f6fdeb5ec24874981621d3ef7b742a1c3fc45d9f68c91e838d0c16a6e90fb63c81a6f90facfb0f23e11
7
+ data.tar.gz: db4d7cc2ca66d8d6cb7c3582cda5433f2b6882998f600a43cda170f58951246fd5394704b4ce6136c461138160c509d0e5bbec3390530329b754b11aa62911dc
data/CHANGELOG.md CHANGED
@@ -5,6 +5,39 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.2.0] - 2025-08-21
9
+
10
+ ### Added
11
+ - **Dual Configuration System**: Support for both home-level (`$HOME/.claude`) and project-level (`$CLAUDE_PROJECT_DIR/.claude`) configurations
12
+ - **Configuration Merging**: Intelligent merging of home and project configs with configurable precedence
13
+ - New environment variable `CLAUDE_HOOKS_CONFIG_MERGE_STRATEGY` to control merge behavior ("project" or "home")
14
+ - New directory access methods: `home_claude_dir`, `project_claude_dir`
15
+ - New path utility methods: `home_path_for(path)`, `project_path_for(path)`
16
+ - Enhanced `path_for(path, base_dir=nil)` method with optional base directory parameter
17
+ - Comprehensive test suite for configuration functionality (`test/` directory)
18
+ - Configuration validation and edge case handling for missing `CLAUDE_PROJECT_DIR`
19
+
20
+ ### Changed
21
+ - **Logs Location**: Logs now always go to `$HOME/.claude/{logDirectory}` regardless of active configuration
22
+ - Configuration loading now supports dual config file locations with intelligent merging
23
+ - Enhanced documentation with comprehensive dual configuration examples
24
+ - Updated API reference with new directory and path methods
25
+
26
+ ### Deprecated
27
+ - `base_dir` method (still functional for backward compatibility)
28
+ - `RUBY_CLAUDE_HOOKS_BASE_DIR` environment variable (still supported as fallback)
29
+
30
+ ### Fixed
31
+ - Graceful handling of undefined `CLAUDE_PROJECT_DIR` environment variable
32
+ - Proper path resolution when project directory is not available
33
+ - Backward compatibility maintained for all existing hook scripts
34
+
35
+ ### Migration Notes
36
+ - Existing configurations continue to work without changes
37
+ - New projects can leverage dual configuration system
38
+ - `base_dir` and legacy `path_for` methods remain functional
39
+ - Environment variables maintain same precedence over config files
40
+
8
41
  ## [0.1.0] - 2025-08-17
9
42
 
10
43
  ### Added
data/README.md CHANGED
@@ -4,6 +4,8 @@ A Ruby DSL (Domain Specific Language) for creating Claude Code hooks. This will
4
4
 
5
5
  [**Why use this instead of writing bash, or simple ruby scripts?**](WHY.md)
6
6
 
7
+ > You might also be interested in my other project, a [Claude Code statusline](https://github.com/gabriel-dehan/claude_monitor_statusline) that shows your Claude usage in realtime ✨.
8
+
7
9
  ## 🚀 Quick Start
8
10
 
9
11
  > [!TIP]
@@ -19,6 +21,7 @@ Here's how to create a simple hook:
19
21
  1. **Create a simple hook script**
20
22
  ```ruby
21
23
  #!/usr/bin/env ruby
24
+ require 'json'
22
25
  require 'claude_hooks'
23
26
 
24
27
  # Inherit from the right hook type class to get access to helper methods
@@ -43,10 +46,11 @@ if __FILE__ == $0
43
46
  end
44
47
  ```
45
48
 
46
- 3. ⚠️ **Make it executable (and test it)**
49
+ 3. ⚠️ **Make it executable**
47
50
  ```bash
48
51
  chmod +x add_context_after_prompt.rb
49
- echo '{"session_id":"test","prompt":"Hello!"}' | ruby add_context_after_prompt.rb
52
+ # Test it
53
+ echo '{"session_id":"test","prompt":"Hello!"}' | ./add_context_after_prompt.rb
50
54
  ```
51
55
 
52
56
  4. **Register it in your `.claude/settings.json`**
@@ -72,83 +76,120 @@ That's it! Your hook will now add context to every user prompt. 🎉
72
76
 
73
77
  ## 📦 Installation
74
78
 
75
- Add to your Gemfile (you can add a Gemfile in your `.claude` directory if needed):
79
+ Install it globally (simpler):
80
+
81
+ ```bash
82
+ $ gem install claude_hooks
83
+ ```
84
+
85
+ **Note:** Claude Code itself will still use the system-installed gem, not the bundled version unless you use `bundle exec` to run it in your `.claude/settings.json`.
86
+
87
+ Or add it to your Gemfile (you can add a Gemfile in your `.claude` directory if needed):
88
+
76
89
 
77
90
  ```ruby
91
+ # .claude/Gemfile
92
+ source 'https://rubygems.org'
93
+
78
94
  gem 'claude_hooks'
79
95
  ```
80
96
 
81
- And then execute:
97
+ And then run:
82
98
 
83
99
  ```bash
84
100
  $ bundle install
85
101
  ```
86
102
 
87
- Or install it globally:
88
-
89
- ```bash
90
- $ gem install claude_hooks
91
- ```
103
+ > [!WARNING]
104
+ > If you use a Gemfile, you need to use `bundle exec` to run your hooks in your `.claude/settings.json`.
92
105
 
93
106
  ### 🔧 Configuration
94
107
 
95
- This gem uses either environment variables or a global configuration file.
108
+ Claude Hooks supports both home-level (`$HOME/.claude`) and project-level (`$CLAUDE_PROJECT_DIR/.claude`) directories. Claude Hooks specific config files (`config/config.json`) found in either directory will be merged together.
96
109
 
110
+ | Directory | Description | Purpose |
111
+ |-----------|-------------|---------|
112
+ | `$HOME/.claude` | Home Claude directory | Global user settings and logs |
113
+ | `$CLAUDE_PROJECT_DIR/.claude` | Project Claude directory | Project-specific settings |
97
114
 
98
- #### Required Configuration Options
115
+ > [!NOTE]
116
+ > Logs always go to `$HOME/.claude/{logDirectory}`
99
117
 
100
- | Option | Description | Default |
101
- |--------|-------------|---------|
102
- | `baseDir` | Base directory for all Claude files | `~/.claude` |
103
- | `logDirectory` | Directory for logs (relative to baseDir) | `logs` |
118
+ #### Environment Variables
104
119
 
105
- #### Environment Variables (Preferred)
106
-
107
- The gem uses environment variables with the `RUBY_CLAUDE_HOOKS_` prefix for configuration:
120
+ You can configure Claude Hooks through environment variables with the `RUBY_CLAUDE_HOOKS_` prefix:
108
121
 
109
122
  ```bash
110
- export RUBY_CLAUDE_HOOKS_BASE_DIR="~/.claude" # Default: ~/.claude
111
- export RUBY_CLAUDE_HOOKS_LOG_DIR="logs" # Default: logs (relative to base_dir)
123
+ # Existing configuration options
124
+ export RUBY_CLAUDE_HOOKS_LOG_DIR="logs" # Default: logs (relative to HOME/.claude)
125
+ export CLAUDE_HOOKS_CONFIG_MERGE_STRATEGY="project" # Config merge strategy: "project" or "home", default: "project"
126
+ export RUBY_CLAUDE_HOOKS_BASE_DIR="~/.claude" # Deprecated: fallback base directory
112
127
 
113
- # You can add any custom configuration
128
+ # Any variable prefixed with RUBY_CLAUDE_HOOKS_ will also be available through the config object
114
129
  export RUBY_CLAUDE_HOOKS_API_KEY="your-api-key"
115
130
  export RUBY_CLAUDE_HOOKS_DEBUG_MODE="true"
116
131
  export RUBY_CLAUDE_HOOKS_USER_NAME="Gabriel"
117
132
  ```
118
133
 
119
- #### Configuration File
134
+ #### Configuration Files
120
135
 
121
- You can choose to use a global configuration file by setting it up in `~/.claude/config/config.json`.
122
- The gem will read from it as fallback for any missing environment variables.
136
+ You can also use configuration files in any of the two locations:
123
137
 
138
+ **Home config** (`$HOME/.claude/config/config.json`):
124
139
  ```json
125
140
  {
126
- "baseDir": "~/.claude",
141
+ // Existing configuration option
127
142
  "logDirectory": "logs",
128
- "apiKey": "your-api-key",
129
- "debugMode": true,
143
+ // Custom configuration options
144
+ "apiKey": "your-global-api-key",
130
145
  "userName": "Gabriel"
131
146
  }
132
147
  ```
133
148
 
134
- #### Accessing Custom Configuration
149
+ **Project config** (`$CLAUDE_PROJECT_DIR/.claude/config/config.json`):
150
+ ```json
151
+ {
152
+ // Custom configuration option
153
+ "projectSpecificConfig": "someValue",
154
+ }
155
+ ```
156
+
157
+ #### Configuration Merging
158
+
159
+ When both config files exist, they will be merged with configurable precedence:
160
+
161
+ - **Default (`project`)**: Project config values override home config values
162
+ - **Home precedence (`home`)**: Home config values override project config values
163
+
164
+ Set merge strategy: `export CLAUDE_HOOKS_CONFIG_MERGE_STRATEGY="home" | "project"` (default: "project")
165
+
166
+ > [!WARNING]
167
+ > Environment Variables > Merged Config Files
168
+
169
+ #### Accessing Configuration Variables
135
170
 
136
171
  You can access any configuration value in your handlers:
137
172
 
138
173
  ```ruby
139
174
  class MyHandler < ClaudeHooks::UserPromptSubmit
140
175
  def call
141
- # Access built-in config
142
- log "Base dir: #{config.base_dir}"
176
+ # Access directory paths
177
+ log "Home Claude dir: #{home_claude_dir}"
178
+ log "Project Claude dir: #{project_claude_dir}" # nil if CLAUDE_PROJECT_DIR not set
179
+ log "Base dir (deprecated): #{base_dir}"
143
180
  log "Logs dir: #{config.logs_directory}"
144
181
 
182
+ # Path utilities
183
+ log "Home config path: #{home_path_for('config')}"
184
+ log "Project hooks path: #{project_path_for('hooks')}" # nil if no project dir
185
+
145
186
  # Access custom config via method calls
146
187
  log "API Key: #{config.api_key}"
147
188
  log "Debug mode: #{config.debug_mode}"
148
189
  log "User: #{config.user_name}"
149
190
 
150
191
  # Or use get_config_value for more control
151
- user_name = config.get_config_value('USER_NAME', 'userName', )
192
+ user_name = config.get_config_value('USER_NAME', 'userName')
152
193
  log "Username: #{user_name}"
153
194
 
154
195
  output_data
@@ -156,18 +197,16 @@ class MyHandler < ClaudeHooks::UserPromptSubmit
156
197
  end
157
198
  ```
158
199
 
159
- **Configuration Priority:** Environment variables always take precedence over config file values.
160
-
161
200
  ## 📖 Table of Contents
162
201
 
163
202
  - [Ruby DSL for Claude Code hooks](#ruby-dsl-for-claude-code-hooks)
164
203
  - [🚀 Quick Start](#-quick-start)
165
204
  - [📦 Installation](#-installation)
166
205
  - [🔧 Configuration](#-configuration)
167
- - [Required Configuration Options](#required-configuration-options)
168
- - [Environment Variables (Preferred)](#environment-variables-preferred)
169
- - [Configuration File](#configuration-file)
170
- - [Accessing Custom Configuration](#accessing-custom-configuration)
206
+ - [Environment Variables](#environment-variables)
207
+ - [Configuration Files](#configuration-files)
208
+ - [Configuration Merging](#configuration-merging)
209
+ - [Accessing Configuration Variables](#accessing-configuration-variables)
171
210
  - [📖 Table of Contents](#-table-of-contents)
172
211
  - [🏗️ Architecture](#️-architecture)
173
212
  - [Core Components](#core-components)
@@ -175,7 +214,7 @@ end
175
214
  - [🪝 Hook Types](#-hook-types)
176
215
  - [🚀 Claude Hook Flow](#-claude-hook-flow)
177
216
  - [A very simplified view of how a hook works in Claude Code](#a-very-simplified-view-of-how-a-hook-works-in-claude-code)
178
- - [🔄 Claude Hook Execution Flow](#-claude-hook-execution-flow)
217
+ - [🔄 Proposal: a more robust Claude Hook execution flow](#-proposal-a-more-robust-claude-hook-execution-flow)
179
218
  - [Basic Hook Handler Structure](#basic-hook-handler-structure)
180
219
  - [Input Fields](#input-fields)
181
220
  - [📚 API Reference](#-api-reference)
@@ -183,7 +222,9 @@ end
183
222
  - [Input Methods](#input-methods)
184
223
  - [Output Methods](#output-methods)
185
224
  - [Class Output Methods](#class-output-methods)
225
+ - [Configuration and Utility Methods](#configuration-and-utility-methods)
186
226
  - [Utility Methods](#utility-methods)
227
+ - [Configuration Methods](#configuration-methods)
187
228
  - [UserPromptSubmit API](#userpromptsubmit-api)
188
229
  - [Input Methods](#input-methods-1)
189
230
  - [Output Methods](#output-methods-1)
@@ -209,9 +250,6 @@ end
209
250
  - [SessionStart API](#sessionstart-api)
210
251
  - [Input Methods](#input-methods-8)
211
252
  - [Output Methods](#output-methods-8)
212
- - [Configuration and Utility Methods](#configuration-and-utility-methods)
213
- - [Configuration Methods](#configuration-methods)
214
- - [Utility Methods](#utility-methods-2)
215
253
  - [📝 Logging](#-logging)
216
254
  - [Log File Location](#log-file-location)
217
255
  - [Log Output Format](#log-output-format)
@@ -225,6 +263,14 @@ end
225
263
  - [🚨 Advices](#-advices)
226
264
  - [⚠️ Troubleshooting](#️-troubleshooting)
227
265
  - [Make your entrypoint scripts executable](#make-your-entrypoint-scripts-executable)
266
+ - [🧪 CLI Debugging](#-cli-debugging)
267
+ - [Basic Usage](#basic-usage)
268
+ - [Customization with Blocks](#customization-with-blocks)
269
+ - [Testing Methods](#testing-methods)
270
+ - [1. Test with STDIN (default)](#1-test-with-stdin-default)
271
+ - [2. Test with default sample data instead of STDIN](#2-test-with-default-sample-data-instead-of-stdin)
272
+ - [3. Test with Sample Data + Customization](#3-test-with-sample-data--customization)
273
+ - [Example Hook with CLI Testing](#example-hook-with-cli-testing)
228
274
  - [🐛 Debugging](#-debugging)
229
275
  - [Test an individual entrypoint](#test-an-individual-entrypoint)
230
276
 
@@ -281,10 +327,10 @@ The framework supports the following hook types:
281
327
 
282
328
  ```mermaid
283
329
  graph LR
284
- A[Hook triggers] --> B[JSON from STDIN] --> C[Hook does its thing] --> D[JSON to STDOUT or STDERR]
330
+ A[Hook triggers] --> B[JSON from STDIN] --> C[Hook does its thing] --> D[JSON to STDOUT or STDERR] --> E[Yields back to Claude Code] --> A
285
331
  ```
286
332
 
287
- ### 🔄 Claude Hook Execution Flow
333
+ ### 🔄 Proposal: a more robust Claude Hook execution flow
288
334
 
289
335
  1. An entrypoint for a hook is set in `~/.claude/settings.json`
290
336
  2. Claude Code calls the entrypoint script (e.g., `hooks/entrypoints/pre_tool_use.rb`)
@@ -301,8 +347,8 @@ graph TD
301
347
  C --> D[📋 Entrypoint<br />Parses JSON from STDIN]
302
348
  D --> E[📋 Entrypoint<br />Calls hook handlers]
303
349
 
304
- E --> F[📝 AppendContextRules.call<br/><em>Returns output_data</em>]
305
- E --> G[📝 PromptGuard.call<br/><em>Returns output_data</em>]
350
+ E --> F[📝 Handler<br />AppendContextRules.call<br/><em>Returns output_data</em>]
351
+ E --> G[📝 Handler<br />PromptGuard.call<br/><em>Returns output_data</em>]
306
352
 
307
353
  F --> J[📋 Entrypoint<br />Calls _ClaudeHooks::UserPromptSubmit.merge_outputs_ to 🔀 merge outputs]
308
354
  G --> J
@@ -405,11 +451,30 @@ Each hook type provides a **class method** `merge_outputs` that will try to inte
405
451
  |--------|-------------|
406
452
  | `merge_outputs(*outputs_data)` | Intelligently merge multiple outputs into a single output |
407
453
 
454
+ ### Configuration and Utility Methods
455
+
456
+ Available in all hooks via the base `ClaudeHooks::Base` class:
457
+
408
458
  #### Utility Methods
409
459
  | Method | Description |
410
460
  |--------|-------------|
411
461
  | `log(message, level: :info)` | Log to session-specific file (levels: :info, :warn, :error) |
412
462
 
463
+ #### Configuration Methods
464
+ | Method | Description |
465
+ |--------|-------------|
466
+ | `home_claude_dir` | Get the home Claude directory (`$HOME/.claude`) |
467
+ | `project_claude_dir` | Get the project Claude directory (`$CLAUDE_PROJECT_DIR/.claude`, or `nil`) |
468
+ | `home_path_for(relative_path)` | Get absolute path relative to home Claude directory |
469
+ | `project_path_for(relative_path)` | Get absolute path relative to project Claude directory (or `nil`) |
470
+ | `base_dir` | Get the base Claude directory (**deprecated**) |
471
+ | `path_for(relative_path, base_dir=nil)` | Get absolute path relative to specified or default base dir (**deprecated**) |
472
+ | `config` | Access the merged configuration object |
473
+ | `config.get_config_value(env_key, config_file_key, default)` | Get any config value with fallback |
474
+ | `config.logs_directory` | Get logs directory path (always under home directory) |
475
+ | `config.your_custom_key` | Access any custom config via method_missing |
476
+
477
+
413
478
  ### UserPromptSubmit API
414
479
 
415
480
  Available when inheriting from `ClaudeHooks::UserPromptSubmit`:
@@ -543,26 +608,6 @@ Available when inheriting from `ClaudeHooks::SessionStart`:
543
608
  | `add_context!(context)` | Alias for `add_additional_context!` |
544
609
  | `empty_additional_context!` | Clear additional context |
545
610
 
546
- ### Configuration and Utility Methods
547
-
548
- Available in all hooks via the base `ClaudeHooks::Base` class:
549
-
550
- #### Configuration Methods
551
- | Method | Description |
552
- |--------|-------------|
553
- | `base_dir` | Get the base Claude directory |
554
- | `path_for(relative_path)` | Get absolute path relative to base dir |
555
- | `config` | Access the full configuration object |
556
- | `config.get_config_value(env_key, config_key, default)` | Get any config value with fallback |
557
- | `config.logs_directory` | Get logs directory path |
558
- | `config.your_custom_key` | Access any custom config via method_missing |
559
-
560
- #### Utility Methods
561
- | Method | Description |
562
- |--------|-------------|
563
- | `log(message, level: :info)` | Log to session-specific file (levels: :info, :warn, :error) |
564
- | `log(level: :info) { block }` | Multiline logging with block support |
565
-
566
611
  ### 📝 Logging
567
612
 
568
613
  `ClaudeHooks::Base` provides a **session logger** that will write logs to session-specific files.
@@ -579,6 +624,14 @@ log <<~TEXT
579
624
  TEXT
580
625
  ```
581
626
 
627
+ You can also use the logger from an entrypoint script:
628
+ ```ruby
629
+ require 'claude_hooks'
630
+
631
+ logger = ClaudeHooks::Logger.new("TEST-SESSION-01", 'entrypoint')
632
+ logger.log "Simple message"
633
+ ```
634
+
582
635
  #### Log File Location
583
636
  Logs are written to session-specific files in the configured log directory:
584
637
  - **Defaults to**: `~/.claude/logs/hooks/session-{session_id}.log`
@@ -737,7 +790,7 @@ exit 0
737
790
  For the operation to stop for a UserPromptSubmit hook, you would return structured JSON data followed by `exit 1`:
738
791
 
739
792
  ```ruby
740
- $stderr.puts JSON.generate({
793
+ STDERR.puts JSON.generate({
741
794
  continue: false,
742
795
  stopReason: "JSON parsing error: #{e.message}",
743
796
  suppressOutput: false
@@ -746,13 +799,13 @@ exit 1
746
799
  ```
747
800
 
748
801
  > [!WARNING]
749
- > Don't forget to use `$stderr.puts` to output the JSON to STDERR.
802
+ > Don't forget to use `STDERR.puts` to output the JSON to STDERR.
750
803
 
751
804
 
752
805
  ## 🚨 Advices
753
806
 
754
807
  1. **Logging**: Use `log()` method instead of `puts` to avoid interfering with JSON output
755
- 2. **Error Handling**: Hooks should handle their own errors and use the `log` method for debugging. For errors, don't forget to exit with the right exit code (1, 2) and output the JSON indicating the error to STDERR using `$stderr.puts`.
808
+ 2. **Error Handling**: Hooks should handle their own errors and use the `log` method for debugging. For errors, don't forget to exit with the right exit code (1, 2) and output the JSON indicating the error to STDERR using `STDERR.puts`.
756
809
  3. **Output Format**: Always return `output_data` or `nil` from your `call` method
757
810
  4. **Path Management**: Use `path_for()` for all file operations relative to the Claude base directory
758
811
 
@@ -767,6 +820,108 @@ chmod +x ~/.claude/hooks/entrypoints/user_prompt_submit.rb
767
820
  ```
768
821
 
769
822
 
823
+ ## 🧪 CLI Debugging
824
+
825
+ The `ClaudeHooks::CLI` module provides utilities to simplify testing hooks in isolation. Instead of writing repetitive JSON parsing and error handling code, you can use the CLI test runner.
826
+
827
+ ### Basic Usage
828
+
829
+ Replace the traditional testing boilerplate:
830
+
831
+ ```ruby
832
+ # Old way (15+ lines of repetitive code)
833
+ if __FILE__ == $0
834
+ begin
835
+ require 'json'
836
+ input_data = JSON.parse(STDIN.read)
837
+ hook = MyHook.new(input_data)
838
+ result = hook.call
839
+ puts JSON.generate(result)
840
+ rescue StandardError => e
841
+ STDERR.puts "Error: #{e.message}"
842
+ puts JSON.generate({
843
+ continue: false,
844
+ stopReason: "Error: #{e.message}",
845
+ suppressOutput: false
846
+ })
847
+ exit 1
848
+ end
849
+ end
850
+ ```
851
+
852
+ With the simple CLI test runner:
853
+
854
+ ```ruby
855
+ # New way (1 line!)
856
+ if __FILE__ == $0
857
+ ClaudeHooks::CLI.test_runner(MyHook)
858
+ end
859
+ ```
860
+
861
+ ### Customization with Blocks
862
+
863
+ You can customize the input data for testing using blocks:
864
+
865
+ ```ruby
866
+ if __FILE__ == $0
867
+ ClaudeHooks::CLI.test_runner(MyHook) do |input_data|
868
+ input_data['debug_mode'] = true
869
+ input_data['custom_field'] = 'test_value'
870
+ input_data['user_name'] = 'TestUser'
871
+ end
872
+ end
873
+ ```
874
+
875
+ ### Testing Methods
876
+
877
+ #### 1. Test with STDIN (default)
878
+ ```ruby
879
+ ClaudeHooks::CLI.test_runner(MyHook)
880
+ # Usage: echo '{"session_id":"test","prompt":"Hello"}' | ruby my_hook.rb
881
+ ```
882
+
883
+ #### 2. Test with default sample data instead of STDIN
884
+ ```ruby
885
+ ClaudeHooks::CLI.run_with_sample_data(MyHook, { 'prompt' => 'test prompt' })
886
+ # Provides default values, no STDIN needed
887
+ ```
888
+
889
+ #### 3. Test with Sample Data + Customization
890
+ ```ruby
891
+ ClaudeHooks::CLI.run_with_sample_data(MyHook) do |input_data|
892
+ input_data['prompt'] = 'Custom test prompt'
893
+ input_data['debug'] = true
894
+ end
895
+ ```
896
+
897
+ ### Example Hook with CLI Testing
898
+
899
+ ```ruby
900
+ #!/usr/bin/env ruby
901
+
902
+ require 'claude_hooks'
903
+
904
+ class MyTestHook < ClaudeHooks::UserPromptSubmit
905
+ def call
906
+ log "Debug mode: #{input_data['debug_mode']}"
907
+ log "Processing: #{prompt}"
908
+
909
+ if input_data['debug_mode']
910
+ log "All input keys: #{input_data.keys.join(', ')}"
911
+ end
912
+
913
+ output_data
914
+ end
915
+ end
916
+
917
+ # Test runner with customization
918
+ if __FILE__ == $0
919
+ ClaudeHooks::CLI.test_runner(MyTestHook) do |input_data|
920
+ input_data['debug_mode'] = true
921
+ end
922
+ end
923
+ ```
924
+
770
925
  ## 🐛 Debugging
771
926
 
772
927
  ### Test an individual entrypoint
@@ -2,8 +2,8 @@
2
2
 
3
3
  require 'claude_hooks'
4
4
  require 'json'
5
- require_relative '../user_prompt_submit/append_rules'
6
- require_relative '../user_prompt_submit/log_user_prompt'
5
+ require_relative '../handlers/user_prompt_submit/append_rules'
6
+ require_relative '../handlers/user_prompt_submit/log_user_prompt'
7
7
 
8
8
  begin
9
9
  # Read input from stdin
@@ -22,16 +22,7 @@ begin
22
22
  # Output final merged result to Claude Code
23
23
  puts JSON.generate(hook_output)
24
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
25
+ exit 0 # Don't forget to exit with the right exit code (0, 1, 2)
35
26
  rescue StandardError => e
36
27
  STDERR.puts "Error in UserPromptSubmit hook: #{e.message} #{e.backtrace.join("\n")}"
37
28
 
@@ -39,28 +39,7 @@ end
39
39
 
40
40
  # If this file is run directly (for testing), call the hook script
41
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
42
+ ClaudeHooks::CLI.test_runner(AppendRules) do |input_data|
43
+ input_data['session_id'] = 'session-id-override-01'
65
44
  end
66
45
  end
@@ -33,18 +33,5 @@ end
33
33
 
34
34
  # If this file is run directly (for testing), call the hook
35
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
36
+ ClaudeHooks::CLI.test_runner(LogUserPrompt)
50
37
  end
@@ -26,7 +26,7 @@ module ClaudeHooks
26
26
 
27
27
  attr_reader :config, :input_data, :output_data, :logger
28
28
  def initialize(input_data = {})
29
- @config = Configuration.config
29
+ @config = Configuration
30
30
  @input_data = input_data
31
31
  @output_data = {
32
32
  'continue' => true,
@@ -108,11 +108,27 @@ module ClaudeHooks
108
108
  # === CONFIG AND UTILITY METHODS ===
109
109
 
110
110
  def base_dir
111
- Configuration.base_dir
111
+ config.base_dir
112
112
  end
113
113
 
114
- def path_for(relative_path)
115
- Configuration.path_for(relative_path)
114
+ def home_claude_dir
115
+ config.home_claude_dir
116
+ end
117
+
118
+ def project_claude_dir
119
+ config.project_claude_dir
120
+ end
121
+
122
+ def path_for(relative_path, base_directory = nil)
123
+ config.path_for(relative_path, base_directory)
124
+ end
125
+
126
+ def home_path_for(relative_path)
127
+ config.home_path_for(relative_path)
128
+ end
129
+
130
+ def project_path_for(relative_path)
131
+ config.project_path_for(relative_path)
116
132
  end
117
133
 
118
134
  # Supports both single messages and blocks for multiline logging
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
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
+ module CLI
10
+ 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
25
+ if block_given?
26
+ yield(input_data)
27
+ 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
37
+ rescue StandardError => e
38
+ handle_error(e, hook_class)
39
+ end
40
+
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
52
+ def test_runner(hook_class, &block)
53
+ 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
+ end
62
+
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
73
+ def run_with_sample_data(hook_class, sample_data = {}, &block)
74
+ default_sample = {
75
+ 'session_id' => 'test-session',
76
+ 'transcript_path' => '/tmp/test_transcript.md',
77
+ 'cwd' => Dir.pwd,
78
+ 'hook_event_name' => hook_class.hook_type
79
+ }
80
+
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)
90
+ end
91
+
92
+ private
93
+
94
+ def read_stdin_input
95
+ stdin_content = STDIN.read.strip
96
+ return {} if stdin_content.empty?
97
+
98
+ JSON.parse(stdin_content)
99
+ rescue JSON::ParserError => e
100
+ raise "Invalid JSON input: #{e.message}"
101
+ end
102
+
103
+ def handle_error(error, hook_class)
104
+ STDERR.puts "Error in #{hook_class.name} hook: #{error.message}"
105
+ STDERR.puts error.backtrace.join("\n") if error.backtrace
106
+
107
+ # Output error response in Claude Code format
108
+ error_response = {
109
+ continue: false,
110
+ stopReason: "#{hook_class.name} execution error: #{error.message}",
111
+ suppressOutput: false
112
+ }
113
+
114
+ puts JSON.generate(error_response)
115
+ exit 1
116
+ end
117
+ end
118
+ end
119
+ end
@@ -16,29 +16,75 @@ module ClaudeHooks
16
16
  def reload!
17
17
  @config = nil
18
18
  @base_dir = nil
19
+ @home_claude_dir = nil
20
+ @project_claude_dir = nil
19
21
  @config_file_path = nil
22
+ @home_config_file_path = nil
23
+ @project_config_file_path = nil
20
24
  end
21
25
 
22
- # Get the base directory from ENV or default
26
+ # Get the home Claude directory (always ~/.claude)
27
+ def home_claude_dir
28
+ @home_claude_dir ||= File.expand_path('~/.claude')
29
+ end
30
+
31
+ # Get the project Claude directory (from CLAUDE_PROJECT_DIR/.claude)
32
+ # Returns nil if CLAUDE_PROJECT_DIR environment variable is not set
33
+ def project_claude_dir
34
+ @project_claude_dir ||= begin
35
+ project_dir = ENV['CLAUDE_PROJECT_DIR']
36
+ if project_dir
37
+ File.expand_path(File.join(project_dir, '.claude'))
38
+ else
39
+ nil
40
+ end
41
+ end
42
+ end
43
+
44
+ # Get the base directory from ENV or default (backward compatibility)
45
+ # This method will determine which base directory to use based on context
23
46
  def base_dir
24
47
  @base_dir ||= begin
48
+ # Check for legacy environment variable first
25
49
  env_base_dir = ENV["#{ENV_PREFIX}BASE_DIR"]
26
- File.expand_path(env_base_dir || '~/.claude')
50
+ if env_base_dir
51
+ File.expand_path(env_base_dir)
52
+ else
53
+ # Default to home directory for backward compatibility
54
+ home_claude_dir
55
+ end
27
56
  end
28
57
  end
29
58
 
30
59
  # 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)
60
+ # Can optionally specify which base directory to use
61
+ def path_for(relative_path, base_directory = nil)
62
+ base_directory ||= base_dir
63
+ File.join(base_directory, relative_path)
33
64
  end
34
65
 
35
- # Get the log directory path
66
+ # Get the full path for a file/directory relative to home_claude_dir
67
+ def home_path_for(relative_path)
68
+ File.join(home_claude_dir, relative_path)
69
+ end
70
+
71
+ # Get the full path for a file/directory relative to project_claude_dir
72
+ # Returns nil if CLAUDE_PROJECT_DIR environment variable is not set
73
+ def project_path_for(relative_path)
74
+ if project_claude_dir
75
+ File.join(project_claude_dir, relative_path)
76
+ else
77
+ nil
78
+ end
79
+ end
80
+
81
+ # Get the log directory path (always relative to home_claude_dir)
36
82
  def logs_directory
37
83
  log_dir = get_config_value('LOG_DIR', 'logDirectory') || 'logs'
38
84
  if log_dir.start_with?('/')
39
85
  log_dir # Absolute path
40
86
  else
41
- path_for(log_dir) # Relative to base_dir
87
+ File.join(home_claude_dir, log_dir) # Always relative to home_claude_dir
42
88
  end
43
89
  end
44
90
 
@@ -63,7 +109,8 @@ module ClaudeHooks
63
109
  def method_missing(method_name, *args, &block)
64
110
  # Convert method name to ENV key format (e.g., my_custom_setting -> MY_CUSTOM_SETTING)
65
111
  env_key = method_name.to_s.upcase
66
- config_key = method_name.to_s
112
+ # Convert snake_case method name to camelCase for config file lookup
113
+ config_key = snake_case_to_camel_case(method_name.to_s)
67
114
 
68
115
  value = get_config_value(env_key, config_key)
69
116
  return value unless value.nil?
@@ -74,36 +121,80 @@ module ClaudeHooks
74
121
  def respond_to_missing?(method_name, include_private = false)
75
122
  # Check if we have a config value for this method
76
123
  env_key = method_name.to_s.upcase
77
- config_key = method_name.to_s
124
+ config_key = snake_case_to_camel_case(method_name.to_s)
78
125
 
79
126
  !get_config_value(env_key, config_key).nil? || super
80
127
  end
81
128
 
82
129
  private
83
130
 
131
+ def snake_case_to_camel_case(snake_str)
132
+ # Convert snake_case to camelCase (e.g., user_name -> userName)
133
+ parts = snake_str.split('_')
134
+ parts.first + parts[1..-1].map(&:capitalize).join
135
+ end
136
+
84
137
  def config_file_path
85
138
  @config_file_path ||= path_for('config/config.json')
86
139
  end
87
140
 
141
+ def home_config_file_path
142
+ @home_config_file_path ||= File.join(home_claude_dir, 'config/config.json')
143
+ end
144
+
145
+ def project_config_file_path
146
+ @project_config_file_path ||= begin
147
+ if project_claude_dir
148
+ File.join(project_claude_dir, 'config/config.json')
149
+ else
150
+ nil
151
+ end
152
+ end
153
+ end
154
+
88
155
  def load_config
89
- # Start with config file
90
- file_config = load_config_file
156
+ # Load and merge config files from both locations
157
+ merged_file_config = load_and_merge_config_files
91
158
 
92
159
  # Merge with ENV variables
93
160
  env_config = load_env_config
94
161
 
95
- # ENV variables take precedence
96
- file_config.merge(env_config)
162
+ # ENV variables take precedence over file configs
163
+ merged_file_config.merge(env_config)
164
+ end
165
+
166
+ def load_and_merge_config_files
167
+ home_config = load_config_file_from_path(home_config_file_path)
168
+ project_config = load_config_file_from_path(project_config_file_path) if project_config_file_path
169
+
170
+ # Determine merge strategy
171
+ merge_strategy = ENV['CLAUDE_HOOKS_CONFIG_MERGE_STRATEGY'] || 'project'
172
+
173
+ if project_config && merge_strategy == 'project'
174
+ # Project config takes precedence
175
+ home_config.merge(project_config)
176
+ elsif project_config && merge_strategy == 'home'
177
+ # Home config takes precedence
178
+ project_config.merge(home_config)
179
+ else
180
+ # Only home config exists or no project config
181
+ home_config
182
+ end
97
183
  end
98
184
 
99
185
  def load_config_file
100
186
  config_file = config_file_path
187
+ load_config_file_from_path(config_file)
188
+ end
189
+
190
+ def load_config_file_from_path(config_file_path)
191
+ return {} unless config_file_path
101
192
 
102
- if File.exist?(config_file)
193
+ if File.exist?(config_file_path)
103
194
  begin
104
- JSON.parse(File.read(config_file))
195
+ JSON.parse(File.read(config_file_path))
105
196
  rescue JSON::ParserError => e
106
- warn "Warning: Error parsing config file #{config_file}: #{e.message}"
197
+ warn "Warning: Error parsing config file #{config_file_path}: #{e.message}"
107
198
  {}
108
199
  end
109
200
  else
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ClaudeHooks
4
- VERSION = "0.1.1"
5
- end
4
+ VERSION = "0.2.0"
5
+ end
data/lib/claude_hooks.rb CHANGED
@@ -4,6 +4,7 @@ require_relative "claude_hooks/version"
4
4
  require_relative "claude_hooks/configuration"
5
5
  require_relative "claude_hooks/logger"
6
6
  require_relative "claude_hooks/base"
7
+ require_relative "claude_hooks/cli"
7
8
  require_relative "claude_hooks/user_prompt_submit"
8
9
  require_relative "claude_hooks/pre_tool_use"
9
10
  require_relative "claude_hooks/post_tool_use"
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: 0.1.1
4
+ version: 0.2.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: 2025-08-17 00:00:00.000000000 Z
11
+ date: 2025-08-21 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: json
@@ -67,13 +67,12 @@ files:
67
67
  - claude_hooks.gemspec
68
68
  - example_dotclaude/commands/.gitkeep
69
69
  - example_dotclaude/hooks/entrypoints/user_prompt_submit.rb
70
- - example_dotclaude/hooks/entrypoints/user_prompt_submit/append_rules.rb
71
- - example_dotclaude/hooks/entrypoints/user_prompt_submit/log_user_prompt.rb
72
70
  - example_dotclaude/hooks/handlers/user_prompt_submit/append_rules.rb
73
71
  - example_dotclaude/hooks/handlers/user_prompt_submit/log_user_prompt.rb
74
72
  - example_dotclaude/settings.json
75
73
  - lib/claude_hooks.rb
76
74
  - lib/claude_hooks/base.rb
75
+ - lib/claude_hooks/cli.rb
77
76
  - lib/claude_hooks/configuration.rb
78
77
  - lib/claude_hooks/logger.rb
79
78
  - lib/claude_hooks/notification.rb
@@ -1,66 +0,0 @@
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
@@ -1,50 +0,0 @@
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