skillrepo 3.2.0 → 4.1.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.
Files changed (53) hide show
  1. package/README.md +137 -27
  2. package/bin/skillrepo.mjs +5 -5
  3. package/package.json +1 -1
  4. package/src/commands/add.mjs +21 -6
  5. package/src/commands/get.mjs +20 -4
  6. package/src/commands/init-cohort-hooks.mjs +127 -0
  7. package/src/commands/init-session-sync.mjs +1 -1
  8. package/src/commands/init.mjs +480 -117
  9. package/src/commands/list.mjs +1 -1
  10. package/src/commands/remove.mjs +10 -2
  11. package/src/commands/uninstall.mjs +13 -2
  12. package/src/commands/update.mjs +112 -19
  13. package/src/lib/agent-hook-merge.mjs +203 -0
  14. package/src/lib/agent-registry.mjs +399 -0
  15. package/src/lib/artifact-registry.mjs +111 -2
  16. package/src/lib/cli-config.mjs +146 -44
  17. package/src/lib/detect-agents.mjs +112 -0
  18. package/src/lib/file-write.mjs +162 -77
  19. package/src/lib/fs-utils.mjs +16 -1
  20. package/src/lib/mcp-merge.mjs +17 -36
  21. package/src/lib/mergers/agent-hook-claude-shape.mjs +519 -0
  22. package/src/lib/mergers/agent-hook-cursor-shape.mjs +318 -0
  23. package/src/lib/mergers/gitignore.mjs +55 -28
  24. package/src/lib/paths.mjs +27 -25
  25. package/src/lib/prompt-multiselect.mjs +324 -0
  26. package/src/lib/removers/agent-hooks.mjs +83 -0
  27. package/src/lib/sync.mjs +18 -19
  28. package/src/test/commands/add.test.mjs +18 -3
  29. package/src/test/commands/init-picker.test.mjs +144 -0
  30. package/src/test/commands/init.test.mjs +508 -41
  31. package/src/test/commands/remove.test.mjs +4 -1
  32. package/src/test/commands/update.test.mjs +148 -3
  33. package/src/test/e2e/cli-agent-permutations.test.mjs +631 -0
  34. package/src/test/e2e/cli-cohort-hooks.test.mjs +393 -0
  35. package/src/test/e2e/cli-commands.test.mjs +39 -13
  36. package/src/test/integration/agent-hooks.integration.test.mjs +340 -0
  37. package/src/test/integration/file-write.integration.test.mjs +31 -10
  38. package/src/test/lib/agent-hook-merge.test.mjs +172 -0
  39. package/src/test/lib/agent-registry.test.mjs +215 -0
  40. package/src/test/lib/artifact-registry.test.mjs +39 -0
  41. package/src/test/lib/cli-config.test.mjs +222 -38
  42. package/src/test/lib/detect-agents.test.mjs +336 -0
  43. package/src/test/lib/file-write-placement.test.mjs +264 -0
  44. package/src/test/lib/file-write.test.mjs +231 -30
  45. package/src/test/lib/mcp-merge.test.mjs +23 -15
  46. package/src/test/lib/paths.test.mjs +53 -17
  47. package/src/test/lib/prompt-multiselect.test.mjs +448 -0
  48. package/src/test/lib/sync.test.mjs +157 -0
  49. package/src/test/mergers/agent-hook-claude-shape.test.mjs +518 -0
  50. package/src/test/mergers/agent-hook-cursor-shape.test.mjs +306 -0
  51. package/src/test/removers/agent-hooks.test.mjs +206 -0
  52. package/src/lib/detect-ides.mjs +0 -44
  53. package/src/test/detect-ides.test.mjs +0 -65
@@ -14,7 +14,7 @@
14
14
  * --json Pipe-friendly JSON output
15
15
  * --key/--url Override credentials
16
16
  *
