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
package/src/commands/update.mjs
CHANGED
|
@@ -21,6 +21,22 @@
|
|
|
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
|
*/
|
|
@@ -62,14 +78,12 @@ import {
|
|
|
62
78
|
*/
|
|
63
79
|
export async function runUpdate(argv, io = {}) {
|
|
64
80
|
const stdout = io.stdout ?? process.stdout;
|
|
65
|
-
|
|
66
|
-
// BLACK_HOLE_STREAM and the normal path forwards `io` to runSync
|
|
67
|
-
// which reads `io.stderr` itself.
|
|
81
|
+
const stderr = io.stderr ?? process.stderr;
|
|
68
82
|
|
|
69
|
-
// ── Pre-pass: detect --session-hook BEFORE resolveFlags runs
|
|
83
|
+
// ── Pre-pass: detect --session-hook / --silent BEFORE resolveFlags runs ─
|
|
70
84
|
//
|
|
71
|
-
// resolveFlags can throw in three categories
|
|
72
|
-
//
|
|
85
|
+
// resolveFlags can throw in three categories the wrapping mode must
|
|
86
|
+
// catch:
|
|
73
87
|
// - `authError` when no access key is configured (e.g., session
|
|
74
88
|
// fires before `skillrepo init` has run — a real first-run
|
|
75
89
|
// scenario, not synthetic)
|
|
@@ -78,15 +92,27 @@ export async function runUpdate(argv, io = {}) {
|
|
|
78
92
|
//
|
|
79
93
|
// All three happen INSIDE resolveFlags, before our try/catch block
|
|
80
94
|
// could see them if we called it after. The only robust answer is
|
|
81
|
-
// to detect
|
|
82
|
-
//
|
|
95
|
+
// to detect the mode flags without invoking resolveFlags, then wrap
|
|
96
|
+
// resolveFlags + runSync together in the error handler.
|
|
83
97
|
//
|
|
84
|
-
// A simple argv scan is safe here:
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
// 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.
|
|
89
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.
|
|
90
116
|
|
|
91
117
|
if (sessionHook) {
|
|
92
118
|
// Session-hook mode: wrap EVERY error path in try/catch so a
|
|
@@ -94,10 +120,13 @@ export async function runUpdate(argv, io = {}) {
|
|
|
94
120
|
try {
|
|
95
121
|
const flags = resolveFlags(argv, {
|
|
96
122
|
acceptPositional(arg) {
|
|
97
|
-
// resolveFlags sees
|
|
98
|
-
//
|
|
99
|
-
//
|
|
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.
|
|
100
128
|
if (arg === "--session-hook") return 1;
|
|
129
|
+
if (arg === "--silent") return 1;
|
|
101
130
|
return false;
|
|
102
131
|
},
|
|
103
132
|
});
|
|
@@ -148,6 +177,58 @@ export async function runUpdate(argv, io = {}) {
|
|
|
148
177
|
return;
|
|
149
178
|
}
|
|
150
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
|
+
|
|
151
232
|
// ── Normal mode: original behavior ───────────────────────────────
|
|
152
233
|
// Forward `io` to runSync so the non-fatal "failed to persist
|
|
153
234
|
// last-sync state" warning lands on the injected stderr stream
|
|
@@ -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
|
+
}
|
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
* Every agent the CLI knows about is declared here exactly once. Path
|
|
5
5
|
* resolvers (file-write.mjs), flag parsers (cli-config.mjs), MCP merge
|
|
6
6
|
* coordinators (mcp-merge.mjs), gitignore management (mergers/gitignore.mjs),
|
|
7
|
-
* detection probes (detect-agents.mjs),
|
|
8
|
-
*
|
|
7
|
+
* detection probes (detect-agents.mjs), agent-hook installers
|
|
8
|
+
* (agent-hook-merge.mjs), and orphan sweeps all derive their vendor lists
|
|
9
|
+
* from this registry.
|
|
9
10
|
*
|
|
10
11
|
* Adding a vendor: append a frozen entry. Renaming a vendor: change `key`
|
|
11
12
|
* and put the old key in `aliases` so prior CLI invocations keep working.
|
|
@@ -46,6 +47,44 @@
|
|
|
46
47
|
* via `node:path.join` so Windows separators are produced
|
|
47
48
|
* automatically at probe time.
|
|
48
49
|
*
|
|
50
|
+
* @typedef {"claude-shape" | "cursor-shape"} AgentHookShape
|
|
51
|
+
* Hook config schema variant the vendor accepts (#1240):
|
|
52
|
+
* - `claude-shape`: nested `{ hooks: { <Event>: [{ hooks: [{
|
|
53
|
+
* type:"command", command, timeout? }] }] } }`. Used by
|
|
54
|
+
* Gemini CLI, Codex CLI, and VS Code + Copilot per their
|
|
55
|
+
* primary docs.
|
|
56
|
+
* - `cursor-shape`: flat `{ version: 1, hooks: { <event>: [{
|
|
57
|
+
* command }] } }`. Used by Cursor.
|
|
58
|
+
*
|
|
59
|
+
* @typedef {Object} AgentHookSpec
|
|
60
|
+
* @property {AgentHookShape} shape - Schema variant the merger uses.
|
|
61
|
+
* @property {string} eventName - Event-array key name. Cursor and
|
|
62
|
+
* VS Code + Copilot use lowercase `sessionStart`; Gemini and
|
|
63
|
+
* Codex use uppercase `SessionStart`. Verify against vendor
|
|
64
|
+
* docs before adding new entries — casing is load-bearing.
|
|
65
|
+
* @property {() => string} pathFn - Resolves the absolute hook config
|
|
66
|
+
* path on the host (always under `os.homedir()` per
|
|
67
|
+
* rule-4-of-the-spec: hook configs are user-scope).
|
|
68
|
+
* @property {string} displayPath - User-facing path label (e.g.
|
|
69
|
+
* `~/.cursor/hooks.json`). Never a real absolute path — leaks
|
|
70
|
+
* the developer's home directory into JSON output.
|
|
71
|
+
* @property {Record<string, unknown>} [entryFields] - Optional extra
|
|
72
|
+
* fields merged into the per-hook entry alongside `command`.
|
|
73
|
+
* Used by Gemini's `timeout: 60000` (milliseconds) and Codex's
|
|
74
|
+
* `timeout: 60` (seconds), the `type: "command"` requirement
|
|
75
|
+
* shared by claude-shape vendors, Cursor's seconds-unit
|
|
76
|
+
* `timeout: 60`, and Gemini's friendly `name: "skillrepo-update"`
|
|
77
|
+
* identifier. Keys MUST be JSON-serializable; the merger does
|
|
78
|
+
* a shallow spread.
|
|
79
|
+
* @property {Record<string, unknown>} [groupFields] - Optional extra
|
|
80
|
+
* fields merged into the OUTER group object (claude-shape
|
|
81
|
+
* only). Gemini requires `matcher: "*"` at this level so the
|
|
82
|
+
* group fires on every SessionStart event; without it the
|
|
83
|
+
* group is filtered out. cursor-shape has no group level
|
|
84
|
+
* (entries live directly in the event array), so this field
|
|
85
|
+
* is ignored for that variant. Like `entryFields`, all keys
|
|
86
|
+
* must be JSON-serializable.
|
|
87
|
+
*
|
|
49
88
|
* @typedef {Object} AgentEntry
|
|
50
89
|
* @property {string} key - Canonical key. Stored in config files.
|
|
51
90
|
* @property {string} displayName - User-facing name (used in prompts and summaries).
|
|
@@ -53,9 +92,30 @@
|
|
|
53
92
|
* @property {PlacementTarget} projectTarget - Where project-scope writes land.
|
|
54
93
|
* @property {PlacementTarget|null} globalTarget - Where personal-scope writes land, or null if the vendor has no personal scope.
|
|
55
94
|
* @property {boolean} hasMcp - True if the CLI has a per-vendor MCP merger; false for file-only vendors (cohort vendors without a documented MCP config path).
|
|
95
|
+
* @property {AgentHookSpec | null} agentHook - Cohort SessionStart-hook
|
|
96
|
+
* config (#1240). `null` for vendors the cohort installer
|
|
97
|
+
* skips:
|
|
98
|
+
* - **Claude Code**: has its own session hook (the legacy
|
|
99
|
+
* `mergers/session-hook.mjs` writes a binaryPath-resolved
|
|
100
|
+
* command into `.claude/settings.local.json`). The cohort
|
|
101
|
+
* installer must NOT write a second hook into the Claude
|
|
102
|
+
* settings file.
|
|
103
|
+
* - **Windsurf**: no documented SessionStart-equivalent
|
|
104
|
+
* event in vendor docs (#1239 epic body — deferred).
|
|
105
|
+
* - **Cline**: per-task semantics + non-standard global
|
|
106
|
+
* path (`~/Documents/Cline/Hooks/`); deferred.
|
|
56
107
|
* @property {readonly DetectionSignal[]} detectionSignals - Fingerprints that mark this agent as present.
|
|
57
108
|
*/
|
|
58
109
|
|
|
110
|
+
// Hook config paths are HOME-relative; resolution must use
|
|
111
|
+
// `node:path.join` so Windows separators are produced at probe time.
|
|
112
|
+
// Hook configs are USER-SCOPE only (gitignored equivalent surface):
|
|
113
|
+
// the SkillRepo skills they sync are per-developer, not committed
|
|
114
|
+
// project state, so the hook config that drives that sync follows
|
|
115
|
+
// suit.
|
|
116
|
+
import { homedir } from "node:os";
|
|
117
|
+
import { join } from "node:path";
|
|
118
|
+
|
|
59
119
|
/** @type {readonly AgentEntry[]} */
|
|
60
120
|
export const AGENT_REGISTRY = Object.freeze([
|
|
61
121
|
Object.freeze({
|
|
@@ -70,6 +130,14 @@ export const AGENT_REGISTRY = Object.freeze([
|
|
|
70
130
|
projectTarget: "claudeProject",
|
|
71
131
|
globalTarget: "claudeGlobal",
|
|
72
132
|
hasMcp: true,
|
|
133
|
+
// Claude Code's session hook predates the cohort framework
|
|
134
|
+
// (#884 / `mergers/session-hook.mjs`). It writes a binaryPath-
|
|
135
|
+
// resolved command into `.claude/settings.local.json` with
|
|
136
|
+
// exit-0-on-error semantics that the cohort framework
|
|
137
|
+
// deliberately doesn't reproduce. Setting `agentHook: null`
|
|
138
|
+
// here is the gate that prevents the cohort installer from
|
|
139
|
+
// writing a duplicate hook into the same settings file.
|
|
140
|
+
agentHook: null,
|
|
73
141
|
detectionSignals: Object.freeze([
|
|
74
142
|
Object.freeze({ type: "env", value: "CLAUDECODE" }),
|
|
75
143
|
Object.freeze({ type: "home", value: ".claude" }),
|
|
@@ -83,6 +151,23 @@ export const AGENT_REGISTRY = Object.freeze([
|
|
|
83
151
|
projectTarget: "agentsProject",
|
|
84
152
|
globalTarget: "agentsGlobal",
|
|
85
153
|
hasMcp: true,
|
|
154
|
+
agentHook: Object.freeze({
|
|
155
|
+
// Cursor docs 2026-05 (#1241): cursor-shape `{ version: 1, hooks:
|
|
156
|
+
// { sessionStart: [{ command, timeout }] } }`. Multi-tool merge
|
|
157
|
+
// surface (1Password, Snyk, Apiiro extend the same file) —
|
|
158
|
+
// round-trip preservation of unknown keys is enforced by the
|
|
159
|
+
// merger's idempotency tests.
|
|
160
|
+
//
|
|
161
|
+
// Cursor's `timeout` is in SECONDS (verified against
|
|
162
|
+
// cursor.com/docs/hooks). Distinct from Gemini's milliseconds
|
|
163
|
+
// and identical to Codex's seconds — comments at each entry
|
|
164
|
+
// call out the unit so a future refactor can't conflate them.
|
|
165
|
+
shape: "cursor-shape",
|
|
166
|
+
eventName: "sessionStart",
|
|
167
|
+
pathFn: () => join(homedir(), ".cursor", "hooks.json"),
|
|
168
|
+
displayPath: "~/.cursor/hooks.json",
|
|
169
|
+
entryFields: Object.freeze({ timeout: 60 }),
|
|
170
|
+
}),
|
|
86
171
|
detectionSignals: Object.freeze([
|
|
87
172
|
Object.freeze({ type: "env", value: "CURSOR_AGENT" }),
|
|
88
173
|
// CURSOR_CLI is the documented fallback when CURSOR_AGENT is
|
|
@@ -100,6 +185,13 @@ export const AGENT_REGISTRY = Object.freeze([
|
|
|
100
185
|
projectTarget: "agentsProject",
|
|
101
186
|
globalTarget: "windsurfGlobal",
|
|
102
187
|
hasMcp: true,
|
|
188
|
+
// Windsurf has no documented SessionStart-equivalent hook event
|
|
189
|
+
// (verified absent in Windsurf docs 2026-05). The cohort
|
|
190
|
+
// installer skips Windsurf — users still get the file-based
|
|
191
|
+
// skill sync, just no auto-refresh on session start. Tracked in
|
|
192
|
+
// #1239 epic body; revisit if/when Windsurf publishes a hook
|
|
193
|
+
// event.
|
|
194
|
+
agentHook: null,
|
|
103
195
|
detectionSignals: Object.freeze([
|
|
104
196
|
// No documented active-session env var (verified absent in
|
|
105
197
|
// Windsurf docs 2026-05). HOME trace under the Codeium prefix
|
|
@@ -115,6 +207,41 @@ export const AGENT_REGISTRY = Object.freeze([
|
|
|
115
207
|
projectTarget: "agentsProject",
|
|
116
208
|
globalTarget: "agentsGlobal",
|
|
117
209
|
hasMcp: false,
|
|
210
|
+
agentHook: Object.freeze({
|
|
211
|
+
// Gemini CLI docs 2026-05 (#1242): claude-shape with a vendor-
|
|
212
|
+
// specific group-level `matcher` field. Required schema:
|
|
213
|
+
// hooks.SessionStart[i] = { matcher: "*", hooks: [{ name,
|
|
214
|
+
// type, command, timeout }] }
|
|
215
|
+
//
|
|
216
|
+
// The `matcher` glob filters which sessions the hook fires for.
|
|
217
|
+
// `"*"` = every session. WITHOUT `matcher`, Gemini silently
|
|
218
|
+
// skips the group (verified against Gemini's hook-filter logic
|
|
219
|
+
// in the gemini-cli source). `groupFields` is the registry
|
|
220
|
+
// mechanism for this — claude-shape merger spreads it into the
|
|
221
|
+
// group it appends.
|
|
222
|
+
//
|
|
223
|
+
// `name: "skillrepo-update"` is a friendly identifier Gemini's
|
|
224
|
+
// hook-debug UI surfaces. Not load-bearing for our dedupe (we
|
|
225
|
+
// fingerprint on `command`), but recommended by Gemini's docs
|
|
226
|
+
// for any tool that owns a hook entry.
|
|
227
|
+
//
|
|
228
|
+
// Hook stdout MUST be valid JSON — satisfied by `--silent`
|
|
229
|
+
// emitting `{}`. Timeout is MILLISECONDS (distinct from
|
|
230
|
+
// Cursor/Codex/Copilot, which use seconds — see those entries).
|
|
231
|
+
// Default is 60000ms but Gemini's docs note the default has
|
|
232
|
+
// shifted in past releases, so we set it explicitly to lock
|
|
233
|
+
// the contract.
|
|
234
|
+
shape: "claude-shape",
|
|
235
|
+
eventName: "SessionStart",
|
|
236
|
+
pathFn: () => join(homedir(), ".gemini", "settings.json"),
|
|
237
|
+
displayPath: "~/.gemini/settings.json",
|
|
238
|
+
entryFields: Object.freeze({
|
|
239
|
+
name: "skillrepo-update",
|
|
240
|
+
type: "command",
|
|
241
|
+
timeout: 60000,
|
|
242
|
+
}),
|
|
243
|
+
groupFields: Object.freeze({ matcher: "*" }),
|
|
244
|
+
}),
|
|
118
245
|
detectionSignals: Object.freeze([
|
|
119
246
|
Object.freeze({ type: "env", value: "GEMINI_CLI" }),
|
|
120
247
|
Object.freeze({ type: "home", value: ".gemini" }),
|
|
@@ -128,6 +255,27 @@ export const AGENT_REGISTRY = Object.freeze([
|
|
|
128
255
|
projectTarget: "agentsProject",
|
|
129
256
|
globalTarget: "agentsGlobal",
|
|
130
257
|
hasMcp: false,
|
|
258
|
+
agentHook: Object.freeze({
|
|
259
|
+
// Codex docs 2026-05 (#1243): claude-shape `hooks.SessionStart[].
|
|
260
|
+
// hooks[]`. CodexHooks is a Stable, default-on feature in the
|
|
261
|
+
// Codex CLI source (`Feature::CodexHooks` / Stage::Stable).
|
|
262
|
+
// Hook stdout becomes model context — `--silent` emits `{}` to
|
|
263
|
+
// avoid polluting the context with human-readable progress
|
|
264
|
+
// lines.
|
|
265
|
+
//
|
|
266
|
+
// `timeout` is in SECONDS (verified against the Codex hooks
|
|
267
|
+
// source). Distinct from Gemini's milliseconds; matches
|
|
268
|
+
// Cursor's units. Codex also accepts a `[hooks]` table in
|
|
269
|
+
// `~/.codex/config.toml`; users with that configuration will
|
|
270
|
+
// see Codex MERGE both sources at runtime, so our JSON file
|
|
271
|
+
// and a hand-written TOML can coexist (documented in the CLI
|
|
272
|
+
// README).
|
|
273
|
+
shape: "claude-shape",
|
|
274
|
+
eventName: "SessionStart",
|
|
275
|
+
pathFn: () => join(homedir(), ".codex", "hooks.json"),
|
|
276
|
+
displayPath: "~/.codex/hooks.json",
|
|
277
|
+
entryFields: Object.freeze({ type: "command", timeout: 60 }),
|
|
278
|
+
}),
|
|
131
279
|
detectionSignals: Object.freeze([
|
|
132
280
|
// Codex has no documented active-session env var. CODEX_HOME
|
|
133
281
|
// is a config var Codex *reads*, not one it sets — listing it
|
|
@@ -144,6 +292,12 @@ export const AGENT_REGISTRY = Object.freeze([
|
|
|
144
292
|
projectTarget: "agentsProject",
|
|
145
293
|
globalTarget: "agentsGlobal",
|
|
146
294
|
hasMcp: false,
|
|
295
|
+
// Cline uses per-task hooks (TaskStart / TaskEnd) rather than a
|
|
296
|
+
// SessionStart equivalent, AND its hook config lives at a
|
|
297
|
+
// non-standard global path (`~/Documents/Cline/Hooks/`) that
|
|
298
|
+
// doesn't fit the user-scope dotfile convention the cohort
|
|
299
|
+
// framework assumes. Deferred per #1239 epic body.
|
|
300
|
+
agentHook: null,
|
|
147
301
|
detectionSignals: Object.freeze([
|
|
148
302
|
// Cline v3.24+ sets CLINE_ACTIVE="true" in spawned shells.
|
|
149
303
|
// Detection treats any truthy value as the signal.
|
|
@@ -159,6 +313,36 @@ export const AGENT_REGISTRY = Object.freeze([
|
|
|
159
313
|
projectTarget: "agentsProject",
|
|
160
314
|
globalTarget: null,
|
|
161
315
|
hasMcp: true,
|
|
316
|
+
agentHook: Object.freeze({
|
|
317
|
+
// VS Code + Copilot docs 2026-05 (#1244): cross-compatible with
|
|
318
|
+
// the Claude / Codex hook schema. The file is per-tool
|
|
319
|
+
// (`~/.copilot/hooks/skillrepo-update.json`) rather than a
|
|
320
|
+
// shared multi-tool merge surface — the merger still uses
|
|
321
|
+
// claude-shape so the file is structurally identical to the
|
|
322
|
+
// other claude-shape configs.
|
|
323
|
+
//
|
|
324
|
+
// Event name is LOWERCASE `sessionStart` per the schema example
|
|
325
|
+
// in VS Code + Copilot's hooks docs. Distinct from Gemini and
|
|
326
|
+
// Codex's uppercase `SessionStart`. Both VS Code and the Copilot
|
|
327
|
+
// CLI read the same file; the lowercase event matches both
|
|
328
|
+
// surfaces.
|
|
329
|
+
//
|
|
330
|
+
// **Preview status**: Copilot's hook system is currently
|
|
331
|
+
// labelled Preview by GitHub. The CLI README and init step-6
|
|
332
|
+
// copy surface this caveat so users know the schema may shift.
|
|
333
|
+
// Cloud-agent runners (GitHub Codespaces) skip gitignored hook
|
|
334
|
+
// files, which is fine: the hook is per-developer and not
|
|
335
|
+
// load-bearing for runner work — same documented limitation as
|
|
336
|
+
// skill placement.
|
|
337
|
+
//
|
|
338
|
+
// `timeout` is in SECONDS (matches Cursor and Codex; distinct
|
|
339
|
+
// from Gemini's milliseconds).
|
|
340
|
+
shape: "claude-shape",
|
|
341
|
+
eventName: "sessionStart",
|
|
342
|
+
pathFn: () => join(homedir(), ".copilot", "hooks", "skillrepo-update.json"),
|
|
343
|
+
displayPath: "~/.copilot/hooks/skillrepo-update.json",
|
|
344
|
+
entryFields: Object.freeze({ type: "command", timeout: 60 }),
|
|
345
|
+
}),
|
|
162
346
|
detectionSignals: Object.freeze([
|
|
163
347
|
// Copilot has no documented active-session env var. The HOME
|
|
164
348
|
// path is the directory the Copilot CLI / VS Code Copilot
|