@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.
- package/README.md +137 -0
- package/dist/agents/antigravity/events.js +42 -0
- package/dist/agents/antigravity/hooks.js +59 -0
- package/dist/agents/antigravity/index.js +28 -0
- package/dist/agents/antigravity/outputs.js +81 -0
- package/dist/agents/antigravity/transport.js +84 -0
- package/dist/agents/antigravity/types.js +11 -0
- package/dist/agents/claude-code/env.js +27 -0
- package/dist/agents/claude-code/events.js +87 -0
- package/dist/agents/claude-code/hooks.js +1000 -0
- package/dist/agents/claude-code/index.js +57 -0
- package/dist/agents/claude-code/outputs.js +579 -0
- package/dist/agents/claude-code/tool-helpers.js +840 -0
- package/dist/agents/claude-code/transport.js +254 -0
- package/dist/agents/claude-code/types.js +11 -0
- package/dist/agents/codex/cli-support.js +318 -0
- package/dist/agents/codex/constants.js +29 -0
- package/dist/agents/codex/events.js +68 -0
- package/dist/agents/codex/hooks.js +93 -0
- package/dist/agents/codex/index.js +21 -0
- package/dist/agents/codex/outputs.js +189 -0
- package/dist/agents/codex/scaffold.js +273 -0
- package/dist/agents/codex/transport.js +139 -0
- package/dist/agents/codex/types.js +1 -0
- package/dist/cli.js +1357 -0
- package/dist/constants.js +39 -0
- package/dist/core/define-hook.js +61 -0
- package/dist/core/env.js +186 -0
- package/dist/core/logger.js +508 -0
- package/dist/core/stdin.js +38 -0
- package/dist/core/transport.js +231 -0
- package/dist/core/types.js +10 -0
- package/dist/index.js +17 -0
- package/dist/scaffold.js +512 -0
- package/package.json +61 -0
- package/types/agents/antigravity/events.d.ts +50 -0
- package/types/agents/antigravity/hooks.d.ts +76 -0
- package/types/agents/antigravity/index.d.ts +27 -0
- package/types/agents/antigravity/outputs.d.ts +107 -0
- package/types/agents/antigravity/transport.d.ts +47 -0
- package/types/agents/antigravity/types.d.ts +58 -0
- package/types/agents/claude-code/env.d.ts +27 -0
- package/types/agents/claude-code/events.d.ts +69 -0
- package/types/agents/claude-code/hooks.d.ts +1182 -0
- package/types/agents/claude-code/index.d.ts +23 -0
- package/types/agents/claude-code/outputs.d.ts +1160 -0
- package/types/agents/claude-code/tool-helpers.d.ts +807 -0
- package/types/agents/claude-code/transport.d.ts +119 -0
- package/types/agents/claude-code/types.d.ts +557 -0
- package/types/agents/codex/cli-support.d.ts +105 -0
- package/types/agents/codex/constants.d.ts +10 -0
- package/types/agents/codex/events.d.ts +63 -0
- package/types/agents/codex/hooks.d.ts +135 -0
- package/types/agents/codex/index.d.ts +19 -0
- package/types/agents/codex/outputs.d.ts +190 -0
- package/types/agents/codex/scaffold.d.ts +14 -0
- package/types/agents/codex/transport.d.ts +74 -0
- package/types/agents/codex/types.d.ts +131 -0
- package/types/cli.d.ts +315 -0
- package/types/constants.d.ts +9 -0
- package/types/core/define-hook.d.ts +39 -0
- package/types/core/env.d.ts +150 -0
- package/types/core/logger.d.ts +476 -0
- package/types/core/stdin.d.ts +21 -0
- package/types/core/transport.d.ts +131 -0
- package/types/core/types.d.ts +137 -0
- package/types/index.d.ts +18 -0
- 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, };
|