@goodfoot/agent-hooks 1.0.0

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.
Files changed (68) hide show
  1. package/README.md +137 -0
  2. package/dist/agents/antigravity/events.js +42 -0
  3. package/dist/agents/antigravity/hooks.js +59 -0
  4. package/dist/agents/antigravity/index.js +28 -0
  5. package/dist/agents/antigravity/outputs.js +81 -0
  6. package/dist/agents/antigravity/transport.js +84 -0
  7. package/dist/agents/antigravity/types.js +11 -0
  8. package/dist/agents/claude-code/env.js +27 -0
  9. package/dist/agents/claude-code/events.js +87 -0
  10. package/dist/agents/claude-code/hooks.js +1000 -0
  11. package/dist/agents/claude-code/index.js +57 -0
  12. package/dist/agents/claude-code/outputs.js +579 -0
  13. package/dist/agents/claude-code/tool-helpers.js +840 -0
  14. package/dist/agents/claude-code/transport.js +254 -0
  15. package/dist/agents/claude-code/types.js +11 -0
  16. package/dist/agents/codex/cli-support.js +318 -0
  17. package/dist/agents/codex/constants.js +29 -0
  18. package/dist/agents/codex/events.js +68 -0
  19. package/dist/agents/codex/hooks.js +93 -0
  20. package/dist/agents/codex/index.js +21 -0
  21. package/dist/agents/codex/outputs.js +189 -0
  22. package/dist/agents/codex/scaffold.js +273 -0
  23. package/dist/agents/codex/transport.js +139 -0
  24. package/dist/agents/codex/types.js +1 -0
  25. package/dist/cli.js +1357 -0
  26. package/dist/constants.js +39 -0
  27. package/dist/core/define-hook.js +61 -0
  28. package/dist/core/env.js +186 -0
  29. package/dist/core/logger.js +508 -0
  30. package/dist/core/stdin.js +38 -0
  31. package/dist/core/transport.js +231 -0
  32. package/dist/core/types.js +10 -0
  33. package/dist/index.js +17 -0
  34. package/dist/scaffold.js +512 -0
  35. package/package.json +61 -0
  36. package/types/agents/antigravity/events.d.ts +50 -0
  37. package/types/agents/antigravity/hooks.d.ts +76 -0
  38. package/types/agents/antigravity/index.d.ts +27 -0
  39. package/types/agents/antigravity/outputs.d.ts +107 -0
  40. package/types/agents/antigravity/transport.d.ts +47 -0
  41. package/types/agents/antigravity/types.d.ts +58 -0
  42. package/types/agents/claude-code/env.d.ts +27 -0
  43. package/types/agents/claude-code/events.d.ts +69 -0
  44. package/types/agents/claude-code/hooks.d.ts +1182 -0
  45. package/types/agents/claude-code/index.d.ts +23 -0
  46. package/types/agents/claude-code/outputs.d.ts +1160 -0
  47. package/types/agents/claude-code/tool-helpers.d.ts +807 -0
  48. package/types/agents/claude-code/transport.d.ts +119 -0
  49. package/types/agents/claude-code/types.d.ts +557 -0
  50. package/types/agents/codex/cli-support.d.ts +105 -0
  51. package/types/agents/codex/constants.d.ts +10 -0
  52. package/types/agents/codex/events.d.ts +63 -0
  53. package/types/agents/codex/hooks.d.ts +135 -0
  54. package/types/agents/codex/index.d.ts +19 -0
  55. package/types/agents/codex/outputs.d.ts +190 -0
  56. package/types/agents/codex/scaffold.d.ts +14 -0
  57. package/types/agents/codex/transport.d.ts +74 -0
  58. package/types/agents/codex/types.d.ts +131 -0
  59. package/types/cli.d.ts +315 -0
  60. package/types/constants.d.ts +9 -0
  61. package/types/core/define-hook.d.ts +39 -0
  62. package/types/core/env.d.ts +150 -0
  63. package/types/core/logger.d.ts +476 -0
  64. package/types/core/stdin.d.ts +21 -0
  65. package/types/core/transport.d.ts +131 -0
  66. package/types/core/types.d.ts +137 -0
  67. package/types/index.d.ts +18 -0
  68. package/types/scaffold.d.ts +46 -0
