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.
@@ -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
- // `stderr` is not used directly — the session-hook path pipes to
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 we must catch under
72
- // session-hook mode:
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 --session-hook without invoking resolveFlags, then
82
- // 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.
83
97
  //
84
- // A simple argv scan is safe here: `--session-hook` has no value
85
- // argument, so a plain `.includes` match can't misinterpret
86
- // positional args. This DOES NOT accept variations like
87
- // `--session-hook=true` or `-SH` — single canonical form only,
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 --session-hook as unknown unless we
98
- // consume it here. Kept for the non-error path the
99
- // 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.
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), and orphan sweeps all derive their
8
- * vendor lists from this registry.
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