@goodfoot/claude-code-hooks 1.0.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,282 @@
1
+ /**
2
+ * Output types and builders for Claude Code hooks.
3
+ *
4
+ * Provides type-safe output builder functions for all 12 hook types. Each builder
5
+ * accepts options that match the wire format expected by Claude Code, with types
6
+ * derived from the Claude Agent SDK's `SyncHookJSONOutput` type.
7
+ * @see https://code.claude.com/docs/en/hooks
8
+ * @module
9
+ */
10
+ // ============================================================================
11
+ // Exit Code Constants
12
+ // ============================================================================
13
+ /**
14
+ * Exit codes used by Claude Code hooks.
15
+ *
16
+ * | Exit Code | Name | When Used | Claude Code Behavior |
17
+ * |-----------|------|-----------|---------------------|
18
+ * | 0 | Success | Handler returns normally | Continue, parse stdout as JSON |
19
+ * | 1 | Error | Invalid input, non-blocking error | Non-blocking, stderr to user only |
20
+ * | 2 | Block | Handler throws OR `stopReason` set | Blocking, stderr shown to Claude |
21
+ */
22
+ export const EXIT_CODES = {
23
+ /** Handler completed successfully. Claude Code parses stdout as JSON. */
24
+ SUCCESS: 0,
25
+ /** Non-blocking error occurred (e.g., invalid input). stderr shown to user only. */
26
+ ERROR: 1,
27
+ /** Handler threw exception OR blocking action requested. stderr shown to Claude. */
28
+ BLOCK: 2
29
+ };
30
+ // ============================================================================
31
+ // Output Builder Factories
32
+ // ============================================================================
33
+ /**
34
+ * Factory for hooks that have hookSpecificOutput with a hookEventName discriminator.
35
+ * @param hookType - The hook type name used as the _type discriminator
36
+ * @returns A builder function that creates the output object
37
+ * @internal
38
+ */
39
+ function createHookSpecificOutputBuilder(hookType) {
40
+ return (options = {}) => {
41
+ const { hookSpecificOutput, ...rest } = options;
42
+ const stdout =
43
+ hookSpecificOutput !== undefined
44
+ ? { ...rest, hookSpecificOutput: { hookEventName: hookType, ...hookSpecificOutput } }
45
+ : rest;
46
+ return { _type: hookType, stdout };
47
+ };
48
+ }
49
+ /**
50
+ * Factory for hooks that only use CommonOptions (simple passthrough).
51
+ * @param hookType - The hook type name used as the _type discriminator
52
+ * @returns A builder function that creates the output object
53
+ * @internal
54
+ */
55
+ function createSimpleOutputBuilder(hookType) {
56
+ return (options = {}) => ({
57
+ _type: hookType,
58
+ stdout: options
59
+ });
60
+ }
61
+ /**
62
+ * Factory for hooks that use decision-based options (Stop, SubagentStop).
63
+ * @param hookType - The hook type name used as the _type discriminator
64
+ * @returns A builder function that creates the output object
65
+ * @internal
66
+ */
67
+ function createDecisionOutputBuilder(hookType) {
68
+ return (options = {}) => ({
69
+ _type: hookType,
70
+ stdout: options
71
+ });
72
+ }
73
+ /**
74
+ * Creates an output for PreToolUse hooks.
75
+ * @param options - Configuration options for the hook output
76
+ * @returns A PreToolUseOutput object ready for the runtime
77
+ * @example
78
+ * ```typescript
79
+ * // Allow tool execution
80
+ * preToolUseOutput({
81
+ * hookSpecificOutput: { permissionDecision: 'allow' }
82
+ * });
83
+ *
84
+ * // Deny with reason
85
+ * preToolUseOutput({
86
+ * hookSpecificOutput: {
87
+ * permissionDecision: 'deny',
88
+ * permissionDecisionReason: 'Dangerous command detected'
89
+ * }
90
+ * });
91
+ *
92
+ * // Allow with modified input
93
+ * preToolUseOutput({
94
+ * hookSpecificOutput: {
95
+ * permissionDecision: 'allow',
96
+ * updatedInput: { command: 'ls -la' }
97
+ * }
98
+ * });
99
+ * ```
100
+ */
101
+ export const preToolUseOutput = /* @__PURE__ */ createHookSpecificOutputBuilder('PreToolUse');
102
+ /**
103
+ * Creates an output for PostToolUse hooks.
104
+ * @param options - Configuration options for the hook output
105
+ * @returns A PostToolUseOutput object ready for the runtime
106
+ * @example
107
+ * ```typescript
108
+ * // Add context after a file read
109
+ * postToolUseOutput({
110
+ * hookSpecificOutput: {
111
+ * additionalContext: 'File contains sensitive data'
112
+ * }
113
+ * });
114
+ * ```
115
+ */
116
+ export const postToolUseOutput = /* @__PURE__ */ createHookSpecificOutputBuilder('PostToolUse');
117
+ /**
118
+ * Creates an output for PostToolUseFailure hooks.
119
+ * @param options - Configuration options for the hook output
120
+ * @returns A PostToolUseFailureOutput object ready for the runtime
121
+ * @example
122
+ * ```typescript
123
+ * postToolUseFailureOutput({
124
+ * hookSpecificOutput: {
125
+ * additionalContext: 'Try using a different approach'
126
+ * }
127
+ * });
128
+ * ```
129
+ */
130
+ export const postToolUseFailureOutput = /* @__PURE__ */ createHookSpecificOutputBuilder('PostToolUseFailure');
131
+ /**
132
+ * Creates an output for UserPromptSubmit hooks.
133
+ * @param options - Configuration options for the hook output
134
+ * @returns A UserPromptSubmitOutput object ready for the runtime
135
+ * @example
136
+ * ```typescript
137
+ * userPromptSubmitOutput({
138
+ * hookSpecificOutput: {
139
+ * additionalContext: 'This project uses TypeScript strict mode'
140
+ * }
141
+ * });
142
+ * ```
143
+ */
144
+ export const userPromptSubmitOutput = /* @__PURE__ */ createHookSpecificOutputBuilder('UserPromptSubmit');
145
+ /**
146
+ * Creates an output for SessionStart hooks.
147
+ * @param options - Configuration options for the hook output
148
+ * @returns A SessionStartOutput object ready for the runtime
149
+ * @example
150
+ * ```typescript
151
+ * sessionStartOutput({
152
+ * hookSpecificOutput: {
153
+ * additionalContext: JSON.stringify({ project: 'my-project' })
154
+ * }
155
+ * });
156
+ * ```
157
+ */
158
+ export const sessionStartOutput = /* @__PURE__ */ createHookSpecificOutputBuilder('SessionStart');
159
+ /**
160
+ * Creates an output for SessionEnd hooks.
161
+ * @param options - Configuration options for the hook output
162
+ * @returns A SessionEndOutput object ready for the runtime
163
+ * @example
164
+ * ```typescript
165
+ * sessionEndOutput({});
166
+ * ```
167
+ */
168
+ export const sessionEndOutput = /* @__PURE__ */ createSimpleOutputBuilder('SessionEnd');
169
+ /**
170
+ * Creates an output for Stop hooks.
171
+ * @param options - Configuration options for the hook output
172
+ * @returns A StopOutput object ready for the runtime
173
+ * @example
174
+ * ```typescript
175
+ * // Allow the stop
176
+ * stopOutput({ decision: 'approve' });
177
+ *
178
+ * // Block with reason
179
+ * stopOutput({
180
+ * decision: 'block',
181
+ * reason: 'There are uncommitted changes'
182
+ * });
183
+ * ```
184
+ */
185
+ export const stopOutput = /* @__PURE__ */ createDecisionOutputBuilder('Stop');
186
+ /**
187
+ * Creates an output for SubagentStart hooks.
188
+ * @param options - Configuration options for the hook output
189
+ * @returns A SubagentStartOutput object ready for the runtime
190
+ * @example
191
+ * ```typescript
192
+ * subagentStartOutput({
193
+ * hookSpecificOutput: {
194
+ * additionalContext: 'Focus on finding patterns'
195
+ * }
196
+ * });
197
+ * ```
198
+ */
199
+ export const subagentStartOutput = /* @__PURE__ */ createHookSpecificOutputBuilder('SubagentStart');
200
+ /**
201
+ * Creates an output for SubagentStop hooks.
202
+ * @param options - Configuration options for the hook output
203
+ * @returns A SubagentStopOutput object ready for the runtime
204
+ * @example
205
+ * ```typescript
206
+ * // Block with reason
207
+ * subagentStopOutput({
208
+ * decision: 'block',
209
+ * reason: 'Task not complete'
210
+ * });
211
+ * ```
212
+ */
213
+ export const subagentStopOutput = /* @__PURE__ */ createDecisionOutputBuilder('SubagentStop');
214
+ /**
215
+ * Creates an output for Notification hooks.
216
+ * @param options - Configuration options for the hook output
217
+ * @returns A NotificationOutput object ready for the runtime
218
+ * @example
219
+ * ```typescript
220
+ * // Add context about the notification
221
+ * notificationOutput({
222
+ * hookSpecificOutput: {
223
+ * additionalContext: 'Notification forwarded to Slack #alerts channel'
224
+ * }
225
+ * });
226
+ *
227
+ * // Suppress the notification
228
+ * notificationOutput({ suppressOutput: true });
229
+ * ```
230
+ */
231
+ export const notificationOutput = /* @__PURE__ */ createHookSpecificOutputBuilder('Notification');
232
+ /**
233
+ * Creates an output for PreCompact hooks.
234
+ * @param options - Configuration options for the hook output
235
+ * @returns A PreCompactOutput object ready for the runtime
236
+ * @example
237
+ * ```typescript
238
+ * preCompactOutput({
239
+ * systemMessage: 'Remember: strict mode is enabled'
240
+ * });
241
+ * ```
242
+ */
243
+ export const preCompactOutput = /* @__PURE__ */ createSimpleOutputBuilder('PreCompact');
244
+ /**
245
+ * Creates an output for PermissionRequest hooks.
246
+ * @param options - Configuration options for the hook output
247
+ * @returns A PermissionRequestOutput object ready for the runtime
248
+ * @example
249
+ * ```typescript
250
+ * // Auto-approve
251
+ * permissionRequestOutput({
252
+ * hookSpecificOutput: {
253
+ * decision: { behavior: 'allow' }
254
+ * }
255
+ * });
256
+ *
257
+ * // Auto-approve with modified input
258
+ * permissionRequestOutput({
259
+ * hookSpecificOutput: {
260
+ * decision: {
261
+ * behavior: 'allow',
262
+ * updatedInput: { file_path: '/safe/path' }
263
+ * }
264
+ * }
265
+ * });
266
+ *
267
+ * // Auto-deny
268
+ * permissionRequestOutput({
269
+ * hookSpecificOutput: {
270
+ * decision: {
271
+ * behavior: 'deny',
272
+ * message: 'Not allowed',
273
+ * interrupt: true
274
+ * }
275
+ * }
276
+ * });
277
+ *
278
+ * // Fall through to normal prompt
279
+ * permissionRequestOutput({});
280
+ * ```
281
+ */
282
+ export const permissionRequestOutput = /* @__PURE__ */ createHookSpecificOutputBuilder('PermissionRequest');
@@ -0,0 +1,222 @@
1
+ /**
2
+ * Runtime module for Claude Code hooks.
3
+ *
4
+ * Handles stdin/stdout/exit code semantics for compiled hook execution.
5
+ * This module is the core orchestrator that:
6
+ * - Reads JSON from stdin (wire format with snake_case properties)
7
+ * - Invokes the hook handler
8
+ * - Writes output to stdout
9
+ * - Manages exit codes
10
+ * @module
11
+ * @example
12
+ * ```typescript
13
+ * // In a compiled hook file
14
+ * import { execute } from '@goodfoot/claude-code-hooks/runtime';
15
+ * import myHook from './my-hook.js';
16
+ *
17
+ * execute(myHook);
18
+ * ```
19
+ * @see https://code.claude.com/docs/en/hooks
20
+ */
21
+ import { persistEnvVar, persistEnvVars } from './env.js';
22
+ import { logger } from './logger.js';
23
+ import { EXIT_CODES } from './outputs.js';
24
+ // ============================================================================
25
+ // Stdin/Stdout Handling
26
+ // ============================================================================
27
+ /**
28
+ * Reads all data from stdin.
29
+ * @returns Promise resolving to the complete stdin content
30
+ */
31
+ async function readStdin() {
32
+ return new Promise((resolve, reject) => {
33
+ const chunks = [];
34
+ // Set encoding first to ensure data events receive strings
35
+ process.stdin.setEncoding('utf-8');
36
+ process.stdin.on('data', (chunk) => {
37
+ chunks.push(chunk);
38
+ });
39
+ process.stdin.on('end', () => {
40
+ resolve(chunks.join(''));
41
+ });
42
+ process.stdin.on('error', (error) => {
43
+ reject(error);
44
+ });
45
+ });
46
+ }
47
+ /**
48
+ * Parses stdin JSON input.
49
+ * @param stdinContent - Raw stdin content
50
+ * @returns Parsed input (wire format with snake_case properties)
51
+ * @throws Error if JSON is malformed
52
+ */
53
+ function parseStdinInput(stdinContent) {
54
+ // Parse JSON - input uses wire format (snake_case) directly
55
+ const rawInput = JSON.parse(stdinContent);
56
+ return rawInput;
57
+ }
58
+ /**
59
+ * Writes hook output to stdout.
60
+ *
61
+ * Output uses camelCase keys per Claude Code hook specification.
62
+ * @param output - The hook output to write
63
+ * @see https://code.claude.com/docs/en/hooks#hook-output-structure
64
+ */
65
+ function writeStdout(output) {
66
+ // Output uses camelCase - no transformation needed
67
+ process.stdout.write(JSON.stringify(output));
68
+ }
69
+ // ============================================================================
70
+ // Error Handling
71
+ // ============================================================================
72
+ /**
73
+ * Creates an error output for malformed stdin JSON.
74
+ * @param error - The parse error
75
+ * @returns HookOutput with empty stdout
76
+ */
77
+ function createMalformedInputOutput(error) {
78
+ logger.error(`Invalid JSON input: ${error instanceof Error ? error.message : String(error)}`);
79
+ return { stdout: {} };
80
+ }
81
+ /**
82
+ * Writes handler error stacktrace to stderr and exits with code 2.
83
+ *
84
+ * When a hook handler throws an exception:
85
+ * - Stacktrace (with sourcemaps if available) is output to stderr
86
+ * - Process exits with code 2 (BLOCK)
87
+ * - No JSON is output to stdout
88
+ * @param error - The error thrown by the handler
89
+ */
90
+ function handleHandlerError(error) {
91
+ // Write stack trace to stderr (sourcemaps are applied automatically by Node.js)
92
+ if (error instanceof Error) {
93
+ process.stderr.write(`${error.stack ?? error.message}\n`);
94
+ } else {
95
+ process.stderr.write(`${String(error)}\n`);
96
+ }
97
+ // Log to file if configured
98
+ logger.error(`Hook handler error: ${error instanceof Error ? error.message : String(error)}`);
99
+ // Clear logger context and close
100
+ logger.clearContext();
101
+ logger.close();
102
+ // Exit with code 2 (BLOCK) - no JSON output
103
+ process.exit(EXIT_CODES.BLOCK);
104
+ }
105
+ /**
106
+ * Converts a SpecificHookOutput to HookOutput for wire format.
107
+ *
108
+ * SpecificHookOutput types have: { _type, exitCode, stdout, stderr? }
109
+ * HookOutput has: { exitCode, stdout, stderr? }
110
+ *
111
+ * Since output builders now produce wire-format directly, this function
112
+ * simply strips the `_type` discriminator field.
113
+ * @param specificOutput - The specific output from a hook handler
114
+ * @returns HookOutput ready for serialization
115
+ * @see https://code.claude.com/docs/en/hooks#hook-output-structure
116
+ * @example
117
+ * ```typescript
118
+ * const specificOutput = preToolUseOutput({ hookSpecificOutput: { permissionDecision: 'allow' } });
119
+ * const hookOutput = convertToHookOutput(specificOutput);
120
+ * // hookOutput: { exitCode: 0, stdout: { hookSpecificOutput: { ... } } }
121
+ * ```
122
+ */
123
+ export function convertToHookOutput(specificOutput) {
124
+ return { stdout: specificOutput.stdout };
125
+ }
126
+ // ============================================================================
127
+ // Execute Function
128
+ // ============================================================================
129
+ /**
130
+ * Executes a hook handler with full runtime orchestration.
131
+ *
132
+ * This is the main entry point that compiled hooks use. When a compiled hook
133
+ * runs as a CLI:
134
+ *
135
+ * 1. Reads all stdin
136
+ * 2. Parses JSON (wire format with snake_case properties)
137
+ * 3. Sets up logger context (hookType, input)
138
+ * 4. Calls handler with input and context (logger)
139
+ * 5. Handles any errors, logs them
140
+ * 6. Writes JSON to stdout
141
+ * 7. Closes logger
142
+ * 8. Exits with appropriate code
143
+ * @param hookFn - The hook function to execute (from hook factory)
144
+ * @example
145
+ * ```typescript
146
+ * // In compiled hook file
147
+ * import { execute } from '@goodfoot/claude-code-hooks/runtime';
148
+ * import { preToolUseHook, preToolUseOutput } from '@goodfoot/claude-code-hooks';
149
+ *
150
+ * const myHook = preToolUseHook({ matcher: 'Bash' }, async (input, { logger }) => {
151
+ * logger.info('Processing Bash command');
152
+ * return preToolUseOutput({ allow: true });
153
+ * });
154
+ *
155
+ * execute(myHook);
156
+ * ```
157
+ * @see https://code.claude.com/docs/en/hooks
158
+ */
159
+ export async function execute(hookFn) {
160
+ let output;
161
+ try {
162
+ // Check for log file configuration conflicts
163
+ // CLAUDE_CODE_HOOKS_CLI_LOG_FILE is injected by the CLI --log parameter
164
+ // CLAUDE_CODE_HOOKS_LOG_FILE is the user's environment variable
165
+ const cliLogFile = process.env['CLAUDE_CODE_HOOKS_CLI_LOG_FILE'];
166
+ const envLogFile = process.env['CLAUDE_CODE_HOOKS_LOG_FILE'];
167
+ if (cliLogFile !== undefined && envLogFile !== undefined && cliLogFile !== envLogFile) {
168
+ // Write error to stderr and exit with error code
169
+ process.stderr.write(
170
+ `Log file configuration conflict: CLI --log="${cliLogFile}" vs CLAUDE_CODE_HOOKS_LOG_FILE="${envLogFile}". ` +
171
+ 'Use only one method to configure hook logging.\n'
172
+ );
173
+ process.exit(EXIT_CODES.ERROR);
174
+ }
175
+ // If CLI log file is set, configure the logger
176
+ if (cliLogFile !== undefined) {
177
+ logger.setLogFile(cliLogFile);
178
+ }
179
+ // Read and parse stdin
180
+ let stdinContent;
181
+ try {
182
+ stdinContent = await readStdin();
183
+ } catch (error) {
184
+ logger.logError(error, 'Failed to read stdin');
185
+ output = createMalformedInputOutput(error);
186
+ return;
187
+ }
188
+ // Parse and transform input
189
+ let input;
190
+ try {
191
+ input = parseStdinInput(stdinContent);
192
+ } catch (error) {
193
+ logger.logError(error, 'Failed to parse stdin JSON');
194
+ output = createMalformedInputOutput(error);
195
+ return;
196
+ }
197
+ // Set logger context
198
+ const hookEventName = hookFn.hookEventName;
199
+ logger.setContext(hookEventName, input);
200
+ // Build context - SessionStart hooks get extended context with persistEnvVar
201
+ const context = hookEventName === 'SessionStart' ? { logger, persistEnvVar, persistEnvVars } : { logger };
202
+ // Execute handler
203
+ try {
204
+ const specificOutput = await hookFn(input, context);
205
+ output = convertToHookOutput(specificOutput);
206
+ } catch (error) {
207
+ // Handler threw - output stacktrace to stderr and exit with code 2
208
+ // This call never returns (process.exit)
209
+ handleHandlerError(error);
210
+ }
211
+ } finally {
212
+ // Write output if we have it
213
+ if (output !== undefined) {
214
+ writeStdout(output.stdout);
215
+ }
216
+ // Clear logger context
217
+ logger.clearContext();
218
+ logger.close();
219
+ // Exit with success (handler errors exit via handleHandlerError with code 2)
220
+ process.exit(EXIT_CODES.SUCCESS);
221
+ }
222
+ }