auriga-cli 1.25.0 → 1.27.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/dist/hooks.js DELETED
@@ -1,965 +0,0 @@
1
- import { spawnSync } from "node:child_process";
2
- import fs from "node:fs";
3
- import os from "node:os";
4
- import path from "node:path";
5
- import { checkbox, confirm, input, select } from "@inquirer/prompts";
6
- import { atomicWriteFile, exec, fetchExtraContentBinary, log, withEsc, } from "./utils.js";
7
- // --- Registry validation ---
8
- // The root hooks registry is legacy-only, but when present it may still be
9
- // loaded from runtime-fetched content. Any downstream code that interpolates
10
- // registry values into shell commands or filesystem paths is one bad config
11
- // away from RCE / arbitrary-file-write. Validate every untrusted value once at
12
- // load time, then trust it through the rest of the install flow.
13
- const HOOK_NAME_RE = /^[a-z][a-z0-9-]*$/;
14
- // Matches a flat brew formula name (`jq`, `pngquant`) OR a fully
15
- // qualified tap-prefixed name (`vjeantet/tap/alerter`) — up to 2
16
- // slashes separating segments. Each segment is the same charset as
17
- // the flat-name form. No shell metachars; safe to pass to `brew install`
18
- // as a single argv item.
19
- const DEP_NAME_RE = /^[a-z0-9][a-z0-9._+-]*(\/[a-z0-9][a-z0-9._+-]*){0,2}$/;
20
- const EVENT_NAME_RE = /^[A-Za-z][A-Za-z0-9_-]*$/;
21
- // Whitelist for hook command templates. The registry is fetched from raw
22
- // GitHub at runtime, and the command string is written verbatim into
23
- // settings.json then executed by Claude Code on every hook fire — so an
24
- // unconstrained string here is direct registry-RCE. We require:
25
- // <runtime> "$HOOK_DIR/<flat-basename>.<ext>"
26
- // where runtime ∈ {node, python3, bash}, the path literal starts with
27
- // $HOOK_DIR/, the basename is a flat alphanumeric identifier (no slashes,
28
- // no dots — so no nested paths and no `..` traversal), the extension is
29
- // alphanumeric, and there are no trailing arguments. Anything else is
30
- // rejected at load time. Adding a runtime, allowing args, or relaxing
31
- // the form requires a code change here, intentionally — see the security
32
- // review trail in PR #7 for context.
33
- const COMMAND_RE = /^(node|python3|bash) "\$HOOK_DIR\/[A-Za-z0-9_-]+\.[A-Za-z0-9]+"$/;
34
- // Claude Code permission-rule syntax for the `if` field:
35
- // <ToolName>(<substring>)
36
- // Tool name: EVENT_NAME-shape identifier, bounded to 64 chars so a
37
- // malicious registry can't inflate settings.json with a gigantic prefix.
38
- // Substring body: 1-200 printable-ASCII chars, with parens, backslash,
39
- // and backtick explicitly excluded so the anchored `\(...\)` wrapper
40
- // actually delimits a well-formed outer parenthesis pair.
41
- //
42
- // The safety argument is NOT that IF_RE strips shell metacharacters —
43
- // `$ " ' ; | & < > *` are all inside the allowed byte range and left
44
- // intact. The defense is that this string never reaches a shell: it's
45
- // written verbatim into settings.json as a JSON string and read by
46
- // Claude Code's in-process permission-rule matcher. A registry
47
- // compromise can widen or misdirect the match pattern (causing the hook
48
- // to fire on unintended inputs or not fire at all) but cannot pivot
49
- // into command execution from this field.
50
- //
51
- // Body range decomposition (what the char class actually covers):
52
- // 0x20-0x27 space through single-quote (excludes nothing)
53
- // 0x2A-0x5B asterisk through left-bracket (excludes `(` 0x28, `)` 0x29)
54
- // 0x5D-0x5F right-bracket through underscore (excludes `\` 0x5C)
55
- // 0x61-0x7E lowercase through tilde (excludes `` ` `` 0x60)
56
- const IF_RE = /^[A-Z][A-Za-z0-9_-]{0,63}\([\x20-\x27\x2A-\x5B\x5D-\x5F\x61-\x7E]{1,200}\)$/;
57
- function isSafeRelativePath(file) {
58
- if (typeof file !== "string" || file.length === 0)
59
- return false;
60
- if (file.startsWith("/") || file.startsWith("\\"))
61
- return false;
62
- if (file.includes("\0"))
63
- return false;
64
- const normalized = path.posix.normalize(file);
65
- if (normalized !== file)
66
- return false;
67
- if (normalized === ".." || normalized.startsWith("../") || normalized.includes("/../"))
68
- return false;
69
- return true;
70
- }
71
- function validateHookEntry(hook, idx) {
72
- if (!hook || typeof hook !== "object") {
73
- throw new Error(`hooks.json: hooks[${idx}] is not an object`);
74
- }
75
- const h = hook;
76
- if (typeof h.name !== "string" || !HOOK_NAME_RE.test(h.name)) {
77
- throw new Error(`hooks.json: hooks[${idx}].name must match ${HOOK_NAME_RE} (got ${JSON.stringify(h.name)})`);
78
- }
79
- if (!Array.isArray(h.files)) {
80
- throw new Error(`hooks.json: hooks[${idx}].files must be an array`);
81
- }
82
- for (const f of h.files) {
83
- if (!isSafeRelativePath(f)) {
84
- throw new Error(`hooks.json: hooks[${idx}].files contains unsafe path ${JSON.stringify(f)}`);
85
- }
86
- }
87
- if (h.preserveFiles !== undefined) {
88
- if (!Array.isArray(h.preserveFiles)) {
89
- throw new Error(`hooks.json: hooks[${idx}].preserveFiles must be an array`);
90
- }
91
- for (const f of h.preserveFiles) {
92
- if (!isSafeRelativePath(f)) {
93
- throw new Error(`hooks.json: hooks[${idx}].preserveFiles contains unsafe path ${JSON.stringify(f)}`);
94
- }
95
- }
96
- }
97
- if (h.deps !== undefined) {
98
- if (!Array.isArray(h.deps)) {
99
- throw new Error(`hooks.json: hooks[${idx}].deps must be an array`);
100
- }
101
- for (const d of h.deps) {
102
- if (!d || typeof d !== "object") {
103
- throw new Error(`hooks.json: hooks[${idx}].deps entry is not an object`);
104
- }
105
- const dn = d.name;
106
- if (typeof dn !== "string" || !DEP_NAME_RE.test(dn)) {
107
- throw new Error(`hooks.json: hooks[${idx}].deps name must match ${DEP_NAME_RE} (got ${JSON.stringify(dn)})`);
108
- }
109
- }
110
- }
111
- if (!Array.isArray(h.runtimePlatforms)) {
112
- throw new Error(`hooks.json: hooks[${idx}].runtimePlatforms must be an array`);
113
- }
114
- if (!Array.isArray(h.settingsEvents)) {
115
- throw new Error(`hooks.json: hooks[${idx}].settingsEvents must be an array`);
116
- }
117
- for (const evt of h.settingsEvents) {
118
- if (!evt || typeof evt !== "object") {
119
- throw new Error(`hooks.json: hooks[${idx}].settingsEvents entry is not an object`);
120
- }
121
- const en = evt.event;
122
- if (typeof en !== "string" || !EVENT_NAME_RE.test(en)) {
123
- throw new Error(`hooks.json: hooks[${idx}].settingsEvents.event must match ${EVENT_NAME_RE} (got ${JSON.stringify(en)})`);
124
- }
125
- const matcher = evt.matcher;
126
- if (matcher !== undefined && (typeof matcher !== "string" || !EVENT_NAME_RE.test(matcher))) {
127
- throw new Error(`hooks.json: hooks[${idx}].settingsEvents.matcher must match ${EVENT_NAME_RE} (got ${JSON.stringify(matcher)})`);
128
- }
129
- const ifRule = evt.if;
130
- if (ifRule !== undefined && (typeof ifRule !== "string" || !IF_RE.test(ifRule))) {
131
- throw new Error(`hooks.json: hooks[${idx}].settingsEvents.if must match ${IF_RE} (got ${JSON.stringify(ifRule)})`);
132
- }
133
- }
134
- if (typeof h.command !== "string" || !COMMAND_RE.test(h.command)) {
135
- throw new Error(`hooks.json: hooks[${idx}].command must match the safe template ${COMMAND_RE} (got ${JSON.stringify(h.command)})`);
136
- }
137
- if (typeof h.marker !== "string" || h.marker.length === 0) {
138
- throw new Error(`hooks.json: hooks[${idx}].marker must be a non-empty string`);
139
- }
140
- if (h.customizeHints !== undefined) {
141
- if (!Array.isArray(h.customizeHints)) {
142
- throw new Error(`hooks.json: hooks[${idx}].customizeHints must be an array`);
143
- }
144
- for (const hint of h.customizeHints) {
145
- if (typeof hint !== "string" || hint.length === 0 || hint.length > 200) {
146
- throw new Error(`hooks.json: hooks[${idx}].customizeHints entries must be non-empty strings ≤200 chars`);
147
- }
148
- }
149
- }
150
- if (h.defaultOn !== undefined && typeof h.defaultOn !== "boolean") {
151
- throw new Error(`hooks.json: hooks[${idx}].defaultOn must be a boolean`);
152
- }
153
- }
154
- /**
155
- * Pure, idempotent settings merge. Deep-clones input, dedupes by two
156
- * checks in priority order:
157
- *
158
- * 1. sentinel `_marker` field — primary key. Survives path drift, lets
159
- * a future uninstall command find our entries unambiguously.
160
- * 2. command-string equality — secondary, catches the case where the
161
- * user (or another tool) already added an equivalent entry by hand
162
- * and never wrote our marker. Without this fallback we would happily
163
- * append a duplicate next to it and the hook would fire twice.
164
- *
165
- * `options.matcher` writes to the container-level `matcher` (tool-name
166
- * filter); `options.ifRule` writes to the action-level `if` (permission-
167
- * rule substring filter, Claude Code ≥ 2026-04). Either or both may be
168
- * absent.
169
- *
170
- * Upgrade path: if an entry with our marker already exists but its
171
- * matcher / if disagrees with the desired values, we update those two
172
- * fields in place (preserving everything else — command, sibling
173
- * actions, the user's other groups — untouched). This is the path for
174
- * a user who installed an older registry version and re-runs the
175
- * installer after hooks.json changed. Pure no-op when the existing
176
- * fields already match.
177
- *
178
- * Inputs are defense-in-depth revalidated here against IF_RE + the
179
- * event-name regex even though registry callers already passed
180
- * loadHooksConfig, so a direct library caller can't write malformed
181
- * values into settings.json by bypassing the registry loader.
182
- *
183
- * Throws if `settings.hooks[event]` exists but is not an array — that
184
- * means the user has hand-edited their settings into a shape we do not
185
- * recognize, and silently replacing it with an empty array would lose
186
- * data. Callers should catch and surface the error to the user.
187
- */
188
- export function addHookToSettings(settings, event, command, marker, options = {}) {
189
- // Defense-in-depth: registry callers pre-validate via loadHooksConfig,
190
- // but a direct programmatic caller could bypass that. Refuse values
191
- // that wouldn't have cleared the registry validator, so settings.json
192
- // can never receive a malformed string through this function.
193
- if (options.matcher !== undefined && !EVENT_NAME_RE.test(options.matcher)) {
194
- throw new Error(`addHookToSettings: options.matcher must match ${EVENT_NAME_RE} (got ${JSON.stringify(options.matcher)})`);
195
- }
196
- if (options.ifRule !== undefined && !IF_RE.test(options.ifRule)) {
197
- throw new Error(`addHookToSettings: options.ifRule must match ${IF_RE} (got ${JSON.stringify(options.ifRule)})`);
198
- }
199
- const next = JSON.parse(JSON.stringify(settings ?? {}));
200
- if (next.hooks !== undefined && (typeof next.hooks !== "object" || Array.isArray(next.hooks))) {
201
- throw new Error(`settings.hooks exists but is not an object; refusing to clobber it`);
202
- }
203
- if (!next.hooks)
204
- next.hooks = {};
205
- const existing = next.hooks[event];
206
- if (existing !== undefined && !Array.isArray(existing)) {
207
- throw new Error(`settings.hooks.${event} exists but is not an array; refusing to clobber it`);
208
- }
209
- const list = existing ?? [];
210
- for (const group of list) {
211
- if (!group?.hooks || !Array.isArray(group.hooks))
212
- continue;
213
- for (const action of group.hooks) {
214
- if (!action)
215
- continue;
216
- if (action._marker === marker) {
217
- // Our entry already exists. Upgrade matcher / if in place if
218
- // they drifted from the desired values; leave command + other
219
- // fields alone (users may have hand-tweaked; we only own the
220
- // two fields registry declares).
221
- let drifted = false;
222
- if (options.matcher !== undefined && group.matcher !== options.matcher) {
223
- group.matcher = options.matcher;
224
- drifted = true;
225
- }
226
- if (options.ifRule !== undefined && action.if !== options.ifRule) {
227
- action.if = options.ifRule;
228
- drifted = true;
229
- }
230
- next.hooks[event] = list;
231
- return { settings: next, mutated: drifted };
232
- }
233
- if (action.type === "command" && action.command === command) {
234
- // A pre-existing entry (manual or from another tool) already
235
- // points at the same command. Coexist with it; do not add a
236
- // duplicate. We deliberately do NOT stamp our marker onto someone
237
- // else's entry — that would silently take ownership of it.
238
- next.hooks[event] = list;
239
- return { settings: next, mutated: false };
240
- }
241
- }
242
- }
243
- const action = { type: "command", command, _marker: marker };
244
- if (options.ifRule !== undefined)
245
- action.if = options.ifRule;
246
- const group = { hooks: [action] };
247
- if (options.matcher !== undefined)
248
- group.matcher = options.matcher;
249
- list.push(group);
250
- next.hooks[event] = list;
251
- return { settings: next, mutated: true };
252
- }
253
- /**
254
- * Pure inverse of addHookToSettings: removes every action carrying
255
- * `_marker` from every event in the settings tree. Returns the mutated
256
- * copy and the count of actions removed. If a group becomes empty after
257
- * removal, the whole group is dropped; if an event becomes empty, the
258
- * event key is dropped.
259
- */
260
- export function removeHookFromSettings(settings, marker) {
261
- const next = JSON.parse(JSON.stringify(settings ?? {}));
262
- if (!next.hooks || typeof next.hooks !== "object" || Array.isArray(next.hooks)) {
263
- return { settings: next, removed: 0 };
264
- }
265
- let removed = 0;
266
- for (const event of Object.keys(next.hooks)) {
267
- const list = next.hooks[event];
268
- if (!Array.isArray(list))
269
- continue;
270
- const newGroups = [];
271
- for (const group of list) {
272
- if (!group?.hooks || !Array.isArray(group.hooks)) {
273
- newGroups.push(group);
274
- continue;
275
- }
276
- const remainingActions = group.hooks.filter((action) => {
277
- if (action && action._marker === marker) {
278
- removed++;
279
- return false;
280
- }
281
- return true;
282
- });
283
- if (remainingActions.length > 0) {
284
- newGroups.push({ ...group, hooks: remainingActions });
285
- }
286
- }
287
- if (newGroups.length > 0) {
288
- next.hooks[event] = newGroups;
289
- }
290
- else {
291
- delete next.hooks[event];
292
- }
293
- }
294
- return { settings: next, removed };
295
- }
296
- const settingsBackedUp = new Set();
297
- function resolveScope(scope, projectBase, hookName) {
298
- if (scope === "user") {
299
- const home = os.homedir();
300
- const dir = path.join(home, ".claude", "hooks", hookName);
301
- return {
302
- scope,
303
- hookDir: dir,
304
- settingsPath: path.join(home, ".claude", "settings.json"),
305
- commandHookDir: dir,
306
- };
307
- }
308
- const projectClaude = path.join(projectBase, ".claude");
309
- return {
310
- scope,
311
- hookDir: path.join(projectClaude, "hooks", hookName),
312
- settingsPath: scope === "project-local"
313
- ? path.join(projectClaude, "settings.local.json")
314
- : path.join(projectClaude, "settings.json"),
315
- commandHookDir: `$CLAUDE_PROJECT_DIR/.claude/hooks/${hookName}`,
316
- };
317
- }
318
- /**
319
- * Non-interactive scope map for hooks.
320
- *
321
- * Non-interactive surface only knows about two values — `project` (the
322
- * default) and `user`. `project-local` exists only in the TTY menu; it's
323
- * a per-developer uncommitted scope and carries enough "did you really
324
- * mean this?" surface area that we gate it behind an interactive
325
- * confirmation rather than exposing it as a CLI flag value.
326
- *
327
- * Exported so `tests/hooks.test.ts` can lock the contract down as a
328
- * unit test.
329
- */
330
- export function mapNonInteractiveScope(scope) {
331
- return scope === "user" ? "user" : "project";
332
- }
333
- function scopeChoices() {
334
- return [
335
- {
336
- name: "Project local — files in ./.claude/hooks/, settings in ./.claude/settings.local.json (per-developer, not committed)",
337
- value: "project-local",
338
- },
339
- {
340
- name: "Project — files in ./.claude/hooks/, settings in ./.claude/settings.json (committed, shared with team)",
341
- value: "project",
342
- },
343
- {
344
- name: "User — files in ~/.claude/hooks/, settings in ~/.claude/settings.json (global, all your projects)",
345
- value: "user",
346
- },
347
- ];
348
- }
349
- // brew package names can be tap-prefixed (`vjeantet/tap/alerter`) but
350
- // the binary the formula installs into PATH is the bare formula name
351
- // (`alerter`). Strip the tap prefix to get the binary to `which` for.
352
- // Hook authors with a brew package whose binary name doesn't match the
353
- // formula name will need to ship a wrapper `bin` field in the future;
354
- // no such hook exists today.
355
- export function depBinary(dep) {
356
- const segments = dep.name.split("/");
357
- return segments[segments.length - 1];
358
- }
359
- function depReady(dep) {
360
- try {
361
- exec(`which ${depBinary(dep)}`);
362
- return true;
363
- }
364
- catch {
365
- return false;
366
- }
367
- }
368
- function brewAvailable() {
369
- try {
370
- exec("which brew");
371
- return true;
372
- }
373
- catch {
374
- return false;
375
- }
376
- }
377
- function installDep(dep) {
378
- // Defense-in-depth: the registry validator already enforced this regex,
379
- // but re-check here so a future code path that constructs a HookDep
380
- // outside the validator still can't shell-inject through this function.
381
- if (!DEP_NAME_RE.test(dep.name)) {
382
- log.error(`refusing to install dep with unsafe name: ${JSON.stringify(dep.name)}`);
383
- return false;
384
- }
385
- console.log(` Installing ${dep.name} via Homebrew (may prompt for password)...`);
386
- // argv form, NOT shell-interpolated — registry compromise can't escape into a shell command.
387
- const result = spawnSync("brew", ["install", dep.name], { stdio: "inherit" });
388
- return result.status === 0;
389
- }
390
- /**
391
- * Pre-flight: ensure all deps are present (or gracefully degraded) before
392
- * touching any files. Returns false to hard-abort the hook install.
393
- */
394
- function preflightDeps(hook) {
395
- for (const dep of hook.deps ?? []) {
396
- if (depReady(dep)) {
397
- log.ok(`${dep.name} ready`);
398
- continue;
399
- }
400
- if (dep.via === "brew") {
401
- if (brewAvailable()) {
402
- if (installDep(dep)) {
403
- log.ok(`${dep.name} installed`);
404
- continue;
405
- }
406
- if (dep.optional) {
407
- log.warn(`${dep.name} install failed; runtime fallback will be used`);
408
- continue;
409
- }
410
- log.error(`${dep.name} install failed (required); aborting`);
411
- return false;
412
- }
413
- if (dep.optional) {
414
- log.warn(`Homebrew not found; ${dep.name} will be skipped. Runtime fallback will be used (no brand icon). Install brew at https://brew.sh and re-run for full features.`);
415
- continue;
416
- }
417
- log.error(`Homebrew not found and ${dep.name} is required. Install brew at https://brew.sh, then re-run.`);
418
- return false;
419
- }
420
- }
421
- return true;
422
- }
423
- /**
424
- * Lazy-fetch a hook's payload files into `packageRoot` so they can be
425
- * copied from there into the user's target directory.
426
- *
427
- * IMPORTANT: this is retained for legacy root-hook installs. New hooks should
428
- * ship inside plugins instead. In production, `packageRoot` is the temp dir
429
- * created by `fetchContentRoot()` (utils.ts) — not the npm package install
430
- * dir. In DEV mode `packageRoot` is the live repo root, so the files are
431
- * already on disk and we skip the fetch.
432
- *
433
- * The hook payload list is owned by `hook.files` in `hooks.json`, which
434
- * loadHooksConfig already validated for path-traversal safety, so each
435
- * `file` here is a known-good relative path.
436
- */
437
- async function ensureHookFilesFetched(hook, packageRoot) {
438
- if (process.env.DEV === "1")
439
- return;
440
- for (const file of hook.files) {
441
- const repoPath = path.posix.join(".claude/hooks", hook.name, file);
442
- const localPath = path.join(packageRoot, repoPath);
443
- if (fs.existsSync(localPath))
444
- continue;
445
- await fetchExtraContentBinary(packageRoot, repoPath);
446
- }
447
- }
448
- function copyHookFiles(hook, packageRoot, destDir) {
449
- fs.mkdirSync(destDir, { recursive: true });
450
- const preserve = new Set(hook.preserveFiles ?? []);
451
- let written = 0;
452
- let preserved = 0;
453
- for (const file of hook.files) {
454
- const dest = path.join(destDir, file);
455
- if (preserve.has(file) && fs.existsSync(dest)) {
456
- preserved++;
457
- continue;
458
- }
459
- const src = path.join(packageRoot, ".claude", "hooks", hook.name, file);
460
- fs.mkdirSync(path.dirname(dest), { recursive: true });
461
- fs.copyFileSync(src, dest);
462
- written++;
463
- }
464
- return { written, preserved };
465
- }
466
- /**
467
- * Snapshot a settings file to `.bak` before the first mutation in this
468
- * session. The naive `copyFileSync(src, dst)` follows symlinks, which
469
- * would let a local attacker pre-symlink `settings.json.bak` at, say,
470
- * `~/.ssh/authorized_keys` and have us clobber the target on the next
471
- * install — same threat class as the tmp-file TOCTOU that
472
- * `atomicWriteFile` plugs. We use the same defense: read the source,
473
- * write to a fresh fd opened with O_CREAT|O_EXCL|O_WRONLY (refuses any
474
- * pre-existing path, including a symlink), then rely on the no-op-if-
475
- * already-backed-up-this-session guard for re-runs.
476
- *
477
- * If the .bak already exists from a previous session, leave it alone —
478
- * the FIRST backup is the one that captures the user's pre-auriga state,
479
- * which is what they care about restoring to.
480
- */
481
- function backupOnce(filePath) {
482
- if (settingsBackedUp.has(filePath))
483
- return;
484
- settingsBackedUp.add(filePath);
485
- if (!fs.existsSync(filePath))
486
- return;
487
- const bakPath = filePath + ".bak";
488
- if (fs.existsSync(bakPath))
489
- return;
490
- const data = fs.readFileSync(filePath);
491
- const fd = fs.openSync(bakPath, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY, 0o600);
492
- try {
493
- fs.writeSync(fd, data);
494
- }
495
- finally {
496
- fs.closeSync(fd);
497
- }
498
- }
499
- /**
500
- * Read and JSON.parse a settings file. Returns {} for missing file.
501
- * Throws on parse error so the caller can abort cleanly *before* any
502
- * file copy, instead of leaving orphan hook files in the target after a
503
- * mid-flight failure.
504
- */
505
- function readSettings(settingsPath) {
506
- if (!fs.existsSync(settingsPath))
507
- return {};
508
- try {
509
- return JSON.parse(fs.readFileSync(settingsPath, "utf8"));
510
- }
511
- catch (e) {
512
- throw new Error(`${settingsPath} is not valid JSON: ${e.message}`);
513
- }
514
- }
515
- /**
516
- * Apply a hook's settingsEvents to an already-parsed settings object,
517
- * write the result atomically if anything changed. The caller MUST have
518
- * pre-validated the file via readSettings() before any file copy.
519
- */
520
- function writeMergedSettings(resolved, hook, parsed) {
521
- let mutated = false;
522
- let next = parsed;
523
- for (const evt of hook.settingsEvents) {
524
- const cmd = hook.command.replace(/\$HOOK_DIR/g, resolved.commandHookDir);
525
- const result = addHookToSettings(next, evt.event, cmd, hook.marker, {
526
- matcher: evt.matcher,
527
- ifRule: evt.if,
528
- });
529
- if (result.mutated)
530
- mutated = true;
531
- next = result.settings;
532
- }
533
- if (mutated) {
534
- backupOnce(resolved.settingsPath);
535
- fs.mkdirSync(path.dirname(resolved.settingsPath), { recursive: true });
536
- atomicWriteFile(resolved.settingsPath, JSON.stringify(next, null, 2) + "\n");
537
- }
538
- return { mutated };
539
- }
540
- export function loadHooksConfig(packageRoot) {
541
- const configPath = path.join(packageRoot, ".claude", "hooks", "hooks.json");
542
- if (!fs.existsSync(configPath))
543
- return { hooks: [] };
544
- const raw = JSON.parse(fs.readFileSync(configPath, "utf-8"));
545
- if (!raw || !Array.isArray(raw.hooks)) {
546
- throw new Error(`${configPath} must have a "hooks" array at the top level`);
547
- }
548
- raw.hooks.forEach((h, i) => validateHookEntry(h, i));
549
- return raw;
550
- }
551
- function relativeFromCwd(absPath) {
552
- const rel = path.relative(process.cwd(), absPath);
553
- return rel.startsWith("..") ? absPath : rel;
554
- }
555
- /**
556
- * Non-interactive single-hook install. Driven by installHooks (which
557
- * collects user choices via prompts) and by tools/verify-hooks.mjs (which
558
- * exercises the install path end-to-end without prompts).
559
- *
560
- * Failure ordering matters: deps run first (no state changes), then
561
- * settings is read AND parsed (still no state changes), and only after
562
- * parsing succeeds do we touch the filesystem to copy hook files. A
563
- * malformed settings file therefore aborts cleanly and leaves nothing
564
- * behind.
565
- */
566
- export async function installHook(hook, scope, projectBase, packageRoot) {
567
- const resolved = resolveScope(scope, projectBase, hook.name);
568
- const base = {
569
- hook: hook.name,
570
- written: 0,
571
- preserved: 0,
572
- scope,
573
- hookDir: resolved.hookDir,
574
- settingsPath: resolved.settingsPath,
575
- settingsMutated: false,
576
- };
577
- if (!preflightDeps(hook)) {
578
- return { ...base, aborted: "deps preflight failed" };
579
- }
580
- // Pre-validate settings BEFORE any filesystem writes. If the file is
581
- // malformed we abort here, before copyHookFiles, so the caller never
582
- // ends up with orphan hook files in the target.
583
- let parsedSettings;
584
- try {
585
- parsedSettings = readSettings(resolved.settingsPath);
586
- }
587
- catch (e) {
588
- return { ...base, aborted: e.message };
589
- }
590
- await ensureHookFilesFetched(hook, packageRoot);
591
- const { written, preserved } = copyHookFiles(hook, packageRoot, resolved.hookDir);
592
- let mutated = false;
593
- let settingsError;
594
- try {
595
- mutated = writeMergedSettings(resolved, hook, parsedSettings).mutated;
596
- }
597
- catch (e) {
598
- settingsError = e.message;
599
- }
600
- return {
601
- ...base,
602
- written,
603
- preserved,
604
- settingsMutated: mutated,
605
- settingsError,
606
- };
607
- }
608
- export function findStaleScopes(hook, currentScope, projectBase) {
609
- const all = ["project-local", "project", "user"];
610
- const stale = [];
611
- for (const s of all) {
612
- if (s === currentScope)
613
- continue;
614
- const r = resolveScope(s, projectBase, hook.name);
615
- if (!fs.existsSync(r.settingsPath))
616
- continue;
617
- let parsed;
618
- try {
619
- parsed = JSON.parse(fs.readFileSync(r.settingsPath, "utf8"));
620
- }
621
- catch {
622
- continue;
623
- }
624
- const removed = removeHookFromSettings(parsed, hook.marker).removed;
625
- if (removed > 0) {
626
- stale.push({ scope: s, settingsPath: r.settingsPath, count: removed });
627
- }
628
- }
629
- return stale;
630
- }
631
- /**
632
- * Remove every action carrying `hook.marker` from the given scope's
633
- * settings file. Atomic write, snapshot-once .bak. Returns the count of
634
- * actions removed (0 if nothing matched or file did not exist).
635
- */
636
- export function cleanHookFromScope(hook, scope, projectBase) {
637
- const r = resolveScope(scope, projectBase, hook.name);
638
- if (!fs.existsSync(r.settingsPath)) {
639
- return { removed: 0, settingsPath: r.settingsPath };
640
- }
641
- let parsed;
642
- try {
643
- parsed = JSON.parse(fs.readFileSync(r.settingsPath, "utf8"));
644
- }
645
- catch {
646
- return { removed: 0, settingsPath: r.settingsPath };
647
- }
648
- const result = removeHookFromSettings(parsed, hook.marker);
649
- if (result.removed > 0) {
650
- backupOnce(r.settingsPath);
651
- atomicWriteFile(r.settingsPath, JSON.stringify(result.settings, null, 2) + "\n");
652
- }
653
- return { removed: result.removed, settingsPath: r.settingsPath };
654
- }
655
- /**
656
- * Non-interactive selection resolver for hooks.
657
- *
658
- * Diverges from resolvePluginSelection in the no-filter case. Hooks can
659
- * carry intrusive side effects (OS notifications, brew deps), so the
660
- * safe default is NOT "install everything". Three cases:
661
- *
662
- * - undefined (no --hook passed) → default-on set (filter on defaultOn !== false)
663
- * - ["*"] (explicit opt-in to everything) → full compatible set
664
- * - explicit names → exactly those (even if defaultOn is false)
665
- */
666
- export function resolveHookSelection(compatible, selected) {
667
- if (!selected)
668
- return compatible.filter((h) => h.defaultOn !== false);
669
- if (selected.length === 1 && selected[0] === "*")
670
- return compatible;
671
- const wanted = new Set(selected);
672
- return compatible.filter((h) => wanted.has(h.name));
673
- }
674
- /**
675
- * Given the full registry and the platform-filtered compatible subset,
676
- * return the names in `selected` that refer to real hooks but aren't
677
- * available on the current platform. Empty result means the selection
678
- * is either fully compatible or references unknown hooks (that case is
679
- * left to the catalog validator — we don't pretend unknown names are
680
- * platform issues).
681
- */
682
- export function findIncompatibleExplicit(all, compatible, selected) {
683
- const compatibleNames = new Set(compatible.map((h) => h.name));
684
- const allNames = new Set(all.map((h) => h.name));
685
- return selected.filter((n) => allNames.has(n) && !compatibleNames.has(n));
686
- }
687
- export async function installHooks(packageRoot, opts) {
688
- const config = loadHooksConfig(packageRoot);
689
- const compatible = config.hooks.filter((h) => h.runtimePlatforms.includes(process.platform));
690
- if (compatible.length === 0) {
691
- if (config.hooks.length === 0) {
692
- log.warn("No legacy hooks are defined. The notify hook moved to the opt-in auriga-notify plugin.");
693
- }
694
- else {
695
- log.warn(`No hooks available for your platform (${process.platform}). Skipping.`);
696
- }
697
- return;
698
- }
699
- // Non-interactive explicit `--hook <name>` has stronger intent than
700
- // the default set: if the caller named a hook that isn't available
701
- // on this platform, fail fast. A silent no-op would let CI pipelines
702
- // report success with the intended hook missing.
703
- if (!opts.interactive && opts.selected && opts.selected[0] !== "*") {
704
- const missing = findIncompatibleExplicit(config.hooks, compatible, opts.selected);
705
- if (missing.length > 0) {
706
- throw new Error(`hook${missing.length > 1 ? "s" : ""} not available on ${process.platform}: ${missing.join(", ")}. Run \`npx -y auriga-cli install hooks --help\` to see platform compatibility.`);
707
- }
708
- }
709
- const selected = opts.interactive
710
- ? await withEsc(checkbox({
711
- message: "Select hooks to install:",
712
- choices: compatible.map((h) => ({
713
- name: `${h.name} — ${h.description}`,
714
- value: h,
715
- checked: h.defaultOn !== false,
716
- })),
717
- }))
718
- : resolveHookSelection(compatible, opts.selected);
719
- // Surface any opt-in hooks skipped by the default set so the user
720
- // isn't silently missing a hook they can see in --help / the README.
721
- // Only fires on the non-interactive undefined-selection path (TTY
722
- // checkbox already shows them unchecked).
723
- if (!opts.interactive && opts.selected === undefined) {
724
- const skippedOptIn = compatible.filter((h) => h.defaultOn === false);
725
- for (const h of skippedOptIn) {
726
- log.skip(`${h.name} (opt-in; re-run \`npx -y auriga-cli install hooks --hook ${h.name}\` to install)`);
727
- }
728
- }
729
- if (selected.length === 0) {
730
- log.skip("No hooks selected");
731
- return;
732
- }
733
- // Non-interactive scope (two values only):
734
- // undefined / "project" → project (shared .claude/settings.json)
735
- // "user" → user (~/.claude/settings.json)
736
- // project-local is reachable only via the TTY menu.
737
- const nonInteractiveScope = mapNonInteractiveScope(opts.scope);
738
- // Lazily prompted on the first project-scoped hook, then reused. Users
739
- // who pick only "user" scope are never asked about a project directory.
740
- //
741
- // Non-interactive path always falls back to `process.cwd()` — the
742
- // parser rejects `--cwd` for any non-workflow type (§3.5 rule 5), so
743
- // reading `opts.cwd` here would just be dead dispatch.
744
- let projectBaseResolved = null;
745
- async function ensureProjectBase() {
746
- if (projectBaseResolved !== null)
747
- return projectBaseResolved;
748
- const projectBase = opts.interactive
749
- ? await withEsc(input({
750
- message: "Hooks install target directory:",
751
- default: process.cwd(),
752
- }))
753
- : process.cwd();
754
- const resolvedPath = path.resolve(projectBase);
755
- if (!fs.existsSync(resolvedPath) || !fs.statSync(resolvedPath).isDirectory()) {
756
- log.error(`Not a valid directory: ${resolvedPath}`);
757
- return null;
758
- }
759
- projectBaseResolved = resolvedPath;
760
- return projectBaseResolved;
761
- }
762
- const failures = [];
763
- for (const hook of selected) {
764
- console.log(`\n· ${hook.name}`);
765
- // Per-hook scope is intentional (not a single upfront prompt like
766
- // plugins.ts / skills.ts): a future user may want personal dev tools
767
- // at user level and project-specific hooks at project level. The
768
- // single-hook case is functionally identical to a single prompt.
769
- const scope = opts.interactive
770
- ? await withEsc(select({
771
- message: `Where to install the ${hook.name} hook?`,
772
- choices: scopeChoices(),
773
- default: "project-local",
774
- }))
775
- : nonInteractiveScope;
776
- // User scope mutates ~/.claude/settings.json — global, affects every
777
- // project on this machine. A passive select label and a one-line warn
778
- // both scroll past quickly. Make the user explicitly opt in to the
779
- // global mutation; default to "no" so a missed Enter is the safe path.
780
- // In non-interactive mode the caller has already expressed intent via
781
- // `--scope user`, so we honor it without another confirmation gate.
782
- if (opts.interactive && scope === "user") {
783
- const proceed = await withEsc(confirm({
784
- message: `Modify your global ~/.claude/settings.json? This affects every project on this machine. A .bak snapshot is taken before any change.`,
785
- default: false,
786
- }));
787
- if (!proceed) {
788
- log.skip(`${hook.name} skipped (user cancelled global install)`);
789
- continue;
790
- }
791
- }
792
- // Project scopes need a target directory; user scope does not.
793
- let projectBaseForHook = "";
794
- if (scope !== "user") {
795
- const base = await ensureProjectBase();
796
- if (base === null)
797
- continue;
798
- projectBaseForHook = base;
799
- }
800
- // Cross-scope cleanup: if this hook's marker is already present in a
801
- // *different* scope's settings file, leaving it there means the hook
802
- // will fire from both scopes. Detect, prompt, clean before installing.
803
- // In non-interactive mode the default (remove stale) is applied
804
- // silently — matches the interactive default: true.
805
- const stale = findStaleScopes(hook, scope, projectBaseForHook);
806
- for (const entry of stale) {
807
- log.warn(`Found existing ${hook.name} hook in ${relativeFromCwd(entry.settingsPath)} (${entry.scope} scope, ${entry.count} entr${entry.count === 1 ? "y" : "ies"})`);
808
- const remove = opts.interactive
809
- ? await withEsc(confirm({
810
- message: `Remove the stale registration so the hook only fires once?`,
811
- default: true,
812
- }))
813
- : true;
814
- if (remove) {
815
- const cleaned = cleanHookFromScope(hook, entry.scope, projectBaseForHook);
816
- log.ok(`removed ${cleaned.removed} from ${relativeFromCwd(cleaned.settingsPath)}`);
817
- }
818
- else {
819
- // The user explicitly chose not to clean — make the consequence
820
- // visible so it isn't a silent footgun. The hook will fire from
821
- // BOTH scopes on every Notification event.
822
- log.warn(`${hook.name} will fire from BOTH ${entry.scope} and ${scope} on every event. Run \`auriga-cli\` again or edit ${relativeFromCwd(entry.settingsPath)} to clean it up later.`);
823
- }
824
- }
825
- let result;
826
- try {
827
- result = await installHook(hook, scope, projectBaseForHook, packageRoot);
828
- }
829
- catch (e) {
830
- log.error(`${hook.name}: ${e.message}`);
831
- failures.push(hook.name);
832
- continue;
833
- }
834
- if (result.aborted) {
835
- log.error(`${hook.name} aborted: ${result.aborted}`);
836
- failures.push(hook.name);
837
- continue;
838
- }
839
- const settingsRel = relativeFromCwd(result.settingsPath);
840
- const dirRel = relativeFromCwd(result.hookDir);
841
- const summary = result.preserved > 0
842
- ? `${hook.name} hook installed at ${dirRel} (${result.written} written, ${result.preserved} preserved)`
843
- : `${hook.name} hook installed at ${dirRel}`;
844
- log.ok(summary);
845
- if (result.settingsError) {
846
- log.error(`${hook.name}: ${result.settingsError}`);
847
- log.warn(`Files were copied to ${dirRel} but settings not updated. Add the hook entry manually if you want it active.`);
848
- // Registration failure leaves the hook installed-but-inactive. Count
849
- // it as a failure so non-interactive `install hooks` exits 2 and the
850
- // caller can retry — quietly reporting success would ship a dead hook.
851
- failures.push(hook.name);
852
- }
853
- else if (result.settingsMutated) {
854
- log.ok(`registered in ${settingsRel}`);
855
- }
856
- else {
857
- log.skip(`already registered in ${settingsRel}`);
858
- }
859
- // Per-hook customize tips, sourced from registry metadata so adding a
860
- // new hook doesn't require touching the installer. `{hookDir}` is
861
- // substituted with the resolved install directory.
862
- const hints = hook.customizeHints ?? [];
863
- if (hints.length > 0) {
864
- console.log(` Customize ${hook.name}:`);
865
- for (const hint of hints) {
866
- console.log(` • ${hint.replace(/\{hookDir\}/g, dirRel)}`);
867
- }
868
- }
869
- else {
870
- console.log(` See ${dirRel}/README.md for customization options.`);
871
- }
872
- }
873
- if (failures.length > 0 && !opts.interactive) {
874
- throw new Error(`${failures.length} hook(s) failed to install: ${failures.join(", ")}`);
875
- }
876
- }
877
- // --- Uninstall ----------------------------------------------------------------
878
- const HOOK_NAME_RE_STRICT = /^[a-z][a-z0-9-]*$/;
879
- /**
880
- * Uninstall a single hook. Defaults to project scope; explicit
881
- * `scope:"user"` cleans `~/.claude/...` instead.
882
- *
883
- * Project scope (default):
884
- * - rm `<cwd>/.claude/hooks/<name>/` directory.
885
- * - Strip the hook's marker from `<cwd>/.claude/settings.json` AND
886
- * `<cwd>/.claude/settings.local.json` (project + project-local share
887
- * the on-disk hook dir, so cleaning both settings files keeps users
888
- * who switched scopes from accumulating dangling registrations).
889
- *
890
- * User scope:
891
- * - rm `~/.claude/hooks/<name>/` directory.
892
- * - Strip the hook's marker from `~/.claude/settings.json`.
893
- * - Project files are NOT touched.
894
- *
895
- * Marker discovery: tries the live registry at `<cwd>` (or the npx
896
- * package root if that fails) so we use the same marker the install path
897
- * stamped in. If the registry can't resolve the hook (renamed / removed
898
- * upstream), we fall back to a `auriga:<name>` convention — every shipped
899
- * hook to date follows it, so the fallback is reliable for the common
900
- * case.
901
- *
902
- * Idempotent: missing hook dir / missing settings / absent marker → no-op.
903
- */
904
- export async function uninstallHook(name, opts) {
905
- if (!HOOK_NAME_RE_STRICT.test(name)) {
906
- throw new Error(`uninstallHook: invalid hook name ${JSON.stringify(name)}`);
907
- }
908
- const cwd = path.resolve(opts.cwd);
909
- const scope = opts.scope ?? "project";
910
- const emit = (line) => { opts.onLog?.(line); };
911
- // Look up marker from the registry. If the registry is absent or the
912
- // hook isn't listed, fall back to `auriga:<name>` (the shipped naming
913
- // convention for every hook in this repo).
914
- let marker = `auriga:${name}`;
915
- try {
916
- const cfg = loadHooksConfig(cwd);
917
- const def = cfg.hooks.find((h) => h.name === name);
918
- if (def)
919
- marker = def.marker;
920
- }
921
- catch {
922
- // No registry at cwd — fine, fall back to the convention.
923
- }
924
- // Build a minimal HookDef stub for cleanHookFromScope. It only reads
925
- // `marker` and `name` (via resolveScope), both of which we have.
926
- const stub = {
927
- name,
928
- description: "",
929
- runtimePlatforms: [],
930
- settingsEvents: [],
931
- command: 'node "$HOOK_DIR/index.mjs"',
932
- files: [],
933
- marker,
934
- };
935
- // resolveScope picks the settings/hooks paths based on scope.
936
- // Project scope covers both project + project-local settings (shared
937
- // on-disk hook dir); user scope covers only ~/.claude/settings.json.
938
- const cleanScopes = scope === "user" ? ["user"] : ["project", "project-local"];
939
- let totalRemoved = 0;
940
- for (const s of cleanScopes) {
941
- const r = cleanHookFromScope(stub, s, cwd);
942
- if (r.removed > 0) {
943
- totalRemoved += r.removed;
944
- log.ok(`${name}: removed ${r.removed} entr${r.removed === 1 ? "y" : "ies"} from ${r.settingsPath}`);
945
- emit(`removed ${r.removed} entr${r.removed === 1 ? "y" : "ies"} from ${r.settingsPath}`);
946
- }
947
- }
948
- if (totalRemoved === 0) {
949
- log.skip(`${name}: no settings entries found`);
950
- emit(`${name}: no settings entries found`);
951
- }
952
- // Hook directory: user → ~/.claude/hooks/<name>; project → <cwd>/.claude/hooks/<name>.
953
- const hookDir = scope === "user"
954
- ? path.join(os.homedir(), ".claude", "hooks", name)
955
- : path.join(cwd, ".claude", "hooks", name);
956
- if (fs.existsSync(hookDir)) {
957
- fs.rmSync(hookDir, { recursive: true, force: true });
958
- log.ok(`${name}: directory removed`);
959
- emit(`removed ${hookDir}`);
960
- }
961
- else {
962
- log.skip(`${name}: directory not present`);
963
- emit(`${name}: directory not present`);
964
- }
965
- }