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.
- package/README.md +137 -27
- package/bin/skillrepo.mjs +5 -5
- package/package.json +1 -1
- package/src/commands/add.mjs +21 -6
- package/src/commands/get.mjs +20 -4
- package/src/commands/init-cohort-hooks.mjs +127 -0
- package/src/commands/init-session-sync.mjs +1 -1
- package/src/commands/init.mjs +480 -117
- package/src/commands/list.mjs +1 -1
- package/src/commands/remove.mjs +10 -2
- package/src/commands/uninstall.mjs +13 -2
- package/src/commands/update.mjs +112 -19
- package/src/lib/agent-hook-merge.mjs +203 -0
- package/src/lib/agent-registry.mjs +399 -0
- package/src/lib/artifact-registry.mjs +111 -2
- package/src/lib/cli-config.mjs +146 -44
- package/src/lib/detect-agents.mjs +112 -0
- package/src/lib/file-write.mjs +162 -77
- package/src/lib/fs-utils.mjs +16 -1
- package/src/lib/mcp-merge.mjs +17 -36
- 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/mergers/gitignore.mjs +55 -28
- package/src/lib/paths.mjs +27 -25
- package/src/lib/prompt-multiselect.mjs +324 -0
- package/src/lib/removers/agent-hooks.mjs +83 -0
- package/src/lib/sync.mjs +18 -19
- package/src/test/commands/add.test.mjs +18 -3
- package/src/test/commands/init-picker.test.mjs +144 -0
- package/src/test/commands/init.test.mjs +508 -41
- package/src/test/commands/remove.test.mjs +4 -1
- package/src/test/commands/update.test.mjs +148 -3
- package/src/test/e2e/cli-agent-permutations.test.mjs +631 -0
- package/src/test/e2e/cli-cohort-hooks.test.mjs +393 -0
- package/src/test/e2e/cli-commands.test.mjs +39 -13
- package/src/test/integration/agent-hooks.integration.test.mjs +340 -0
- package/src/test/integration/file-write.integration.test.mjs +31 -10
- package/src/test/lib/agent-hook-merge.test.mjs +172 -0
- package/src/test/lib/agent-registry.test.mjs +215 -0
- package/src/test/lib/artifact-registry.test.mjs +39 -0
- package/src/test/lib/cli-config.test.mjs +222 -38
- package/src/test/lib/detect-agents.test.mjs +336 -0
- package/src/test/lib/file-write-placement.test.mjs +264 -0
- package/src/test/lib/file-write.test.mjs +231 -30
- package/src/test/lib/mcp-merge.test.mjs +23 -15
- package/src/test/lib/paths.test.mjs +53 -17
- package/src/test/lib/prompt-multiselect.test.mjs +448 -0
- package/src/test/lib/sync.test.mjs +157 -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
- package/src/lib/detect-ides.mjs +0 -44
- package/src/test/detect-ides.test.mjs +0 -65
package/src/commands/list.mjs
CHANGED
package/src/commands/remove.mjs
CHANGED
|
@@ -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 / --
|
|
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 {
|
|
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
|
-
:
|
|
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 / --
|
|
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.
|
package/src/commands/update.mjs
CHANGED
|
@@ -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
|
-
* --
|
|
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 {
|
|
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
|
-
|
|
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
|
|
68
|
-
//
|
|
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
|
|
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
|
|
78
|
-
//
|
|
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:
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
//
|
|
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
|
|
94
|
-
//
|
|
95
|
-
//
|
|
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
|
+
}
|