auriga-cli 1.25.0 → 1.26.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 +17 -22
- package/README.zh-CN.md +18 -22
- package/dist/api-types.d.ts +24 -16
- package/dist/apply-handlers.js +29 -26
- package/dist/catalog.d.ts +1 -2
- package/dist/catalog.json +2 -3
- package/dist/cli.d.ts +20 -0
- package/dist/cli.js +169 -48
- package/dist/guide.js +15 -12
- package/dist/help.js +10 -28
- package/dist/plugins.d.ts +9 -0
- package/dist/plugins.js +20 -2
- package/dist/preset.d.ts +38 -0
- package/dist/preset.js +84 -0
- package/dist/scan-catalog.js +2 -6
- package/dist/server.d.ts +7 -4
- package/dist/server.js +18 -7
- package/dist/state.d.ts +1 -6
- package/dist/state.js +0 -104
- package/dist/types.d.ts +1 -1
- package/dist/types.js +0 -1
- package/dist/utils.d.ts +7 -1
- package/package.json +4 -4
- package/dist/hooks.d.ts +0 -236
- package/dist/hooks.js +0 -965
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
|
-
}
|