@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 +597 -604
- package/dist/constants.js +12 -12
- package/dist/env.js +36 -38
- package/dist/hooks.js +22 -22
- package/dist/index.js +37 -67
- package/dist/logger.js +371 -362
- package/dist/outputs.js +33 -34
- package/dist/runtime.js +99 -96
- package/dist/scaffold.js +178 -183
- package/dist/tool-helpers.js +255 -103
- package/dist/types.js +35 -0
- package/package.json +8 -11
- package/types/cli.d.ts +67 -98
- package/types/constants.d.ts +1 -1
- package/types/env.d.ts +16 -16
- package/types/hooks.d.ts +168 -254
- package/types/index.d.ts +15 -130
- package/types/logger.d.ts +285 -285
- package/types/outputs.d.ts +154 -229
- package/types/runtime.d.ts +4 -6
- package/types/scaffold.d.ts +6 -6
- package/types/tool-helpers.d.ts +206 -77
- package/types/types.d.ts +497 -0
- package/dist/inputs.js +0 -35
- package/dist/tool-inputs.js +0 -21
package/dist/cli.js
CHANGED
|
@@ -14,19 +14,19 @@
|
|
|
14
14
|
* ```
|
|
15
15
|
* @module
|
|
16
16
|
*/
|
|
17
|
-
import * as crypto from
|
|
18
|
-
import * as fs from
|
|
19
|
-
import * as os from
|
|
20
|
-
import * as path from
|
|
21
|
-
import * as esbuild from
|
|
22
|
-
import { glob } from
|
|
23
|
-
import ts from
|
|
24
|
-
import { HOOK_FACTORY_TO_EVENT } from
|
|
25
|
-
import { scaffoldProject } from
|
|
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 =
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
225
|
-
|
|
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
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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
|
-
|
|
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
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
import
|
|
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
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
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
|
-
|
|
449
|
-
|
|
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
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
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
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
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
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
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
|
-
|
|
544
|
-
|
|
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 =
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
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 =
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
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
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
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
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
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
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
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
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
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
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
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
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
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
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
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
|
-
|
|
900
|
-
|
|
901
|
-
|
|
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, };
|