skillrepo 3.2.0 → 4.0.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 (39) hide show
  1. package/README.md +90 -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-session-sync.mjs +1 -1
  7. package/src/commands/init.mjs +435 -111
  8. package/src/commands/list.mjs +1 -1
  9. package/src/commands/remove.mjs +10 -2
  10. package/src/commands/uninstall.mjs +1 -1
  11. package/src/commands/update.mjs +15 -3
  12. package/src/lib/agent-registry.mjs +215 -0
  13. package/src/lib/cli-config.mjs +146 -44
  14. package/src/lib/detect-agents.mjs +112 -0
  15. package/src/lib/file-write.mjs +162 -77
  16. package/src/lib/mcp-merge.mjs +17 -36
  17. package/src/lib/mergers/gitignore.mjs +55 -28
  18. package/src/lib/paths.mjs +27 -25
  19. package/src/lib/prompt-multiselect.mjs +324 -0
  20. package/src/lib/sync.mjs +18 -19
  21. package/src/test/commands/add.test.mjs +18 -3
  22. package/src/test/commands/init-picker.test.mjs +144 -0
  23. package/src/test/commands/init.test.mjs +228 -42
  24. package/src/test/commands/remove.test.mjs +4 -1
  25. package/src/test/commands/update.test.mjs +13 -3
  26. package/src/test/e2e/cli-agent-permutations.test.mjs +631 -0
  27. package/src/test/e2e/cli-commands.test.mjs +39 -13
  28. package/src/test/integration/file-write.integration.test.mjs +31 -10
  29. package/src/test/lib/agent-registry.test.mjs +215 -0
  30. package/src/test/lib/cli-config.test.mjs +222 -38
  31. package/src/test/lib/detect-agents.test.mjs +336 -0
  32. package/src/test/lib/file-write-placement.test.mjs +264 -0
  33. package/src/test/lib/file-write.test.mjs +231 -30
  34. package/src/test/lib/mcp-merge.test.mjs +23 -15
  35. package/src/test/lib/paths.test.mjs +53 -17
  36. package/src/test/lib/prompt-multiselect.test.mjs +448 -0
  37. package/src/test/lib/sync.test.mjs +157 -0
  38. package/src/lib/detect-ides.mjs +0 -44
  39. package/src/test/detect-ides.test.mjs +0 -65
@@ -33,11 +33,14 @@
33
33
  * - Blocked extensions — rejected (matches server BLOCKED_EXTENSIONS)
34
34
  * - Skill name vs frontmatter `name` — must match
35
35
  *
36
- * • Vendor placement: Claude Code uses .claude/skills/<name>/ (project)
37
- * or ~/.claude/skills/<name>/ (--global) per Anthropic's documented
38
- * convention. Other detected vendors without their own convention
39
- * fall back to <cwd>/skills/<name>/, with /skills/ added to .gitignore
40
- * on first creation. See follow-up #876.
36
+ * • Vendor placement: per-vendor decisions live in `agent-registry.mjs`.
37
+ * Claude Code writes to .claude/skills/<name>/ (Anthropic's documented
38
+ * convention); the cohort of cursor / windsurf / gemini / codex / cline
39
+ * / copilot shares .agents/skills/<name>/ for project scope (with
40
+ * `.agents/skills/` added to .gitignore on first cohort write). Personal
41
+ * scope honors per-vendor conventions where they exist (Windsurf has its
42
+ * own personal path under ~/.codeium/windsurf/skills/). See
43
+ * `packages/cli/docs/vendor-paths.md` for the full citation list.
41
44
  */
42
45
 
