skillrepo 4.0.0 → 4.2.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 +49 -2
- package/bin/skillrepo.mjs +8 -0
- package/package.json +10 -4
- package/src/commands/init-cohort-hooks.mjs +127 -0
- package/src/commands/init.mjs +45 -6
- package/src/commands/push.mjs +187 -0
- package/src/commands/uninstall.mjs +12 -1
- package/src/commands/update.mjs +97 -16
- package/src/lib/agent-hook-merge.mjs +203 -0
- package/src/lib/agent-registry.mjs +186 -2
- package/src/lib/artifact-registry.mjs +111 -2
- package/src/lib/fs-utils.mjs +16 -1
- package/src/lib/http.mjs +169 -11
- package/src/lib/mergers/agent-hook-claude-shape.mjs +519 -0
- package/src/lib/mergers/agent-hook-cursor-shape.mjs +318 -0
- package/src/lib/removers/agent-hooks.mjs +83 -0
- package/src/lib/skill-walk.mjs +97 -0
- package/src/test/commands/init.test.mjs +281 -0
- package/src/test/commands/push.test.mjs +289 -0
- package/src/test/commands/update.test.mjs +135 -0
- package/src/test/dispatcher.test.mjs +10 -2
- package/src/test/e2e/cli-cohort-hooks.test.mjs +393 -0
- package/src/test/e2e/mock-server.mjs +92 -10
- package/src/test/integration/agent-hooks.integration.test.mjs +340 -0
- package/src/test/lib/agent-hook-merge.test.mjs +172 -0
- package/src/test/lib/artifact-registry.test.mjs +39 -0
- package/src/test/lib/http.test.mjs +242 -1
- package/src/test/lib/skill-walk.test.mjs +127 -0
- package/src/test/mergers/agent-hook-claude-shape.test.mjs +518 -0
- package/src/test/mergers/agent-hook-cursor-shape.test.mjs +306 -0
- package/src/test/removers/agent-hooks.test.mjs +206 -0
|
@@ -0,0 +1,519 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SessionStart-hook installer/uninstaller for the claude-shape vendors:
|
|
3
|
+
* Gemini CLI, Codex CLI, and VS Code + Copilot (#1240).
|
|
4
|
+
*
|
|
5
|
+
* Each of these vendors accepts the nested-array hook config Anthropic
|
|
6
|
+
* documents for Claude Code, with vendor-specific extras layered on:
|
|
7
|
+
*
|
|
8
|
+
* {
|
|
9
|
+
* "hooks": {
|
|
10
|
+
* "<EventName>": [
|
|
11
|
+
* {
|
|
12
|
+
* ...groupFields, // e.g. Gemini's `matcher: "*"`
|
|
13
|
+
* "hooks": [
|
|
14
|
+
* { ...entryFields, // e.g. Gemini's `name`,
|
|
15
|
+
* // Codex's `timeout: 60`
|
|
16
|
+
* "command": "<canonical>" }
|
|
17
|
+
* ]
|
|
18
|
+
* }
|
|
19
|
+
* ]
|
|
20
|
+
* }
|
|
21
|
+
* }
|
|
22
|
+
*
|
|
23
|
+
* "<EventName>" is per-vendor (`SessionStart` for Gemini and Codex,
|
|
24
|
+
* lowercase `sessionStart` for VS Code + Copilot) and comes from the
|
|
25
|
+
* agent registry's `agentHook.eventName` field. The inner per-hook
|
|
26
|
+
* entry is built by merging the universal `command`
|
|
27
|
+
* (`AGENT_HOOK_COMMAND` from artifact-registry.mjs) with the vendor's
|
|
28
|
+
* `agentHook.entryFields`. The OUTER group object spreads the vendor's
|
|
29
|
+
* `agentHook.groupFields` on fresh install AND when upgrading from a
|
|
30
|
+
* Phase-0-era group that lacks them — the latter is the upgrade-path
|
|
31
|
+
* gate for Gemini's `matcher: "*"` (without it Gemini silently filters
|
|
32
|
+
* out the group and the hook never fires).
|
|
33
|
+
*
|
|
34
|
+
* ## Why a separate module from session-hook.mjs
|
|
35
|
+
*
|
|
36
|
+
* The legacy `session-hook.mjs` is Claude-Code-specific:
|
|
37
|
+
* - Resolves an absolute `binaryPath` via PATH lookup (Claude doesn't
|
|
38
|
+
* load PATH at hook time)
|
|
39
|
+
* - Wraps the command in shell-specific quoting + `|| true` backstop
|
|
40
|
+
* - Writes to `.claude/settings.local.json`
|
|
41
|
+
* - Honors `--session-hook`'s exit-0-on-error contract
|
|
42
|
+
*
|
|
43
|
+
* The cohort hooks have NONE of these concerns:
|
|
44
|
+
* - `npx --yes skillrepo update --silent` is the universal command;
|
|
45
|
+
* no binaryPath resolution
|
|
46
|
+
* - Cohort vendors invoke hooks through the user's normal shell, so
|
|
47
|
+
* no shell-quoting is needed
|
|
48
|
+
* - Each vendor has its own user-scope hook config file
|
|
49
|
+
* - `--silent` exits non-zero on real failure (the cohort vendors
|
|
50
|
+
* don't block session start on hook failure the way Claude does)
|
|
51
|
+
*
|
|
52
|
+
* Forking the merger is cheaper than a single function with a "claude
|
|
53
|
+
* legacy mode vs cohort mode" branch: the two flows share almost no
|
|
54
|
+
* logic — only the basic JSON-walk-by-fingerprint pattern, which is
|
|
55
|
+
* tightly coupled to the per-shape schema anyway.
|
|
56
|
+
*
|
|
57
|
+
* ## Idempotency contract (mirrors session-hook.mjs)
|
|
58
|
+
*
|
|
59
|
+
* `mergeClaudeShapeAgentHook` is safe to re-run:
|
|
60
|
+
* - First run: no SkillRepo entry present → installs, returns "installed"
|
|
61
|
+
* - Re-run with same command: no-op, returns "unchanged"
|
|
62
|
+
* - Re-run after manual edit (e.g. extension-only change): replaces
|
|
63
|
+
* the SkillRepo entry in place, returns "updated"
|
|
64
|
+
* - Other tools' entries are NEVER touched. The walk filters by
|
|
65
|
+
* `AGENT_HOOK_FINGERPRINT` substring on the `command` field.
|
|
66
|
+
*
|
|
67
|
+
* ## Atomic writes
|
|
68
|
+
*
|
|
69
|
+
* The hook config files are read by their respective agents at session
|
|
70
|
+
* start. A half-written JSON would break every subsequent session.
|
|
71
|
+
* `writeFileAtomic` (temp file + rename + unlink-on-failure) is the
|
|
72
|
+
* mandatory write path.
|
|
73
|
+
*/
|
|
74
|
+
|
|
75
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
76
|
+
import { writeFileAtomic } from "../fs-utils.mjs";
|
|
77
|
+
import {
|
|
78
|
+
AGENT_HOOK_COMMAND,
|
|
79
|
+
AGENT_HOOK_FINGERPRINT,
|
|
80
|
+
} from "../artifact-registry.mjs";
|
|
81
|
+
import { getAgentByKey } from "../agent-registry.mjs";
|
|
82
|
+
import { diskError, validationError } from "../errors.mjs";
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Build the per-hook entry the merger writes. The `entryFields` from
|
|
86
|
+
* the agent registry's `agentHook.entryFields` (e.g. Gemini's
|
|
87
|
+
* `{ type: "command", timeout: 60000 }`) are spread BEFORE `command`
|
|
88
|
+
* so the user can NEVER override the command via a registry typo —
|
|
89
|
+
* the explicit assignment of `command: AGENT_HOOK_COMMAND` wins.
|
|
90
|
+
*
|
|
91
|
+
* Exported so tests can assert the exact JSON-serializable shape.
|
|
92
|
+
*
|
|
93
|
+
* @param {object} entryFields - Vendor-specific extras from the agent
|
|
94
|
+
* registry. Treated as JSON-serializable; non-serializable keys
|
|
95
|
+
* will surface at write time as a JSON.stringify error and are
|
|
96
|
+
* the registry author's problem, not this function's.
|
|
97
|
+
* @returns {Record<string, unknown>}
|
|
98
|
+
*/
|
|
99
|
+
export function buildClaudeShapeEntry(entryFields = {}) {
|
|
100
|
+
return {
|
|
101
|
+
...entryFields,
|
|
102
|
+
command: AGENT_HOOK_COMMAND,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Install (or refresh) the cohort SessionStart hook in a claude-shape
|
|
108
|
+
* vendor's hook config file. Creates the file if it doesn't exist.
|
|
109
|
+
*
|
|
110
|
+
* @param {object} options
|
|
111
|
+
* @param {string} options.vendorKey - Canonical key from the agent
|
|
112
|
+
* registry. Must be a registry entry whose `agentHook.shape`
|
|
113
|
+
* is `"claude-shape"`. Other shapes throw a validation error.
|
|
114
|
+
* @returns {{
|
|
115
|
+
* path: string;
|
|
116
|
+
* action: "installed" | "updated" | "unchanged";
|
|
117
|
+
* command: string;
|
|
118
|
+
* }}
|
|
119
|
+
* Action values:
|
|
120
|
+
* - `"installed"` — no prior SkillRepo hook, we added one
|
|
121
|
+
* - `"updated"` — prior SkillRepo hook existed but the entry differed,
|
|
122
|
+
* replaced in place
|
|
123
|
+
* - `"unchanged"` — exact entry already present; no-op
|
|
124
|
+
*/
|
|
125
|
+
export function mergeClaudeShapeAgentHook({ vendorKey }) {
|
|
126
|
+
const { hookSpec } = resolveAgent(vendorKey);
|
|
127
|
+
const filePath = hookSpec.pathFn();
|
|
128
|
+
const eventName = hookSpec.eventName;
|
|
129
|
+
const desiredEntry = buildClaudeShapeEntry(hookSpec.entryFields);
|
|
130
|
+
|
|
131
|
+
const config = readConfigOrEmpty(filePath, hookSpec.displayPath);
|
|
132
|
+
|
|
133
|
+
if (!config.hooks || typeof config.hooks !== "object") {
|
|
134
|
+
config.hooks = {};
|
|
135
|
+
}
|
|
136
|
+
if (!Array.isArray(config.hooks[eventName])) {
|
|
137
|
+
config.hooks[eventName] = [];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Walk `hooks.<EventName>[i].hooks[j]` exhaustively for entries
|
|
141
|
+
// whose `command` matches the SkillRepo fingerprint. The walk MUST
|
|
142
|
+
// mirror the remover's filter in `removers/agent-hooks.mjs`; both
|
|
143
|
+
// sides use `AGENT_HOOK_FINGERPRINT` as the single source of truth
|
|
144
|
+
// so they can't drift.
|
|
145
|
+
//
|
|
146
|
+
// Exhaustive walk (not break-on-first) is the symmetric installer
|
|
147
|
+
// counterpart to the remover's exhaustive filter: the remover
|
|
148
|
+
// strips ALL matching entries, so the installer also has to handle
|
|
149
|
+
// ALL matching entries. Without this, a prior-run double-install
|
|
150
|
+
// (or manual edit, or a future code path that produces two entries
|
|
151
|
+
// by mistake) leaves a ghost the installer doesn't see. Fixing on
|
|
152
|
+
// every install is cheap and means the post-install invariant
|
|
153
|
+
// "exactly one SkillRepo entry per cohort hook config" holds even
|
|
154
|
+
// through bug-recovery scenarios.
|
|
155
|
+
let primaryHandled = false; // tracks whether we've already replaced/preserved one
|
|
156
|
+
let foundChanged = false; // any actual mutation (updated or stripped duplicate)
|
|
157
|
+
let foundUnchangedExact = false; // first match was already byte-equal to desired
|
|
158
|
+
let groupsToCompact = false; // a group's hooks array became empty during walk
|
|
159
|
+
|
|
160
|
+
for (const group of config.hooks[eventName]) {
|
|
161
|
+
if (!group || typeof group !== "object" || !Array.isArray(group.hooks)) {
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
const survivingInGroup = [];
|
|
165
|
+
let groupMutated = false;
|
|
166
|
+
for (const inner of group.hooks) {
|
|
167
|
+
if (
|
|
168
|
+
inner &&
|
|
169
|
+
typeof inner === "object" &&
|
|
170
|
+
typeof inner.command === "string" &&
|
|
171
|
+
inner.command.includes(AGENT_HOOK_FINGERPRINT)
|
|
172
|
+
) {
|
|
173
|
+
if (!primaryHandled) {
|
|
174
|
+
primaryHandled = true;
|
|
175
|
+
if (entriesEqual(inner, desiredEntry)) {
|
|
176
|
+
foundUnchangedExact = true;
|
|
177
|
+
survivingInGroup.push(inner);
|
|
178
|
+
} else {
|
|
179
|
+
foundChanged = true;
|
|
180
|
+
groupMutated = true; // in-place replacement at the same index
|
|
181
|
+
survivingInGroup.push(desiredEntry);
|
|
182
|
+
}
|
|
183
|
+
} else {
|
|
184
|
+
// Duplicate SkillRepo entry from a prior buggy run or
|
|
185
|
+
// manual edit. Drop it; the primary slot above carries
|
|
186
|
+
// the canonical entry.
|
|
187
|
+
foundChanged = true;
|
|
188
|
+
groupMutated = true; // length-changing drop
|
|
189
|
+
}
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
survivingInGroup.push(inner);
|
|
193
|
+
}
|
|
194
|
+
// Write back if we replaced (same length, different content) OR
|
|
195
|
+
// dropped duplicates (smaller length). Length-only equality is
|
|
196
|
+
// not sufficient — an in-place replacement leaves length
|
|
197
|
+
// unchanged but the content needs to be written. Round-2
|
|
198
|
+
// reviewer caught this gap.
|
|
199
|
+
if (groupMutated) {
|
|
200
|
+
group.hooks = survivingInGroup;
|
|
201
|
+
if (group.hooks.length === 0) groupsToCompact = true;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Drop fully-emptied groups so the file doesn't accumulate
|
|
206
|
+
// structurally-empty husks across multiple install/uninstall
|
|
207
|
+
// cycles. Mirrors the remover's empty-container collapse.
|
|
208
|
+
if (groupsToCompact) {
|
|
209
|
+
config.hooks[eventName] = config.hooks[eventName].filter((g) => {
|
|
210
|
+
if (!g || typeof g !== "object" || !Array.isArray(g.hooks)) return true;
|
|
211
|
+
return g.hooks.length > 0;
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Vendor-specific group-level fields (#1242 Gemini). When the
|
|
216
|
+
// registry declares `groupFields` (e.g. `{ matcher: "*" }` for
|
|
217
|
+
// Gemini), every group that contains our entry MUST carry them.
|
|
218
|
+
// This handles two scenarios:
|
|
219
|
+
//
|
|
220
|
+
// - **Fresh install** (no primaryHandled match): the append at
|
|
221
|
+
// the bottom of this function spreads `groupFields` into the
|
|
222
|
+
// new group.
|
|
223
|
+
// - **Phase-0-era upgrade** (entry exists but in a group lacking
|
|
224
|
+
// `matcher`): the loop below adds the missing fields to the
|
|
225
|
+
// group containing our entry. Without this, a Phase-0-installed
|
|
226
|
+
// Gemini hook stays silently broken after the upgrade — the
|
|
227
|
+
// entry is correct but Gemini's matcher filter rejects the
|
|
228
|
+
// group, so the hook never fires.
|
|
229
|
+
//
|
|
230
|
+
// **Always-re-add contract**: a user who deliberately deletes a
|
|
231
|
+
// required `groupFields` value (e.g. removes `matcher: "*"` from
|
|
232
|
+
// their Gemini config) will have it re-added on the next install.
|
|
233
|
+
// The `=== undefined` guard means we never overwrite a user-set
|
|
234
|
+
// value (e.g. `matcher: "narrower-pattern"`) — but a missing field
|
|
235
|
+
// is treated as "broken state to repair," not "user choice to
|
|
236
|
+
// honor." This is intentional: `groupFields` are required for the
|
|
237
|
+
// hook to fire at all, so silent self-repair is the right default
|
|
238
|
+
// for a deletion that would otherwise leave the hook dead. Falsy
|
|
239
|
+
// non-undefined values (`""`, `null`, `false`) are NOT re-written —
|
|
240
|
+
// we trust those as deliberate user input even though they may
|
|
241
|
+
// produce a broken hook. A user who accidentally types
|
|
242
|
+
// `matcher: ""` is making an explicit choice we won't second-guess;
|
|
243
|
+
// a user who didn't set the field at all is in unrepaired upgrade
|
|
244
|
+
// state. (Alternative-considered: widen to "fix any falsy value"
|
|
245
|
+
// would clobber a future user setting `matcher: false` to
|
|
246
|
+
// intentionally disable the hook for a session — different bug,
|
|
247
|
+
// worse class.)
|
|
248
|
+
//
|
|
249
|
+
// Idempotent: if the field is already present (any value, falsy or
|
|
250
|
+
// truthy) we don't touch it, and the merger only reports `updated`
|
|
251
|
+
// when something actually changed.
|
|
252
|
+
if (hookSpec.groupFields && primaryHandled) {
|
|
253
|
+
for (const group of config.hooks[eventName]) {
|
|
254
|
+
if (!group || typeof group !== "object" || !Array.isArray(group.hooks)) {
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
const containsOurs = group.hooks.some(
|
|
258
|
+
(h) =>
|
|
259
|
+
h &&
|
|
260
|
+
typeof h.command === "string" &&
|
|
261
|
+
h.command.includes(AGENT_HOOK_FINGERPRINT),
|
|
262
|
+
);
|
|
263
|
+
if (!containsOurs) continue;
|
|
264
|
+
for (const [k, v] of Object.entries(hookSpec.groupFields)) {
|
|
265
|
+
if (group[k] === undefined) {
|
|
266
|
+
group[k] = v;
|
|
267
|
+
foundChanged = true;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
let matchedAction;
|
|
274
|
+
if (!primaryHandled) {
|
|
275
|
+
// No prior entry — append a new group with a single hook. We
|
|
276
|
+
// append (not prepend) so user-authored groups retain their
|
|
277
|
+
// relative order. All known claude-shape vendors fire all
|
|
278
|
+
// groups in sequence.
|
|
279
|
+
//
|
|
280
|
+
// Spread `groupFields` (e.g. Gemini's `matcher: "*"`) BEFORE
|
|
281
|
+
// `hooks` so a registry typo can't override the canonical
|
|
282
|
+
// `hooks` array. Same pattern as `entryFields` in
|
|
283
|
+
// `buildClaudeShapeEntry`.
|
|
284
|
+
const newGroup = {
|
|
285
|
+
...(hookSpec.groupFields ?? {}),
|
|
286
|
+
hooks: [desiredEntry],
|
|
287
|
+
};
|
|
288
|
+
config.hooks[eventName].push(newGroup);
|
|
289
|
+
matchedAction = "installed";
|
|
290
|
+
} else if (foundChanged) {
|
|
291
|
+
matchedAction = "updated";
|
|
292
|
+
} else if (foundUnchangedExact) {
|
|
293
|
+
matchedAction = "unchanged";
|
|
294
|
+
} else {
|
|
295
|
+
// Defensive: unreachable. primaryHandled implies one of the two
|
|
296
|
+
// outcomes above. Leaving an explicit branch makes the future
|
|
297
|
+
// refactor's failure obvious.
|
|
298
|
+
matchedAction = "updated";
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (matchedAction === "unchanged") {
|
|
302
|
+
return {
|
|
303
|
+
path: hookSpec.displayPath,
|
|
304
|
+
action: "unchanged",
|
|
305
|
+
command: AGENT_HOOK_COMMAND,
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
writeFileAtomic(filePath, JSON.stringify(config, null, 2) + "\n");
|
|
310
|
+
|
|
311
|
+
return {
|
|
312
|
+
path: hookSpec.displayPath,
|
|
313
|
+
action: matchedAction,
|
|
314
|
+
command: AGENT_HOOK_COMMAND,
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Strip the SkillRepo cohort hook from a claude-shape vendor's hook
|
|
320
|
+
* config. Idempotent and safe under concurrent edits — non-SkillRepo
|
|
321
|
+
* hooks and unrelated event-arrays are never touched.
|
|
322
|
+
*
|
|
323
|
+
* @param {object} options
|
|
324
|
+
* @param {string} options.vendorKey
|
|
325
|
+
* @param {boolean} [options.dryRun=false]
|
|
326
|
+
* @returns {{
|
|
327
|
+
* path: string;
|
|
328
|
+
* action: "removed" | "would-remove" | "skipped" | "unchanged";
|
|
329
|
+
* error?: string;
|
|
330
|
+
* }}
|
|
331
|
+
* Action values:
|
|
332
|
+
* - `"removed"` — at least one matching entry stripped
|
|
333
|
+
* - `"would-remove"`— dryRun and a matching entry exists
|
|
334
|
+
* - `"skipped"` — file does not exist OR is unparseable
|
|
335
|
+
* - `"unchanged"` — file exists, parseable, no matching entry
|
|
336
|
+
*/
|
|
337
|
+
export function unmergeClaudeShapeAgentHook({ vendorKey, dryRun = false }) {
|
|
338
|
+
const { hookSpec } = resolveAgent(vendorKey);
|
|
339
|
+
const filePath = hookSpec.pathFn();
|
|
340
|
+
const eventName = hookSpec.eventName;
|
|
341
|
+
|
|
342
|
+
if (!existsSync(filePath)) {
|
|
343
|
+
return { path: hookSpec.displayPath, action: "skipped" };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const raw = readFileSync(filePath, "utf-8");
|
|
347
|
+
if (raw.trim().length === 0) {
|
|
348
|
+
return { path: hookSpec.displayPath, action: "unchanged" };
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
let config;
|
|
352
|
+
try {
|
|
353
|
+
config = JSON.parse(raw);
|
|
354
|
+
} catch (err) {
|
|
355
|
+
return {
|
|
356
|
+
path: hookSpec.displayPath,
|
|
357
|
+
action: "skipped",
|
|
358
|
+
error: `Cannot parse ${hookSpec.displayPath}: ${err.message}. Fix or delete the file and re-run.`,
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
if (
|
|
363
|
+
!config ||
|
|
364
|
+
typeof config !== "object" ||
|
|
365
|
+
!config.hooks ||
|
|
366
|
+
typeof config.hooks !== "object" ||
|
|
367
|
+
!Array.isArray(config.hooks[eventName])
|
|
368
|
+
) {
|
|
369
|
+
return { path: hookSpec.displayPath, action: "unchanged" };
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const originalGroups = config.hooks[eventName];
|
|
373
|
+
let anyRemoved = false;
|
|
374
|
+
|
|
375
|
+
const newGroups = originalGroups
|
|
376
|
+
.map((group) => {
|
|
377
|
+
if (!group || typeof group !== "object" || !Array.isArray(group.hooks)) {
|
|
378
|
+
return group;
|
|
379
|
+
}
|
|
380
|
+
const beforeCount = group.hooks.length;
|
|
381
|
+
const survivingHooks = group.hooks.filter((h) => {
|
|
382
|
+
if (!h || typeof h !== "object" || typeof h.command !== "string") {
|
|
383
|
+
return true;
|
|
384
|
+
}
|
|
385
|
+
const matches = h.command.includes(AGENT_HOOK_FINGERPRINT);
|
|
386
|
+
if (matches) {
|
|
387
|
+
anyRemoved = true;
|
|
388
|
+
return false;
|
|
389
|
+
}
|
|
390
|
+
return true;
|
|
391
|
+
});
|
|
392
|
+
if (survivingHooks.length !== beforeCount && survivingHooks.length === 0) {
|
|
393
|
+
// Whole group was ours — drop it.
|
|
394
|
+
return null;
|
|
395
|
+
}
|
|
396
|
+
if (survivingHooks.length !== beforeCount) {
|
|
397
|
+
return { ...group, hooks: survivingHooks };
|
|
398
|
+
}
|
|
399
|
+
return group;
|
|
400
|
+
})
|
|
401
|
+
.filter((g) => g !== null);
|
|
402
|
+
|
|
403
|
+
if (!anyRemoved) {
|
|
404
|
+
return { path: hookSpec.displayPath, action: "unchanged" };
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
if (dryRun) {
|
|
408
|
+
return { path: hookSpec.displayPath, action: "would-remove" };
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Clean up empty containers so the file doesn't accumulate dead
|
|
412
|
+
// structure: empty event-array → drop the key; empty `hooks`
|
|
413
|
+
// object → drop the key. Mirrors `removers/settings.mjs`.
|
|
414
|
+
if (newGroups.length === 0) {
|
|
415
|
+
delete config.hooks[eventName];
|
|
416
|
+
} else {
|
|
417
|
+
config.hooks[eventName] = newGroups;
|
|
418
|
+
}
|
|
419
|
+
if (Object.keys(config.hooks).length === 0) {
|
|
420
|
+
delete config.hooks;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
writeFileAtomic(filePath, JSON.stringify(config, null, 2) + "\n");
|
|
424
|
+
return { path: hookSpec.displayPath, action: "removed" };
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// ── Internal helpers ───────────────────────────────────────────────
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Look up the registry entry and validate it's a claude-shape vendor.
|
|
431
|
+
* Throws `validationError` on unknown key or wrong shape — both are
|
|
432
|
+
* caller bugs the dispatcher should never produce.
|
|
433
|
+
*
|
|
434
|
+
* @param {string} vendorKey
|
|
435
|
+
* @returns {{ entry: import("../agent-registry.mjs").AgentEntry, hookSpec: import("../agent-registry.mjs").AgentHookSpec }}
|
|
436
|
+
*/
|
|
437
|
+
function resolveAgent(vendorKey) {
|
|
438
|
+
const entry = getAgentByKey(vendorKey);
|
|
439
|
+
if (!entry) {
|
|
440
|
+
throw validationError(
|
|
441
|
+
`Unknown agent key: ${vendorKey}. Add it to AGENT_REGISTRY.`,
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
if (!entry.agentHook) {
|
|
445
|
+
throw validationError(
|
|
446
|
+
`Agent "${vendorKey}" has no agentHook spec — cannot install a SessionStart hook.`,
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
if (entry.agentHook.shape !== "claude-shape") {
|
|
450
|
+
throw validationError(
|
|
451
|
+
`Agent "${vendorKey}" has shape "${entry.agentHook.shape}", expected "claude-shape".`,
|
|
452
|
+
{
|
|
453
|
+
hint: "Use the matching shape merger (e.g. mergeCursorShapeAgentHook).",
|
|
454
|
+
},
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
return { entry, hookSpec: entry.agentHook };
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Read an existing JSON config or return an empty object. Throws a
|
|
462
|
+
* `diskError` for an unparseable non-empty file — silently overwriting
|
|
463
|
+
* a corrupt file would destroy any user-authored hooks we can't read.
|
|
464
|
+
*
|
|
465
|
+
* @param {string} filePath
|
|
466
|
+
* @param {string} displayPath - For the error message; never the
|
|
467
|
+
* absolute path (leaks the user's home).
|
|
468
|
+
*/
|
|
469
|
+
function readConfigOrEmpty(filePath, displayPath) {
|
|
470
|
+
if (!existsSync(filePath)) return {};
|
|
471
|
+
const raw = readFileSync(filePath, "utf-8");
|
|
472
|
+
if (raw.trim().length === 0) return {};
|
|
473
|
+
let parsed;
|
|
474
|
+
try {
|
|
475
|
+
parsed = JSON.parse(raw);
|
|
476
|
+
} catch (err) {
|
|
477
|
+
throw diskError(
|
|
478
|
+
`Cannot parse ${displayPath}: ${err.message}. Fix or delete the file, then re-run.`,
|
|
479
|
+
{ cause: err },
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
483
|
+
throw diskError(
|
|
484
|
+
`${displayPath} must be a JSON object at the top level.`,
|
|
485
|
+
);
|
|
486
|
+
}
|
|
487
|
+
return parsed;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Deep-equal-by-JSON for two plain objects. Used to detect "identical
|
|
492
|
+
* entry already present" without imposing an order on keys (a key
|
|
493
|
+
* order change should NOT trigger an "updated" return — JSON object
|
|
494
|
+
* key order is not load-bearing for any of the target agents).
|
|
495
|
+
*
|
|
496
|
+
* The implementation re-serializes with a stable key order: this is
|
|
497
|
+
* cheap (entries are small — a `command` string plus 0–2 metadata
|
|
498
|
+
* fields) and avoids the cyclic-reference / Date / RegExp pitfalls of
|
|
499
|
+
* a recursive deep-equal. Callers must only pass JSON-safe values,
|
|
500
|
+
* which `buildClaudeShapeEntry` guarantees.
|
|
501
|
+
*
|
|
502
|
+
* @param {object} a
|
|
503
|
+
* @param {object} b
|
|
504
|
+
* @returns {boolean}
|
|
505
|
+
*/
|
|
506
|
+
function entriesEqual(a, b) {
|
|
507
|
+
return stableJson(a) === stableJson(b);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function stableJson(value) {
|
|
511
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
512
|
+
return JSON.stringify(value);
|
|
513
|
+
}
|
|
514
|
+
const sortedKeys = Object.keys(value).sort();
|
|
515
|
+
const parts = sortedKeys.map(
|
|
516
|
+
(k) => `${JSON.stringify(k)}:${stableJson(value[k])}`,
|
|
517
|
+
);
|
|
518
|
+
return `{${parts.join(",")}}`;
|
|
519
|
+
}
|