skillrepo 3.2.0 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +137 -27
  2. package/bin/skillrepo.mjs +5 -5
  3. package/package.json +1 -1
  4. package/src/commands/add.mjs +21 -6
  5. package/src/commands/get.mjs +20 -4
  6. package/src/commands/init-cohort-hooks.mjs +127 -0
  7. package/src/commands/init-session-sync.mjs +1 -1
  8. package/src/commands/init.mjs +480 -117
  9. package/src/commands/list.mjs +1 -1
  10. package/src/commands/remove.mjs +10 -2
  11. package/src/commands/uninstall.mjs +13 -2
  12. package/src/commands/update.mjs +112 -19
  13. package/src/lib/agent-hook-merge.mjs +203 -0
  14. package/src/lib/agent-registry.mjs +399 -0
  15. package/src/lib/artifact-registry.mjs +111 -2
  16. package/src/lib/cli-config.mjs +146 -44
  17. package/src/lib/detect-agents.mjs +112 -0
  18. package/src/lib/file-write.mjs +162 -77
  19. package/src/lib/fs-utils.mjs +16 -1
  20. package/src/lib/mcp-merge.mjs +17 -36
  21. package/src/lib/mergers/agent-hook-claude-shape.mjs +519 -0
  22. package/src/lib/mergers/agent-hook-cursor-shape.mjs +318 -0
  23. package/src/lib/mergers/gitignore.mjs +55 -28
  24. package/src/lib/paths.mjs +27 -25
  25. package/src/lib/prompt-multiselect.mjs +324 -0
  26. package/src/lib/removers/agent-hooks.mjs +83 -0
  27. package/src/lib/sync.mjs +18 -19
  28. package/src/test/commands/add.test.mjs +18 -3
  29. package/src/test/commands/init-picker.test.mjs +144 -0
  30. package/src/test/commands/init.test.mjs +508 -41
  31. package/src/test/commands/remove.test.mjs +4 -1
  32. package/src/test/commands/update.test.mjs +148 -3
  33. package/src/test/e2e/cli-agent-permutations.test.mjs +631 -0
  34. package/src/test/e2e/cli-cohort-hooks.test.mjs +393 -0
  35. package/src/test/e2e/cli-commands.test.mjs +39 -13
  36. package/src/test/integration/agent-hooks.integration.test.mjs +340 -0
  37. package/src/test/integration/file-write.integration.test.mjs +31 -10
  38. package/src/test/lib/agent-hook-merge.test.mjs +172 -0
  39. package/src/test/lib/agent-registry.test.mjs +215 -0
  40. package/src/test/lib/artifact-registry.test.mjs +39 -0
  41. package/src/test/lib/cli-config.test.mjs +222 -38
  42. package/src/test/lib/detect-agents.test.mjs +336 -0
  43. package/src/test/lib/file-write-placement.test.mjs +264 -0
  44. package/src/test/lib/file-write.test.mjs +231 -30
  45. package/src/test/lib/mcp-merge.test.mjs +23 -15
  46. package/src/test/lib/paths.test.mjs +53 -17
  47. package/src/test/lib/prompt-multiselect.test.mjs +448 -0
  48. package/src/test/lib/sync.test.mjs +157 -0
  49. package/src/test/mergers/agent-hook-claude-shape.test.mjs +518 -0
  50. package/src/test/mergers/agent-hook-cursor-shape.test.mjs +306 -0
  51. package/src/test/removers/agent-hooks.test.mjs +206 -0
  52. package/src/lib/detect-ides.mjs +0 -44
  53. package/src/test/detect-ides.test.mjs +0 -65