43
46
  import {
@@ -55,12 +58,17 @@ import { readFileSafe, writeFileSafe } from "./fs-utils.mjs";
55
58
  import {
56
59
  claudeSkillsProject,
57
60
  claudeSkillsGlobal,
58
- projectSkillsFallback,
59
- projectSkillsFallbackRoot,
60
61
  claudeSkillsProjectRoot,
61
62
  claudeSkillsGlobalRoot,
63
+ agentsSkillsProject,
64
+ agentsSkillsProjectRoot,
65
+ agentsSkillsGlobal,
66
+ agentsSkillsGlobalRoot,
67
+ windsurfSkillsGlobal,
68
+ windsurfSkillsGlobalRoot,
62
69
  gitignorePath,
63
70
  } from "./paths.mjs";
71
+ import { AGENT_REGISTRY, getAgentByKey } from "./agent-registry.mjs";
64
72
  import { CliError, validationError, diskError } from "./errors.mjs";
65
73
  import { platformConventions } from "./platform.mjs";
66
74
 
@@ -83,7 +91,7 @@ export const BLOCKED_EXTENSIONS = new Set([
83
91
  ".wasm",
84
92
  ]);
85
93
 
86
- const GITIGNORE_SKILLS_LINE = "/skills/";
94
+ const GITIGNORE_AGENTS_SKILLS_LINE = ".agents/skills/";
87
95
  const GITIGNORE_SKILLS_HEADER = "# SkillRepo (CLI-managed library skills)";
88
96
 
89
97
  // ── Types (JSDoc only — this is plain JS) ───────────────────────────────
@@ -102,9 +110,25 @@ const GITIGNORE_SKILLS_HEADER = "# SkillRepo (CLI-managed library skills)";
102
110
  */
103
111
 
104
112
  /**
105
- * @typedef {"claudeProject" | "claudeGlobal" | "projectFallback"} PlacementTarget
113
+ * @typedef {"claudeProject" | "claudeGlobal" | "agentsProject" | "agentsGlobal" | "windsurfGlobal"} PlacementTarget
106
114
  */
107
115
 
116
+ /** @type {readonly PlacementTarget[]} */
117
+ const ALL_TARGETS = Object.freeze([
118
+ "claudeProject",
119
+ "claudeGlobal",
120
+ "agentsProject",
121
+ "agentsGlobal",
122
+ "windsurfGlobal",
123
+ ]);
124
+
125
+ /** @type {readonly PlacementTarget[]} */
126
+ const GLOBAL_TARGETS = Object.freeze([
127
+ "claudeGlobal",
128
+ "agentsGlobal",
129
+ "windsurfGlobal",
130
+ ]);
131
+
108
132
  // ── Public API ──────────────────────────────────────────────────────────
109
133
 
110
134
  /**
@@ -165,8 +189,64 @@ export function resolvePlacementDir(target, skillName) {
165
189
  return claudeSkillsProject(skillName);
166
190
  case "claudeGlobal":
167
191
  return claudeSkillsGlobal(skillName);
168
- case "projectFallback":
169
- return projectSkillsFallback(skillName);
192
+ case "agentsProject":
193
+ return agentsSkillsProject(skillName);
194
+ case "agentsGlobal":
195
+ return agentsSkillsGlobal(skillName);
196
+ case "windsurfGlobal":
197
+ return windsurfSkillsGlobal(skillName);
198
+ default: {
199
+ throw validationError(`Unknown placement target: ${target}`);
200
+ }
201
+ }
202
+ }
203
+
204
+ /**
205
+ * Human-readable path label for a placement target. Used in success
206
+ * messages so the user sees where files actually landed (`.agents/skills/`
207
+ * for cohort vendors, not the misleading `.claude/skills/` previously
208
+ * hardcoded in add/get).
209
+ *
210
+ * @param {PlacementTarget} target
211
+ * @returns {string}
212
+ */
213
+ export function describePlacementTarget(target) {
214
+ switch (target) {
215
+ case "claudeProject":
216
+ return ".claude/skills/";
217
+ case "claudeGlobal":
218
+ return "~/.claude/skills/";
219
+ case "agentsProject":
220
+ return ".agents/skills/";
221
+ case "agentsGlobal":
222
+ return "~/.agents/skills/";
223
+ case "windsurfGlobal":
224
+ return "~/.codeium/windsurf/skills/";
225
+ default:
226
+ return target;
227
+ }
228
+ }
229
+
230
+ /**
231
+ * Map a `PlacementTarget` to its parent root resolver. Used by
232
+ * `cleanupOrphans` to know which directories to scan for `.tmp`/`.old`
233
+ * siblings.
234
+ *
235
+ * @param {PlacementTarget} target
236
+ * @returns {() => string}
237
+ */
238
+ function placementRootFn(target) {
239
+ switch (target) {
240
+ case "claudeProject":
241
+ return claudeSkillsProjectRoot;
242
+ case "claudeGlobal":
243
+ return claudeSkillsGlobalRoot;
244
+ case "agentsProject":
245
+ return agentsSkillsProjectRoot;
246
+ case "agentsGlobal":
247
+ return agentsSkillsGlobalRoot;
248
+ case "windsurfGlobal":
249
+ return windsurfSkillsGlobalRoot;
170
250
  default: {
171
251
  throw validationError(`Unknown placement target: ${target}`);
172
252
  }
@@ -174,46 +254,52 @@ export function resolvePlacementDir(target, skillName) {
174
254
  }
175
255
 
176
256
  /**
177
- * Compute the placement targets for a write based on detected/requested
178
- * vendors and the --global flag.
257
+ * Compute the placement targets for a write based on requested vendors
258
+ * and the --global flag.
179
259
  *
180
- * Rules:
181
- * - --global only ["claudeGlobal"] (other vendors don't have a
182
- * documented global convention; we don't synthesize one)
183
- * - claudeCode in vendors "claudeProject"
184
- * - any non-claudeCode vendor in vendors (cursor, windsurf, vscode)
185
- * → "projectFallback" (single fallback, deduped — multiple
186
- * non-claude vendors share the same fallback)
260
+ * Each vendor's `projectTarget` and `globalTarget` come from
261
+ * `agent-registry.mjs`. Multiple vendors mapping to the same target
262
+ * (e.g. cursor + windsurf both at `agentsProject`) collapse into one
263
+ * write. A vendor with `globalTarget === null` is rejected under
264
+ * `--global` because the vendor has no documented personal scope.
187
265
  *
188
266
  * @param {object} options
189
- * @param {string[]} options.vendors - List of vendor keys (claudeCode, cursor, windsurf, vscode).
190
- * @param {boolean} [options.global] - If true, write to personal/global Claude Code dir only.
267
+ * @param {string[]} options.vendors - List of canonical vendor keys.
268
+ * @param {boolean} [options.global] - If true, use each vendor's globalTarget.
191
269
  * @returns {PlacementTarget[]}
192
270
  */
193
271
  export function placementTargetsFor({ vendors, global = false }) {
194
- if (global) {
195
- return ["claudeGlobal"];
196
- }
197
-
198
272
  if (!Array.isArray(vendors) || vendors.length === 0) {
199
273
  throw validationError(
200
- "No vendors specified. Pass --ide claude (or another vendor) explicitly.",
274
+ "No vendors specified. Pass --agent claude (or another target) explicitly.",
201
275
  );
202
276
  }
203
277
 
204
278
  const targets = [];
205
- let needsFallback = false;
206
279
  for (const vendor of vendors) {
207
- if (vendor === "claudeCode") {
208
- targets.push("claudeProject");
209
- } else if (vendor === "cursor" || vendor === "windsurf" || vendor === "vscode") {
210
- needsFallback = true;
211
- } else {
212
- throw validationError(`Unknown vendor: ${vendor}`);
280
+ const entry = getAgentByKey(vendor);
281
+ if (!entry) {
282
+ throw validationError(`Unknown vendor: ${vendor}`, {
283
+ hint: `Valid keys: ${AGENT_REGISTRY.map((e) => e.key).join(", ")}.`,
284
+ });
285
+ }
286
+ const target = global ? entry.globalTarget : entry.projectTarget;
287
+ if (target === null) {
288
+ const supported = AGENT_REGISTRY
289
+ .filter((e) => e.globalTarget !== null)
290
+ .map((e) => e.key)
291
+ .join(", ");
292
+ throw validationError(
293
+ `${vendor} has no documented personal scope, so --global is not supported.`,
294
+ {
295
+ hint:
296
+ `Drop --global, or use --agent with a vendor that supports it (${supported}).`,
297
+ },
298
+ );
299
+ }
300
+ if (!targets.includes(target)) {
301
+ targets.push(target);
213
302
  }
214
- }
215
- if (needsFallback) {
216
- targets.push("projectFallback");
217
303
  }
218
304
  return targets;
219
305
  }
@@ -264,7 +350,7 @@ export function readFrontmatterName(files) {
264
350
  * skip-if-unchanged enhancement at this layer would duplicate that work
265
351
  * and require re-reading every file from disk.
266
352
  *
267
- * Pre-flight: `.gitignore` setup for the projectFallback target runs
353
+ * Pre-flight: `.gitignore` setup for the agentsProject target runs
268
354
  * BEFORE any per-target write. This is intentional — running it inline
269
355
  * inside the loop would let a read-only `.gitignore` failure abort the
270
356
  * loop AFTER an earlier vendor (e.g., claudeCode) had already
@@ -277,14 +363,15 @@ export function writeSkillDir(skill, options = {}) {
277
363
 
278
364
  const targets = placementTargetsFor(options);
279
365
 
280
- // Pre-flight: if any target requires the project /skills/ fallback,
281
- // ensure .gitignore is set up before starting any writes. A failure
282
- // here is recoverable (user fixes their .gitignore and re-runs)
283
- // because nothing has hit disk yet. A failure here AFTER a successful
284
- // claudeProject write would leave the user with a half-applied state
285
- // and an error message that doesn't reflect what's actually on disk.
286
- if (targets.includes("projectFallback")) {
287
- ensureFallbackGitignore();
366
+ // Pre-flight: if any target writes to .agents/skills/, ensure
367
+ // .gitignore is set up before starting any writes. A failure here
368
+ // is recoverable (user fixes their .gitignore and re-runs) because
369
+ // nothing has hit disk yet. A failure here AFTER a successful
370
+ // claudeProject write would leave the user with a half-applied
371
+ // state and an error message that doesn't reflect what's actually
372
+ // on disk.
373
+ if (targets.includes("agentsProject")) {
374
+ ensureAgentsGitignore();
288
375
  }
289
376
 
290
377
  const written = [];
@@ -377,26 +464,26 @@ export function removeSkillDir(skillName, options = {}) {
377
464
  * @returns {{ cleaned: string[] }}
378
465
  */
379
466
  export function cleanupOrphans(options = {}) {
380
- const roots = [];
381
- if (options.global) {
382
- roots.push(claudeSkillsGlobalRoot());
467
+ const roots = new Set();
468
+ if (Array.isArray(options.vendors) && options.vendors.length > 0) {
469
+ const targets = placementTargetsFor({
470
+ vendors: options.vendors,
471
+ global: !!options.global,
472
+ });
473
+ for (const target of targets) {
474
+ roots.add(placementRootFn(target)());
475
+ }
476
+ } else if (options.global) {
477
+ // No vendors under --global — sweep every known global root so a
478
+ // stale orphan from any prior --global write is cleaned.
479
+ for (const target of GLOBAL_TARGETS) {
480
+ roots.add(placementRootFn(target)());
481
+ }
383
482
  } else {
384
- if (Array.isArray(options.vendors) && options.vendors.length > 0) {
385
- if (options.vendors.includes("claudeCode")) {
386
- roots.push(claudeSkillsProjectRoot());
387
- }
388
- if (
389
- options.vendors.includes("cursor") ||
390
- options.vendors.includes("windsurf") ||
391
- options.vendors.includes("vscode")
392
- ) {
393
- roots.push(projectSkillsFallbackRoot());
394
- }
395
- } else {
396
- // No vendors specified — clean everything we know about.
397
- roots.push(claudeSkillsProjectRoot());
398
- roots.push(projectSkillsFallbackRoot());
399
- roots.push(claudeSkillsGlobalRoot());
483
+ // No vendors and no --global sweep every root we know about so
484
+ // any orphan from any prior run is cleaned.
485
+ for (const target of ALL_TARGETS) {
486
+ roots.add(placementRootFn(target)());
400
487
  }
401
488
  }
402
489
 
@@ -666,17 +753,14 @@ function writeSkillToDir(skill, targetDir) {
666
753
  }
667
754
 
668
755
  /**
669
- * Ensure the project /skills/ directory is gitignored. Idempotent —
670
- * skips if the entry is already present. Creates .gitignore if missing.
756
+ * Ensure `.agents/skills/` is gitignored. Idempotent — skips if the
757
+ * entry is already present. Creates .gitignore if missing.
671
758
  *
672
759
  * Throws diskError on any write failure so a read-only or symlinked
673
760
  * .gitignore surfaces as a user-visible error instead of silent data
674
- * loss. This is the explicit fix for the architect's PR1 review item:
675
- * `writeFileSafe` was previously called without a try/catch and a
676
- * failing write would leave the user with skills on disk but no
677
- * .gitignore protection — the next `git add` would commit them.
761
+ * loss.
678
762
  */
679
- function ensureFallbackGitignore() {
763
+ function ensureAgentsGitignore() {
680
764
  const filePath = gitignorePath();
681
765
  let existing = null;
682
766
  try {
@@ -684,18 +768,19 @@ function ensureFallbackGitignore() {
684
768
  } catch (err) {
685
769
  throw diskError(`Cannot read ${filePath}: ${err.message}`, {
686
770
  cause: err,
687
- hint: "Update your .gitignore manually to add /skills/ before re-running.",
771
+ hint:
772
+ `Update your .gitignore manually to add ${GITIGNORE_AGENTS_SKILLS_LINE} before re-running.`,
688
773
  });
689
774
  }
690
775
 
691
- if (existing !== null && existing.includes(GITIGNORE_SKILLS_LINE)) {
776
+ if (existing !== null && existing.includes(GITIGNORE_AGENTS_SKILLS_LINE)) {
692
777
  return; // Already present — no-op
693
778
  }
694
779
 
695
780
  const newContent =
696
781
  existing === null
697
- ? `${GITIGNORE_SKILLS_HEADER}\n${GITIGNORE_SKILLS_LINE}\n`
698
- : `${existing}${existing.endsWith("\n") ? "" : "\n"}\n${GITIGNORE_SKILLS_HEADER}\n${GITIGNORE_SKILLS_LINE}\n`;
782
+ ? `${GITIGNORE_SKILLS_HEADER}\n${GITIGNORE_AGENTS_SKILLS_LINE}\n`
783
+ : `${existing}${existing.endsWith("\n") ? "" : "\n"}\n${GITIGNORE_SKILLS_HEADER}\n${GITIGNORE_AGENTS_SKILLS_LINE}\n`;
699
784
 
700
785
  try {
701
786
  writeFileSafe(filePath, newContent);
@@ -703,7 +788,7 @@ function ensureFallbackGitignore() {
703
788
  throw diskError(`Cannot update ${filePath}: ${err.message}`, {
704
789
  cause: err,
705
790
  hint:
706
- "The CLI needs to add /skills/ to .gitignore so library skills don't get committed. " +
791
+ `The CLI needs to add ${GITIGNORE_AGENTS_SKILLS_LINE} to .gitignore so library skills don't get committed. ` +
707
792
  "Update .gitignore manually and re-run, or remove the read-only/symlinked constraint.",
708
793
  });
709
794
  }
@@ -46,6 +46,7 @@ import { mergeWindsurfMcpConfig } from "./mergers/windsurf-mcp.mjs";
46
46
  import { mergeVscodeMcpConfig } from "./mergers/vscode-mcp.mjs";
47
47
  import { confirm as realConfirm } from "./prompt.mjs";
48
48
  import { validationError } from "./errors.mjs";
49
+ import { getAgentByKey } from "./agent-registry.mjs";
49
50
 
50
51
  /**
51
52
  * @typedef {Object} McpMergeResult
@@ -69,7 +70,7 @@ import { validationError } from "./errors.mjs";
69
70
  * user for each one unless `yes` is true.
70
71
  *
71
72
  * @param {object} options
72
- * @param {string[]} options.vendors - Vendor keys: claudeCode | cursor | windsurf | vscode
73
+ * @param {string[]} options.vendors - Canonical vendor keys (see agent-registry.mjs).
73
74
  * @param {string} options.mcpUrl - The SkillRepo MCP endpoint URL
74
75
  * @param {boolean} [options.yes] - Skip prompts (auto-accept everything)
75
76
  * @param {object} [options.io] - Injected streams for testability
@@ -111,18 +112,26 @@ export async function mergeMcpForVendors(options) {
111
112
  }
112
113
 
113
114
  // Deduplicate vendors so a caller passing `["claudeCode",
114
- // "claudeCode"]` doesn't run the merger twice. The round-1 review
115
- // flagged this as a latent caller trap — `cli-config.mjs`'s
116
- // parseVendorList doesn't dedupe either, so `--ide claude,claude`
117
- // would reach this loop twice for the same vendor. Idempotent on
118
- // a single vendor key (Set preserves first-seen insertion order).
115
+ // "claudeCode"]` doesn't run the merger twice. Defense-in-depth
116
+ // `cli-config.mjs`'s `parseAgentList` already dedupes, but a future
117
+ // caller building the vendor list manually could reintroduce the
118
+ // duplicate. Idempotent on a single vendor key (Set preserves
119
+ // first-seen insertion order).
119
120
  const uniqueVendors = Array.from(new Set(vendors));
120
121
 
121
122
  const results = [];
122
123
  for (const vendor of uniqueVendors) {
124
+ const entry = getAgentByKey(vendor);
125
+ // Vendors with `hasMcp: false` are file-only (no documented MCP
126
+ // config the CLI knows how to merge). Skipping silently is correct
127
+ // — they're a deliberate registry classification, not user error.
128
+ // A truly unknown vendor (no registry entry at all) is a caller
129
+ // bug; surface that as a "failed" result so it can't hide.
130
+ if (entry && entry.hasMcp === false) {
131
+ continue;
132
+ }
123
133
  const vendorInfo = VENDOR_INFO[vendor];
124
134
  if (!vendorInfo) {
125
- // Unknown vendor — skip with a recorded failure
126
135
  results.push({
127
136
  vendor,
128
137
  path: "(unknown)",
@@ -183,34 +192,6 @@ export async function mergeMcpForVendors(options) {
183
192
  return results;
184
193
  }
185
194
 
186
- /**
187
- * Print MCP config JSON for undetected IDEs so the user can paste
188
- * it into their own IDE's MCP settings manually. Called by init
189
- * when no IDEs were detected or when the user wants to see the
190
- * raw config shape.
191
- *
192
- * @param {string} mcpUrl
193
- * @param {object} [io]
194
- * @param {NodeJS.WritableStream} [io.stdout=process.stdout]
195
- */
196
- export function printManualMcpInstructions(mcpUrl, io = {}) {
197
- const stdout = io.stdout ?? process.stdout;
198
- const config = {
199
- mcpServers: {
200
- skillrepo: {
201
- type: "http",
202
- url: mcpUrl,
203
- headers: {
204
- Authorization: "Bearer ${SKILLREPO_ACCESS_KEY}",
205
- },
206
- },
207
- },
208
- };
209
- stdout.write("\n To configure MCP manually, add this to your IDE's MCP settings:\n\n");
210
- stdout.write(JSON.stringify(config, null, 2).replace(/^/gm, " ") + "\n\n");
211
- stdout.write(" The SKILLREPO_ACCESS_KEY env var is set in .env.local.\n\n");
212
- }
213
-
214
195
  // ── Internals ──────────────────────────────────────────────────────────
215
196
 
216
197
  /**
@@ -233,7 +214,7 @@ const VENDOR_INFO = {
233
214
  pathFn: windsurfMcpJson,
234
215
  displayPath: "~/.codeium/windsurf/mcp_config.json",
235
216
  },
236
- vscode: {
217
+ copilot: {
237
218
  merger: mergeVscodeMcpConfig,
238
219
  pathFn: vscodeMcpJson,
239
220
  displayPath: ".vscode/mcp.json",
@@ -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