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.
- package/README.md +90 -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-session-sync.mjs +1 -1
- package/src/commands/init.mjs +435 -111
- package/src/commands/list.mjs +1 -1
- package/src/commands/remove.mjs +10 -2
- package/src/commands/uninstall.mjs +1 -1
- package/src/commands/update.mjs +15 -3
- package/src/lib/agent-registry.mjs +215 -0
- 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/mcp-merge.mjs +17 -36
- 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/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 +228 -42
- package/src/test/commands/remove.test.mjs +4 -1
- package/src/test/commands/update.test.mjs +13 -3
- package/src/test/e2e/cli-agent-permutations.test.mjs +631 -0
- package/src/test/e2e/cli-commands.test.mjs +39 -13
- package/src/test/integration/file-write.integration.test.mjs +31 -10
- package/src/test/lib/agent-registry.test.mjs +215 -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/lib/detect-ides.mjs +0 -44
- package/src/test/detect-ides.test.mjs +0 -65
|
@@ -432,7 +432,9 @@ describe("CLI E2E — read commands", () => {
|
|
|
432
432
|
// ── PR3b: init ──────────────────────────────────────────────────
|
|
433
433
|
|
|
434
434
|
it("init writes config + MCP + runs first sync (happy path)", async () => {
|
|
435
|
-
// Create a .claude/ marker so
|
|
435
|
+
// Create a .claude/ marker so detection finds claudeCode (the
|
|
436
|
+
// Phase 3 picker pre-checks the Claude Code row when its signal
|
|
437
|
+
// fires).
|
|
436
438
|
mkdirSync(join(tempDir, ".claude"), { recursive: true });
|
|
437
439
|
|
|
438
440
|
const r = await runCli([
|
|
@@ -468,28 +470,52 @@ describe("CLI E2E — read commands", () => {
|
|
|
468
470
|
assert.equal(json.account.slug, "mock");
|
|
469
471
|
});
|
|
470
472
|
|
|
471
|
-
it("init --
|
|
473
|
+
it("init --agent claude works in empty dir (non-interactive scenario)", async () => {
|
|
472
474
|
const r = await runCli([
|
|
473
475
|
"init",
|
|
474
476
|
"--key", VALID_KEY,
|
|
475
477
|
"--url", `http://127.0.0.1:${port}`,
|
|
476
478
|
"--yes",
|
|
477
|
-
"--
|
|
479
|
+
"--agent", "claude",
|
|
478
480
|
]);
|
|
479
481
|
assert.equal(r.status, 0, `stderr: ${r.stderr}`);
|
|
480
482
|
assert.ok(existsSync(join(tempDir, ".mcp.json")));
|
|
481
483
|
});
|
|
482
484
|
|
|
483
|
-
it("init
|
|
484
|
-
// No
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
485
|
+
it("init in an empty dir under --yes configures both default targets (#1236)", async () => {
|
|
486
|
+
// Phase 3 (#1236) replaced the "No agent targets detected → refuse"
|
|
487
|
+
// branch with the two-row picker. Under --yes with no detection
|
|
488
|
+
// signals, both rows are pre-checked (the spec rationale: writing
|
|
489
|
+
// a few KB the user didn't strictly need is trivial; CI running
|
|
490
|
+
// `init --yes` on a fresh clone and writing nothing is broken
|
|
491
|
+
// automation). The CLI must succeed and configure both targets.
|
|
492
|
+
//
|
|
493
|
+
// The env-var clears below force the genuine "no signal" state.
|
|
494
|
+
// The test host's shell may have CLAUDECODE=1 (Claude Code
|
|
495
|
+
// session), CURSOR_AGENT=1, etc. — without clearing those, the
|
|
496
|
+
// subprocess detection would pre-fire, defeating the test.
|
|
497
|
+
const r = await runCli(
|
|
498
|
+
[
|
|
499
|
+
"init",
|
|
500
|
+
"--key", VALID_KEY,
|
|
501
|
+
"--url", `http://127.0.0.1:${port}`,
|
|
502
|
+
"--yes",
|
|
503
|
+
"--json",
|
|
504
|
+
],
|
|
505
|
+
{
|
|
506
|
+
CLAUDECODE: "",
|
|
507
|
+
CURSOR_AGENT: "",
|
|
508
|
+
CURSOR_CLI: "",
|
|
509
|
+
GEMINI_CLI: "",
|
|
510
|
+
CLINE_ACTIVE: "",
|
|
511
|
+
},
|
|
512
|
+
);
|
|
513
|
+
assert.equal(r.status, 0, `stderr: ${r.stderr}`);
|
|
514
|
+
const json = JSON.parse(r.stdout);
|
|
515
|
+
assert.equal(json.action, "initialized");
|
|
516
|
+
// Both targets present: claudeCode + the cohort.
|
|
517
|
+
assert.ok(json.vendors.includes("claudeCode"));
|
|
518
|
+
assert.ok(json.vendors.length > 1, "cohort vendors must also be configured");
|
|
493
519
|
});
|
|
494
520
|
|
|
495
521
|
it("init with 401 from validate exits 2", async () => {
|
|
@@ -108,9 +108,12 @@ describe("file-write.mjs integration — round-trip", () => {
|
|
|
108
108
|
}
|
|
109
109
|
});
|
|
110
110
|
|
|
111
|
-
it("writes to global dir under HOME with --global", () => {
|
|
111
|
+
it("writes to global dir under HOME with --global --agent claudeCode", () => {
|
|
112
112
|
const skill = multiFileSkill();
|
|
113
|
-
const result = writeSkillDir(skill, {
|
|
113
|
+
const result = writeSkillDir(skill, {
|
|
114
|
+
global: true,
|
|
115
|
+
vendors: ["claudeCode"],
|
|
116
|
+
});
|
|
114
117
|
assert.equal(result.written.length, 1);
|
|
115
118
|
const dir = result.written[0];
|
|
116
119
|
assert.ok(dir.startsWith(process.env.HOME), "should write under HOME");
|
|
@@ -190,13 +193,13 @@ describe("file-write.mjs integration — orphan recovery", () => {
|
|
|
190
193
|
|
|
191
194
|
it("cleanupOrphans removes injected .tmp/ (with live siblings) and .old/ across all roots", () => {
|
|
192
195
|
// Inject orphans in claudeProject root, claudeGlobal root, and
|
|
193
|
-
//
|
|
196
|
+
// agentsProject root. .tmp/ entries need a live sibling so the
|
|
194
197
|
// safety invariant doesn't preserve them as recoverable.
|
|
195
198
|
const claudeProjectRoot = join(process.cwd(), ".claude", "skills");
|
|
196
|
-
const
|
|
199
|
+
const agentsRoot = join(process.cwd(), ".agents", "skills");
|
|
197
200
|
const globalRoot = join(process.env.HOME, ".claude", "skills");
|
|
198
201
|
mkdirSync(claudeProjectRoot, { recursive: true });
|
|
199
|
-
mkdirSync(
|
|
202
|
+
mkdirSync(agentsRoot, { recursive: true });
|
|
200
203
|
mkdirSync(globalRoot, { recursive: true });
|
|
201
204
|
|
|
202
205
|
// ghost-1: live sibling + .tmp + .old (.tmp gets cleaned because live exists)
|
|
@@ -205,8 +208,8 @@ describe("file-write.mjs integration — orphan recovery", () => {
|
|
|
205
208
|
mkdirSync(join(claudeProjectRoot, "ghost-1.old"));
|
|
206
209
|
writeFileSync(join(claudeProjectRoot, "ghost-1.tmp", "garbage.txt"), "leftover");
|
|
207
210
|
|
|
208
|
-
// ghost-2: just a .old/ in the
|
|
209
|
-
mkdirSync(join(
|
|
211
|
+
// ghost-2: just a .old/ in the agents cohort root (.old has no invariant)
|
|
212
|
+
mkdirSync(join(agentsRoot, "ghost-2.old"));
|
|
210
213
|
|
|
211
214
|
// ghost-3: live sibling + .tmp in the global root
|
|
212
215
|
mkdirSync(join(globalRoot, "ghost-3"));
|
|
@@ -217,7 +220,7 @@ describe("file-write.mjs integration — orphan recovery", () => {
|
|
|
217
220
|
|
|
218
221
|
assert.ok(!existsSync(join(claudeProjectRoot, "ghost-1.tmp")));
|
|
219
222
|
assert.ok(!existsSync(join(claudeProjectRoot, "ghost-1.old")));
|
|
220
|
-
assert.ok(!existsSync(join(
|
|
223
|
+
assert.ok(!existsSync(join(agentsRoot, "ghost-2.old")));
|
|
221
224
|
assert.ok(!existsSync(join(globalRoot, "ghost-3.tmp")));
|
|
222
225
|
// Live siblings preserved
|
|
223
226
|
assert.ok(existsSync(join(claudeProjectRoot, "ghost-1")));
|
|
@@ -272,14 +275,32 @@ describe("file-write.mjs integration — remove + .gitignore", () => {
|
|
|
272
275
|
}
|
|
273
276
|
});
|
|
274
277
|
|
|
275
|
-
it("write to
|
|
278
|
+
it("write to .agents/skills/ creates .gitignore entry; remove does not delete it", () => {
|
|
276
279
|
writeSkillDir(multiFileSkill(), { vendors: ["cursor"] });
|
|
277
280
|
const giBefore = readFileSync(join(process.cwd(), ".gitignore"), "utf-8");
|
|
278
|
-
assert.match(giBefore,
|
|
281
|
+
assert.match(giBefore, /\.agents\/skills\//);
|
|
279
282
|
|
|
280
283
|
removeSkillDir("pdf-helper", { vendors: ["cursor"] });
|
|
281
284
|
const giAfter = readFileSync(join(process.cwd(), ".gitignore"), "utf-8");
|
|
282
285
|
// remove should not touch .gitignore
|
|
283
286
|
assert.equal(giAfter, giBefore);
|
|
284
287
|
});
|
|
288
|
+
|
|
289
|
+
it("cohort dedupe end-to-end: vendors=[cursor, windsurf] writes ONE skill", () => {
|
|
290
|
+
const skill = multiFileSkill();
|
|
291
|
+
const result = writeSkillDir(skill, { vendors: ["cursor", "windsurf"] });
|
|
292
|
+
assert.equal(
|
|
293
|
+
result.written.length,
|
|
294
|
+
1,
|
|
295
|
+
"cohort vendors must collapse to a single write",
|
|
296
|
+
);
|
|
297
|
+
const dir = result.written[0];
|
|
298
|
+
assert.ok(
|
|
299
|
+
dir.includes(join(".agents", "skills", "pdf-helper")),
|
|
300
|
+
`expected dir under .agents/skills/, got ${dir}`,
|
|
301
|
+
);
|
|
302
|
+
for (const file of skill.files) {
|
|
303
|
+
assert.ok(existsSync(join(dir, file.path)));
|
|
304
|
+
}
|
|
305
|
+
});
|
|
285
306
|
});
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for src/lib/agent-registry.mjs (#1234).
|
|
3
|
+
*
|
|
4
|
+
* Locks the registry shape: 7 entries, no duplicate keys, no
|
|
5
|
+
* duplicate aliases, all targets resolvable in
|
|
6
|
+
* `resolvePlacementDir`. The registry is the single source of truth
|
|
7
|
+
* for placement decisions; a regression here breaks every downstream
|
|
8
|
+
* caller (file-write, cli-config, mcp-merge, gitignore).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, it, beforeEach, afterEach } from "node:test";
|
|
12
|
+
import assert from "node:assert/strict";
|
|
13
|
+
import { mkdtempSync, mkdirSync, rmSync } from "node:fs";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { tmpdir } from "node:os";
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
AGENT_REGISTRY,
|
|
19
|
+
getAgentByKey,
|
|
20
|
+
getAgentByAlias,
|
|
21
|
+
} from "../../lib/agent-registry.mjs";
|
|
22
|
+
import { resolvePlacementDir } from "../../lib/file-write.mjs";
|
|
23
|
+
import {
|
|
24
|
+
captureHome,
|
|
25
|
+
setSandboxHome,
|
|
26
|
+
restoreHome,
|
|
27
|
+
} from "../helpers/sandbox-home.mjs";
|
|
28
|
+
|
|
29
|
+
const EXPECTED_KEYS = [
|
|
30
|
+
"claudeCode",
|
|
31
|
+
"cursor",
|
|
32
|
+
"windsurf",
|
|
33
|
+
"gemini",
|
|
34
|
+
"codex",
|
|
35
|
+
"cline",
|
|
36
|
+
"copilot",
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
describe("AGENT_REGISTRY — shape", () => {
|
|
40
|
+
it("contains exactly the seven supported agents", () => {
|
|
41
|
+
const keys = AGENT_REGISTRY.map((entry) => entry.key);
|
|
42
|
+
assert.deepEqual(keys.sort(), [...EXPECTED_KEYS].sort());
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("each entry has the required fields", () => {
|
|
46
|
+
for (const entry of AGENT_REGISTRY) {
|
|
47
|
+
assert.equal(typeof entry.key, "string", `key for ${entry.key}`);
|
|
48
|
+
assert.equal(typeof entry.displayName, "string", `displayName for ${entry.key}`);
|
|
49
|
+
assert.ok(Array.isArray(entry.aliases), `aliases for ${entry.key}`);
|
|
50
|
+
assert.equal(typeof entry.projectTarget, "string", `projectTarget for ${entry.key}`);
|
|
51
|
+
assert.ok(
|
|
52
|
+
entry.globalTarget === null || typeof entry.globalTarget === "string",
|
|
53
|
+
`globalTarget for ${entry.key} must be string|null`,
|
|
54
|
+
);
|
|
55
|
+
assert.equal(typeof entry.hasMcp, "boolean", `hasMcp for ${entry.key} must be boolean`);
|
|
56
|
+
assert.ok(
|
|
57
|
+
Array.isArray(entry.detectionSignals),
|
|
58
|
+
`detectionSignals for ${entry.key} must be an array`,
|
|
59
|
+
);
|
|
60
|
+
assert.ok(
|
|
61
|
+
entry.detectionSignals.length > 0,
|
|
62
|
+
`detectionSignals for ${entry.key} must be non-empty`,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("every detection signal has a valid type and a non-empty value", () => {
|
|
68
|
+
// Locks the DetectionSignal shape: a typo'd `type` would silently
|
|
69
|
+
// fall through detect-agents.mjs's probe switch and never fire.
|
|
70
|
+
const VALID_TYPES = new Set(["env", "home", "project"]);
|
|
71
|
+
for (const entry of AGENT_REGISTRY) {
|
|
72
|
+
for (const signal of entry.detectionSignals) {
|
|
73
|
+
assert.ok(
|
|
74
|
+
VALID_TYPES.has(signal.type),
|
|
75
|
+
`${entry.key} signal has invalid type "${signal.type}"`,
|
|
76
|
+
);
|
|
77
|
+
assert.equal(
|
|
78
|
+
typeof signal.value,
|
|
79
|
+
"string",
|
|
80
|
+
`${entry.key} signal value must be a string`,
|
|
81
|
+
);
|
|
82
|
+
assert.ok(
|
|
83
|
+
signal.value.length > 0,
|
|
84
|
+
`${entry.key} signal value must be non-empty`,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("hasMcp matches the four-vendor MCP merger inventory", () => {
|
|
91
|
+
const withMcp = AGENT_REGISTRY.filter((entry) => entry.hasMcp).map((e) => e.key).sort();
|
|
92
|
+
assert.deepEqual(
|
|
93
|
+
withMcp,
|
|
94
|
+
["claudeCode", "copilot", "cursor", "windsurf"],
|
|
95
|
+
"hasMcp must reflect which vendors have an MCP merger today",
|
|
96
|
+
);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("has no duplicate canonical keys", () => {
|
|
100
|
+
const keys = AGENT_REGISTRY.map((entry) => entry.key);
|
|
101
|
+
assert.equal(keys.length, new Set(keys).size, "duplicate canonical keys");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("has no duplicate aliases (across all entries)", () => {
|
|
105
|
+
const allAliases = AGENT_REGISTRY.flatMap((entry) => entry.aliases);
|
|
106
|
+
assert.equal(
|
|
107
|
+
allAliases.length,
|
|
108
|
+
new Set(allAliases).size,
|
|
109
|
+
"duplicate alias across registry entries",
|
|
110
|
+
);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("no alias collides with a canonical key from a different entry", () => {
|
|
114
|
+
for (const entry of AGENT_REGISTRY) {
|
|
115
|
+
for (const alias of entry.aliases) {
|
|
116
|
+
const collision = AGENT_REGISTRY.find(
|
|
117
|
+
(other) => other.key === alias && other.key !== entry.key,
|
|
118
|
+
);
|
|
119
|
+
assert.equal(
|
|
120
|
+
collision,
|
|
121
|
+
undefined,
|
|
122
|
+
`alias "${alias}" on "${entry.key}" collides with canonical key of "${collision?.key}"`,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("copilot is the only vendor with no global target", () => {
|
|
129
|
+
const noGlobal = AGENT_REGISTRY.filter((entry) => entry.globalTarget === null);
|
|
130
|
+
assert.deepEqual(
|
|
131
|
+
noGlobal.map((e) => e.key),
|
|
132
|
+
["copilot"],
|
|
133
|
+
);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe("getAgentByKey", () => {
|
|
138
|
+
it("returns the entry for a known canonical key", () => {
|
|
139
|
+
const entry = getAgentByKey("claudeCode");
|
|
140
|
+
assert.equal(entry?.key, "claudeCode");
|
|
141
|
+
assert.equal(entry?.projectTarget, "claudeProject");
|
|
142
|
+
assert.equal(entry?.globalTarget, "claudeGlobal");
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("returns undefined for an unknown key", () => {
|
|
146
|
+
assert.equal(getAgentByKey("jetbrains"), undefined);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("does not resolve aliases (only canonical keys)", () => {
|
|
150
|
+
// 'claude' is an alias of 'claudeCode' — getAgentByKey is strict.
|
|
151
|
+
assert.equal(getAgentByKey("claude"), undefined);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
describe("getAgentByAlias", () => {
|
|
156
|
+
it("resolves a canonical key to itself", () => {
|
|
157
|
+
assert.equal(getAgentByAlias("cursor"), "cursor");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("resolves the 'claude' alias to 'claudeCode'", () => {
|
|
161
|
+
assert.equal(getAgentByAlias("claude"), "claudeCode");
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("resolves the 'vscode' alias to 'copilot'", () => {
|
|
165
|
+
assert.equal(getAgentByAlias("vscode"), "copilot");
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("returns null for an unknown name", () => {
|
|
169
|
+
assert.equal(getAgentByAlias("jetbrains"), null);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("is case-sensitive", () => {
|
|
173
|
+
assert.equal(getAgentByAlias("CLAUDE"), null);
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
describe("AGENT_REGISTRY — placement targets resolve", () => {
|
|
178
|
+
let sandbox;
|
|
179
|
+
let originalCwd;
|
|
180
|
+
/** @type {import("../helpers/sandbox-home.mjs").HomeEnvSnapshot} */
|
|
181
|
+
let originalHomeEnv;
|
|
182
|
+
|
|
183
|
+
beforeEach(() => {
|
|
184
|
+
sandbox = mkdtempSync(join(tmpdir(), "cli-agent-reg-"));
|
|
185
|
+
mkdirSync(join(sandbox, "project"), { recursive: true });
|
|
186
|
+
mkdirSync(join(sandbox, "home"), { recursive: true });
|
|
187
|
+
originalCwd = process.cwd();
|
|
188
|
+
originalHomeEnv = captureHome();
|
|
189
|
+
process.chdir(join(sandbox, "project"));
|
|
190
|
+
setSandboxHome(join(sandbox, "home"));
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
afterEach(() => {
|
|
194
|
+
process.chdir(originalCwd);
|
|
195
|
+
restoreHome(originalHomeEnv);
|
|
196
|
+
if (sandbox) rmSync(sandbox, { recursive: true, force: true });
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("every projectTarget resolves without throwing", () => {
|
|
200
|
+
for (const entry of AGENT_REGISTRY) {
|
|
201
|
+
const dir = resolvePlacementDir(entry.projectTarget, "test-skill");
|
|
202
|
+
assert.equal(typeof dir, "string");
|
|
203
|
+
assert.ok(dir.length > 0);
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("every non-null globalTarget resolves without throwing", () => {
|
|
208
|
+
for (const entry of AGENT_REGISTRY) {
|
|
209
|
+
if (entry.globalTarget === null) continue;
|
|
210
|
+
const dir = resolvePlacementDir(entry.globalTarget, "test-skill");
|
|
211
|
+
assert.equal(typeof dir, "string");
|
|
212
|
+
assert.ok(dir.length > 0);
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
});
|