17
- * No --global / --ide flags — `list` is a library-state inspector,
17
+ * No --global / --agent flags — `list` is a library-state inspector,
18
18
  * not a writer.
19
19
  */
20
20
 
@@ -32,13 +32,17 @@
32
32
  * - 403 scope → scopeError (via http.mjs) with write-key hint
33
33
  * - 401 → authError
34
34
  *
35
- * Flags: --global / --ide / --json / --key / --url
35
+ * Flags: --global / --agent / --json / --key / --url
36
36
  * Positional: <@owner/name>
37
37
  */
38
38
 
39
39
  import { removeSkillFromLibrary } from "../lib/http.mjs";
40
40
  import { removeSkillDir, cleanupOrphans } from "../lib/file-write.mjs";
41
- import { resolveFlags, effectiveVendors } from "../lib/cli-config.mjs";
41
+ import {
42
+ resolveFlags,
43
+ effectiveVendors,
44
+ requireVendorTargets,
45
+ } from "../lib/cli-config.mjs";
42
46
  import { parseIdentifier, formatIdentifier } from "../lib/identifier.mjs";
43
47
  import { validationError } from "../lib/errors.mjs";
44
48
 
@@ -75,6 +79,10 @@ export async function runRemove(argv, io = {}) {
75
79
 
76
80
  const { owner, name } = parseIdentifier(identifier);
77
81
  const vendors = effectiveVendors(flags);
82
+ // `remove` deletes specific files from disk — `--agent none` means
83
+ // "no targets to delete from", which is a no-op the user almost
84
+ // certainly didn't intend. Reject early.
85
+ requireVendorTargets(vendors, "remove");
78
86
 
79
87
  // Pre-flight: clean any .old/ orphans from a prior crashed write.
80
88
  // `remove` isn't writing new files, but it IS deleting, and the
@@ -58,6 +58,7 @@ import { removeCursorMcp } from "../lib/removers/cursor-mcp.mjs";
58
58
  import { removeVscodeMcp } from "../lib/removers/vscode-mcp.mjs";
59
59
  import { removeWindsurfMcp } from "../lib/removers/windsurf-mcp.mjs";
60
60
  import { removeSettingsSessionHook } from "../lib/removers/settings.mjs";
61
+ import { removeAgentHookArtifact } from "../lib/removers/agent-hooks.mjs";
61
62
  import { confirm } from "../lib/prompt.mjs";
62
63
  import { resolveFlags } from "../lib/cli-config.mjs";
63
64
  import { diskError } from "../lib/errors.mjs";
@@ -398,6 +399,14 @@ function runForDescriptor(descriptor, { dryRun }) {
398
399
  if (descriptor.kind === "directory") {
399
400
  return removeDirectoryArtifact(descriptor, { dryRun });
400
401
  }
402
+ // Cohort SessionStart-hook descriptors (#1240) dispatch by `kind`
403
+ // rather than by id: every `kind: "agent-hook"` descriptor goes to
404
+ // the same batch remover, which uses the descriptor's `vendorKey`
405
+ // to route to the per-shape merger. Adding a new cohort vendor =
406
+ // one registry entry + one descriptor; this dispatch never changes.
407
+ if (descriptor.kind === "agent-hook") {
408
+ return removeAgentHookArtifact(descriptor, { dryRun });
409
+ }
401
410
  const fn = FILE_REMOVERS[descriptor.id];
402
411
  if (!fn) {
403
412
  // Only reachable for descriptors that share a remover with a
@@ -418,7 +427,9 @@ function renderPreviewLine(descriptor, result) {
418
427
  ? " [dir] "
419
428
  : descriptor.kind === "line" || descriptor.kind === "section"
420
429
  ? " [lines] "
421
- : " [entry] ";
430
+ : descriptor.kind === "agent-hook"
431
+ ? " [hook] "
432
+ : " [entry] ";
422
433
  const detail = result.error
423
434
  ? `→ ${result.error}`
424
435
  : result.detail
@@ -430,7 +441,7 @@ function renderPreviewLine(descriptor, result) {
430
441
  function parseUninstallFlags(argv) {
431
442
  let dryRun = false;
432
443
  let yes = false;
433
- // Reuse resolveFlags for the standard --global / --json / --ide /
444
+ // Reuse resolveFlags for the standard --global / --json / --agent /
434
445
  // --key / --url shape. resolveFlags ignores unknown flags when an
435
446
  // acceptPositional callback is provided that can consume them —
436
447
  // the callback pattern matches init's own parsing.
@@ -7,7 +7,7 @@
7
7
  *
8
8
  * Flags (parsed by the shared `resolveFlags` helper):
9
9
  * --global Write to ~/.claude/skills/ instead of project-local
10
- * --ide <list> Comma-separated vendor list
10
+ * --agent <list> Comma-separated agent target list
11
11
  * --json Print summary as JSON
12
12
  * --key <key> Override config-file access key
13
13
  * --url <url> Override config-file server URL
@@ -21,12 +21,32 @@
21
21
  * command behaves as before (exit non-zero on
22
22
  * network / auth / disk failures).
23
23
  *
24
+ * v4.1.0 silent mode (#1240):
25
+ * --silent Suppress stdout: write `{}` on success, propagate
26
+ * failures (non-zero exit) with the error on stderr.
27
+ * Used by the per-agent SessionStart hooks (Cursor,
28
+ * Gemini CLI, Codex CLI, VS Code + Copilot) the
29
+ * cohort installer writes via `npx --yes skillrepo
30
+ * update --silent`. Gemini CLI specifically requires
31
+ * hook stdout to be valid JSON; the empty `{}`
32
+ * satisfies that. Distinct from `--session-hook` —
33
+ * `--silent` does NOT suppress error exit codes,
34
+ * because none of the cohort agents block session
35
+ * start on non-zero hook exits the way Claude Code
36
+ * does. If primary-source evidence later shows a
37
+ * cohort vendor DOES block, give that vendor its
38
+ * own contract flag rather than widening this one.
39
+ *
24
40
  * Exit codes are inherited from sync.mjs / http.mjs error types,
25
41
  * EXCEPT under `--session-hook` — see the flag's contract below.
26
42
  */
27
43
 
28
44
  import { runSync } from "../lib/sync.mjs";
29
- import { resolveFlags, effectiveVendors } from "../lib/cli-config.mjs";
45
+ import {
46
+ resolveFlags,
47
+ effectiveVendors,
48
+ requireVendorTargets,
49
+ } from "../lib/cli-config.mjs";
30
50
 
31
51
  /**
32
52
  * Run `update`.
@@ -58,31 +78,41 @@ import { resolveFlags, effectiveVendors } from "../lib/cli-config.mjs";
58
78
  */
59
79
  export async function runUpdate(argv, io = {}) {
60
80
  const stdout = io.stdout ?? process.stdout;
61
- // `stderr` is not used directly — the session-hook path pipes to
62
- // BLACK_HOLE_STREAM and the normal path forwards `io` to runSync
63
- // which reads `io.stderr` itself.
81
+ const stderr = io.stderr ?? process.stderr;
64
82
 
65
- // ── Pre-pass: detect --session-hook BEFORE resolveFlags runs ─────
83
+ // ── Pre-pass: detect --session-hook / --silent BEFORE resolveFlags runs
66
84
  //
67
- // resolveFlags can throw in three categories we must catch under
68
- // session-hook mode:
85
+ // resolveFlags can throw in three categories the wrapping mode must
86
+ // catch:
69
87
  // - `authError` when no access key is configured (e.g., session
70
88
  // fires before `skillrepo init` has run — a real first-run
71
89
  // scenario, not synthetic)
72
90
  // - `validationError` on unknown flags
73
- // - `validationError` from parseVendorList on a malformed --ide
91
+ // - `validationError` from parseAgentList on a malformed --agent
74
92
  //
75
93
  // All three happen INSIDE resolveFlags, before our try/catch block
76
94
  // could see them if we called it after. The only robust answer is
77
- // to detect --session-hook without invoking resolveFlags, then
78
- // wrap resolveFlags + runSync together in the error handler.
95
+ // to detect the mode flags without invoking resolveFlags, then wrap
96
+ // resolveFlags + runSync together in the error handler.
79
97
  //
80
- // A simple argv scan is safe here: `--session-hook` has no value
81
- // argument, so a plain `.includes` match can't misinterpret
82
- // positional args. This DOES NOT accept variations like
83
- // `--session-hook=true` or `-SH` — single canonical form only,
84
- // matching what the installer writes.
98
+ // A simple argv scan is safe here: neither flag takes a value, so a
99
+ // plain `.includes` match can't misinterpret positional args. This
100
+ // DOES NOT accept variations like `--session-hook=true` or `-S` —
101
+ // single canonical form only, matching what the installers write.
85
102
  const sessionHook = argv.includes("--session-hook");
103
+ const silent = argv.includes("--silent");
104
+
105
+ // Precedence: when both flags appear in argv, `--session-hook`
106
+ // wins because the order of the branches below dispatches it
107
+ // first. The session-hook path's `acceptPositional` accepts both
108
+ // flags so it doesn't reject `--silent` as unknown. The silent
109
+ // path only accepts `--silent` (different exit-code contract).
110
+ // Don't reorder these branches without coordinating with the two
111
+ // hook-installer paths in `commands/init-cohort-hooks.mjs` and
112
+ // `mergers/session-hook.mjs`. Practical exposure: zero — neither
113
+ // installer writes a hook command containing both flags. The
114
+ // precedence is defensive against a future code path or a manual
115
+ // user edit, not an active scenario.
86
116
 
87
117
  if (sessionHook) {
88
118
  // Session-hook mode: wrap EVERY error path in try/catch so a
@@ -90,14 +120,24 @@ export async function runUpdate(argv, io = {}) {
90
120
  try {
91
121
  const flags = resolveFlags(argv, {
92
122
  acceptPositional(arg) {
93
- // resolveFlags sees --session-hook as unknown unless we
94
- // consume it here. Kept for the non-error path the
95
- // real "catch errors" logic is the outer try.
123
+ // resolveFlags sees these as unknown unless we consume them
124
+ // here. Both flags are accepted because a future caller may
125
+ // combine them (defense installers should pick one); the
126
+ // outer mode dispatch above already chose `session-hook` if
127
+ // present, so accepting `--silent` here is a no-op tolerance.
96
128
  if (arg === "--session-hook") return 1;
129
+ if (arg === "--silent") return 1;
97
130
  return false;
98
131
  },
99
132
  });
100
133
  const vendors = effectiveVendors(flags);
134
+ // `--agent none` makes `update` a no-op — the user opted out of
135
+ // placement, so there's nowhere to write the synced library.
136
+ // Reject in session-hook mode too: the contract is "exit 0 on
137
+ // any error", but the requireVendorTargets throw is a typed
138
+ // validation error and the outer try/catch maps it to the
139
+ // documented one-line failure message.
140
+ requireVendorTargets(vendors, "update");
101
141
 
102
142
  const summary = await runSync({
103
143
  serverUrl: flags.serverUrl,
@@ -137,12 +177,65 @@ export async function runUpdate(argv, io = {}) {
137
177
  return;
138
178
  }
139
179
 
180
+ // ── Silent mode (#1240): cohort SessionStart hook contract ──────
181
+ //
182
+ // Used by Cursor / Gemini CLI / Codex CLI / VS Code + Copilot. Their
183
+ // SessionStart hook config invokes `npx --yes skillrepo update
184
+ // --silent`. Contract:
185
+ //
186
+ // - stdout produces ONE valid-JSON line on success: `{}`. Gemini
187
+ // CLI specifically requires hook stdout to be JSON; the empty
188
+ // object is the minimal valid value that injects no model
189
+ // context. Other vendors tolerate it.
190
+ // - On failure, stdout writes nothing extra; the typed error
191
+ // propagates through the dispatcher's catch which writes to
192
+ // stderr and exits with the appropriate code. We do NOT write
193
+ // `{}` on failure — partial JSON output alongside an error
194
+ // message on stderr would mislead a hook runner that treats
195
+ // stdout as model context.
196
+ // - All sync progress lines (the "failed to persist last-sync
197
+ // state" warning from sync.mjs, etc.) are routed to a black-hole
198
+ // stdout so they cannot leak into the JSON expectation.
199
+ //
200
+ // Distinct from `--session-hook`. That mode is Claude-Code-specific
201
+ // and contracts "EXIT 0 ON ALL ERRORS" because Claude Code's hook
202
+ // runner blocks session start on non-zero exits. The cohort vendors
203
+ // handled here have no documented session-blocking behavior, so a
204
+ // failure should surface as a real exit code — the user can then
205
+ // investigate via `skillrepo update` directly.
206
+ if (silent) {
207
+ const flags = resolveFlags(argv, {
208
+ acceptPositional(arg) {
209
+ if (arg === "--silent") return 1;
210
+ return false;
211
+ },
212
+ });
213
+ const vendors = effectiveVendors(flags);
214
+ requireVendorTargets(vendors, "update");
215
+
216
+ await runSync({
217
+ serverUrl: flags.serverUrl,
218
+ apiKey: flags.apiKey,
219
+ vendors,
220
+ global: flags.global,
221
+ // sync.mjs surfaces non-fatal warnings (e.g. failed to persist
222
+ // last-sync state) via stderr; preserve that channel so a real
223
+ // operator running `update --silent` from a terminal can still
224
+ // see them. Stdout is the contract-bearing stream and stays
225
+ // black-hole until we emit `{}` ourselves.
226
+ io: { stdout: BLACK_HOLE_STREAM, stderr },
227
+ });
228
+ stdout.write("{}\n");
229
+ return;
230
+ }
231
+
140
232
  // ── Normal mode: original behavior ───────────────────────────────
141
233
  // Forward `io` to runSync so the non-fatal "failed to persist
142
234
  // last-sync state" warning lands on the injected stderr stream
143
235
  // when tests inject one.
144
236
  const flags = resolveFlags(argv);
145
237
  const vendors = effectiveVendors(flags);
238
+ requireVendorTargets(vendors, "update");
146
239
 
147
240
  const summary = await runSync({
148
241
  serverUrl: flags.serverUrl,
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Cohort SessionStart-hook fan-out (#1240).
3
+ *
4
+ * Public API:
5
+ *
6
+ * - `installAgentHookFor(vendorKey)` → install or refresh the hook
7
+ * for one vendor.
8
+ * - `uninstallAgentHookFor(vendorKey, options)` → strip the hook
9
+ * from one vendor.
10
+ * - `installAgentHooksForVendors({ vendors, io? })` → fan-out
11
+ * wrapper that runs `installAgentHookFor` for each vendor and
12
+ * aggregates results, mirroring `mcp-merge.mjs` for the cohort
13
+ * session-hook flow.
14
+ *
15
+ * The dispatcher's job is to translate a registry vendorKey into the
16
+ * correct per-shape merger call. The merger choice is data-driven via
17
+ * `agent-registry.mjs`'s `agentHook.shape` field — adding a new shape
18
+ * is a registry edit + a new merger module + a new switch arm here.
19
+ *
20
+ * ## Why fan-out instead of a single `installAll()`
21
+ *
22
+ * The init flow only installs hooks for vendors the picker selected,
23
+ * not every vendor in the registry. A bare `installAll()` would
24
+ * install Cursor's hook for a user who picked only Gemini. The
25
+ * fan-out wrapper accepts the explicit vendor list so the call site
26
+ * (init step 6) decides scope.
27
+ *
28
+ * Per-vendor failures are NOT fatal to the fan-out: one broken hook
29
+ * config doesn't abort init. The result array reports each vendor's
30
+ * outcome so the caller can summarize and surface failures
31
+ * individually. Mirrors `mergeMcpForVendors`'s same-error contract.
32
+ */
33
+
34
+ import { getAgentByKey } from "./agent-registry.mjs";
35
+ import {
36
+ mergeClaudeShapeAgentHook,
37
+ unmergeClaudeShapeAgentHook,
38
+ } from "./mergers/agent-hook-claude-shape.mjs";
39
+ import {
40
+ mergeCursorShapeAgentHook,
41
+ unmergeCursorShapeAgentHook,
42
+ } from "./mergers/agent-hook-cursor-shape.mjs";
43
+ import { validationError } from "./errors.mjs";
44
+
45
+ /**
46
+ * @typedef {Object} AgentHookInstallResult
47
+ * @property {string} vendorKey
48
+ * @property {string} displayName
49
+ * @property {string} path - User-facing path (e.g. `~/.cursor/hooks.json`).
50
+ * @property {"installed" | "updated" | "unchanged" | "failed"} action
51
+ * @property {string} [reason] - Present when action is "failed".
52
+ */
53
+
54
+ /**
55
+ * @typedef {Object} AgentHookUninstallResult
56
+ * @property {string} vendorKey
57
+ * @property {string} displayName
58
+ * @property {string} path
59
+ * @property {"removed" | "would-remove" | "skipped" | "unchanged" | "failed"} action
60
+ * @property {string} [error] - Present when action is "skipped" due to
61
+ * a parse error, or when action is "failed".
62
+ */
63
+
64
+ /**
65
+ * Install the cohort SessionStart hook for one vendor. Throws
66
+ * `validationError` for unknown vendors or vendors whose `agentHook`
67
+ * is null (Claude Code, Windsurf, Cline). The dispatcher does NOT
68
+ * silently no-op on null: a caller asking to install a hook for a
69
+ * vendor with no spec is a programming error, and a silent no-op
70
+ * would mask it.
71
+ *
72
+ * @param {string} vendorKey
73
+ * @returns {{ path: string; action: "installed" | "updated" | "unchanged"; command: string }}
74
+ */
75
+ export function installAgentHookFor(vendorKey) {
76
+ const entry = requireRegistryEntryWithHook(vendorKey, "install");
77
+ switch (entry.agentHook.shape) {
78
+ case "claude-shape":
79
+ return mergeClaudeShapeAgentHook({ vendorKey });
80
+ case "cursor-shape":
81
+ return mergeCursorShapeAgentHook({ vendorKey });
82
+ default:
83
+ // The agent-registry typedef caps `shape` to the known set;
84
+ // hitting this branch means a registry author added a new shape
85
+ // value without wiring a merger. Surface the gap loudly.
86
+ throw validationError(
87
+ `Unknown agentHook.shape "${entry.agentHook.shape}" for vendor "${vendorKey}". Wire a merger in agent-hook-merge.mjs.`,
88
+ );
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Uninstall the cohort SessionStart hook for one vendor. See
94
+ * `installAgentHookFor` for the validation contract.
95
+ *
96
+ * @param {string} vendorKey
97
+ * @param {object} [options]
98
+ * @param {boolean} [options.dryRun=false]
99
+ * @returns {{ path: string; action: "removed" | "would-remove" | "skipped" | "unchanged"; error?: string }}
100
+ */
101
+ export function uninstallAgentHookFor(vendorKey, { dryRun = false } = {}) {
102
+ const entry = requireRegistryEntryWithHook(vendorKey, "uninstall");
103
+ switch (entry.agentHook.shape) {
104
+ case "claude-shape":
105
+ return unmergeClaudeShapeAgentHook({ vendorKey, dryRun });
106
+ case "cursor-shape":
107
+ return unmergeCursorShapeAgentHook({ vendorKey, dryRun });
108
+ default:
109
+ throw validationError(
110
+ `Unknown agentHook.shape "${entry.agentHook.shape}" for vendor "${vendorKey}". Wire a remover in agent-hook-merge.mjs.`,
111
+ );
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Fan-out installer for an explicit list of vendor keys. Used by init
117
+ * step 6's cohort sibling. Vendors without an `agentHook` spec
118
+ * (Claude Code, Windsurf, Cline) are silently skipped — they're a
119
+ * deliberate registry classification, not a caller error. Truly
120
+ * unknown vendor keys are NOT silently skipped: they surface as
121
+ * `action: "failed"` so a typo in `--agent` doesn't hide.
122
+ *
123
+ * @param {object} options
124
+ * @param {string[]} options.vendors - Canonical vendor keys.
125
+ * @returns {AgentHookInstallResult[]}
126
+ */
127
+ export function installAgentHooksForVendors({ vendors }) {
128
+ if (!Array.isArray(vendors)) {
129
+ throw validationError(
130
+ "installAgentHooksForVendors: vendors must be an array of canonical agent keys.",
131
+ );
132
+ }
133
+
134
+ // Dedupe to avoid running the merger twice on a caller that built a
135
+ // vendor list with a duplicate (e.g. a future `--agent agents,gemini`
136
+ // expansion). Set preserves first-seen order.
137
+ const uniqueVendors = Array.from(new Set(vendors));
138
+ const results = [];
139
+
140
+ for (const vendorKey of uniqueVendors) {
141
+ const entry = getAgentByKey(vendorKey);
142
+ if (!entry) {
143
+ // Unknown key. Don't silently skip — that hides typos. Surface
144
+ // as a failure with a clear reason.
145
+ results.push({
146
+ vendorKey,
147
+ displayName: vendorKey,
148
+ path: "(unknown)",
149
+ action: "failed",
150
+ reason: `Unknown agent key: ${vendorKey}`,
151
+ });
152
+ continue;
153
+ }
154
+ if (!entry.agentHook) {
155
+ // Deliberate skip: Claude Code uses the legacy session-hook;
156
+ // Windsurf and Cline are deferred per the agent-registry
157
+ // docstring. Skipping silently is correct.
158
+ continue;
159
+ }
160
+
161
+ try {
162
+ const r = installAgentHookFor(vendorKey);
163
+ results.push({
164
+ vendorKey,
165
+ displayName: entry.displayName,
166
+ path: r.path,
167
+ action: r.action,
168
+ });
169
+ } catch (err) {
170
+ results.push({
171
+ vendorKey,
172
+ displayName: entry.displayName,
173
+ path: entry.agentHook.displayPath,
174
+ action: "failed",
175
+ reason: err?.message ?? String(err),
176
+ });
177
+ }
178
+ }
179
+
180
+ return results;
181
+ }
182
+
183
+ // ── Internals ─────────────────────────────────────────────────────
184
+
185
+ function requireRegistryEntryWithHook(vendorKey, verb) {
186
+ const entry = getAgentByKey(vendorKey);
187
+ if (!entry) {
188
+ throw validationError(
189
+ `Cannot ${verb} cohort hook for unknown agent: ${vendorKey}.`,
190
+ );
191
+ }
192
+ if (!entry.agentHook) {
193
+ throw validationError(
194
+ `Cannot ${verb} cohort hook for "${vendorKey}" — no agentHook spec.`,
195
+ {
196
+ hint:
197
+ "Claude Code uses the legacy session-hook (mergers/session-hook.mjs); " +
198
+ "Windsurf and Cline are deferred (#1239 epic).",
199
+ },
200
+ );
201
+ }
202
+ return entry;
203
+ }