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.
@@ -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
+ }