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
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single source of truth for the agent vendors the CLI supports (#1234).
|
|
3
|
+
*
|
|
4
|
+
* Every agent the CLI knows about is declared here exactly once. Path
|
|
5
|
+
* resolvers (file-write.mjs), flag parsers (cli-config.mjs), MCP merge
|
|
6
|
+
* coordinators (mcp-merge.mjs), gitignore management (mergers/gitignore.mjs),
|
|
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.
|
|
10
|
+
*
|
|
11
|
+
* Adding a vendor: append a frozen entry. Renaming a vendor: change `key`
|
|
12
|
+
* and put the old key in `aliases` so prior CLI invocations keep working.
|
|
13
|
+
*
|
|
14
|
+
* The two-target placement model (per the verified vendor matrix):
|
|
15
|
+
* - Claude Code uses Anthropic's documented `.claude/skills/` path.
|
|
16
|
+
* - Every other supported agent shares `.agents/skills/` for project
|
|
17
|
+
* scope and (with the exception of Windsurf and Copilot) for personal
|
|
18
|
+
* scope. Windsurf has a vendor-specific personal path under
|
|
19
|
+
* `~/.codeium/windsurf/skills/`. Copilot has no personal scope.
|
|
20
|
+
*
|
|
21
|
+
* Detection signals (`detectionSignals`) are the primary-source-verified
|
|
22
|
+
* fingerprints `detect-agents.mjs` probes when picking which agents have
|
|
23
|
+
* footprint on this machine / in this project. Three signal types:
|
|
24
|
+
*
|
|
25
|
+
* - `env` — an environment variable the agent sets in shells/sub-processes
|
|
26
|
+
* it spawns. Truthy presence in `process.env` indicates an active session.
|
|
27
|
+
* NOTE: only assert env vars the vendor's own docs document as set BY the
|
|
28
|
+
* agent in spawned shells. Variables the agent merely *reads* (e.g.
|
|
29
|
+
* `CODEX_HOME` for Codex's config dir) are config inputs, not detection
|
|
30
|
+
* signals — listing them here would produce false positives.
|
|
31
|
+
* - `home` — a HOME-relative path the agent creates on first run. Used as
|
|
32
|
+
* a "this user has installed the agent" proxy when no env var is
|
|
33
|
+
* documented.
|
|
34
|
+
* - `project` — a CWD-relative dotfile / dotdir the user keeps in the
|
|
35
|
+
* repo to drive agent behavior. Used as the "configured in this repo"
|
|
36
|
+
* signal.
|
|
37
|
+
*
|
|
38
|
+
* Detection is registry-driven: adding a new signal is a registry edit,
|
|
39
|
+
* no detect-agents.mjs change required.
|
|
40
|
+
*
|
|
41
|
+
* @typedef {"claudeProject" | "claudeGlobal" | "agentsProject" | "agentsGlobal" | "windsurfGlobal"} PlacementTarget
|
|
42
|
+
*
|
|
43
|
+
* @typedef {Object} DetectionSignal
|
|
44
|
+
* @property {"env"|"home"|"project"} type
|
|
45
|
+
* @property {string} value - Env var name, HOME-relative path, or
|
|
46
|
+
* project (CWD)-relative path. Use forward slashes; resolution is
|
|
47
|
+
* via `node:path.join` so Windows separators are produced
|
|
48
|
+
* automatically at probe time.
|
|
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
|
+
*
|
|
88
|
+
* @typedef {Object} AgentEntry
|
|
89
|
+
* @property {string} key - Canonical key. Stored in config files.
|
|
90
|
+
* @property {string} displayName - User-facing name (used in prompts and summaries).
|
|
91
|
+
* @property {string[]} aliases - Alternate names accepted on the command line.
|
|
92
|
+
* @property {PlacementTarget} projectTarget - Where project-scope writes land.
|
|
93
|
+
* @property {PlacementTarget|null} globalTarget - Where personal-scope writes land, or null if the vendor has no personal scope.
|
|
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.
|
|
107
|
+
* @property {readonly DetectionSignal[]} detectionSignals - Fingerprints that mark this agent as present.
|
|
108
|
+
*/
|
|
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
|
+
|
|
119
|
+
/** @type {readonly AgentEntry[]} */
|
|
120
|
+
export const AGENT_REGISTRY = Object.freeze([
|
|
121
|
+
Object.freeze({
|
|
122
|
+
key: "claudeCode",
|
|
123
|
+
displayName: "Claude Code",
|
|
124
|
+
// `claude` is the canonical short token (also recognized
|
|
125
|
+
// explicitly by parseAgentList for the target-shortcut role).
|
|
126
|
+
// `claude-code` is the kebab-case form some users reach for by
|
|
127
|
+
// habit; accepting it as a silent alias matches the spec's
|
|
128
|
+
// "vendor names accepted silently as courtesy aliases" rule.
|
|
129
|
+
aliases: Object.freeze(["claude", "claude-code"]),
|
|
130
|
+
projectTarget: "claudeProject",
|
|
131
|
+
globalTarget: "claudeGlobal",
|
|
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,
|
|
141
|
+
detectionSignals: Object.freeze([
|
|
142
|
+
Object.freeze({ type: "env", value: "CLAUDECODE" }),
|
|
143
|
+
Object.freeze({ type: "home", value: ".claude" }),
|
|
144
|
+
Object.freeze({ type: "project", value: ".claude" }),
|
|
145
|
+
]),
|
|
146
|
+
}),
|
|
147
|
+
Object.freeze({
|
|
148
|
+
key: "cursor",
|
|
149
|
+
displayName: "Cursor",
|
|
150
|
+
aliases: Object.freeze([]),
|
|
151
|
+
projectTarget: "agentsProject",
|
|
152
|
+
globalTarget: "agentsGlobal",
|
|
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
|
+
}),
|
|
171
|
+
detectionSignals: Object.freeze([
|
|
172
|
+
Object.freeze({ type: "env", value: "CURSOR_AGENT" }),
|
|
173
|
+
// CURSOR_CLI is the documented fallback when CURSOR_AGENT is
|
|
174
|
+
// not set in older Cursor builds; both fire under the same
|
|
175
|
+
// OR semantics so listing both costs nothing.
|
|
176
|
+
Object.freeze({ type: "env", value: "CURSOR_CLI" }),
|
|
177
|
+
Object.freeze({ type: "home", value: ".cursor" }),
|
|
178
|
+
Object.freeze({ type: "project", value: ".cursor" }),
|
|
179
|
+
]),
|
|
180
|
+
}),
|
|
181
|
+
Object.freeze({
|
|
182
|
+
key: "windsurf",
|
|
183
|
+
displayName: "Windsurf",
|
|
184
|
+
aliases: Object.freeze([]),
|
|
185
|
+
projectTarget: "agentsProject",
|
|
186
|
+
globalTarget: "windsurfGlobal",
|
|
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,
|
|
195
|
+
detectionSignals: Object.freeze([
|
|
196
|
+
// No documented active-session env var (verified absent in
|
|
197
|
+
// Windsurf docs 2026-05). HOME trace under the Codeium prefix
|
|
198
|
+
// is the strongest available signal.
|
|
199
|
+
Object.freeze({ type: "home", value: ".codeium/windsurf" }),
|
|
200
|
+
Object.freeze({ type: "project", value: ".windsurf" }),
|
|
201
|
+
]),
|
|
202
|
+
}),
|
|
203
|
+
Object.freeze({
|
|
204
|
+
key: "gemini",
|
|
205
|
+
displayName: "Gemini CLI",
|
|
206
|
+
aliases: Object.freeze([]),
|
|
207
|
+
projectTarget: "agentsProject",
|
|
208
|
+
globalTarget: "agentsGlobal",
|
|
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
|
+
}),
|
|
245
|
+
detectionSignals: Object.freeze([
|
|
246
|
+
Object.freeze({ type: "env", value: "GEMINI_CLI" }),
|
|
247
|
+
Object.freeze({ type: "home", value: ".gemini" }),
|
|
248
|
+
Object.freeze({ type: "project", value: ".gemini" }),
|
|
249
|
+
]),
|
|
250
|
+
}),
|
|
251
|
+
Object.freeze({
|
|
252
|
+
key: "codex",
|
|
253
|
+
displayName: "Codex CLI",
|
|
254
|
+
aliases: Object.freeze([]),
|
|
255
|
+
projectTarget: "agentsProject",
|
|
256
|
+
globalTarget: "agentsGlobal",
|
|
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
|
+
}),
|
|
279
|
+
detectionSignals: Object.freeze([
|
|
280
|
+
// Codex has no documented active-session env var. CODEX_HOME
|
|
281
|
+
// is a config var Codex *reads*, not one it sets — listing it
|
|
282
|
+
// would produce false positives for users who simply have the
|
|
283
|
+
// var pointed elsewhere. Detection is HOME + project only.
|
|
284
|
+
Object.freeze({ type: "home", value: ".codex" }),
|
|
285
|
+
Object.freeze({ type: "project", value: ".codex" }),
|
|
286
|
+
]),
|
|
287
|
+
}),
|
|
288
|
+
Object.freeze({
|
|
289
|
+
key: "cline",
|
|
290
|
+
displayName: "Cline",
|
|
291
|
+
aliases: Object.freeze([]),
|
|
292
|
+
projectTarget: "agentsProject",
|
|
293
|
+
globalTarget: "agentsGlobal",
|
|
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,
|
|
301
|
+
detectionSignals: Object.freeze([
|
|
302
|
+
// Cline v3.24+ sets CLINE_ACTIVE="true" in spawned shells.
|
|
303
|
+
// Detection treats any truthy value as the signal.
|
|
304
|
+
Object.freeze({ type: "env", value: "CLINE_ACTIVE" }),
|
|
305
|
+
Object.freeze({ type: "home", value: ".cline" }),
|
|
306
|
+
Object.freeze({ type: "project", value: ".cline" }),
|
|
307
|
+
]),
|
|
308
|
+
}),
|
|
309
|
+
Object.freeze({
|
|
310
|
+
key: "copilot",
|
|
311
|
+
displayName: "VS Code + Copilot",
|
|
312
|
+
aliases: Object.freeze(["vscode"]),
|
|
313
|
+
projectTarget: "agentsProject",
|
|
314
|
+
globalTarget: null,
|
|
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
|
+
}),
|
|
346
|
+
detectionSignals: Object.freeze([
|
|
347
|
+
// Copilot has no documented active-session env var. The HOME
|
|
348
|
+
// path is the directory the Copilot CLI / VS Code Copilot
|
|
349
|
+
// extension creates on first run; the project path is the
|
|
350
|
+
// documented Agent Skills location for VS Code + Copilot.
|
|
351
|
+
Object.freeze({ type: "home", value: ".copilot" }),
|
|
352
|
+
Object.freeze({ type: "project", value: ".github/skills" }),
|
|
353
|
+
]),
|
|
354
|
+
}),
|
|
355
|
+
]);
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Canonical keys of every cohort vendor — every registry entry whose
|
|
359
|
+
* project-scope writes land in `.agents/skills/`. Derived from the
|
|
360
|
+
* registry so a future cohort addition flows through every consumer
|
|
361
|
+
* without touching their code. Used by `cli-config.mjs` to expand the
|
|
362
|
+
* `--agent agents` token, and by `init.mjs` to translate the
|
|
363
|
+
* "Other agents" picker row to a vendor list.
|
|
364
|
+
*
|
|
365
|
+
* @type {readonly string[]}
|
|
366
|
+
*/
|
|
367
|
+
export const AGENTS_COHORT_KEYS = Object.freeze(
|
|
368
|
+
AGENT_REGISTRY.filter((entry) => entry.projectTarget === "agentsProject").map(
|
|
369
|
+
(entry) => entry.key,
|
|
370
|
+
),
|
|
371
|
+
);
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Look up a registry entry by its canonical key. Returns `undefined`
|
|
375
|
+
* for unknown keys — callers should treat that as a programming error.
|
|
376
|
+
*
|
|
377
|
+
* @param {string} key
|
|
378
|
+
* @returns {AgentEntry | undefined}
|
|
379
|
+
*/
|
|
380
|
+
export function getAgentByKey(key) {
|
|
381
|
+
return AGENT_REGISTRY.find((entry) => entry.key === key);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Resolve an alias (or canonical key) to its canonical key. Returns
|
|
386
|
+
* `null` for inputs that match neither a canonical key nor any
|
|
387
|
+
* declared alias. The function is case-sensitive — alias normalization
|
|
388
|
+
* is the caller's responsibility.
|
|
389
|
+
*
|
|
390
|
+
* @param {string} alias
|
|
391
|
+
* @returns {string | null}
|
|
392
|
+
*/
|
|
393
|
+
export function getAgentByAlias(alias) {
|
|
394
|
+
for (const entry of AGENT_REGISTRY) {
|
|
395
|
+
if (entry.key === alias) return entry.key;
|
|
396
|
+
if (entry.aliases.includes(alias)) return entry.key;
|
|
397
|
+
}
|
|
398
|
+
return null;
|
|
399
|
+
}
|
|
@@ -42,6 +42,13 @@ import {
|
|
|
42
42
|
} from "./paths.mjs";
|
|
43
43
|
import { join } from "node:path";
|
|
44
44
|
import { homedir } from "node:os";
|
|
45
|
+
// Cohort agent-hook descriptors derive from the agent registry so the
|
|
46
|
+
// catalog never drifts from the registry's `agentHook` data. Pure data
|
|
47
|
+
// composition: no fs, no dynamic behavior beyond reading frozen
|
|
48
|
+
// constants. The DATA-ONLY rule for this module forbids fs access and
|
|
49
|
+
// runtime mutation; reading another data-only module's exports is in
|
|
50
|
+
// scope.
|
|
51
|
+
import { AGENT_REGISTRY } from "./agent-registry.mjs";
|
|
45
52
|
|
|
46
53
|
// ── Fingerprint constants exported for cross-module consumption ───────
|
|
47
54
|
|
|
@@ -139,13 +146,84 @@ export const VSCODE_INPUT_ID = "skillrepo-api-key";
|
|
|
139
146
|
*/
|
|
140
147
|
export const SESSION_HOOK_FINGERPRINT = " update --session-hook";
|
|
141
148
|
|
|
149
|
+
/**
|
|
150
|
+
* The canonical SessionStart-hook command the CLI installs into every
|
|
151
|
+
* cohort agent's hook config (#1240). Cohort agents = Cursor, Gemini
|
|
152
|
+
* CLI, Codex CLI, VS Code + Copilot — every cohort vendor whose
|
|
153
|
+
* placement target is `agentsProject` AND whose vendor docs publish a
|
|
154
|
+
* SessionStart-equivalent hook surface.
|
|
155
|
+
*
|
|
156
|
+
* The command is intentionally short and shell-portable:
|
|
157
|
+
*
|
|
158
|
+
* - `npx --yes` works on Windows (cmd.exe + PowerShell) and POSIX
|
|
159
|
+
* shells with no shell-builtin dependency. The session-hook
|
|
160
|
+
* installer's `<absolute-path>` + `|| true` design is
|
|
161
|
+
* Claude-Code-specific (Claude doesn't load PATH); the cohort
|
|
162
|
+
* vendors all spawn hooks through the user's normal shell where
|
|
163
|
+
* `npx` resolves naturally.
|
|
164
|
+
* - `--yes` suppresses npx's interactive "Need to install …" prompt
|
|
165
|
+
* so the hook never blocks waiting for stdin.
|
|
166
|
+
* - `--silent` (#1240) suppresses stdout to a single `{}` line on
|
|
167
|
+
* success. Gemini CLI specifically requires hook stdout to be
|
|
168
|
+
* valid JSON; the empty object is the minimal valid value that
|
|
169
|
+
* injects no model context. Other vendors tolerate it.
|
|
170
|
+
*
|
|
171
|
+
* Distinct from `--session-hook` (Claude-Code-specific, exit-0 on
|
|
172
|
+
* every error). See `commands/update.mjs`'s docstring for the
|
|
173
|
+
* cross-flag contract table.
|
|
174
|
+
*/
|
|
175
|
+
export const AGENT_HOOK_COMMAND = "npx --yes skillrepo update --silent";
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Substring that identifies a cohort SessionStart-hook entry as
|
|
179
|
+
* SkillRepo-owned (#1240). The cohort installers write a `command`
|
|
180
|
+
* field equal to `AGENT_HOOK_COMMAND`; the remover walks each cohort
|
|
181
|
+
* agent's hook config and filters out any entry whose command
|
|
182
|
+
* contains this substring.
|
|
183
|
+
*
|
|
184
|
+
* Same word-boundary rationale as `SESSION_HOOK_FINGERPRINT`: the
|
|
185
|
+
* leading space requires `skillrepo` to be preceded by whitespace
|
|
186
|
+
* (i.e., its own argv token), so a hypothetical command like
|
|
187
|
+
* `myapp-skillrepo update --silent` cannot false-match.
|
|
188
|
+
*
|
|
189
|
+
* Both ends of the fingerprint are deliberately permissive — the
|
|
190
|
+
* fingerprint is shorter than the full command at BOTH the prefix
|
|
191
|
+
* and suffix:
|
|
192
|
+
* - PREFIX tolerance: a future change to the wrapper invocation
|
|
193
|
+
* (e.g., switching from `npx --yes` to `bunx`) preserves this
|
|
194
|
+
* substring and stays round-trip compatible with installed
|
|
195
|
+
* hook entries.
|
|
196
|
+
* - SUFFIX tolerance: a future suffix addition (e.g., piping to
|
|
197
|
+
* a logger, or adding a `--quiet`-style alias flag) ALSO
|
|
198
|
+
* preserves the substring. This is intentional: the remover
|
|
199
|
+
* must match every legitimate evolution of the SkillRepo hook
|
|
200
|
+
* command without forcing a fingerprint bump.
|
|
201
|
+
*
|
|
202
|
+
* The trade-off is that an unrelated tool's hook command containing
|
|
203
|
+
* the literal sequence ` skillrepo update --silent` somewhere in its
|
|
204
|
+
* argv would false-match. The two-token combination
|
|
205
|
+
* `skillrepo update --silent` is specific enough that no real-world
|
|
206
|
+
* non-SkillRepo command realistically produces it; the fingerprint
|
|
207
|
+
* test in `agent-hook-{claude,cursor}-shape.test.mjs` covers the
|
|
208
|
+
* round-trip but does not enumerate every possible false-positive.
|
|
209
|
+
* If a real-world collision ever surfaces, the right fix is to bump
|
|
210
|
+
* the fingerprint to a more specific marker (e.g., a version-pinned
|
|
211
|
+
* sentinel) and gate the change with a backward-compat test that
|
|
212
|
+
* proves both old and new shapes are matched.
|
|
213
|
+
*
|
|
214
|
+
* Any change to the substring itself MUST be coordinated with the
|
|
215
|
+
* installer mergers (agent-hook-{claude,cursor}-shape.mjs) and the
|
|
216
|
+
* remover (removers/agent-hooks.mjs).
|
|
217
|
+
*/
|
|
218
|
+
export const AGENT_HOOK_FINGERPRINT = " skillrepo update --silent";
|
|
219
|
+
|
|
142
220
|
// ── Artifact descriptors ────────────────────────────────────────────
|
|
143
221
|
|
|
144
222
|
/**
|
|
145
223
|
* @typedef {Object} ArtifactDescriptor
|
|
146
224
|
* @property {string} id - Stable identifier. Shows up in --json output.
|
|
147
225
|
* @property {"project" | "global"} scope - Which teardown pass owns this.
|
|
148
|
-
* @property {"json-key" | "json-input" | "line" | "section" | "directory"} kind
|
|
226
|
+
* @property {"json-key" | "json-input" | "line" | "section" | "directory" | "agent-hook"} kind
|
|
149
227
|
* How the remover deletes this artifact.
|
|
150
228
|
* @property {() => string} pathFn - Resolves the on-disk path (absolute).
|
|
151
229
|
* @property {string} displayPath - Human-readable path for output (e.g.
|
|
@@ -158,7 +236,13 @@ export const SESSION_HOOK_FINGERPRINT = " update --session-hook";
|
|
|
158
236
|
* @property {string} [sectionHeader] - For section: the header line;
|
|
159
237
|
* every line under it up to a blank-line sentinel is owned.
|
|
160
238
|
* @property {string} [commandFingerprint] - For the SessionStart hook
|
|
161
|
-
* entry: `command` field
|
|
239
|
+
* entry (kind="json-key" or "agent-hook"): `command` field
|
|
240
|
+
* contains this substring.
|
|
241
|
+
* @property {string} [vendorKey] - For kind="agent-hook" (#1240):
|
|
242
|
+
* AGENT_REGISTRY key whose `agentHook` spec drives the walk.
|
|
243
|
+
* The remover dispatches to the per-shape merger via this key
|
|
244
|
+
* rather than inlining the shape data here, so the registry
|
|
245
|
+
* stays static even as the per-shape merger logic evolves.
|
|
162
246
|
*/
|
|
163
247
|
|
|
164
248
|
/**
|
|
@@ -284,6 +368,31 @@ export const ARTIFACT_REGISTRY = Object.freeze([
|
|
|
284
368
|
displayPath: "~/.claude/settings.local.json",
|
|
285
369
|
commandFingerprint: SESSION_HOOK_FINGERPRINT,
|
|
286
370
|
}),
|
|
371
|
+
|
|
372
|
+
// ── Cohort agent-hook artifacts (#1240) ────────────────────────────
|
|
373
|
+
//
|
|
374
|
+
// One descriptor per cohort vendor whose `agentHook` spec is non-null
|
|
375
|
+
// in the agent registry. Each descriptor's `kind: "agent-hook"`
|
|
376
|
+
// routes the uninstaller to `removers/agent-hooks.mjs`, which uses
|
|
377
|
+
// the `vendorKey` to look up the per-shape walk in the agent
|
|
378
|
+
// registry. The cohort hooks live under `~/.<vendor>/...` (user
|
|
379
|
+
// scope), so all four are scope: "global".
|
|
380
|
+
//
|
|
381
|
+
// These descriptors are derived (filter+map) rather than hand-typed
|
|
382
|
+
// so the catalog cannot drift from `agent-registry.mjs`'s `agentHook`
|
|
383
|
+
// data. The agent registry is the single source of truth for
|
|
384
|
+
// per-vendor hook configuration; this is just its uninstall index.
|
|
385
|
+
...AGENT_REGISTRY.filter((entry) => entry.agentHook !== null).map((entry) =>
|
|
386
|
+
Object.freeze({
|
|
387
|
+
id: `agent-hook-${entry.key}`,
|
|
388
|
+
scope: "global",
|
|
389
|
+
kind: "agent-hook",
|
|
390
|
+
pathFn: entry.agentHook.pathFn,
|
|
391
|
+
displayPath: entry.agentHook.displayPath,
|
|
392
|
+
commandFingerprint: AGENT_HOOK_FINGERPRINT,
|
|
393
|
+
vendorKey: entry.key,
|
|
394
|
+
}),
|
|
395
|
+
),
|
|
287
396
|
]);
|
|
288
397
|
|
|
289
398
|
/**
|