@goodfoot/claude-code-hooks 1.0.9 → 1.0.11

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