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
package/src/lib/file-write.mjs
CHANGED
|
@@ -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:
|
|
37
|
-
*
|
|
38
|
-
* convention
|
|
39
|
-
*
|
|
40
|
-
*
|
|
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
|
|
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" | "
|
|
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 "
|
|
169
|
-
return
|
|
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
|
|
178
|
-
*
|
|
257
|
+
* Compute the placement targets for a write based on requested vendors
|
|
258
|
+
* and the --global flag.
|
|
179
259
|
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
*
|
|
183
|
-
*
|
|
184
|
-
*
|
|
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
|
|
190
|
-
* @param {boolean} [options.global] - If true,
|
|
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 --
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
|
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
|
|
281
|
-
//
|
|
282
|
-
//
|
|
283
|
-
//
|
|
284
|
-
// claudeProject write would leave the user with a half-applied
|
|
285
|
-
// and an error message that doesn't reflect what's actually
|
|
286
|
-
|
|
287
|
-
|
|
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.
|
|
382
|
-
|
|
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
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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
|
|
670
|
-
*
|
|
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.
|
|
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
|
|
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:
|
|
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(
|
|
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${
|
|
698
|
-
: `${existing}${existing.endsWith("\n") ? "" : "\n"}\n${GITIGNORE_SKILLS_HEADER}\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
|
-
|
|
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
|
}
|
package/src/lib/fs-utils.mjs
CHANGED
|
@@ -87,7 +87,22 @@ export function writeFileAtomic(filePath, content, { mode } = {}) {
|
|
|
87
87
|
throw new Error(`${filePath} is a directory, expected a file`);
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
-
|
|
90
|
+
// Per-process unique temp suffix prevents two concurrent writers
|
|
91
|
+
// (e.g. a SessionStart hook firing `skillrepo update --silent` while
|
|
92
|
+
// the user runs `skillrepo uninstall --global`) from writing to the
|
|
93
|
+
// same `.tmp` path and producing non-deterministic final content.
|
|
94
|
+
// The cohort SessionStart hooks (#1240) made this race observable
|
|
95
|
+
// in production: hook configs at user-scope can be touched by both
|
|
96
|
+
// the hook runner and the uninstaller simultaneously.
|
|
97
|
+
//
|
|
98
|
+
// `process.pid` plus a `Math.random()` token gives uniqueness across
|
|
99
|
+
// processes AND across rapid same-process re-entrant writes. The
|
|
100
|
+
// token is base-36 chars from a 64-bit float — collision probability
|
|
101
|
+
// between siblings within the rename window is astronomically low.
|
|
102
|
+
// No cryptographic strength required: this is a temp-file suffix,
|
|
103
|
+
// not a security boundary.
|
|
104
|
+
const uniqueSuffix = `${process.pid}.${Math.random().toString(36).slice(2, 10)}`;
|
|
105
|
+
const tmpPath = `${filePath}.${uniqueSuffix}.tmp`;
|
|
91
106
|
try {
|
|
92
107
|
writeFileSync(tmpPath, content, "utf-8");
|
|
93
108
|
} catch (err) {
|
package/src/lib/mcp-merge.mjs
CHANGED
|
@@ -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 -
|
|
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.
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
//
|
|
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
|
-
|
|
217
|
+
copilot: {
|
|
237
218
|
merger: mergeVscodeMcpConfig,
|
|
238
219
|
pathFn: vscodeMcpJson,
|
|
239
220
|
displayPath: ".vscode/mcp.json",
|