@@ -0,0 +1,318 @@
1
+ /**
2
+ * SessionStart-hook installer/uninstaller for cursor-shape vendors
3
+ * (#1240). Currently only Cursor itself, but the shape is documented
4
+ * separately so a future vendor that ships the same flat-array
5
+ * sessionStart schema slots in without code changes.
6
+ *
7
+ * Cursor docs 2026-05 specify the file shape:
8
+ *
9
+ * {
10
+ * "version": 1,
11
+ * "hooks": {
12
+ * "sessionStart": [
13
+ * { "command": "..." }
14
+ * ]
15
+ * }
16
+ * }
17
+ *
18
+ * Differences from the claude-shape merger:
19
+ *
20
+ * - `version: 1` at the top level. The merger preserves it on
21
+ * re-write and creates it for fresh files. A future Cursor
22
+ * `version: 2` is a real possibility — when that lands, the
23
+ * migration is a separate PR that bumps the writer here AND
24
+ * adds an upgrade-path test that proves both shapes round-trip
25
+ * through the remover.
26
+ * - Single-level `hooks.<event>` array (no nested `{ hooks: [...] }`
27
+ * groups). Walks are correspondingly shallower.
28
+ * - Event-name casing differs: lowercase `sessionStart` per Cursor
29
+ * docs. Comes from the agent registry's `agentHook.eventName`.
30
+ * - Multi-tool merge surface: 1Password, Snyk, and Apiiro all
31
+ * extend `~/.cursor/hooks.json`. The walk MUST preserve unknown
32
+ * entries — round-trip preservation is the load-bearing
33
+ * idempotency test.
34
+ *
35
+ * The shared concerns (atomic writes, fingerprint matching, idempotent
36
+ * actions, dispatcher integration) are intentionally implemented
37
+ * twice — once here, once in `agent-hook-claude-shape.mjs` — rather
38
+ * than abstracted. The architect review on #1240 specifically called
39
+ * out that a single-function `variant` switch becomes load-bearing
40
+ * config that no type system enforces; per-shape modules are the
41
+ * idiom that scales as new variants land.
42
+ */
43
+
44
+ import { existsSync, readFileSync } from "node:fs";
45
+ import { writeFileAtomic } from "../fs-utils.mjs";
46
+ import {
47
+ AGENT_HOOK_COMMAND,
48
+ AGENT_HOOK_FINGERPRINT,
49
+ } from "../artifact-registry.mjs";
50
+ import { getAgentByKey } from "../agent-registry.mjs";
51
+ import { diskError, validationError } from "../errors.mjs";
52
+
53
+ /**
54
+ * Build the per-hook entry the cursor-shape merger writes. The
55
+ * `entryFields` from the agent registry's `agentHook.entryFields` are
56
+ * spread BEFORE `command` so the registry can NEVER override the
57
+ * canonical command via a typo. Cursor itself has no extra entry
58
+ * fields today, but the spread is symmetrical with the claude-shape
59
+ * merger to keep both code paths consistent.
60
+ *
61
+ * @param {object} entryFields
62
+ * @returns {Record<string, unknown>}
63
+ */
64
+ export function buildCursorShapeEntry(entryFields = {}) {
65
+ return {
66
+ ...entryFields,
67
+ command: AGENT_HOOK_COMMAND,
68
+ };
69
+ }
70
+
71
+ /**
72
+ * Install (or refresh) the cohort SessionStart hook in a cursor-shape
73
+ * vendor's hook config file. Creates the file with `version: 1` at
74
+ * the top level if it doesn't exist.
75
+ *
76
+ * @param {object} options
77
+ * @param {string} options.vendorKey
78
+ * @returns {{
79
+ * path: string;
80
+ * action: "installed" | "updated" | "unchanged";
81
+ * command: string;
82
+ * }}
83
+ */
84
+ export function mergeCursorShapeAgentHook({ vendorKey }) {
85
+ const { hookSpec } = resolveAgent(vendorKey);
86
+ const filePath = hookSpec.pathFn();
87
+ const eventName = hookSpec.eventName;
88
+ const desiredEntry = buildCursorShapeEntry(hookSpec.entryFields);
89
+
90
+ const config = readConfigOrEmpty(filePath, hookSpec.displayPath);
91
+
92
+ // Preserve any existing `version` value if the file already had one;
93
+ // otherwise default to 1 per Cursor's documented shape. A future
94
+ // user manually setting `version: 2` does NOT get silently demoted
95
+ // — we only set the default on a brand-new file.
96
+ if (config.version === undefined) {
97
+ config.version = 1;
98
+ }
99
+ if (!config.hooks || typeof config.hooks !== "object") {
100
+ config.hooks = {};
101
+ }
102
+ if (!Array.isArray(config.hooks[eventName])) {
103
+ config.hooks[eventName] = [];
104
+ }
105
+
106
+ // Exhaustive walk: drop any prior SkillRepo entries beyond the
107
+ // first match. The remover strips ALL fingerprint-matching entries,
108
+ // so the installer also has to handle ALL of them — otherwise a
109
+ // prior buggy run that left two ghosts would leave one behind on
110
+ // re-install. Cheap defense; aligns the installer-remover
111
+ // invariant ("exactly one SkillRepo entry per cohort hook config").
112
+ let primaryHandled = false;
113
+ let foundChanged = false;
114
+ let foundUnchangedExact = false;
115
+
116
+ const survivors = [];
117
+ let arrayMutated = false;
118
+ for (const inner of config.hooks[eventName]) {
119
+ if (
120
+ inner &&
121
+ typeof inner === "object" &&
122
+ typeof inner.command === "string" &&
123
+ inner.command.includes(AGENT_HOOK_FINGERPRINT)
124
+ ) {
125
+ if (!primaryHandled) {
126
+ primaryHandled = true;
127
+ if (entriesEqual(inner, desiredEntry)) {
128
+ foundUnchangedExact = true;
129
+ survivors.push(inner);
130
+ } else {
131
+ foundChanged = true;
132
+ arrayMutated = true; // in-place replacement at same index
133
+ survivors.push(desiredEntry);
134
+ }
135
+ } else {
136
+ // Duplicate from a prior buggy run — drop it.
137
+ foundChanged = true;
138
+ arrayMutated = true; // length-changing drop
139
+ }
140
+ continue;
141
+ }
142
+ survivors.push(inner);
143
+ }
144
+ // Write back when we mutated the array — either by replacing the
145
+ // primary entry in place (same length) or by dropping duplicates
146
+ // (smaller length). A length-only check would miss the
147
+ // replacement case.
148
+ if (arrayMutated) {
149
+ config.hooks[eventName] = survivors;
150
+ }
151
+
152
+ let matchedAction;
153
+ if (!primaryHandled) {
154
+ config.hooks[eventName].push(desiredEntry);
155
+ matchedAction = "installed";
156
+ } else if (foundChanged) {
157
+ matchedAction = "updated";
158
+ } else if (foundUnchangedExact) {
159
+ matchedAction = "unchanged";
160
+ } else {
161
+ matchedAction = "updated";
162
+ }
163
+
164
+ if (matchedAction === "unchanged") {
165
+ return {
166
+ path: hookSpec.displayPath,
167
+ action: "unchanged",
168
+ command: AGENT_HOOK_COMMAND,
169
+ };
170
+ }
171
+
172
+ writeFileAtomic(filePath, JSON.stringify(config, null, 2) + "\n");
173
+
174
+ return {
175
+ path: hookSpec.displayPath,
176
+ action: matchedAction,
177
+ command: AGENT_HOOK_COMMAND,
178
+ };
179
+ }
180
+
181
+ /**
182
+ * Strip the SkillRepo cohort hook from a cursor-shape vendor's hook
183
+ * config. Idempotent — non-SkillRepo entries and unknown event arrays
184
+ * are never touched.
185
+ *
186
+ * @param {object} options
187
+ * @param {string} options.vendorKey
188
+ * @param {boolean} [options.dryRun=false]
189
+ * @returns {{
190
+ * path: string;
191
+ * action: "removed" | "would-remove" | "skipped" | "unchanged";
192
+ * error?: string;
193
+ * }}
194
+ */
195
+ export function unmergeCursorShapeAgentHook({ vendorKey, dryRun = false }) {
196
+ const { hookSpec } = resolveAgent(vendorKey);
197
+ const filePath = hookSpec.pathFn();
198
+ const eventName = hookSpec.eventName;
199
+
200
+ if (!existsSync(filePath)) {
201
+ return { path: hookSpec.displayPath, action: "skipped" };
202
+ }
203
+
204
+ const raw = readFileSync(filePath, "utf-8");
205
+ if (raw.trim().length === 0) {
206
+ return { path: hookSpec.displayPath, action: "unchanged" };
207
+ }
208
+
209
+ let config;
210
+ try {
211
+ config = JSON.parse(raw);
212
+ } catch (err) {
213
+ return {
214
+ path: hookSpec.displayPath,
215
+ action: "skipped",
216
+ error: `Cannot parse ${hookSpec.displayPath}: ${err.message}. Fix or delete the file and re-run.`,
217
+ };
218
+ }
219
+
220
+ if (
221
+ !config ||
222
+ typeof config !== "object" ||
223
+ !config.hooks ||
224
+ typeof config.hooks !== "object" ||
225
+ !Array.isArray(config.hooks[eventName])
226
+ ) {
227
+ return { path: hookSpec.displayPath, action: "unchanged" };
228
+ }
229
+
230
+ const beforeCount = config.hooks[eventName].length;
231
+ const survivors = config.hooks[eventName].filter((h) => {
232
+ if (!h || typeof h !== "object" || typeof h.command !== "string") {
233
+ return true;
234
+ }
235
+ return !h.command.includes(AGENT_HOOK_FINGERPRINT);
236
+ });
237
+
238
+ if (survivors.length === beforeCount) {
239
+ return { path: hookSpec.displayPath, action: "unchanged" };
240
+ }
241
+
242
+ if (dryRun) {
243
+ return { path: hookSpec.displayPath, action: "would-remove" };
244
+ }
245
+
246
+ if (survivors.length === 0) {
247
+ delete config.hooks[eventName];
248
+ } else {
249
+ config.hooks[eventName] = survivors;
250
+ }
251
+ if (Object.keys(config.hooks).length === 0) {
252
+ delete config.hooks;
253
+ }
254
+
255
+ writeFileAtomic(filePath, JSON.stringify(config, null, 2) + "\n");
256
+ return { path: hookSpec.displayPath, action: "removed" };
257
+ }
258
+
259
+ // ── Internal helpers ───────────────────────────────────────────────
260
+
261
+ function resolveAgent(vendorKey) {
262
+ const entry = getAgentByKey(vendorKey);
263
+ if (!entry) {
264
+ throw validationError(
265
+ `Unknown agent key: ${vendorKey}. Add it to AGENT_REGISTRY.`,
266
+ );
267
+ }
268
+ if (!entry.agentHook) {
269
+ throw validationError(
270
+ `Agent "${vendorKey}" has no agentHook spec — cannot install a SessionStart hook.`,
271
+ );
272
+ }
273
+ if (entry.agentHook.shape !== "cursor-shape") {
274
+ throw validationError(
275
+ `Agent "${vendorKey}" has shape "${entry.agentHook.shape}", expected "cursor-shape".`,
276
+ {
277
+ hint: "Use the matching shape merger (e.g. mergeClaudeShapeAgentHook).",
278
+ },
279
+ );
280
+ }
281
+ return { entry, hookSpec: entry.agentHook };
282
+ }
283
+
284
+ function readConfigOrEmpty(filePath, displayPath) {
285
+ if (!existsSync(filePath)) return {};
286
+ const raw = readFileSync(filePath, "utf-8");
287
+ if (raw.trim().length === 0) return {};
288
+ let parsed;
289
+ try {
290
+ parsed = JSON.parse(raw);
291
+ } catch (err) {
292
+ throw diskError(
293
+ `Cannot parse ${displayPath}: ${err.message}. Fix or delete the file, then re-run.`,
294
+ { cause: err },
295
+ );
296
+ }
297
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
298
+ throw diskError(
299
+ `${displayPath} must be a JSON object at the top level.`,
300
+ );
301
+ }
302
+ return parsed;
303
+ }
304
+
305
+ function entriesEqual(a, b) {
306
+ return stableJson(a) === stableJson(b);
307
+ }
308
+
309
+ function stableJson(value) {
310
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
311
+ return JSON.stringify(value);
312
+ }
313
+ const sortedKeys = Object.keys(value).sort();
314
+ const parts = sortedKeys.map(
315
+ (k) => `${JSON.stringify(k)}:${stableJson(value[k])}`,
316
+ );
317
+ return `{${parts.join(",")}}`;
318
+ }
@@ -1,22 +1,14 @@
1
1
  /**
2
- * Merger for .gitignore — adds the three paths `skillrepo init` writes
3
- * that must not be committed.
4
- *
5
- * This module is v3.0.0-rewritten. The v2.0.0 version added a single
6
- * `.claude/rules/skillrepo-*.md` pattern for the now-deleted rules
7
- * delivery flow. The hooks were removed in #835 and the rules-delivery
8
- * model was replaced with direct skill syncing to `.claude/skills/`.
9
- * The three paths this merger adds are:
2
+ * Merger for .gitignore — adds the paths `skillrepo init` writes that
3
+ * must not be committed.
10
4
  *
5
+ * Always added:
11
6
  * - `.env.local` — contains SKILLREPO_ACCESS_KEY, a live credential
12
- * - `.claude/skills/` — per-developer synced library content
13
7
  * - `.claude/settings.local.json` — Claude Code per-user settings
14
8
  *
15
- * The `.env.local` entry is the security-critical one: without it, a
16
- * developer running `skillrepo init` in a fresh project could commit
17
- * their access key on the next `git add .`. PR4 round-3 review caught
18
- * that the docs promised this behavior but the CLI never actually
19
- * wrote the entries — this merger closes the gap.
9
+ * Conditionally added based on the `vendors` option:
10
+ * - `.claude/skills/` when any vendor maps to `claudeProject`
11
+ * - `.agents/skills/` when any vendor maps to `agentsProject`
20
12
  *
21
13
  * Idempotent: entries already present are skipped. A single call
22
14
  * either adds all missing entries in one grouped section or exits
@@ -24,35 +16,70 @@
24
16
  */
