@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.
package/dist/cli.js ADDED
@@ -0,0 +1,914 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * CLI tool for compiling Claude Code hooks using esbuild.
4
+ *
5
+ * Compiles TypeScript hooks to standalone ESM modules and generates hooks.json
6
+ * with correct command paths and matcher configurations.
7
+ * @example
8
+ * ```bash
9
+ * # Compile hooks and generate hooks.json
10
+ * claude-code-hooks -i "hooks/**\/*.ts" -o "./dist/hooks.json"
11
+ *
12
+ * # With runtime logging (equivalent to CLAUDE_CODE_HOOKS_LOG_FILE)
13
+ * claude-code-hooks -i "hooks/**\/*.ts" -o "./dist/hooks.json" --log /tmp/hooks.log
14
+ * ```
15
+ * @module
16
+ */
17
+ import * as crypto from 'node:crypto';
18
+ import * as fs from 'node:fs';
19
+ import * as path from 'node:path';
20
+ import * as esbuild from 'esbuild';
21
+ import { glob } from 'glob';
22
+ import ts from 'typescript';
23
+ import { HOOK_FACTORY_TO_EVENT } from './constants.js';
24
+ import { scaffoldProject } from './scaffold.js';
25
+ // ============================================================================
26
+ // Constants
27
+ // ============================================================================
28
+ const VERSION = '1.0.0';
29
+ const HELP_TEXT = `
30
+ @goodfoot/claude-code-hooks - Type-safe, compiled hooks for Claude Code
31
+
32
+ Description:
33
+ This tool acts as a build system for Claude Code hooks. It scans your TypeScript files for
34
+ exported hook factories (e.g., preToolUseHook), compiles them into standalone ESM modules,
35
+ and generates a hooks.json manifest that you can reference in your Claude Code configuration.
36
+
37
+ Usage:
38
+ npx -y @goodfoot/claude-code-hooks -i <glob> -o <path> [options]
39
+ npx -y @goodfoot/claude-code-hooks --scaffold <dir> --hooks <types> -o <path>
40
+
41
+ Build Mode (compile existing hooks):
42
+ -i, --input <glob>
43
+ Glob pattern to find your hook source files.
44
+ Example: "hooks/**/*.ts" (Quotes are recommended to prevent shell expansion)
45
+
46
+ -o, --output <path>
47
+ Path where the hooks.json manifest should be generated.
48
+ Compiled hook files (.mjs) will be placed in the same directory as this file.
49
+ Example: "dist/hooks.json"
50
+
51
+ Scaffold Mode (create new hook project):
52
+ --scaffold <directory>
53
+ Create a new hook project at the specified directory path.
54
+ The directory must not already exist.
55
+ Example: --scaffold ./my-hooks
56
+
57
+ --hooks <types>
58
+ Comma-separated list of hook types to generate in the scaffolded project.
59
+ Valid types: PreToolUse, PostToolUse, PostToolUseFailure, Notification,
60
+ UserPromptSubmit, SessionStart, SessionEnd, Stop,
61
+ SubagentStart, SubagentStop, PreCompact, PermissionRequest
62
+ Example: --hooks Stop,SubagentStop,PreToolUse
63
+
64
+ -o, --output <path>
65
+ In scaffold mode, configures where the generated build script will output hooks.json.
66
+ This path is relative to the scaffolded project directory.
67
+ Example: -o dist/hooks.json
68
+
69
+ Optional Arguments:
70
+ --log <path>
71
+ Path to a log file for runtime hook logging.
72
+ If provided, all context.logger calls within your hooks will write to this file.
73
+ This is equivalent to setting the CLAUDE_CODE_HOOKS_LOG_FILE environment variable.
74
+ Example: "/tmp/claude-hooks.log"
75
+
76
+ --executable <path>
77
+ Node executable path to use in generated commands (default: "node").
78
+ Use this to specify a custom node path in the generated hooks.json commands.
79
+ Example: "/usr/local/bin/node" or "node22"
80
+
81
+ -h, --help
82
+ Show this help message.
83
+
84
+ -v, --version
85
+ Show the current version of the CLI.
86
+
87
+ Examples:
88
+ 1. Basic Compilation:
89
+ npx -y @goodfoot/claude-code-hooks -i "hooks/**/*.ts" -o "dist/hooks.json"
90
+
91
+ 2. With Runtime Logging:
92
+ npx -y @goodfoot/claude-code-hooks -i "src/hooks/*.ts" -o "build/hooks.json" --log /tmp/claude-hooks.log
93
+
94
+ 3. Scaffold a New Hook Project:
95
+ npx -y @goodfoot/claude-code-hooks --scaffold ./my-hooks --hooks Stop,SubagentStop -o dist/hooks.json
96
+
97
+ 4. With Custom Node Executable:
98
+ npx -y @goodfoot/claude-code-hooks -i "hooks/**/*.ts" -o "dist/hooks.json" --executable /usr/local/bin/node
99
+
100
+ 5. Configure Claude to use the hooks:
101
+ After building, add this to your ~/.claude/config.json:
102
+ {
103
+ "hooks": "/absolute/path/to/your/project/dist/hooks.json"
104
+ }
105
+
106
+ Troubleshooting:
107
+ - Ensure your hook files use 'export default'.
108
+ - Use absolute paths in your glob patterns if relative paths aren't finding files.
109
+ - Check the log file specified by --log if hooks don't seem to run.
110
+ `;
111
+ // ============================================================================
112
+ // Logging
113
+ // ============================================================================
114
+ let logFile;
115
+ /**
116
+ * Initializes the log file if a path is provided.
117
+ * @param logPath - Optional path to log file
118
+ * @internal
119
+ */
120
+ function _initLog(logPath) {
121
+ if (logPath !== undefined) {
122
+ const logDir = path.dirname(logPath);
123
+ if (!fs.existsSync(logDir)) {
124
+ fs.mkdirSync(logDir, { recursive: true });
125
+ }
126
+ logFile = fs.createWriteStream(logPath, { flags: 'a' });
127
+ }
128
+ }
129
+ /**
130
+ * Closes the log file if open.
131
+ */
132
+ function closeLog() {
133
+ if (logFile !== undefined) {
134
+ logFile.close();
135
+ logFile = undefined;
136
+ }
137
+ }
138
+ /**
139
+ * Logs a message to the log file (if configured).
140
+ * Does NOT write to stdout/stderr to avoid interfering with hook protocol.
141
+ * @param level - Log level
142
+ * @param message - Log message
143
+ * @param data - Optional additional data
144
+ */
145
+ function log(level, message, data) {
146
+ if (logFile !== undefined) {
147
+ const entry = {
148
+ timestamp: new Date().toISOString(),
149
+ level,
150
+ message,
151
+ ...(data !== undefined ? { data } : {})
152
+ };
153
+ logFile.write(JSON.stringify(entry) + '\n');
154
+ }
155
+ }
156
+ // ============================================================================
157
+ // Argument Parsing
158
+ // ============================================================================
159
+ /**
160
+ * Parses command-line arguments.
161
+ * @param argv - Process argv (usually process.argv.slice(2))
162
+ * @returns Parsed arguments
163
+ */
164
+ function parseArgs(argv) {
165
+ const args = {
166
+ input: '',
167
+ output: '',
168
+ help: false,
169
+ version: false
170
+ };
171
+ for (let i = 0; i < argv.length; i++) {
172
+ const arg = argv[i];
173
+ switch (arg) {
174
+ case '-i':
175
+ case '--input':
176
+ args.input = argv[++i] ?? '';
177
+ break;
178
+ case '-o':
179
+ case '--output':
180
+ args.output = argv[++i] ?? '';
181
+ break;
182
+ case '--log':
183
+ args.log = argv[++i];
184
+ break;
185
+ case '-h':
186
+ case '--help':
187
+ args.help = true;
188
+ break;
189
+ case '-v':
190
+ case '--version':
191
+ args.version = true;
192
+ break;
193
+ case '--scaffold':
194
+ args.scaffold = argv[++i] ?? '';
195
+ break;
196
+ case '--hooks':
197
+ args.hooks = argv[++i] ?? '';
198
+ break;
199
+ case '--executable':
200
+ args.executable = argv[++i] ?? '';
201
+ break;
202
+ default:
203
+ // Unknown argument - ignore
204
+ break;
205
+ }
206
+ }
207
+ return args;
208
+ }
209
+ /**
210
+ * Validates CLI arguments and returns error message if invalid.
211
+ * @param args - Parsed CLI arguments
212
+ * @returns Error message if invalid, undefined if valid
213
+ */
214
+ function validateArgs(args) {
215
+ if (args.help || args.version) {
216
+ return undefined;
217
+ }
218
+ // Scaffold mode validation
219
+ if (args.scaffold !== undefined && args.scaffold !== '') {
220
+ if (args.hooks === undefined || args.hooks === '') {
221
+ return 'Scaffold mode requires --hooks argument (comma-separated hook types)';
222
+ }
223
+ if (args.output === '') {
224
+ return 'Scaffold mode requires -o/--output argument (path for generated hooks.json)';
225
+ }
226
+ // In scaffold mode, --input is not required
227
+ return undefined;
228
+ }
229
+ // Normal build mode validation
230
+ if (args.input === '') {
231
+ return 'Missing required argument: -i/--input <glob>';
232
+ }
233
+ if (args.output === '') {
234
+ return 'Missing required argument: -o/--output <path>';
235
+ }
236
+ return undefined;
237
+ }
238
+ // ============================================================================
239
+ // TypeScript AST Analysis
240
+ // ============================================================================
241
+ /**
242
+ * Extracts hook metadata from a TypeScript source file using AST analysis.
243
+ *
244
+ * Looks for default exports that call hook factory functions (preToolUseHook, etc.)
245
+ * and extracts the hook type, matcher, and timeout from the config object.
246
+ * @param sourcePath - Absolute path to the TypeScript source file
247
+ * @returns Extracted hook metadata or undefined if not a valid hook file
248
+ * @example
249
+ * ```typescript
250
+ * // For a file containing:
251
+ * // export default preToolUseHook({ matcher: 'Bash', timeout: 5000 }, handler);
252
+ *
253
+ * const metadata = analyzeHookFile('/path/to/hook.ts');
254
+ * // { hookEventName: 'PreToolUse', matcher: 'Bash', timeout: 5000 }
255
+ * ```
256
+ */
257
+ function analyzeHookFile(sourcePath) {
258
+ const sourceCode = fs.readFileSync(sourcePath, 'utf-8');
259
+ const sourceFile = ts.createSourceFile(sourcePath, sourceCode, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
260
+ let metadata;
261
+ /**
262
+ * Recursively visits AST nodes to find hook factory calls.
263
+ * @param node - The AST node to visit
264
+ */
265
+ function visit(node) {
266
+ // Look for export default or export = assignment
267
+ if (ts.isExportAssignment(node) && !node.isExportEquals) {
268
+ // export default <expression>
269
+ const expression = node.expression;
270
+ const result = extractHookMetadataFromExpression(expression);
271
+ if (result !== undefined) {
272
+ metadata = result;
273
+ }
274
+ }
275
+ // Also check for: export default preToolUseHook(...)
276
+ // which might be wrapped in other expressions
277
+ ts.forEachChild(node, visit);
278
+ }
279
+ /**
280
+ * Extracts metadata from a call expression to a hook factory.
281
+ * @param expression - The expression node to analyze
282
+ * @returns Hook metadata if found, undefined otherwise
283
+ */
284
+ function extractHookMetadataFromExpression(expression) {
285
+ // Handle direct call: preToolUseHook({ ... }, handler)
286
+ if (ts.isCallExpression(expression)) {
287
+ return extractFromCallExpression(expression);
288
+ }
289
+ // Handle await: await preToolUseHook(...)
290
+ if (ts.isAwaitExpression(expression)) {
291
+ return extractHookMetadataFromExpression(expression.expression);
292
+ }
293
+ // Handle parenthesized: (preToolUseHook(...))
294
+ if (ts.isParenthesizedExpression(expression)) {
295
+ return extractHookMetadataFromExpression(expression.expression);
296
+ }
297
+ return undefined;
298
+ }
299
+ /**
300
+ * Extracts metadata from a CallExpression node.
301
+ * @param callExpr - The call expression to extract metadata from
302
+ * @returns Hook metadata if the call is to a hook factory, undefined otherwise
303
+ */
304
+ function extractFromCallExpression(callExpr) {
305
+ // Get the function being called
306
+ const callee = callExpr.expression;
307
+ let factoryName;
308
+ if (ts.isIdentifier(callee)) {
309
+ factoryName = callee.text;
310
+ } else if (ts.isPropertyAccessExpression(callee)) {
311
+ // Could be namespace.preToolUseHook
312
+ factoryName = callee.name.text;
313
+ }
314
+ if (factoryName === undefined) {
315
+ return undefined;
316
+ }
317
+ // Check if it's a known hook factory
318
+ const hookEventName = HOOK_FACTORY_TO_EVENT[factoryName];
319
+ if (hookEventName === undefined) {
320
+ return undefined;
321
+ }
322
+ // Extract config from first argument
323
+ const configArg = callExpr.arguments[0];
324
+ let matcher;
325
+ let timeout;
326
+ if (configArg !== undefined && ts.isObjectLiteralExpression(configArg)) {
327
+ for (const prop of configArg.properties) {
328
+ if (!ts.isPropertyAssignment(prop)) continue;
329
+ const propName = ts.isIdentifier(prop.name) ? prop.name.text : undefined;
330
+ if (propName === undefined) continue;
331
+ if (propName === 'matcher') {
332
+ // Extract string value
333
+ if (ts.isStringLiteral(prop.initializer)) {
334
+ matcher = prop.initializer.text;
335
+ } else if (ts.isNoSubstitutionTemplateLiteral(prop.initializer)) {
336
+ matcher = prop.initializer.text;
337
+ }
338
+ } else if (propName === 'timeout') {
339
+ // Extract number value
340
+ if (ts.isNumericLiteral(prop.initializer)) {
341
+ timeout = Number(prop.initializer.text);
342
+ }
343
+ }
344
+ }
345
+ }
346
+ return { hookEventName, matcher, timeout };
347
+ }
348
+ visit(sourceFile);
349
+ return metadata;
350
+ }
351
+ // ============================================================================
352
+ // Hook File Discovery
353
+ // ============================================================================
354
+ /**
355
+ * Discovers hook files matching the glob pattern.
356
+ * @param pattern - Glob pattern for hook files
357
+ * @param cwd - Current working directory for relative patterns
358
+ * @returns Array of absolute paths to hook files
359
+ */
360
+ async function discoverHookFiles(pattern, cwd) {
361
+ const files = await glob(pattern, {
362
+ cwd,
363
+ absolute: true,
364
+ nodir: true
365
+ });
366
+ return files.filter((file) => file.endsWith('.ts') || file.endsWith('.mts'));
367
+ }
368
+ /**
369
+ * Compiles a TypeScript hook file to a self-contained ESM executable.
370
+ *
371
+ * Creates a wrapper that imports the hook and calls execute(), then bundles
372
+ * everything together including the runtime.
373
+ * @param options - Compilation options
374
+ * @returns Compiled output content as a string
375
+ */
376
+ async function compileHook(options) {
377
+ const { sourcePath, outputDir, logFilePath } = options;
378
+ // Create a temporary wrapper file that imports the hook and executes it
379
+ const tempDir = path.join(outputDir, '_temp_' + Date.now().toString());
380
+ const wrapperPath = path.join(tempDir, 'wrapper.ts');
381
+ const tempOutput = path.join(tempDir, 'output.mjs');
382
+ // Get the path to the runtime module (relative to this CLI)
383
+ const runtimePath = path.resolve(path.dirname(new URL(import.meta.url).pathname), './runtime.js');
384
+ try {
385
+ // Ensure temp directory exists
386
+ fs.mkdirSync(tempDir, { recursive: true });
387
+ // Build log file injection code if specified
388
+ const logFileInjection =
389
+ logFilePath !== undefined
390
+ ? `process.env['CLAUDE_CODE_HOOKS_CLI_LOG_FILE'] = ${JSON.stringify(logFilePath)};\n`
391
+ : '';
392
+ // Create wrapper that imports the hook and calls execute
393
+ const wrapperContent = `${logFileInjection}
394
+ import hook from '${sourcePath.replace(/\\/g, '/')}';
395
+ import { execute } from '${runtimePath.replace(/\\/g, '/')}';
396
+
397
+ execute(hook);
398
+ `;
399
+ fs.writeFileSync(wrapperPath, wrapperContent, 'utf-8');
400
+ await esbuild.build({
401
+ entryPoints: [wrapperPath],
402
+ outfile: tempOutput,
403
+ format: 'esm',
404
+ platform: 'node',
405
+ target: 'node20',
406
+ bundle: true,
407
+ sourcemap: 'inline',
408
+ minify: false,
409
+ // Keep node built-ins external
410
+ external: [
411
+ 'node:*',
412
+ 'http',
413
+ 'https',
414
+ 'url',
415
+ 'stream',
416
+ 'zlib',
417
+ 'events',
418
+ 'buffer',
419
+ 'util',
420
+ 'path',
421
+ 'fs',
422
+ 'os',
423
+ 'crypto',
424
+ 'child_process',
425
+ 'perf_hooks',
426
+ 'async_hooks',
427
+ 'diagnostics_channel'
428
+ ],
429
+ // Ensure we get clean ESM output
430
+ mainFields: ['module', 'main'],
431
+ conditions: ['import', 'node']
432
+ });
433
+ // Read the compiled content
434
+ const content = fs.readFileSync(tempOutput, 'utf-8');
435
+ // Clean up temp directory
436
+ fs.rmSync(tempDir, { recursive: true });
437
+ return content;
438
+ } catch (error) {
439
+ // Clean up temp directory on error
440
+ if (fs.existsSync(tempDir)) {
441
+ fs.rmSync(tempDir, { recursive: true });
442
+ }
443
+ throw error;
444
+ }
445
+ }
446
+ /**
447
+ * Generates a content hash (SHA-256, 8-char prefix) for a compiled hook.
448
+ * @param content - Compiled hook content
449
+ * @returns 8-character hex hash
450
+ */
451
+ function generateContentHash(content) {
452
+ const hash = crypto.createHash('sha256').update(content).digest('hex');
453
+ return hash.substring(0, 8);
454
+ }
455
+ /**
456
+ * Compiles all discovered hooks and returns their metadata.
457
+ * @param options - Compilation options
458
+ * @returns Array of compiled hook information
459
+ */
460
+ async function compileAllHooks(options) {
461
+ const { hookFiles, outputDir, logFilePath } = options;
462
+ const compiledHooks = [];
463
+ // Ensure output directory exists
464
+ if (!fs.existsSync(outputDir)) {
465
+ fs.mkdirSync(outputDir, { recursive: true });
466
+ }
467
+ for (const sourcePath of hookFiles) {
468
+ log('info', `Analyzing hook file: ${sourcePath}`);
469
+ // Extract metadata from source
470
+ const metadata = analyzeHookFile(sourcePath);
471
+ if (metadata === undefined) {
472
+ log('warn', `Skipping ${sourcePath}: not a valid hook file (no hook factory found)`);
473
+ continue;
474
+ }
475
+ log('info', `Found hook: ${metadata.hookEventName}`, {
476
+ matcher: metadata.matcher,
477
+ timeout: metadata.timeout
478
+ });
479
+ // Compile the hook
480
+ log('info', `Compiling: ${sourcePath}`);
481
+ const compiledContent = await compileHook({ sourcePath, outputDir, logFilePath });
482
+ // Generate content hash
483
+ const hash = generateContentHash(compiledContent);
484
+ // Determine output filename
485
+ const baseName = path.basename(sourcePath, path.extname(sourcePath));
486
+ const outputFilename = `${baseName}.${hash}.mjs`;
487
+ const outputPath = path.join(outputDir, outputFilename);
488
+ // Write compiled output with shebang for direct execution
489
+ // --enable-source-maps enables stack traces with original source locations
490
+ const shebang = '#!/usr/bin/env -S node --enable-source-maps\n';
491
+ fs.writeFileSync(outputPath, shebang + compiledContent, { encoding: 'utf-8', mode: 0o755 });
492
+ log('info', `Wrote: ${outputPath}`);
493
+ compiledHooks.push({
494
+ sourcePath,
495
+ outputPath,
496
+ outputFilename,
497
+ metadata
498
+ });
499
+ }
500
+ return compiledHooks;
501
+ }
502
+ // ============================================================================
503
+ // hooks.json Generation
504
+ // ============================================================================
505
+ /**
506
+ * Groups compiled hooks by event type, then by matcher pattern.
507
+ * @param compiledHooks - Array of compiled hooks
508
+ * @returns Nested map: EventType -> Matcher -> Hooks
509
+ */
510
+ function groupHooksByEventAndMatcher(compiledHooks) {
511
+ const groups = new Map();
512
+ for (const hook of compiledHooks) {
513
+ const eventName = hook.metadata.hookEventName;
514
+ const matcher = hook.metadata.matcher;
515
+ let eventGroup = groups.get(eventName);
516
+ if (eventGroup === undefined) {
517
+ eventGroup = new Map();
518
+ groups.set(eventName, eventGroup);
519
+ }
520
+ const existing = eventGroup.get(matcher);
521
+ if (existing !== undefined) {
522
+ existing.push(hook);
523
+ } else {
524
+ eventGroup.set(matcher, [hook]);
525
+ }
526
+ }
527
+ return groups;
528
+ }
529
+ /**
530
+ * Auto-detects the hook context and root directory based on directory structure.
531
+ *
532
+ * Detection logic:
533
+ * - If output path contains `.claude/` directory segment → agent context, root is parent of .claude/
534
+ * - If `.claude-plugin/` directory exists within 3 levels up → plugin context, root is that directory
535
+ * - Default: plugin context with hooks.json parent directory as root
536
+ * @param outputPath - Absolute path to the hooks.json output file
537
+ * @returns Detected hook context and root directory
538
+ */
539
+ function detectHookContext(outputPath) {
540
+ // Normalize path separators for cross-platform compatibility
541
+ const normalizedPath = outputPath.replace(/\\/g, '/');
542
+ // Check if the output path is within a .claude/ directory (agent hooks)
543
+ // This matches paths like: /project/.claude/hooks/hooks.json
544
+ const claudeMatch = normalizedPath.match(/^(.+)\/\.claude\//);
545
+ if (claudeMatch !== null) {
546
+ return {
547
+ context: 'agent',
548
+ rootDir: claudeMatch[1]
549
+ };
550
+ }
551
+ // Check if a .claude-plugin/ directory exists relative to the output
552
+ // Walk up from the output directory to find .claude-plugin/, but limit to 4 levels
553
+ // This supports structures like: plugin-root/src/hooks/output/hooks.json
554
+ let currentDir = path.dirname(outputPath);
555
+ const root = path.parse(currentDir).root;
556
+ const maxLevels = 4;
557
+ let level = 0;
558
+ while (currentDir !== root && level < maxLevels) {
559
+ const pluginDir = path.join(currentDir, '.claude-plugin');
560
+ if (fs.existsSync(pluginDir) && fs.statSync(pluginDir).isDirectory()) {
561
+ return {
562
+ context: 'plugin',
563
+ rootDir: currentDir
564
+ };
565
+ }
566
+ currentDir = path.dirname(currentDir);
567
+ level++;
568
+ }
569
+ // Default to plugin context with output directory as root
570
+ return {
571
+ context: 'plugin',
572
+ rootDir: path.dirname(outputPath)
573
+ };
574
+ }
575
+ /**
576
+ * Generates a command path based on the hook context.
577
+ *
578
+ * Calculates the relative path from the root directory to the build directory.
579
+ * Prepends the node executable.
580
+ *
581
+ * - `plugin`: Uses `node $CLAUDE_PLUGIN_ROOT/hooks/build/filename`
582
+ * - `agent`: Uses `node "$CLAUDE_PROJECT_DIR"/.claude/hooks/build/filename`
583
+ * @param filename - The compiled hook filename
584
+ * @param buildDir - Absolute path to the build directory
585
+ * @param contextInfo - Hook context info including root directory
586
+ * @param executable - Node executable path (default: "node")
587
+ * @returns The command path string
588
+ */
589
+ function generateCommandPath(filename, buildDir, contextInfo, executable = 'node') {
590
+ // Calculate relative path from root to build directory
591
+ const relativeBuildPath = path.relative(contextInfo.rootDir, buildDir);
592
+ // Normalize to forward slashes for cross-platform compatibility
593
+ const normalizedRelativePath = relativeBuildPath.replace(/\\/g, '/');
594
+ if (contextInfo.context === 'agent') {
595
+ // Agent hooks use $CLAUDE_PROJECT_DIR with shell-style quoting
596
+ return `${executable} "$CLAUDE_PROJECT_DIR"/${normalizedRelativePath}/${filename}`;
597
+ }
598
+ // Plugin hooks use $CLAUDE_PLUGIN_ROOT
599
+ return `${executable} $CLAUDE_PLUGIN_ROOT/${normalizedRelativePath}/${filename}`;
600
+ }
601
+ /**
602
+ * Generates the hooks.json content in Claude Code's expected format.
603
+ *
604
+ * Format: { hooks: { EventType: [ { matcher?, hooks: [...] } ] } }
605
+ * @param compiledHooks - Array of compiled hooks
606
+ * @param buildDir - Absolute path to the build directory
607
+ * @param contextInfo - Hook context info for path resolution
608
+ * @param executable - Node executable path (default: "node")
609
+ * @returns The hooks.json structure
610
+ */
611
+ function generateHooksJson(compiledHooks, buildDir, contextInfo, executable = 'node') {
612
+ const groups = groupHooksByEventAndMatcher(compiledHooks);
613
+ const hooks = {};
614
+ for (const [eventName, matcherGroups] of groups) {
615
+ const entries = [];
616
+ for (const [matcher, hookList] of matcherGroups) {
617
+ const entry = {
618
+ hooks: hookList.map((hook) => ({
619
+ type: 'command',
620
+ command: generateCommandPath(hook.outputFilename, buildDir, contextInfo, executable),
621
+ ...(hook.metadata.timeout !== undefined ? { timeout: hook.metadata.timeout } : {})
622
+ }))
623
+ };
624
+ // Only include matcher if defined
625
+ if (matcher !== undefined) {
626
+ entry.matcher = matcher;
627
+ }
628
+ entries.push(entry);
629
+ }
630
+ hooks[eventName] = entries;
631
+ }
632
+ return {
633
+ hooks,
634
+ __generated: {
635
+ files: compiledHooks.map((h) => h.outputFilename),
636
+ timestamp: new Date().toISOString()
637
+ }
638
+ };
639
+ }
640
+ /**
641
+ * Reads an existing hooks.json file if it exists.
642
+ * @param outputPath - Path to the hooks.json file
643
+ * @returns Parsed HooksJson or undefined if file doesn't exist
644
+ */
645
+ function readExistingHooksJson(outputPath) {
646
+ if (!fs.existsSync(outputPath)) {
647
+ return undefined;
648
+ }
649
+ try {
650
+ const content = fs.readFileSync(outputPath, 'utf-8');
651
+ return JSON.parse(content);
652
+ } catch (error) {
653
+ log('warn', 'Failed to parse existing hooks.json, will overwrite', {
654
+ error: error instanceof Error ? error.message : String(error)
655
+ });
656
+ return undefined;
657
+ }
658
+ }
659
+ /**
660
+ * Removes previously generated hook files from disk.
661
+ * Only removes files that were tracked in __generated.files.
662
+ * @param existingHooksJson - The existing hooks.json content
663
+ * @param outputDir - Directory containing the generated files
664
+ */
665
+ function removeOldGeneratedFiles(existingHooksJson, outputDir) {
666
+ const filesToRemove = existingHooksJson.__generated?.files ?? [];
667
+ for (const filename of filesToRemove) {
668
+ const filePath = path.join(outputDir, filename);
669
+ if (fs.existsSync(filePath)) {
670
+ try {
671
+ fs.unlinkSync(filePath);
672
+ log('info', `Removed old generated file: ${filename}`);
673
+ } catch (error) {
674
+ log('warn', `Failed to remove old generated file: ${filename}`, {
675
+ error: error instanceof Error ? error.message : String(error)
676
+ });
677
+ }
678
+ }
679
+ }
680
+ }
681
+ /**
682
+ * Extracts hooks from an existing hooks.json that were NOT generated by this package.
683
+ * Identifies generated hooks by checking if their command path matches the generated file pattern.
684
+ * @param existingHooksJson - The existing hooks.json content
685
+ * @returns Object containing preserved hooks (keyed by event type)
686
+ */
687
+ function extractPreservedHooks(existingHooksJson) {
688
+ const generatedFiles = new Set(existingHooksJson.__generated?.files ?? []);
689
+ const preserved = {};
690
+ for (const [eventType, entries] of Object.entries(existingHooksJson.hooks)) {
691
+ const preservedEntries = [];
692
+ for (const entry of entries) {
693
+ // Filter out hooks whose command matches a generated file
694
+ const preservedHooks = entry.hooks.filter((hook) => {
695
+ // Extract filename from the command path
696
+ // Command format: ${CLAUDE_PLUGIN_ROOT:-./}/filename.hash.mjs
697
+ const match = hook.command.match(/\/([^/]+)$/);
698
+ const filename = match ? match[1] : '';
699
+ return !generatedFiles.has(filename);
700
+ });
701
+ if (preservedHooks.length > 0) {
702
+ preservedEntries.push({
703
+ ...entry,
704
+ hooks: preservedHooks
705
+ });
706
+ }
707
+ }
708
+ if (preservedEntries.length > 0) {
709
+ preserved[eventType] = preservedEntries;
710
+ }
711
+ }
712
+ return preserved;
713
+ }
714
+ /**
715
+ * Merges preserved hooks with newly generated hooks.
716
+ * Preserved hooks are added first, then new hooks are appended.
717
+ * @param newHooksJson - The newly generated hooks.json content
718
+ * @param preservedHooks - Hooks to preserve from the existing hooks.json
719
+ * @returns Merged HooksJson
720
+ */
721
+ function mergeHooksJson(newHooksJson, preservedHooks) {
722
+ const mergedHooks = {};
723
+ // Get all event types from both sources
724
+ const allEventTypes = new Set([...Object.keys(preservedHooks), ...Object.keys(newHooksJson.hooks)]);
725
+ for (const eventType of allEventTypes) {
726
+ const preserved = preservedHooks[eventType] ?? [];
727
+ const generated = newHooksJson.hooks[eventType] ?? [];
728
+ // Combine preserved and generated entries
729
+ mergedHooks[eventType] = [...preserved, ...generated];
730
+ }
731
+ return {
732
+ hooks: mergedHooks,
733
+ __generated: newHooksJson.__generated
734
+ };
735
+ }
736
+ /**
737
+ * Writes hooks.json to the specified path atomically.
738
+ * Uses write-to-temp-then-rename pattern for atomicity.
739
+ * @param hooksJson - The hooks.json content
740
+ * @param outputPath - Path to write hooks.json
741
+ */
742
+ function writeHooksJson(hooksJson, outputPath) {
743
+ const dir = path.dirname(outputPath);
744
+ if (!fs.existsSync(dir)) {
745
+ fs.mkdirSync(dir, { recursive: true });
746
+ }
747
+ // Write to a temporary file first, then rename for atomicity
748
+ const tempPath = `${outputPath}.tmp.${process.pid}`;
749
+ const content = JSON.stringify(hooksJson, null, 2) + '\n';
750
+ try {
751
+ fs.writeFileSync(tempPath, content, 'utf-8');
752
+ fs.renameSync(tempPath, outputPath);
753
+ } catch (error) {
754
+ // Clean up temp file if rename failed
755
+ if (fs.existsSync(tempPath)) {
756
+ try {
757
+ fs.unlinkSync(tempPath);
758
+ } catch {
759
+ // Ignore cleanup errors
760
+ }
761
+ }
762
+ throw error;
763
+ }
764
+ }
765
+ // ============================================================================
766
+ // Main Entry Point
767
+ // ============================================================================
768
+ /**
769
+ * Main CLI entry point.
770
+ */
771
+ async function main() {
772
+ const rawArgs = process.argv.slice(2);
773
+ const args = parseArgs(rawArgs);
774
+ // Handle help or no args
775
+ if (args.help || rawArgs.length === 0) {
776
+ process.stdout.write(HELP_TEXT);
777
+ process.exit(0);
778
+ }
779
+ // Handle version
780
+ if (args.version) {
781
+ process.stdout.write(`claude-code-hooks v${VERSION}\n`);
782
+ process.exit(0);
783
+ }
784
+ // Validate arguments
785
+ const validationError = validateArgs(args);
786
+ if (validationError !== undefined) {
787
+ process.stderr.write(`Error: ${validationError}\n\n`);
788
+ process.stdout.write(HELP_TEXT);
789
+ process.exit(1);
790
+ }
791
+ // Handle scaffold mode
792
+ if (args.scaffold !== undefined && args.scaffold !== '') {
793
+ const hookNames = (args.hooks ?? '').split(',').filter((h) => h.length > 0);
794
+ scaffoldProject({
795
+ directory: args.scaffold,
796
+ hooks: hookNames,
797
+ outputPath: args.output
798
+ });
799
+ process.exit(0);
800
+ }
801
+ try {
802
+ const cwd = process.cwd();
803
+ const outputPath = path.resolve(cwd, args.output);
804
+ const hooksJsonDir = path.dirname(outputPath);
805
+ // Compiled hooks go in a 'build' subdirectory relative to hooks.json
806
+ const buildDir = path.join(hooksJsonDir, 'build');
807
+ // Resolve log file path to absolute if provided
808
+ const logFilePath = args.log !== undefined ? path.resolve(cwd, args.log) : undefined;
809
+ log('info', 'Starting hook compilation', {
810
+ input: args.input,
811
+ output: args.output,
812
+ logFilePath,
813
+ cwd
814
+ });
815
+ // Discover hook files
816
+ const hookFiles = await discoverHookFiles(args.input, cwd);
817
+ log('info', `Discovered ${hookFiles.length} hook files`, { files: hookFiles });
818
+ if (hookFiles.length === 0) {
819
+ process.stderr.write(`No hook files found matching pattern: ${args.input}\n`);
820
+ process.exit(1);
821
+ }
822
+ // Read existing hooks.json to preserve non-generated hooks
823
+ const existingHooksJson = readExistingHooksJson(outputPath);
824
+ let preservedHooks = {};
825
+ if (existingHooksJson !== undefined) {
826
+ log('info', 'Found existing hooks.json, will preserve non-generated hooks');
827
+ // Extract hooks that were NOT generated by this package
828
+ preservedHooks = extractPreservedHooks(existingHooksJson);
829
+ // Remove old generated files from disk
830
+ removeOldGeneratedFiles(existingHooksJson, buildDir);
831
+ const preservedCount = Object.values(preservedHooks).reduce(
832
+ (sum, entries) => sum + entries.reduce((s, e) => s + e.hooks.length, 0),
833
+ 0
834
+ );
835
+ log('info', `Preserved ${preservedCount} hooks from other sources`);
836
+ }
837
+ // Compile all hooks
838
+ const compiledHooks = await compileAllHooks({ hookFiles, outputDir: buildDir, logFilePath });
839
+ if (compiledHooks.length === 0) {
840
+ process.stderr.write('No valid hooks found in discovered files.\n');
841
+ process.exit(1);
842
+ }
843
+ // Auto-detect hook context based on output path
844
+ const hookContext = detectHookContext(outputPath);
845
+ log('info', `Detected hook context: ${hookContext.context}`, { rootDir: hookContext.rootDir });
846
+ // Generate hooks.json for newly compiled hooks
847
+ const executable = args.executable !== undefined && args.executable !== '' ? args.executable : 'node';
848
+ const newHooksJson = generateHooksJson(compiledHooks, buildDir, hookContext, executable);
849
+ // Merge with preserved hooks
850
+ const finalHooksJson = mergeHooksJson(newHooksJson, preservedHooks);
851
+ writeHooksJson(finalHooksJson, outputPath);
852
+ log('info', 'Compilation complete', {
853
+ hooksCompiled: compiledHooks.length,
854
+ outputPath
855
+ });
856
+ // Output summary to stdout
857
+ process.stdout.write(`Compiled ${compiledHooks.length} hooks to ${buildDir}\n`);
858
+ if (Object.keys(preservedHooks).length > 0) {
859
+ const preservedCount = Object.values(preservedHooks).reduce(
860
+ (sum, entries) => sum + entries.reduce((s, e) => s + e.hooks.length, 0),
861
+ 0
862
+ );
863
+ process.stdout.write(`Preserved ${preservedCount} hooks from other sources\n`);
864
+ }
865
+ process.stdout.write(`Generated ${outputPath}\n`);
866
+ process.exit(0);
867
+ } catch (error) {
868
+ const message = error instanceof Error ? error.message : String(error);
869
+ log('error', 'Build failed', { error: message });
870
+ process.stderr.write(`Error: ${message}\n`);
871
+ process.exit(1);
872
+ } finally {
873
+ closeLog();
874
+ }
875
+ }
876
+ // Run main only when executed directly (not when imported for testing)
877
+ // Check if this file is the entry point by checking if import.meta.url matches process.argv[1]
878
+ // Resolves symlinks to handle npm bin symlinks correctly
879
+ const isDirectExecution = (() => {
880
+ try {
881
+ const scriptPath = process.argv[1];
882
+ if (!scriptPath) return false;
883
+ // Resolve symlinks to get the real path (npm creates symlinks in node_modules/.bin)
884
+ const realScriptPath = fs.realpathSync(scriptPath);
885
+ const scriptUrl = new URL(`file://${realScriptPath}`);
886
+ return import.meta.url === scriptUrl.href;
887
+ } catch {
888
+ return false;
889
+ }
890
+ })();
891
+ if (isDirectExecution) {
892
+ main().catch((error) => {
893
+ process.stderr.write(`Fatal error: ${error instanceof Error ? error.message : String(error)}\n`);
894
+ process.exit(1);
895
+ });
896
+ }
897
+ // Export for testing
898
+ export {
899
+ parseArgs,
900
+ validateArgs,
901
+ analyzeHookFile,
902
+ discoverHookFiles,
903
+ compileHook,
904
+ generateContentHash,
905
+ detectHookContext,
906
+ generateCommandPath,
907
+ generateHooksJson,
908
+ groupHooksByEventAndMatcher,
909
+ readExistingHooksJson,
910
+ removeOldGeneratedFiles,
911
+ extractPreservedHooks,
912
+ mergeHooksJson,
913
+ HOOK_FACTORY_TO_EVENT
914
+ };