package/dist/cli.js ADDED
@@ -0,0 +1,1357 @@
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
+ * agent-hooks --agent claude-code -i "hooks/**\/*.ts" -o "./dist/hooks.json"
11
+ *
12
+ * # With runtime logging (equivalent to AGENT_HOOKS_LOG_FILE)
13
+ * agent-hooks --agent claude-code -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 { fileURLToPath } from "node:url";
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
+ // ============================================================================
27
+ // Constants
28
+ // ============================================================================
29
+ const VERSION = "1.0.0";
30
+ const DEFAULT_ESBUILD_LOADERS = {
31
+ ".md": "text",
32
+ };
33
+ const VALID_ESBUILD_LOADERS = [
34
+ "base64",
35
+ "binary",
36
+ "copy",
37
+ "css",
38
+ "dataurl",
39
+ "empty",
40
+ "file",
41
+ "js",
42
+ "json",
43
+ "jsx",
44
+ "local-css",
45
+ "text",
46
+ "ts",
47
+ "tsx",
48
+ ];
49
+ const HELP_TEXT = `
50
+ @goodfoot/agent-hooks - Type-safe, compiled hooks for Claude Code, Codex, and Antigravity
51
+
52
+ Description:
53
+ This tool acts as a build system for agent hooks. It scans your TypeScript files for
54
+ exported hook factories (e.g., preToolUseHook), compiles them into standalone ESM modules,
55
+ and generates a hooks.json manifest that you can reference in your Claude Code configuration.
56
+
57
+ Usage:
58
+ npx -y @goodfoot/agent-hooks --agent <agent> -i <glob> -o <path> [options]
59
+ npx -y @goodfoot/agent-hooks --agent <agent> --scaffold <dir> --hooks <types> -o <path>
60
+
61
+ Codex-specific options (with --agent codex):
62
+ --plugin-root Force plugin mode: emit \${PLUGIN_ROOT}-relative commands and
63
+ stable, hash-free filenames. Auto-enabled when a .codex-plugin/
64
+ marker is found by walking up from the output path. Outputs
65
+ under a .codex/ directory use git-toplevel-relative commands.
66
+
67
+ Required Arguments:
68
+ --agent <claude-code|codex|antigravity>
69
+ The agent runtime the hooks are built for. Determines the hook surface
70
+ validated at build time and the runtime module linked into each compiled
71
+ bundle. There is no default: omitting this flag is an error.
72
+ Example: --agent claude-code
73
+
74
+ Build Mode (compile existing hooks):
75
+ -i, --input <glob>
76
+ Glob pattern to find your hook source files.
77
+ Example: "hooks/**/*.ts" (Quotes are recommended to prevent shell expansion)
78
+
79
+ -o, --output <path>
80
+ Path where the hooks.json manifest should be generated.
81
+ Compiled hook files (.mjs) will be placed in the same directory as this file.
82
+ Example: "dist/hooks.json"
83
+
84
+ Scaffold Mode (create new hook project):
85
+ --scaffold <directory>
86
+ Create a new hook project at the specified directory path.
87
+ The directory must not already exist.
88
+ Example: --scaffold ./my-hooks
89
+
90
+ --hooks <types>
91
+ Comma-separated list of hook types to generate in the scaffolded project.
92
+ Valid types: PreToolUse, PostToolUse, PostToolUseFailure, Notification,
93
+ UserPromptSubmit, SessionStart, SessionEnd, Stop,
94
+ SubagentStart, SubagentStop, PreCompact, PermissionRequest
95
+ Example: --hooks Stop,SubagentStop,PreToolUse
96
+
97
+ -o, --output <path>
98
+ In scaffold mode, configures where the generated build script will output hooks.json.
99
+ This path is relative to the scaffolded project directory.
100
+ Example: -o dist/hooks.json
101
+
102
+ Optional Arguments:
103
+ --log <path>
104
+ Hardcode a log file path into the compiled bundle.
105
+ All context.logger calls within your hooks will write to this file.
106
+ A runtime AGENT_HOOKS_LOG_FILE env var overrides this hardcoded path.
107
+ Cannot be combined with --log-env-var.
108
+ Example: "/tmp/claude-hooks.log"
109
+
110
+ --log-env-var <var>
111
+ Name of the environment variable that will supply the log file path at runtime.
112
+ The Logger reads process.env[VAR_NAME] at startup instead of a hardcoded path.
113
+ Use this when the log path must be determined dynamically (e.g. across worktrees).
114
+ Cannot be combined with --log.
115
+ Example: --log-env-var MY_PLUGIN_LOG_FILE
116
+
117
+ --executable <path>
118
+ Node executable path to use in generated commands (default: "node").
119
+ Use this to specify a custom node path in the generated hooks.json commands.
120
+ Example: "/usr/local/bin/node" or "node22"
121
+
122
+ --loader <ext=type>
123
+ Register an esbuild loader for non-code imports used by hooks.
124
+ Repeat the flag to enable additional extensions.
125
+ Example: --loader .txt=text --loader .svg=dataurl
126
+ Default loaders: .md=text
127
+
128
+ --stable-names (default)
129
+ Emit hash-free compiled bundles (<name>.mjs). Keeps generated hooks.json
130
+ byte-stable across rebuilds so Claude Code's hook trust hash stays valid
131
+ and users do not have to re-trust hooks after every update. Stale hashed
132
+ leftovers are pruned automatically.
133
+
134
+ --no-stable-names
135
+ Restore the pre-1.7 behavior: hashed compiled bundles (<name>.<hash>.mjs).
136
+
137
+ --sourcemap (default)
138
+ Embed an inline sourcemap in each compiled bundle so stack traces show
139
+ original TypeScript source locations.
140
+
141
+ --no-sourcemap
142
+ Emit bundles without the inline sourcemap, shrinking compiled output by
143
+ roughly 85-90%. The content hash is unaffected.
144
+
145
+ -h, --help
146
+ Show this help message.
147
+
148
+ -v, --version
149
+ Show the current version of the CLI.
150
+
151
+ Examples:
152
+ 1. Basic Compilation:
153
+ npx -y @goodfoot/agent-hooks --agent claude-code -i "hooks/**/*.ts" -o "dist/hooks.json"
154
+
155
+ 2. With Hardcoded Log Path:
156
+ npx -y @goodfoot/agent-hooks --agent claude-code -i "src/hooks/*.ts" -o "bin/hooks.json" --log /tmp/claude-hooks.log
157
+
158
+ 2b. With Dynamic Log Path (env var set at runtime):
159
+ npx -y @goodfoot/agent-hooks --agent claude-code -i "src/hooks/*.ts" -o "bin/hooks.json" --log-env-var AGENT_HOOKS_LOG_FILE
160
+
161
+ 3. Scaffold a New Hook Project:
162
+ npx -y @goodfoot/agent-hooks --agent claude-code --scaffold ./my-hooks --hooks Stop,SubagentStop -o dist/hooks.json
163
+
164
+ 4. With Custom Node Executable:
165
+ npx -y @goodfoot/agent-hooks --agent claude-code -i "hooks/**/*.ts" -o "dist/hooks.json" --executable /usr/local/bin/node
166
+
167
+ 4b. With Text Assets:
168
+ npx -y @goodfoot/agent-hooks --agent claude-code -i "hooks/**/*.ts" -o "dist/hooks.json" --loader .txt=text
169
+
170
+ 5. Build Codex hooks (plugin mode via .codex-plugin marker or --plugin-root):
171
+ npx -y @goodfoot/agent-hooks --agent codex -i "src/**/*.ts" -o ".codex/hooks.json"
172
+ npx -y @goodfoot/agent-hooks --agent codex -i "src/**/*.ts" -o "my-plugin/hooks/hooks.json" --plugin-root
173
+
174
+ 6. Configure Claude to use the hooks:
175
+ After building, add this to your ~/.claude/config.json:
176
+ {
177
+ "hooks": "/absolute/path/to/your/project/dist/hooks.json"
178
+ }
179
+
180
+ Troubleshooting:
181
+ - Always pass --agent; there is no default agent.
182
+ - Ensure your hook files use 'export default'.
183
+ - Use absolute paths in your glob patterns if relative paths aren't finding files.
184
+ - Check the log file specified by --log (or the env var named by --log-env-var) if hooks don't seem to run.
185
+ `;
186
+ // ============================================================================
187
+ // Logging
188
+ // ============================================================================
189
+ let logFile;
190
+ /**
191
+ * Initializes the log file if a path is provided.
192
+ * @param logPath - Optional path to log file
193
+ * @internal
194
+ */
195
+ function _initLog(logPath) {
196
+ if (logPath !== undefined) {
197
+ const logDir = path.dirname(logPath);
198
+ if (!fs.existsSync(logDir)) {
199
+ fs.mkdirSync(logDir, { recursive: true });
200
+ }
201
+ logFile = fs.createWriteStream(logPath, { flags: "a" });
202
+ }
203
+ }
204
+ /**
205
+ * Closes the log file if open.
206
+ */
207
+ function closeLog() {
208
+ if (logFile !== undefined) {
209
+ logFile.close();
210
+ logFile = undefined;
211
+ }
212
+ }
213
+ /**
214
+ * Logs a message to the log file (if configured).
215
+ * Does NOT write to stdout/stderr to avoid interfering with hook protocol.
216
+ * @param level - Log level
217
+ * @param message - Log message
218
+ * @param data - Optional additional data
219
+ */
220
+ function log(level, message, data) {
221
+ if (logFile !== undefined) {
222
+ const entry = {
223
+ timestamp: new Date().toISOString(),
224
+ level,
225
+ message,
226
+ ...(data !== undefined ? { data } : {}),
227
+ };
228
+ logFile.write(`${JSON.stringify(entry)}\n`);
229
+ }
230
+ }
231
+ // ============================================================================
232
+ // Argument Parsing
233
+ // ============================================================================
234
+ /**
235
+ * Parses command-line arguments.
236
+ * @param argv - Process argv (usually process.argv.slice(2))
237
+ * @returns Parsed arguments
238
+ */
239
+ function parseArgs(argv) {
240
+ const args = {
241
+ agent: "",
242
+ input: "",
243
+ output: "",
244
+ help: false,
245
+ version: false,
246
+ loaderFlags: [],
247
+ pluginRoot: false,
248
+ // Undefined unless explicitly passed: the Claude Code branch treats it
249
+ // as true, while the Codex branch defaults by command context
250
+ // (plugin mode → stable, otherwise hashed).
251
+ stableNames: undefined,
252
+ sourcemap: true,
253
+ };
254
+ for (let i = 0; i < argv.length; i++) {
255
+ const arg = argv[i];
256
+ switch (arg) {
257
+ case "--agent":
258
+ args.agent = argv[++i] ?? "";
259
+ break;
260
+ case "-i":
261
+ case "--input":
262
+ args.input = argv[++i] ?? "";
263
+ break;
264
+ case "-o":
265
+ case "--output":
266
+ args.output = argv[++i] ?? "";
267
+ break;
268
+ case "--log":
269
+ args.log = argv[++i];
270
+ break;
271
+ case "--log-env-var":
272
+ args.logEnvVar = argv[++i];
273
+ break;
274
+ case "-h":
275
+ case "--help":
276
+ args.help = true;
277
+ break;
278
+ case "-v":
279
+ case "--version":
280
+ args.version = true;
281
+ break;
282
+ case "--scaffold":
283
+ args.scaffold = argv[++i] ?? "";
284
+ break;
285
+ case "--hooks":
286
+ args.hooks = argv[++i] ?? "";
287
+ break;
288
+ case "--executable":
289
+ args.executable = argv[++i] ?? "";
290
+ break;
291
+ case "--loader":
292
+ args.loaderFlags.push(argv[++i] ?? "");
293
+ break;
294
+ case "--plugin-root":
295
+ args.pluginRoot = true;
296
+ break;
297
+ case "--stable-names":
298
+ args.stableNames = true;
299
+ break;
300
+ case "--no-stable-names":
301
+ args.stableNames = false;
302
+ break;
303
+ case "--sourcemap":
304
+ args.sourcemap = true;
305
+ break;
306
+ case "--no-sourcemap":
307
+ args.sourcemap = false;
308
+ break;
309
+ default:
310
+ // Unknown argument - ignore
311
+ break;
312
+ }
313
+ }
314
+ return args;
315
+ }
316
+ /**
317
+ * Validates CLI arguments and returns error message if invalid.
318
+ * @param args - Parsed CLI arguments
319
+ * @returns Error message if invalid, undefined if valid
320
+ */
321
+ function validateArgs(args) {
322
+ if (args.help || args.version) {
323
+ return undefined;
324
+ }
325
+ // --agent is required and never inferred (plan step 2.3). Validate the
326
+ // value against the agents this release knows, so a typo fails closed
327
+ // instead of silently building for the wrong runtime.
328
+ const VALID_AGENTS = ["claude-code", "codex", "antigravity"];
329
+ const isKnownAgent = (value) => VALID_AGENTS.includes(value);
330
+ if (!isKnownAgent(args.agent)) {
331
+ return args.agent === ""
332
+ ? "Missing required argument: --agent <claude-code|codex|antigravity>"
333
+ : `Invalid --agent value: ${args.agent}. Valid agents: ${VALID_AGENTS.join(", ")}`;
334
+ }
335
+ // Scaffold mode validation
336
+ if (args.scaffold !== undefined && args.scaffold !== "") {
337
+ if (args.hooks === undefined || args.hooks === "") {
338
+ return "Scaffold mode requires --hooks argument (comma-separated hook types)";
339
+ }
340
+ if (args.output === "") {
341
+ return "Scaffold mode requires -o/--output argument (path for generated hooks.json)";
342
+ }
343
+ // In scaffold mode, --input is not required
344
+ return undefined;
345
+ }
346
+ // Normal build mode validation
347
+ if (args.log !== undefined && args.logEnvVar !== undefined) {
348
+ return "Cannot use --log and --log-env-var together: choose one method to configure log output";
349
+ }
350
+ if (args.input === "") {
351
+ return "Missing required argument: -i/--input <glob>";
352
+ }
353
+ if (args.output === "") {
354
+ return "Missing required argument: -o/--output <path>";
355
+ }
356
+ for (const loaderFlag of args.loaderFlags ?? []) {
357
+ const parsedLoader = parseLoaderFlag(loaderFlag);
358
+ if (parsedLoader === undefined) {
359
+ return `Invalid --loader value: ${loaderFlag}. Expected .ext=type`;
360
+ }
361
+ if (!VALID_ESBUILD_LOADERS.includes(parsedLoader.loader)) {
362
+ return `Invalid esbuild loader type for ${parsedLoader.extension}: ${parsedLoader.loader}`;
363
+ }
364
+ }
365
+ return undefined;
366
+ }
367
+ function parseLoaderFlag(spec) {
368
+ const separatorIndex = spec.indexOf("=");
369
+ if (separatorIndex <= 0 || separatorIndex === spec.length - 1) {
370
+ return undefined;
371
+ }
372
+ const extension = spec.slice(0, separatorIndex);
373
+ const loader = spec.slice(separatorIndex + 1);
374
+ if (!extension.startsWith(".") || extension.length < 2) {
375
+ return undefined;
376
+ }
377
+ return { extension, loader: loader };
378
+ }
379
+ function buildLoaderMap(loaderFlags = []) {
380
+ const loaders = { ...DEFAULT_ESBUILD_LOADERS };
381
+ for (const loaderFlag of loaderFlags) {
382
+ const parsedLoader = parseLoaderFlag(loaderFlag);
383
+ if (parsedLoader !== undefined) {
384
+ loaders[parsedLoader.extension] = parsedLoader.loader;
385
+ }
386
+ }
387
+ return loaders;
388
+ }
389
+ // ============================================================================
390
+ // TypeScript AST Analysis
391
+ // ============================================================================
392
+ /**
393
+ * Extracts hook metadata from a TypeScript source file using AST analysis.
394
+ *
395
+ * Looks for default exports that call hook factory functions (preToolUseHook, etc.)
396
+ * and extracts the hook type, matcher, and timeout from the config object.
397
+ * @param sourcePath - Absolute path to the TypeScript source file
398
+ * @returns Extracted hook metadata or undefined if not a valid hook file
399
+ * @example
400
+ * ```typescript
401
+ * // For a file containing:
402
+ * // export default preToolUseHook({ matcher: 'Bash', timeout: 5000 }, handler);
403
+ *
404
+ * const metadata = analyzeHookFile('/path/to/hook.ts');
405
+ * // { hookEventName: 'PreToolUse', matcher: 'Bash', timeout: 5000 }
406
+ * ```
407
+ */
408
+ function analyzeHookFile(sourcePath) {
409
+ const sourceCode = fs.readFileSync(sourcePath, "utf-8");
410
+ const sourceFile = ts.createSourceFile(sourcePath, sourceCode, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
411
+ let metadata;
412
+ /**
413
+ * Recursively visits AST nodes to find hook factory calls.
414
+ * @param node - The AST node to visit
415
+ */
416
+ function visit(node) {
417
+ // Look for export default or export = assignment
418
+ if (ts.isExportAssignment(node) && !node.isExportEquals) {
419
+ // export default <expression>
420
+ const expression = node.expression;
421
+ const result = extractHookMetadataFromExpression(expression);
422
+ if (result !== undefined) {
423
+ metadata = result;
424
+ }
425
+ }
426
+ // Also check for: export default preToolUseHook(...)
427
+ // which might be wrapped in other expressions
428
+ ts.forEachChild(node, visit);
429
+ }
430
+ /**
431
+ * Extracts metadata from a call expression to a hook factory.
432
+ * @param expression - The expression node to analyze
433
+ * @returns Hook metadata if found, undefined otherwise
434
+ */
435
+ function extractHookMetadataFromExpression(expression) {
436
+ // Handle direct call: preToolUseHook({ ... }, handler)
437
+ if (ts.isCallExpression(expression)) {
438
+ return extractFromCallExpression(expression);
439
+ }
440
+ // Handle await: await preToolUseHook(...)
441
+ if (ts.isAwaitExpression(expression)) {
442
+ return extractHookMetadataFromExpression(expression.expression);
443
+ }
444
+ // Handle parenthesized: (preToolUseHook(...))
445
+ if (ts.isParenthesizedExpression(expression)) {
446
+ return extractHookMetadataFromExpression(expression.expression);
447
+ }
448
+ return undefined;
449
+ }
450
+ /**
451
+ * Extracts metadata from a CallExpression node.
452
+ * @param callExpr - The call expression to extract metadata from
453
+ * @returns Hook metadata if the call is to a hook factory, undefined otherwise
454
+ */
455
+ function extractFromCallExpression(callExpr) {
456
+ // Get the function being called
457
+ const callee = callExpr.expression;
458
+ let factoryName;
459
+ if (ts.isIdentifier(callee)) {
460
+ factoryName = callee.text;
461
+ }
462
+ else if (ts.isPropertyAccessExpression(callee)) {
463
+ // Could be namespace.preToolUseHook
464
+ factoryName = callee.name.text;
465
+ }
466
+ if (factoryName === undefined) {
467
+ return undefined;
468
+ }
469
+ // Check if it's a known hook factory
470
+ const hookEventName = HOOK_FACTORY_TO_EVENT[factoryName];
471
+ if (hookEventName === undefined) {
472
+ return undefined;
473
+ }
474
+ // Extract config from first argument
475
+ const configArg = callExpr.arguments[0];
476
+ let matcher;
477
+ let timeout;
478
+ if (configArg !== undefined && ts.isObjectLiteralExpression(configArg)) {
479
+ for (const prop of configArg.properties) {
480
+ if (!ts.isPropertyAssignment(prop))
481
+ continue;
482
+ const propName = ts.isIdentifier(prop.name) ? prop.name.text : undefined;
483
+ if (propName === undefined)
484
+ continue;
485
+ if (propName === "matcher") {
486
+ // Extract string value
487
+ if (ts.isStringLiteral(prop.initializer)) {
488
+ matcher = prop.initializer.text;
489
+ }
490
+ else if (ts.isNoSubstitutionTemplateLiteral(prop.initializer)) {
491
+ matcher = prop.initializer.text;
492
+ }
493
+ }
494
+ else if (propName === "timeout") {
495
+ // Extract number value
496
+ if (ts.isNumericLiteral(prop.initializer)) {
497
+ timeout = Number(prop.initializer.text);
498
+ }
499
+ }
500
+ }
501
+ }
502
+ return { hookEventName, matcher, timeout };
503
+ }
504
+ visit(sourceFile);
505
+ return metadata;
506
+ }
507
+ // ============================================================================
508
+ // Hook File Discovery
509
+ // ============================================================================
510
+ /**
511
+ * Discovers hook files matching the glob pattern.
512
+ * @param pattern - Glob pattern for hook files
513
+ * @param cwd - Current working directory for relative patterns
514
+ * @returns Array of absolute paths to hook files
515
+ */
516
+ async function discoverHookFiles(pattern, cwd) {
517
+ // `glob` treats backslashes as escape characters and requires POSIX
518
+ // separators even on Windows. Callers routinely build patterns with
519
+ // `path.join` (native separators), so normalize before matching. This is
520
+ // a no-op on POSIX where paths already use forward slashes.
521
+ const globPattern = pattern.replace(/\\/g, "/");
522
+ const files = await glob(globPattern, {
523
+ cwd,
524
+ absolute: true,
525
+ nodir: true,
526
+ });
527
+ return files.filter((file) => file.endsWith(".ts") || file.endsWith(".mts"));
528
+ }
529
+ /**
530
+ * Re-roots a realpath through the node_modules symlink chain visible from
531
+ * `resolveDir`, so relative paths computed against it do not depend on how
532
+ * deeply the checkout is nested below a shared install.
533
+ *
534
+ * Walks `resolveDir`'s ancestors for a `node_modules` directory whose
535
+ * realpath contains `realPath` (e.g. a checkout-level symlink to a shared
536
+ * install) and returns `realPath` as seen through that directory. Also
537
+ * recognizes the package directory itself being symlinked into that
538
+ * `node_modules` — whether the package lives in a store (pnpm-style store
539
+ * link, npx cache, external install) or in the checkout's own source tree
540
+ * (`packages/` with a `node_modules` entry symlinked back into it) — and
541
+ * re-roots through the symlink form, so esbuild records one module identity
542
+ * regardless of install topology. Falls back to the realpath when no such
543
+ * node_modules exists — with no symlink involved, the realpath is already
544
+ * checkout-local.
545
+ */
546
+ function symlinkVisiblePath(realPath, resolveDir) {
547
+ // Position of the runtime relative to its own nearest node_modules
548
+ // ancestor, used below to recognize the same package reached through a
549
+ // symlink in a node_modules on the resolveDir walk.
550
+ let pkgRelPath;
551
+ for (let dir = path.dirname(realPath);;) {
552
+ if (path.basename(dir) === "node_modules") {
553
+ pkgRelPath = path.relative(dir, realPath);
554
+ break;
555
+ }
556
+ const parent = path.dirname(dir);
557
+ if (parent === dir) {
558
+ break;
559
+ }
560
+ dir = parent;
561
+ }
562
+ // The runtime's package entry name (`[scope/]name`) from its own nearest
563
+ // package.json, and the runtime's position within that package's tree,
564
+ // used below to recognize the same package reached through its usual
565
+ // node_modules entry when the package directory itself is not under any
566
+ // node_modules — the checkout's own source tree.
567
+ let pkgEntry;
568
+ let pkgRel;
569
+ for (let dir = path.dirname(realPath);;) {
570
+ const pkgJsonPath = path.join(dir, "package.json");
571
+ if (fs.existsSync(pkgJsonPath)) {
572
+ try {
573
+ const manifest = JSON.parse(fs.readFileSync(pkgJsonPath, "utf-8"));
574
+ if (typeof manifest.name === "string" && /^(@[^/]+\/)?[^/]+$/.test(manifest.name)) {
575
+ pkgEntry = manifest.name;
576
+ pkgRel = path.relative(dir, realPath);
577
+ }
578
+ }
579
+ catch {
580
+ // Not a readable package.json — the realpath is not under a package.
581
+ }
582
+ break;
583
+ }
584
+ const parent = path.dirname(dir);
585
+ if (parent === dir) {
586
+ break;
587
+ }
588
+ dir = parent;
589
+ }
590
+ // The runtime's real on-disk path: the `.js` specifier maps to the `.ts`
591
+ // source when the CLI runs from its TypeScript sources (tsx); the `.js`
592
+ // form is the compiled artifact in an installed package. Identity checks
593
+ // below must compare real files, so resolve the form that exists.
594
+ const realPathOnDisk = fs.existsSync(realPath) ? realPath : realPath.replace(/\.js$/, ".ts");
595
+ for (let dir = resolveDir;;) {
596
+ const nodeModules = path.join(dir, "node_modules");
597
+ if (fs.existsSync(nodeModules)) {
598
+ const resolved = fs.realpathSync(nodeModules);
599
+ if (realPath.startsWith(resolved + path.sep)) {
600
+ return path.join(nodeModules, path.relative(resolved, realPath));
601
+ }
602
+ if (pkgEntry !== undefined && pkgRel !== undefined) {
603
+ // The package directory itself is symlinked into this node_modules
604
+ // under its own name (pnpm-style store link, npx cache, external
605
+ // install, or the checkout's own source tree): reach the runtime
606
+ // through the same symlink form so esbuild records one module
607
+ // identity regardless of install topology. The identity check is
608
+ // file-level (realpath equality), so it also covers the package's
609
+ // own tree reaching the shared install through nested symlinks
610
+ // (e.g. a `dist/` directory linked into the store).
611
+ const visible = path.join(nodeModules, pkgEntry, pkgRel);
612
+ const visibleOnDisk = fs.existsSync(visible) ? visible : visible.replace(/\.js$/, ".ts");
613
+ if (fs.existsSync(visibleOnDisk) && fs.realpathSync(visibleOnDisk) === realPathOnDisk) {
614
+ return visible;
615
+ }
616
+ }
617
+ if (pkgRelPath !== undefined) {
618
+ // A store link under a different entry name than the package's own:
619
+ // the runtime's position within its nearest node_modules matches a
620
+ // symlink in this one. Reach the runtime through the same symlink
621
+ // form so esbuild records one module identity regardless of install
622
+ // topology.
623
+ const visible = path.join(nodeModules, pkgRelPath);
624
+ const visibleOnDisk = fs.existsSync(visible) ? visible : visible.replace(/\.js$/, ".ts");
625
+ if (fs.existsSync(visibleOnDisk) && fs.realpathSync(visibleOnDisk) === realPathOnDisk) {
626
+ return visible;
627
+ }
628
+ }
629
+ }
630
+ const parent = path.dirname(dir);
631
+ if (parent === dir) {
632
+ return realPath;
633
+ }
634
+ dir = parent;
635
+ }
636
+ }
637
+ /**
638
+ * Compiles a TypeScript hook file to a self-contained ESM executable.
639
+ *
640
+ * Uses esbuild's stdin option to avoid writing temporary wrapper files to disk.
641
+ * This produces stable, reproducible builds by:
642
+ * - Using a distinct sourcefile name to avoid import resolution conflicts
643
+ * - Eliminating environment-specific temp paths from source comments
644
+ *
645
+ * Uses a two-step process to generate stable content hashes:
646
+ * 1. Compile WITHOUT sourcemaps → generate stable content hash
647
+ * 2. Compile WITH sourcemaps → final output content
648
+ *
649
+ * @param options - Compilation options
650
+ * @returns Compiled content and stable content hash
651
+ */
652
+ async function compileHook(options) {
653
+ const { sourcePath, logFilePath, logEnvVar, loaders, sourcemap } = options;
654
+ // Get the path to the runtime module (absolute, then converted to relative).
655
+ // Use fileURLToPath, not `new URL(...).pathname`: on Windows the latter
656
+ // yields `/C:/...` (leading slash before the drive letter), which corrupts
657
+ // path.resolve/path.relative and emits a broken `../../../C:/...` import.
658
+ //
659
+ // import.meta.url is the CLI module's realpath (Node dereferences symlinks
660
+ // by default), which sits outside the checkout when node_modules is a
661
+ // symlink to a shared install. Re-root it through the checkout's own
662
+ // node_modules symlink so the import specifier below — and everything
663
+ // esbuild records about it — is identical across checkouts regardless of
664
+ // nesting depth.
665
+ const resolveDir = path.dirname(sourcePath);
666
+ const runtimePathAbsolute = symlinkVisiblePath(path.resolve(path.dirname(fileURLToPath(import.meta.url)), "./agents/claude-code/transport.js"), resolveDir);
667
+ // Compute relative paths from resolveDir to avoid absolute paths in source maps.
668
+ // This ensures reproducible builds regardless of checkout directory.
669
+ const relativeSourcePath = `./${path.basename(sourcePath)}`;
670
+ let relativeRuntimePath = path.relative(resolveDir, runtimePathAbsolute);
671
+ // Ensure the specifier reads as a relative path: when the runtime is
672
+ // reached through the checkout's own node_modules, path.relative yields
673
+ // "node_modules/..." with no leading "../", which esbuild would otherwise
674
+ // interpret as a package path.
675
+ if (!relativeRuntimePath.startsWith(".")) {
676
+ relativeRuntimePath = `./${relativeRuntimePath}`;
677
+ }
678
+ // Create wrapper content that imports the hook and calls execute
679
+ // Uses relative paths to produce reproducible builds
680
+ const wrapperContent = `import hook from '${relativeSourcePath.replace(/\\/g, "/")}';
681
+ import { execute } from '${relativeRuntimePath.replace(/\\/g, "/")}';
682
+
683
+ execute(hook);
684
+ `;
685
+ // Use stdin instead of a temp file - sourcefile becomes the stable reference
686
+ // The sourcefile name must be distinct from the actual source file to avoid
687
+ // esbuild resolving the import to the stdin content instead of the real file
688
+ const baseName = path.basename(sourcePath, path.extname(sourcePath));
689
+ const stdinOptions = {
690
+ contents: wrapperContent,
691
+ resolveDir,
692
+ sourcefile: `${baseName}-entry.ts`,
693
+ loader: "ts",
694
+ };
695
+ // Banner to polyfill CJS globals for dependencies bundled into ESM.
696
+ // Some packages (e.g. typescript) use require(), __filename, and __dirname
697
+ // internally, which are unavailable in ESM scope.
698
+ //
699
+ // Log configuration is also embedded here (before all bundled code) so the
700
+ // Logger singleton reads it at construction time.
701
+ //
702
+ // --log PATH: sets AGENT_HOOKS_LOG_FILE if not already present
703
+ // (runtime env var wins over the hardcoded path)
704
+ // --log-env-var NAME: unconditionally sets AGENT_HOOKS_LOG_ENV_VAR
705
+ // (the var name is fixed at build time, no runtime override)
706
+ const logFileLines = logFilePath !== undefined
707
+ ? [
708
+ `if (!process.env['AGENT_HOOKS_LOG_FILE']) {`,
709
+ ` process.env['AGENT_HOOKS_LOG_FILE'] = ${JSON.stringify(logFilePath)};`,
710
+ `}`,
711
+ ]
712
+ : [];
713
+ const logEnvVarLines = logEnvVar !== undefined ? [`process.env['AGENT_HOOKS_LOG_ENV_VAR'] = ${JSON.stringify(logEnvVar)};`] : [];
714
+ const esmRequireBanner = [
715
+ `import { createRequire as __createRequire } from "node:module";`,
716
+ `import { fileURLToPath as __fileURLToPath } from "node:url";`,
717
+ `import { dirname as __pathDirname } from "node:path";`,
718
+ `const require = __createRequire(import.meta.url);`,
719
+ `const __filename = __fileURLToPath(import.meta.url);`,
720
+ `const __dirname = __pathDirname(__filename);`,
721
+ ...logFileLines,
722
+ ...logEnvVarLines,
723
+ ].join("\n");
724
+ // Common esbuild options
725
+ const commonOptions = {
726
+ stdin: stdinOptions,
727
+ loader: loaders,
728
+ format: "esm",
729
+ platform: "node",
730
+ target: "node20",
731
+ bundle: true,
732
+ minify: false,
733
+ treeShaking: true,
734
+ write: false, // Return content directly via outputFiles
735
+ banner: { js: esmRequireBanner },
736
+ // Preserve symlink identity so module-boundary comments and sourcemaps
737
+ // are computed relative to the checkout-local symlink path rather than
738
+ // the dereferenced realpath, which varies by checkout nesting depth.
739
+ preserveSymlinks: true,
740
+ // Keep node built-ins external
741
+ external: [
742
+ "node:*",
743
+ "http",
744
+ "https",
745
+ "url",
746
+ "stream",
747
+ "zlib",
748
+ "events",
749
+ "buffer",
750
+ "util",
751
+ "path",
752
+ "fs",
753
+ "os",
754
+ "crypto",
755
+ "child_process",
756
+ "perf_hooks",
757
+ "async_hooks",
758
+ "diagnostics_channel",
759
+ ],
760
+ // Ensure we get clean ESM output
761
+ mainFields: ["module", "main"],
762
+ conditions: ["import", "node"],
763
+ };
764
+ // Step 1: Compile WITHOUT sourcemaps to generate stable content hash
765
+ const resultNoSourcemap = await esbuild.build({
766
+ ...commonOptions,
767
+ sourcemap: false,
768
+ });
769
+ const contentForHash = resultNoSourcemap.outputFiles?.[0]?.text;
770
+ if (contentForHash === undefined) {
771
+ throw new Error(`esbuild produced no output for ${sourcePath}`);
772
+ }
773
+ const contentHash = generateContentHash(contentForHash);
774
+ // Step 2: Compile WITH sourcemaps for final output. With --no-sourcemap
775
+ // this pass is skipped entirely: pass-1 output is byte-identical to pass-2
776
+ // output minus the trailing sourceMappingURL comment (verified on esbuild
777
+ // 0.24.2), so the sourcemap-free content is emitted directly. The content
778
+ // hash is always derived from pass-1 output, so it is unchanged by the flag.
779
+ if (sourcemap === false) {
780
+ return { content: contentForHash, contentHash };
781
+ }
782
+ const resultWithSourcemap = await esbuild.build({
783
+ ...commonOptions,
784
+ sourcemap: "inline",
785
+ });
786
+ const content = resultWithSourcemap.outputFiles?.[0]?.text;
787
+ if (content === undefined) {
788
+ throw new Error(`esbuild produced no output for ${sourcePath}`);
789
+ }
790
+ return { content, contentHash };
791
+ }
792
+ /**
793
+ * Generates a content hash (SHA-256, 8-char prefix) for a compiled hook.
794
+ * @param content - Compiled hook content
795
+ * @returns 8-character hex hash
796
+ */
797
+ function generateContentHash(content) {
798
+ const hash = crypto.createHash("sha256").update(content).digest("hex");
799
+ return hash.substring(0, 8);
800
+ }
801
+ /**
802
+ * Compiles all discovered hooks and returns their metadata.
803
+ * @param options - Compilation options
804
+ * @returns Array of compiled hook information
805
+ */
806
+ async function compileAllHooks(options) {
807
+ const { hookFiles, outputDir, logFilePath, logEnvVar, loaders } = options;
808
+ const stableNames = options.stableNames !== false;
809
+ const compiledHooks = [];
810
+ const writtenFilenames = new Set();
811
+ // Ensure output directory exists
812
+ if (!fs.existsSync(outputDir)) {
813
+ fs.mkdirSync(outputDir, { recursive: true });
814
+ }
815
+ for (const sourcePath of hookFiles) {
816
+ log("info", `Analyzing hook file: ${sourcePath}`);
817
+ // Extract metadata from source
818
+ const metadata = analyzeHookFile(sourcePath);
819
+ if (metadata === undefined) {
820
+ log("warn", `Skipping ${sourcePath}: not a valid hook file (no hook factory found)`);
821
+ continue;
822
+ }
823
+ log("info", `Found hook: ${metadata.hookEventName}`, {
824
+ matcher: metadata.matcher,
825
+ timeout: metadata.timeout,
826
+ });
827
+ // Compile the hook (two-step process for stable content hash)
828
+ log("info", `Compiling: ${sourcePath}`);
829
+ const { content, contentHash } = await compileHook({
830
+ sourcePath,
831
+ outputDir,
832
+ logFilePath,
833
+ logEnvVar,
834
+ loaders,
835
+ sourcemap: options.sourcemap,
836
+ });
837
+ // Determine output filename. Stable names (the default) keep generated
838
+ // hooks.json byte-stable across rebuilds so Claude Code's hook trust hash
839
+ // stays valid; --no-stable-names restores the legacy hashed form.
840
+ const baseName = path.basename(sourcePath, path.extname(sourcePath));
841
+ const outputFilename = stableNames ? `${baseName}.mjs` : `${baseName}.${contentHash}.mjs`;
842
+ const outputPath = path.join(outputDir, outputFilename);
843
+ // Write compiled output with shebang for direct execution
844
+ // --enable-source-maps enables stack traces with original source locations
845
+ const shebang = "#!/usr/bin/env -S node --enable-source-maps\n";
846
+ fs.writeFileSync(outputPath, shebang + content, { encoding: "utf-8", mode: 0o755 });
847
+ log("info", `Wrote: ${outputPath}`);
848
+ writtenFilenames.add(outputFilename);
849
+ compiledHooks.push({
850
+ sourcePath,
851
+ outputPath,
852
+ outputFilename,
853
+ metadata,
854
+ });
855
+ }
856
+ if (stableNames) {
857
+ pruneStaleHashedBundles(outputDir, writtenFilenames);
858
+ }
859
+ return compiledHooks;
860
+ }
861
+ /**
862
+ * Removes hashed `<name>.<hash>.mjs` siblings of the stable bundles we just
863
+ * wrote, so re-running the build with --stable-names cleans up leftovers from
864
+ * previous --no-stable-names (or pre-1.7) runs.
865
+ */
866
+ function pruneStaleHashedBundles(outputDir, keepFilenames) {
867
+ if (!fs.existsSync(outputDir)) {
868
+ return;
869
+ }
870
+ const hashedBundlePattern = /^(.+)\.[0-9a-f]{8}\.mjs$/;
871
+ for (const entry of fs.readdirSync(outputDir)) {
872
+ if (keepFilenames.has(entry)) {
873
+ continue;
874
+ }
875
+ const match = entry.match(hashedBundlePattern);
876
+ if (match === null) {
877
+ continue;
878
+ }
879
+ const stableEquivalent = `${match[1]}.mjs`;
880
+ if (keepFilenames.has(stableEquivalent)) {
881
+ fs.rmSync(path.join(outputDir, entry), { force: true });
882
+ }
883
+ }
884
+ }
885
+ // ============================================================================
886
+ // hooks.json Generation
887
+ // ============================================================================
888
+ /**
889
+ * Groups compiled hooks by event type, then by matcher pattern.
890
+ * @param compiledHooks - Array of compiled hooks
891
+ * @returns Nested map: EventType -> Matcher -> Hooks
892
+ */
893
+ function groupHooksByEventAndMatcher(compiledHooks) {
894
+ const groups = new Map();
895
+ for (const hook of compiledHooks) {
896
+ const eventName = hook.metadata.hookEventName;
897
+ const matcher = hook.metadata.matcher;
898
+ let eventGroup = groups.get(eventName);
899
+ if (eventGroup === undefined) {
900
+ eventGroup = new Map();
901
+ groups.set(eventName, eventGroup);
902
+ }
903
+ const existing = eventGroup.get(matcher);
904
+ if (existing !== undefined) {
905
+ existing.push(hook);
906
+ }
907
+ else {
908
+ eventGroup.set(matcher, [hook]);
909
+ }
910
+ }
911
+ return groups;
912
+ }
913
+ /**
914
+ * Auto-detects the hook context and root directory based on directory structure.
915
+ *
916
+ * Detection logic:
917
+ * - If output path contains `.claude/` directory segment → agent context, root is parent of .claude/
918
+ * - If `.claude-plugin/` directory exists within 3 levels up → plugin context, root is that directory
919
+ * - Default: plugin context with hooks.json parent directory as root
920
+ * @param outputPath - Absolute path to the hooks.json output file
921
+ * @returns Detected hook context and root directory
922
+ */
923
+ function detectHookContext(outputPath) {
924
+ // Normalize path separators for cross-platform compatibility
925
+ const normalizedPath = outputPath.replace(/\\/g, "/");
926
+ // Plugin marker takes precedence: if a .claude-plugin/ directory exists by
927
+ // walking up from the output, this is a plugin build regardless of whether
928
+ // the output path happens to also contain a .claude/ segment.
929
+ let currentDir = path.dirname(outputPath);
930
+ const root = path.parse(currentDir).root;
931
+ const maxLevels = 4;
932
+ let level = 0;
933
+ while (currentDir !== root && level < maxLevels) {
934
+ const pluginDir = path.join(currentDir, ".claude-plugin");
935
+ if (fs.existsSync(pluginDir) && fs.statSync(pluginDir).isDirectory()) {
936
+ return {
937
+ context: "plugin",
938
+ rootDir: currentDir,
939
+ };
940
+ }
941
+ currentDir = path.dirname(currentDir);
942
+ level++;
943
+ }
944
+ // Check if the output path is within a .claude/ directory (agent hooks)
945
+ // This matches paths like: /project/.claude/hooks/hooks.json
946
+ const claudeMatch = normalizedPath.match(/^(.+)\/\.claude\//);
947
+ if (claudeMatch !== null) {
948
+ // Slice the original (native-separator) path to preserve OS-appropriate
949
+ // separators in the returned rootDir. The match group length equals the
950
+ // length of the root prefix in the normalized path, which has the same
951
+ // length as the original since normalization is a 1:1 character swap.
952
+ return {
953
+ context: "agent",
954
+ rootDir: outputPath.slice(0, claudeMatch[1].length),
955
+ };
956
+ }
957
+ // Default to plugin context with output directory as root
958
+ return {
959
+ context: "plugin",
960
+ rootDir: path.dirname(outputPath),
961
+ };
962
+ }
963
+ /**
964
+ * Generates a command path based on the hook context.
965
+ *
966
+ * Calculates the relative path from the root directory to the bin directory.
967
+ * Prepends the node executable.
968
+ *
969
+ * - `plugin`: Uses `node "$CLAUDE_PLUGIN_ROOT"/hooks/bin/filename`
970
+ * - `agent`: Uses `node "$CLAUDE_PROJECT_DIR"/.claude/hooks/bin/filename`
971
+ * @param filename - The compiled hook filename
972
+ * @param buildDir - Absolute path to the bin directory
973
+ * @param contextInfo - Hook context info including root directory
974
+ * @param executable - Node executable path (default: "node")
975
+ * @returns The command path string
976
+ */
977
+ function generateCommandPath(filename, buildDir, contextInfo, executable = "node") {
978
+ // Calculate relative path from root to bin directory
979
+ const relativeBuildPath = path.relative(contextInfo.rootDir, buildDir);
980
+ // Normalize to forward slashes for cross-platform compatibility
981
+ const normalizedRelativePath = relativeBuildPath.replace(/\\/g, "/");
982
+ if (contextInfo.context === "agent") {
983
+ // Agent hooks use $CLAUDE_PROJECT_DIR with shell-style quoting
984
+ return `${executable} "$CLAUDE_PROJECT_DIR"/${normalizedRelativePath}/${filename}`;
985
+ }
986
+ // Plugin hooks use $CLAUDE_PLUGIN_ROOT
987
+ return `${executable} "$CLAUDE_PLUGIN_ROOT"/${normalizedRelativePath}/${filename}`;
988
+ }
989
+ /**
990
+ * Generates the hooks.json content in Claude Code's expected format.
991
+ *
992
+ * Format: { hooks: { EventType: [ { matcher?, hooks: [...] } ] } }
993
+ * @param compiledHooks - Array of compiled hooks
994
+ * @param buildDir - Absolute path to the bin directory
995
+ * @param contextInfo - Hook context info for path resolution
996
+ * @param executable - Node executable path (default: "node")
997
+ * @returns The hooks.json structure
998
+ */
999
+ function generateHooksJson(compiledHooks, buildDir, contextInfo, executable = "node") {
1000
+ const groups = groupHooksByEventAndMatcher(compiledHooks);
1001
+ const hooks = {};
1002
+ for (const [eventName, matcherGroups] of groups) {
1003
+ const entries = [];
1004
+ for (const [matcher, hookList] of matcherGroups) {
1005
+ const entry = {
1006
+ hooks: hookList.map((hook) => ({
1007
+ type: "command",
1008
+ command: generateCommandPath(hook.outputFilename, buildDir, contextInfo, executable),
1009
+ ...(hook.metadata.timeout !== undefined ? { timeout: hook.metadata.timeout } : {}),
1010
+ })),
1011
+ };
1012
+ // Only include matcher if defined
1013
+ if (matcher !== undefined) {
1014
+ entry.matcher = matcher;
1015
+ }
1016
+ entries.push(entry);
1017
+ }
1018
+ hooks[eventName] = entries;
1019
+ }
1020
+ return {
1021
+ hooks,
1022
+ __generated: {
1023
+ files: compiledHooks.map((h) => h.outputFilename),
1024
+ timestamp: new Date().toISOString(),
1025
+ },
1026
+ };
1027
+ }
1028
+ /**
1029
+ * Reads an existing hooks.json file if it exists.
1030
+ * @param outputPath - Path to the hooks.json file
1031
+ * @returns Parsed HooksJson or undefined if file doesn't exist
1032
+ */
1033
+ function readExistingHooksJson(outputPath) {
1034
+ if (!fs.existsSync(outputPath)) {
1035
+ return undefined;
1036
+ }
1037
+ try {
1038
+ const content = fs.readFileSync(outputPath, "utf-8");
1039
+ return JSON.parse(content);
1040
+ }
1041
+ catch (error) {
1042
+ log("warn", "Failed to parse existing hooks.json, will overwrite", {
1043
+ error: error instanceof Error ? error.message : String(error),
1044
+ });
1045
+ return undefined;
1046
+ }
1047
+ }
1048
+ /**
1049
+ * Removes previously generated hook files from disk.
1050
+ * Only removes files that were tracked in __generated.files.
1051
+ * @param existingHooksJson - The existing hooks.json content
1052
+ * @param outputDir - Directory containing the generated files
1053
+ */
1054
+ function removeOldGeneratedFiles(existingHooksJson, outputDir) {
1055
+ const filesToRemove = existingHooksJson.__generated?.files ?? [];
1056
+ for (const filename of filesToRemove) {
1057
+ const filePath = path.join(outputDir, filename);
1058
+ if (fs.existsSync(filePath)) {
1059
+ try {
1060
+ fs.unlinkSync(filePath);
1061
+ log("info", `Removed old generated file: ${filename}`);
1062
+ }
1063
+ catch (error) {
1064
+ log("warn", `Failed to remove old generated file: ${filename}`, {
1065
+ error: error instanceof Error ? error.message : String(error),
1066
+ });
1067
+ }
1068
+ }
1069
+ }
1070
+ }
1071
+ /**
1072
+ * Extracts hooks from an existing hooks.json that were NOT generated by this package.
1073
+ * Identifies generated hooks by checking if their command path matches the generated file pattern.
1074
+ * @param existingHooksJson - The existing hooks.json content
1075
+ * @returns Object containing preserved hooks (keyed by event type)
1076
+ */
1077
+ function extractPreservedHooks(existingHooksJson) {
1078
+ const generatedFiles = new Set(existingHooksJson.__generated?.files ?? []);
1079
+ const preserved = {};
1080
+ for (const [eventType, entries] of Object.entries(existingHooksJson.hooks)) {
1081
+ if (!entries)
1082
+ continue;
1083
+ const preservedEntries = [];
1084
+ for (const entry of entries) {
1085
+ // Filter out hooks whose command matches a generated file
1086
+ const preservedHooks = entry.hooks.filter((hook) => {
1087
+ // Extract filename from the command path
1088
+ // Command format: ${CLAUDE_PLUGIN_ROOT:-./}/filename.hash.mjs
1089
+ const match = hook.command.match(/\/([^/]+)$/);
1090
+ const filename = match ? match[1] : "";
1091
+ return !generatedFiles.has(filename);
1092
+ });
1093
+ if (preservedHooks.length > 0) {
1094
+ preservedEntries.push({
1095
+ ...entry,
1096
+ hooks: preservedHooks,
1097
+ });
1098
+ }
1099
+ }
1100
+ if (preservedEntries.length > 0) {
1101
+ preserved[eventType] = preservedEntries;
1102
+ }
1103
+ }
1104
+ return preserved;
1105
+ }
1106
+ /**
1107
+ * Merges preserved hooks with newly generated hooks.
1108
+ * Preserved hooks are added first, then new hooks are appended.
1109
+ * @param newHooksJson - The newly generated hooks.json content
1110
+ * @param preservedHooks - Hooks to preserve from the existing hooks.json
1111
+ * @returns Merged HooksJson
1112
+ */
1113
+ function mergeHooksJson(newHooksJson, preservedHooks) {
1114
+ const mergedHooks = {};
1115
+ // Get all event types from both sources
1116
+ const allEventTypes = new Set([
1117
+ ...Object.keys(preservedHooks),
1118
+ ...Object.keys(newHooksJson.hooks),
1119
+ ]);
1120
+ for (const eventType of allEventTypes) {
1121
+ const preserved = preservedHooks[eventType] ?? [];
1122
+ const generated = newHooksJson.hooks[eventType] ?? [];
1123
+ // Combine preserved and generated entries
1124
+ mergedHooks[eventType] = [...preserved, ...generated];
1125
+ }
1126
+ return {
1127
+ hooks: mergedHooks,
1128
+ __generated: newHooksJson.__generated,
1129
+ };
1130
+ }
1131
+ /**
1132
+ * Writes hooks.json to the specified path atomically.
1133
+ * Uses write-to-temp-then-rename pattern for atomicity.
1134
+ * @param hooksJson - The hooks.json content
1135
+ * @param outputPath - Path to write hooks.json
1136
+ */
1137
+ function writeHooksJson(hooksJson, outputPath) {
1138
+ const dir = path.dirname(outputPath);
1139
+ if (!fs.existsSync(dir)) {
1140
+ fs.mkdirSync(dir, { recursive: true });
1141
+ }
1142
+ // Write to a temporary file first, then rename for atomicity
1143
+ const tempPath = `${outputPath}.tmp.${process.pid}`;
1144
+ const content = `${JSON.stringify(hooksJson, null, 2)}\n`;
1145
+ try {
1146
+ fs.writeFileSync(tempPath, content, "utf-8");
1147
+ fs.renameSync(tempPath, outputPath);
1148
+ }
1149
+ catch (error) {
1150
+ // Clean up temp file if rename failed
1151
+ if (fs.existsSync(tempPath)) {
1152
+ try {
1153
+ fs.unlinkSync(tempPath);
1154
+ }
1155
+ catch (cleanupError) {
1156
+ log("warn", "Failed to clean up temp file", { path: tempPath, error: cleanupError });
1157
+ }
1158
+ }
1159
+ throw error;
1160
+ }
1161
+ }
1162
+ // ============================================================================
1163
+ // Main Entry Point
1164
+ // ============================================================================
1165
+ /**
1166
+ * Main CLI entry point.
1167
+ */
1168
+ async function main() {
1169
+ const rawArgs = process.argv.slice(2);
1170
+ const args = parseArgs(rawArgs);
1171
+ // Handle help. A bare invocation is NOT a help request anymore: with
1172
+ // --agent required there is no default behavior to fall back to, so empty
1173
+ // argv falls through to validateArgs and fails closed.
1174
+ if (args.help) {
1175
+ process.stdout.write(HELP_TEXT);
1176
+ process.exit(0);
1177
+ }
1178
+ // Handle version
1179
+ if (args.version) {
1180
+ process.stdout.write(`agent-hooks v${VERSION}\n`);
1181
+ process.exit(0);
1182
+ }
1183
+ // Validate arguments
1184
+ const validationError = validateArgs(args);
1185
+ if (validationError !== undefined) {
1186
+ process.stderr.write(`Error: ${validationError}\n\n`);
1187
+ process.stdout.write(HELP_TEXT);
1188
+ process.exit(1);
1189
+ }
1190
+ // Codex parity (plan step 3): its own AST analysis (statusMessage-aware),
1191
+ // command-context detection (.codex-plugin marker / .codex local mode),
1192
+ // plugin-mode stable-names default, and hooks.json shape.
1193
+ if (args.agent === "codex") {
1194
+ const { runCodexCli } = await import("./agents/codex/cli-support.js");
1195
+ try {
1196
+ await runCodexCli({
1197
+ input: args.input,
1198
+ output: args.output,
1199
+ executable: args.executable,
1200
+ loaderFlags: args.loaderFlags,
1201
+ pluginRoot: args.pluginRoot === true,
1202
+ stableNames: args.stableNames,
1203
+ sourcemap: args.sourcemap,
1204
+ scaffold: args.scaffold,
1205
+ hooks: args.hooks,
1206
+ });
1207
+ process.stdout.write(`Generated ${path.resolve(process.cwd(), args.output)}\n`);
1208
+ process.exit(0);
1209
+ }
1210
+ catch (error) {
1211
+ const message = error instanceof Error ? error.message : String(error);
1212
+ log("error", "Codex build failed", { error: message });
1213
+ process.stderr.write(`Error: ${message}\n`);
1214
+ process.exit(1);
1215
+ }
1216
+ finally {
1217
+ closeLog();
1218
+ }
1219
+ }
1220
+ // Antigravity ships in step 5; fail closed until then.
1221
+ if (args.agent !== "claude-code") {
1222
+ process.stderr.write(`Error: --agent ${args.agent} is not implemented in this release; only "claude-code" and "codex" build today (antigravity ships in step 5).\n`);
1223
+ process.exit(1);
1224
+ }
1225
+ // Handle scaffold mode
1226
+ if (args.scaffold !== undefined && args.scaffold !== "") {
1227
+ const hookNames = (args.hooks ?? "").split(",").filter((h) => h.length > 0);
1228
+ scaffoldProject({
1229
+ directory: args.scaffold,
1230
+ hooks: hookNames,
1231
+ outputPath: args.output,
1232
+ });
1233
+ process.exit(0);
1234
+ }
1235
+ try {
1236
+ const cwd = process.cwd();
1237
+ const outputPath = path.resolve(cwd, args.output);
1238
+ const hooksJsonDir = path.dirname(outputPath);
1239
+ // Compiled hooks go in a 'bin' subdirectory relative to hooks.json
1240
+ const buildDir = path.join(hooksJsonDir, "bin");
1241
+ // Resolve log file path: --log flag takes priority, then AGENT_HOOKS_LOG_FILE env var
1242
+ const logFileRaw = args.log ?? process.env.AGENT_HOOKS_LOG_FILE;
1243
+ const logFilePath = logFileRaw !== undefined ? path.resolve(cwd, logFileRaw) : undefined;
1244
+ // --log-env-var names the env var the Logger should read at runtime
1245
+ const logEnvVar = args.logEnvVar;
1246
+ const loaders = buildLoaderMap(args.loaderFlags);
1247
+ log("info", "Starting hook compilation", {
1248
+ input: args.input,
1249
+ output: args.output,
1250
+ logFilePath,
1251
+ logEnvVar,
1252
+ loaders,
1253
+ cwd,
1254
+ });
1255
+ // Discover hook files
1256
+ const hookFiles = await discoverHookFiles(args.input, cwd);
1257
+ log("info", `Discovered ${hookFiles.length} hook files`, { files: hookFiles });
1258
+ if (hookFiles.length === 0) {
1259
+ process.stderr.write(`Error: No hook files found matching pattern: ${args.input}\n`);
1260
+ process.exit(1);
1261
+ }
1262
+ // Read existing hooks.json to preserve non-generated hooks
1263
+ const existingHooksJson = readExistingHooksJson(outputPath);
1264
+ let preservedHooks = {};
1265
+ if (existingHooksJson !== undefined) {
1266
+ log("info", "Found existing hooks.json, will preserve non-generated hooks");
1267
+ // Extract hooks that were NOT generated by this package
1268
+ preservedHooks = extractPreservedHooks(existingHooksJson);
1269
+ // Remove old generated files from disk
1270
+ removeOldGeneratedFiles(existingHooksJson, buildDir);
1271
+ const preservedCount = Object.values(preservedHooks).reduce((sum, entries) => sum + (entries?.reduce((s, e) => s + e.hooks.length, 0) ?? 0), 0);
1272
+ log("info", `Preserved ${preservedCount} hooks from other sources`);
1273
+ }
1274
+ // Compile all hooks
1275
+ const compiledHooks = await compileAllHooks({
1276
+ hookFiles,
1277
+ outputDir: buildDir,
1278
+ logFilePath,
1279
+ logEnvVar,
1280
+ loaders,
1281
+ stableNames: args.stableNames,
1282
+ sourcemap: args.sourcemap,
1283
+ });
1284
+ if (compiledHooks.length === 0 && hookFiles.length > 0) {
1285
+ process.stderr.write("Error: No valid hooks found in discovered files.\n");
1286
+ process.exit(1);
1287
+ }
1288
+ // Auto-detect hook context based on output path
1289
+ const hookContext = detectHookContext(outputPath);
1290
+ log("info", `Detected hook context: ${hookContext.context}`, { rootDir: hookContext.rootDir });
1291
+ // Generate hooks.json for newly compiled hooks
1292
+ const executable = args.executable !== undefined && args.executable !== "" ? args.executable : "node";
1293
+ const newHooksJson = generateHooksJson(compiledHooks, buildDir, hookContext, executable);
1294
+ // Preserve timestamp if generated files haven't changed
1295
+ if (existingHooksJson !== undefined) {
1296
+ const existingFiles = [...(existingHooksJson.__generated?.files ?? [])].sort();
1297
+ const newFiles = [...newHooksJson.__generated.files].sort();
1298
+ const filesUnchanged = existingFiles.length === newFiles.length && existingFiles.every((f, i) => f === newFiles[i]);
1299
+ if (filesUnchanged && existingHooksJson.__generated?.timestamp) {
1300
+ newHooksJson.__generated.timestamp = existingHooksJson.__generated.timestamp;
1301
+ log("info", "Files unchanged, preserving existing timestamp");
1302
+ }
1303
+ }
1304
+ // Merge with preserved hooks
1305
+ const finalHooksJson = mergeHooksJson(newHooksJson, preservedHooks);
1306
+ writeHooksJson(finalHooksJson, outputPath);
1307
+ log("info", "Compilation complete", {
1308
+ hooksCompiled: compiledHooks.length,
1309
+ outputPath,
1310
+ });
1311
+ // Output summary to stdout
1312
+ process.stdout.write(`Compiled ${compiledHooks.length} hooks to ${buildDir}\n`);
1313
+ if (Object.keys(preservedHooks).length > 0) {
1314
+ const preservedCount = Object.values(preservedHooks).reduce((sum, entries) => sum + (entries?.reduce((s, e) => s + e.hooks.length, 0) ?? 0), 0);
1315
+ process.stdout.write(`Preserved ${preservedCount} hooks from other sources\n`);
1316
+ }
1317
+ process.stdout.write(`Generated ${outputPath}\n`);
1318
+ process.exit(0);
1319
+ }
1320
+ catch (error) {
1321
+ const message = error instanceof Error ? error.message : String(error);
1322
+ log("error", "Build failed", { error: message });
1323
+ process.stderr.write(`Error: ${message}\n`);
1324
+ process.exit(1);
1325
+ }
1326
+ finally {
1327
+ closeLog();
1328
+ }
1329
+ }
1330
+ // Run main only when executed directly (not when imported for testing)
1331
+ // Check if this file is the entry point by checking if import.meta.url matches process.argv[1]
1332
+ // Resolves symlinks to handle npm bin symlinks correctly
1333
+ const isDirectExecution = (() => {
1334
+ try {
1335
+ const scriptPath = process.argv[1];
1336
+ if (!scriptPath)
1337
+ return false;
1338
+ // Resolve symlinks to get the real path (npm creates symlinks in node_modules/.bin)
1339
+ const realScriptPath = fs.realpathSync(scriptPath);
1340
+ const scriptUrl = new URL(`file://${realScriptPath}`);
1341
+ return import.meta.url === scriptUrl.href;
1342
+ }
1343
+ catch {
1344
+ return false;
1345
+ }
1346
+ })();
1347
+ if (isDirectExecution) {
1348
+ main().catch((error) => {
1349
+ process.stderr.write(`Fatal error: ${error instanceof Error ? error.message : String(error)}\n`);
1350
+ process.exit(1);
1351
+ });
1352
+ }
1353
+ // Export for testing
1354
+ // Shared build primitives reused by the per-agent CLI branches (single
1355
+ // source for driver logic — the duplication check forbids re-deriving these
1356
+ // inside agents/*).
1357
+ export { analyzeHookFile, buildLoaderMap, buildLoaderMap as buildEsbuildLoaderMap, compileHook, detectHookContext, discoverHookFiles, discoverHookFiles as discoverHookSourceFiles, extractPreservedHooks, generateCommandPath, generateContentHash, generateContentHash as sha256Prefix8, generateHooksJson, groupHooksByEventAndMatcher, HOOK_FACTORY_TO_EVENT, mergeHooksJson, parseArgs, parseLoaderFlag, parseLoaderFlag as parseEsbuildLoaderFlag, pruneStaleHashedBundles, readExistingHooksJson, removeOldGeneratedFiles, symlinkVisiblePath, validateArgs, };