25
17
 
26
18
  import { readFileSafe, writeFileSafe } from "../fs-utils.mjs";
27
- import { join } from "node:path";
19
+ import { gitignorePath } from "../paths.mjs";
20
+ import { placementTargetsFor } from "../file-write.mjs";
28
21
 
29
22
  const SECTION_HEADER = "# SkillRepo CLI (added by `skillrepo init`)";
30
- const REQUIRED_ENTRIES = [
23
+ const ALWAYS_ENTRIES = Object.freeze([
31
24
  ".env.local",
32
- ".claude/skills/",
33
25
  ".claude/settings.local.json",
34
- ];
26
+ ]);
27
+
28
+ /**
29
+ * Compute the gitignore entries to ensure based on the placement
30
+ * targets the caller will write to. When no vendors are passed, fall
31
+ * back to the historical default of `.claude/skills/` so existing
32
+ * call sites that haven't been threaded through `vendors` keep
33
+ * working.
34
+ *
35
+ * @param {{ vendors?: string[], global?: boolean }} options
36
+ * @returns {string[]}
37
+ */
38
+ function entriesFor(options) {
39
+ const entries = [...ALWAYS_ENTRIES];
40
+ if (!Array.isArray(options.vendors) || options.vendors.length === 0) {
41
+ entries.push(".claude/skills/");
42
+ return entries;
43
+ }
44
+ let targets;
45
+ try {
46
+ targets = placementTargetsFor({
47
+ vendors: options.vendors,
48
+ global: !!options.global,
49
+ });
50
+ } catch {
51
+ // Caller passed an unknown vendor; fall back to the always-set
52
+ // entries plus `.claude/skills/` so we never under-protect.
53
+ entries.push(".claude/skills/");
54
+ return entries;
55
+ }
56
+ if (targets.includes("claudeProject")) entries.push(".claude/skills/");
57
+ if (targets.includes("agentsProject")) entries.push(".agents/skills/");
58
+ return entries;
59
+ }
35
60
 
