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