36
61
  /**
37
- * Ensure the three init-required paths are in .gitignore. Creates the
38
- * file if it doesn't exist. Idempotent — returns `"skipped"` if every
62
+ * Ensure the init-required paths are in .gitignore. Creates the file
63
+ * if it doesn't exist. Idempotent — returns `"skipped"` if every
39
64
  * required entry is already present, `"created"` if the file didn't
40
65
  * exist, `"updated"` if it did and at least one entry was missing.
41
66
  *
67
+ * @param {{ vendors?: string[], global?: boolean }} [options]
42
68
  * @returns {{ path: string; action: "created" | "updated" | "skipped"; added: string[] }}
43
69
  */
44
- export function mergeGitignore() {
45
- const gitignorePath = join(process.cwd(), ".gitignore");
46
- const existing = readFileSafe(gitignorePath);
70
+ export function mergeGitignore(options = {}) {
71
+ const requiredEntries = entriesFor(options);
72
+ const filePath = gitignorePath();
73
+ const existing = readFileSafe(filePath);
47
74
 
48
75
  if (existing === null) {
49
76
  // Fresh .gitignore — write all required entries as one section.
50
- const content = renderSection(REQUIRED_ENTRIES);
51
- writeFileSafe(gitignorePath, content);
77
+ const content = renderSection(requiredEntries);
78
+ writeFileSafe(filePath, content);
52
79
  return {
53
80
  path: ".gitignore",
54
81
  action: "created",
55
- added: [...REQUIRED_ENTRIES],
82
+ added: [...requiredEntries],
56
83
  };
57
84
  }
58
85
 
@@ -60,7 +87,7 @@ export function mergeGitignore() {
60
87
  // `.env.local.backup` or a comment `# .env.local` — split on
61
88
  // newlines and trim so we match the entry literally.
62
89
  const lines = existing.split(/\r?\n/).map((l) => l.trim());
63
- const missing = REQUIRED_ENTRIES.filter((entry) => !lines.includes(entry));
90
+ const missing = requiredEntries.filter((entry) => !lines.includes(entry));
64
91
 
65
92
  if (missing.length === 0) {
66
93
  return {
@@ -75,7 +102,7 @@ export function mergeGitignore() {
75
102
  const lineEnding = existing.includes("\r\n") ? "\r\n" : "\n";
76
103
  const separator = existing.endsWith("\n") ? "" : lineEnding;
77
104
  const block = renderSection(missing, lineEnding);
78
- writeFileSafe(gitignorePath, existing + separator + lineEnding + block);
105
+ writeFileSafe(filePath, existing + separator + lineEnding + block);
79
106
 
80
107
  return {
81
108
  path: ".gitignore",
package/src/lib/paths.mjs CHANGED
@@ -28,25 +28,13 @@ export const vscodeMcpJson = () => join(cwd(), ".vscode", "mcp.json");
28
28
  export const globalConfigPath = () => join(homedir(), ".claude", "skillrepo", "config.json");
29
29
  export const globalLastSyncPath = () => join(homedir(), ".claude", "skillrepo", ".last-sync");
30
30
 
31
- // ── Skill placement targets (added in #646 / PR1) ─────────────────────
31
+ // ── Skill placement targets ────────────────────────────────────────────
32
32
  //
33
- // Claude Code documents two skill discovery locations at
34
- // https://code.claude.com/docs/en/skills:
35
- //
36
- // Personal: ~/.claude/skills/<name>/SKILL.md
37
- // Project: <cwd>/.claude/skills/<name>/SKILL.md
38
- //
39
- // The `name` segment must match the `name` field in the SKILL.md
40
- // frontmatter per the agentskills.io spec — the file-write pipeline
41
- // enforces this at write time.
42
- //
43
- // Other detected vendors (Cursor, Windsurf, VS Code Copilot) do not
44
- // currently document an on-disk skill discovery convention. For those
45
- // vendors, the file-write pipeline writes to a project-level fallback
46
- // at `<cwd>/skills/<name>/`, with an entry added to .gitignore on
47
- // first write so the user-specific skill set never leaks into the repo
48
- // history. See follow-up issue #876 for tracking when those IDEs
49
- // publish their own conventions.
33
+ // Per-vendor placement decisions live in `agent-registry.mjs`. This
34
+ // module exposes the path resolvers each placement target maps to;
35
+ // `file-write.mjs` switches on the registry's PlacementTarget union to
36
+ // pick the right resolver. See `packages/cli/docs/vendor-paths.md` for
37
+ // the verified vendor-by-vendor reference and primary-source citations.
50
38
 
51
39
  /** Claude Code project-local skill directory for a specific skill name. */
52
40
  export const claudeSkillsProject = (name) => join(cwd(), ".claude", "skills", name);
@@ -54,25 +42,39 @@ export const claudeSkillsProject = (name) => join(cwd(), ".claude", "skills", na
54
42
  /** Claude Code personal/global skill directory for a specific skill name. */
55
43
  export const claudeSkillsGlobal = (name) => join(homedir(), ".claude", "skills", name);
56
44
 
57
- /** Project-local fallback skills root (used when --ide includes a vendor without a documented convention). */
58
- export const projectSkillsFallbackRoot = () => join(cwd(), "skills");
59
-
60
- /** Project-local fallback for a specific skill name. */
61
- export const projectSkillsFallback = (name) => join(cwd(), "skills", name);
62
-
63
45
  /** Parent directory of the project-local Claude Code skills (used by orphan cleanup). */
64
46
  export const claudeSkillsProjectRoot = () => join(cwd(), ".claude", "skills");
65
47
 
66
48
  /** Parent directory of the personal/global Claude Code skills (used by orphan cleanup). */
67
49
  export const claudeSkillsGlobalRoot = () => join(homedir(), ".claude", "skills");
68
50
 
51
+ /** Cross-vendor `.agents/skills/<name>/` project-local placement (cursor, windsurf, gemini, codex, cline, copilot). */
52
+ export const agentsSkillsProject = (name) => join(cwd(), ".agents", "skills", name);
53
+
54
+ /** Parent of the project-local `.agents/skills/` cohort root (used by orphan cleanup). */
55
+ export const agentsSkillsProjectRoot = () => join(cwd(), ".agents", "skills");
56
+
57
+ /** Cross-vendor personal `.agents/skills/<name>/` placement (cursor, gemini, codex, cline). */
58
+ export const agentsSkillsGlobal = (name) => join(homedir(), ".agents", "skills", name);
59
+
60
+ /** Parent of the personal `.agents/skills/` cohort root (used by orphan cleanup). */
61
+ export const agentsSkillsGlobalRoot = () => join(homedir(), ".agents", "skills");
62
+
63
+ /** Windsurf's vendor-specific personal placement under `~/.codeium/windsurf/skills/<name>/`. */
64
+ export const windsurfSkillsGlobal = (name) =>
65
+ join(homedir(), ".codeium", "windsurf", "skills", name);
66
+
67
+ /** Parent of the Windsurf personal skills root (used by orphan cleanup). */
68
+ export const windsurfSkillsGlobalRoot = () =>
69
+ join(homedir(), ".codeium", "windsurf", "skills");
70
+
69
71
  // ── Shared ────────────────────────────────────────────────────────────
70
72
 
71
73
  export const envLocal = () => join(cwd(), ".env.local");
72
74
 
73
75
  /**
74
76
  * Project .gitignore — used by the file-write pipeline to ensure the
75
- * project /skills/ fallback directory is gitignored on first write.
77
+ * `.agents/skills/` cohort directory is gitignored on first write.
76
78
  */
77
79
  export const gitignorePath = () => join(cwd(), ".gitignore");
78
80