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
|
@@ -0,0 +1,631 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* E2E permutation matrix for the `--agent` flag (placement epic
|
|
3
|
+
* #876, #1234, #1235, #1236).
|
|
4
|
+
*
|
|
5
|
+
* These tests verify USER INTENT, not implementation internals. Each
|
|
6
|
+
* case spawns the real CLI binary, then asserts on the user-visible
|
|
7
|
+
* post-state: which directories exist on disk, what their SKILL.md
|
|
8
|
+
* frontmatter actually says, what `.gitignore` contains, and the
|
|
9
|
+
* process exit code + stderr text.
|
|
10
|
+
*
|
|
11
|
+
* Unit tests under `src/test/lib/file-write-placement.test.mjs`
|
|
12
|
+
* already cover the data layer (`placementTargetsFor` return values).
|
|
13
|
+
* The gap this file closes is the user-layer: "after I run init
|
|
14
|
+
* with these flags, what do I see in my project on disk?"
|
|
15
|
+
*
|
|
16
|
+
* The harness pattern is the same as the read-commands E2E file —
|
|
17
|
+
* shared `createMockServer`, per-test mkdtemp for cwd + HOME, and a
|
|
18
|
+
* `runCli` helper that always resolves with stdout/stderr/status.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { describe, it, before, after, beforeEach, afterEach } from "node:test";
|
|
22
|
+
import assert from "node:assert/strict";
|
|
23
|
+
import {
|
|
24
|
+
mkdtempSync,
|
|
25
|
+
rmSync,
|
|
26
|
+
existsSync,
|
|
27
|
+
readFileSync,
|
|
28
|
+
} from "node:fs";
|
|
29
|
+
import { join, dirname, resolve } from "node:path";
|
|
30
|
+
import { tmpdir } from "node:os";
|
|
31
|
+
import { execFile } from "node:child_process";
|
|
32
|
+
import { fileURLToPath } from "node:url";
|
|
33
|
+
|
|
34
|
+
import { createMockServer } from "./mock-server.mjs";
|
|
35
|
+
|
|
36
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
37
|
+
const CLI_BIN = resolve(__dirname, "../../../bin/skillrepo.mjs");
|
|
38
|
+
const VALID_KEY = "sk_live_test_e2e";
|
|
39
|
+
|
|
40
|
+
let server;
|
|
41
|
+
let port;
|
|
42
|
+
let tempDir;
|
|
43
|
+
let tempHome;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Build a `SyncSkill`-shaped object the mock server's library route
|
|
47
|
+
* returns. The SKILL.md content is a real frontmatter block so the
|
|
48
|
+
* frontmatter-validity assertions in each test are meaningful.
|
|
49
|
+
*/
|
|
50
|
+
function makeSkill(owner, name, content = `body of ${name}`) {
|
|
51
|
+
return {
|
|
52
|
+
owner,
|
|
53
|
+
name,
|
|
54
|
+
version: "1.0.0",
|
|
55
|
+
description: `${name} description`,
|
|
56
|
+
files: [
|
|
57
|
+
{
|
|
58
|
+
path: "SKILL.md",
|
|
59
|
+
content: `---\nname: ${name}\ndescription: ${name} description\n---\n\n${content}\n`,
|
|
60
|
+
sha256: "x",
|
|
61
|
+
size: 100,
|
|
62
|
+
contentType: "text/markdown",
|
|
63
|
+
},
|
|
64
|
+
],
|
|
65
|
+
updatedAt: "2025-01-01T00:00:00Z",
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function runCli(args, extraEnv = {}) {
|
|
70
|
+
return new Promise((resolve) => {
|
|
71
|
+
execFile(
|
|
72
|
+
process.execPath,
|
|
73
|
+
[CLI_BIN, ...args],
|
|
74
|
+
{
|
|
75
|
+
cwd: tempDir,
|
|
76
|
+
encoding: "utf-8",
|
|
77
|
+
timeout: 15_000,
|
|
78
|
+
env: {
|
|
79
|
+
...process.env,
|
|
80
|
+
HOME: tempHome,
|
|
81
|
+
NO_COLOR: "1",
|
|
82
|
+
NODE_NO_WARNINGS: "1",
|
|
83
|
+
SKILLREPO_ACCESS_KEY: "",
|
|
84
|
+
SKILLREPO_TIMEOUT_MS: "5000",
|
|
85
|
+
...extraEnv,
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
(err, stdout, stderr) => {
|
|
89
|
+
resolve({
|
|
90
|
+
stdout: stdout ?? "",
|
|
91
|
+
stderr: stderr ?? "",
|
|
92
|
+
status: err ? err.code ?? 1 : 0,
|
|
93
|
+
});
|
|
94
|
+
},
|
|
95
|
+
);
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Seed two skills the mock server's library endpoint returns. Two
|
|
101
|
+
* skills (rather than one) makes sure the assertions catch the
|
|
102
|
+
* common bug of "first skill writes correctly, second falls through
|
|
103
|
+
* to a wrong target."
|
|
104
|
+
*/
|
|
105
|
+
function seedTwoSkills() {
|
|
106
|
+
server.setLibraryResponse({
|
|
107
|
+
skills: [
|
|
108
|
+
makeSkill("alice", "pdf-helper"),
|
|
109
|
+
makeSkill("bob", "code-review"),
|
|
110
|
+
],
|
|
111
|
+
removals: [],
|
|
112
|
+
syncedAt: "x",
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const SEEDED_NAMES = ["pdf-helper", "code-review"];
|
|
117
|
+
|
|
118
|
+
/** Read a SKILL.md file and assert it has a valid `name:`/`description:` frontmatter block. */
|
|
119
|
+
function assertValidSkillFile(filePath, expectedName) {
|
|
120
|
+
assert.ok(existsSync(filePath), `expected skill file at ${filePath}`);
|
|
121
|
+
const content = readFileSync(filePath, "utf-8");
|
|
122
|
+
// Frontmatter fence must be present.
|
|
123
|
+
assert.match(content, /^---\r?\n/, `frontmatter fence missing in ${filePath}`);
|
|
124
|
+
// `name:` must match the seeded skill.
|
|
125
|
+
assert.match(
|
|
126
|
+
content,
|
|
127
|
+
new RegExp(`\\nname:\\s*${expectedName}\\b`),
|
|
128
|
+
`name field missing or wrong in ${filePath}`,
|
|
129
|
+
);
|
|
130
|
+
// `description:` must be present.
|
|
131
|
+
assert.match(
|
|
132
|
+
content,
|
|
133
|
+
/\ndescription:\s*\S/,
|
|
134
|
+
`description field missing in ${filePath}`,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
describe("CLI E2E — --agent permutation matrix", () => {
|
|
139
|
+
before(async () => {
|
|
140
|
+
server = createMockServer({});
|
|
141
|
+
port = await server.start();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
after(async () => {
|
|
145
|
+
if (server) await server.stop();
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
beforeEach(() => {
|
|
149
|
+
tempDir = mkdtempSync(join(tmpdir(), "cli-e2e-agent-"));
|
|
150
|
+
tempHome = mkdtempSync(join(tmpdir(), "cli-e2e-home-"));
|
|
151
|
+
server.setLibraryResponse({ skills: [], removals: [], syncedAt: "x" });
|
|
152
|
+
server.setEtag(null);
|
|
153
|
+
server.clearSkillResponses();
|
|
154
|
+
server.setSearchResponse({ skills: [], pagination: { total: 0, limit: 20, offset: 0 } });
|
|
155
|
+
server.clearAddResponses();
|
|
156
|
+
server.clearRemoveResponses();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
afterEach(() => {
|
|
160
|
+
if (tempDir) rmSync(tempDir, { recursive: true, force: true });
|
|
161
|
+
if (tempHome) rmSync(tempHome, { recursive: true, force: true });
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// ── init permutations (project scope) ──────────────────────────────
|
|
165
|
+
|
|
166
|
+
it("init --agent claude writes Claude-only placement", async () => {
|
|
167
|
+
seedTwoSkills();
|
|
168
|
+
const r = await runCli([
|
|
169
|
+
"init",
|
|
170
|
+
"--key", VALID_KEY,
|
|
171
|
+
"--url", `http://127.0.0.1:${port}`,
|
|
172
|
+
"--yes",
|
|
173
|
+
"--agent", "claude",
|
|
174
|
+
]);
|
|
175
|
+
assert.equal(r.status, 0, `stderr: ${r.stderr}`);
|
|
176
|
+
|
|
177
|
+
// Config saved under tempHome
|
|
178
|
+
assert.ok(existsSync(join(tempHome, ".claude", "skillrepo", "config.json")));
|
|
179
|
+
|
|
180
|
+
// Each seeded skill is on disk under .claude/skills/<name>/SKILL.md
|
|
181
|
+
for (const name of SEEDED_NAMES) {
|
|
182
|
+
assertValidSkillFile(join(tempDir, ".claude", "skills", name, "SKILL.md"), name);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// .gitignore protects the Claude path but NOT the agents path
|
|
186
|
+
const gi = readFileSync(join(tempDir, ".gitignore"), "utf-8");
|
|
187
|
+
assert.match(gi, /(^|\n)\.claude\/skills\/(\r?\n|$)/, `.gitignore missing .claude/skills/: ${gi}`);
|
|
188
|
+
assert.doesNotMatch(gi, /(^|\n)\.agents\/skills\/(\r?\n|$)/, `.gitignore should not contain .agents/skills/: ${gi}`);
|
|
189
|
+
|
|
190
|
+
// No agents-cohort directory created
|
|
191
|
+
assert.ok(!existsSync(join(tempDir, ".agents", "skills")), ".agents/skills/ should not exist for claude-only");
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("init --agent agents writes agents-cohort placement only", async () => {
|
|
195
|
+
seedTwoSkills();
|
|
196
|
+
const r = await runCli([
|
|
197
|
+
"init",
|
|
198
|
+
"--key", VALID_KEY,
|
|
199
|
+
"--url", `http://127.0.0.1:${port}`,
|
|
200
|
+
"--yes",
|
|
201
|
+
"--agent", "agents",
|
|
202
|
+
]);
|
|
203
|
+
assert.equal(r.status, 0, `stderr: ${r.stderr}`);
|
|
204
|
+
|
|
205
|
+
for (const name of SEEDED_NAMES) {
|
|
206
|
+
assertValidSkillFile(join(tempDir, ".agents", "skills", name, "SKILL.md"), name);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const gi = readFileSync(join(tempDir, ".gitignore"), "utf-8");
|
|
210
|
+
assert.match(gi, /(^|\n)\.agents\/skills\/(\r?\n|$)/, `.gitignore missing .agents/skills/: ${gi}`);
|
|
211
|
+
assert.doesNotMatch(gi, /(^|\n)\.claude\/skills\/(\r?\n|$)/, `.gitignore should not contain .claude/skills/: ${gi}`);
|
|
212
|
+
|
|
213
|
+
assert.ok(!existsSync(join(tempDir, ".claude", "skills")), ".claude/skills/ should not exist for agents-only");
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("init --agent claude,agents writes both placements with identical content", async () => {
|
|
217
|
+
seedTwoSkills();
|
|
218
|
+
const r = await runCli([
|
|
219
|
+
"init",
|
|
220
|
+
"--key", VALID_KEY,
|
|
221
|
+
"--url", `http://127.0.0.1:${port}`,
|
|
222
|
+
"--yes",
|
|
223
|
+
"--agent", "claude,agents",
|
|
224
|
+
]);
|
|
225
|
+
assert.equal(r.status, 0, `stderr: ${r.stderr}`);
|
|
226
|
+
|
|
227
|
+
for (const name of SEEDED_NAMES) {
|
|
228
|
+
const claudePath = join(tempDir, ".claude", "skills", name, "SKILL.md");
|
|
229
|
+
const agentsPath = join(tempDir, ".agents", "skills", name, "SKILL.md");
|
|
230
|
+
assertValidSkillFile(claudePath, name);
|
|
231
|
+
assertValidSkillFile(agentsPath, name);
|
|
232
|
+
// Identical content across the two targets
|
|
233
|
+
assert.equal(
|
|
234
|
+
readFileSync(claudePath, "utf-8"),
|
|
235
|
+
readFileSync(agentsPath, "utf-8"),
|
|
236
|
+
`SKILL.md content should be identical between claude and agents targets for ${name}`,
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const gi = readFileSync(join(tempDir, ".gitignore"), "utf-8");
|
|
241
|
+
assert.match(gi, /(^|\n)\.claude\/skills\/(\r?\n|$)/);
|
|
242
|
+
assert.match(gi, /(^|\n)\.agents\/skills\/(\r?\n|$)/);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("init --agent none configures credentials but writes no skills", async () => {
|
|
246
|
+
seedTwoSkills();
|
|
247
|
+
const r = await runCli([
|
|
248
|
+
"init",
|
|
249
|
+
"--key", VALID_KEY,
|
|
250
|
+
"--url", `http://127.0.0.1:${port}`,
|
|
251
|
+
"--yes",
|
|
252
|
+
"--agent", "none",
|
|
253
|
+
]);
|
|
254
|
+
assert.equal(r.status, 0, `stderr: ${r.stderr}`);
|
|
255
|
+
|
|
256
|
+
// Config still happens
|
|
257
|
+
assert.ok(existsSync(join(tempHome, ".claude", "skillrepo", "config.json")));
|
|
258
|
+
// Gitignore still happens — at minimum, .env.local must be in it
|
|
259
|
+
const giPath = join(tempDir, ".gitignore");
|
|
260
|
+
assert.ok(existsSync(giPath), ".gitignore should exist");
|
|
261
|
+
const gi = readFileSync(giPath, "utf-8");
|
|
262
|
+
assert.match(gi, /(^|\n)\.env\.local(\r?\n|$)/, `.gitignore missing .env.local: ${gi}`);
|
|
263
|
+
|
|
264
|
+
// No skill placement directories created
|
|
265
|
+
assert.ok(!existsSync(join(tempDir, ".claude", "skills")), ".claude/skills/ must not be written for --agent none");
|
|
266
|
+
assert.ok(!existsSync(join(tempDir, ".agents", "skills")), ".agents/skills/ must not be written for --agent none");
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it("init --agent cursor (vendor alias) writes to .agents/skills, not .cursor/skills", async () => {
|
|
270
|
+
seedTwoSkills();
|
|
271
|
+
const r = await runCli([
|
|
272
|
+
"init",
|
|
273
|
+
"--key", VALID_KEY,
|
|
274
|
+
"--url", `http://127.0.0.1:${port}`,
|
|
275
|
+
"--yes",
|
|
276
|
+
"--agent", "cursor",
|
|
277
|
+
]);
|
|
278
|
+
assert.equal(r.status, 0, `stderr: ${r.stderr}`);
|
|
279
|
+
|
|
280
|
+
for (const name of SEEDED_NAMES) {
|
|
281
|
+
assertValidSkillFile(join(tempDir, ".agents", "skills", name, "SKILL.md"), name);
|
|
282
|
+
}
|
|
283
|
+
// The product decision: Cursor uses the cohort `.agents/skills/`,
|
|
284
|
+
// NOT a per-vendor `.cursor/skills/` directory.
|
|
285
|
+
assert.ok(
|
|
286
|
+
!existsSync(join(tempDir, ".cursor", "skills")),
|
|
287
|
+
".cursor/skills/ must not exist — Cursor placement uses .agents/skills/",
|
|
288
|
+
);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it("init --agent claude-code (kebab alias) writes to .claude/skills", async () => {
|
|
292
|
+
seedTwoSkills();
|
|
293
|
+
const r = await runCli([
|
|
294
|
+
"init",
|
|
295
|
+
"--key", VALID_KEY,
|
|
296
|
+
"--url", `http://127.0.0.1:${port}`,
|
|
297
|
+
"--yes",
|
|
298
|
+
"--agent", "claude-code",
|
|
299
|
+
]);
|
|
300
|
+
assert.equal(r.status, 0, `stderr: ${r.stderr}`);
|
|
301
|
+
|
|
302
|
+
for (const name of SEEDED_NAMES) {
|
|
303
|
+
assertValidSkillFile(join(tempDir, ".claude", "skills", name, "SKILL.md"), name);
|
|
304
|
+
}
|
|
305
|
+
assert.ok(!existsSync(join(tempDir, ".agents", "skills")), ".agents/skills/ should not exist for claude-code alias");
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it("init --agent gemini,codex,cline dedupes to a single .agents/skills/<name> per skill", async () => {
|
|
309
|
+
seedTwoSkills();
|
|
310
|
+
const r = await runCli([
|
|
311
|
+
"init",
|
|
312
|
+
"--key", VALID_KEY,
|
|
313
|
+
"--url", `http://127.0.0.1:${port}`,
|
|
314
|
+
"--yes",
|
|
315
|
+
"--agent", "gemini,codex,cline",
|
|
316
|
+
]);
|
|
317
|
+
assert.equal(r.status, 0, `stderr: ${r.stderr}`);
|
|
318
|
+
|
|
319
|
+
for (const name of SEEDED_NAMES) {
|
|
320
|
+
assertValidSkillFile(join(tempDir, ".agents", "skills", name, "SKILL.md"), name);
|
|
321
|
+
}
|
|
322
|
+
// No per-vendor directories — these three vendors all collapse
|
|
323
|
+
// onto the cohort `.agents/skills/` target.
|
|
324
|
+
assert.ok(!existsSync(join(tempDir, ".gemini", "skills")), ".gemini/skills/ should not exist");
|
|
325
|
+
assert.ok(!existsSync(join(tempDir, ".codex", "skills")), ".codex/skills/ should not exist");
|
|
326
|
+
assert.ok(!existsSync(join(tempDir, ".cline", "skills")), ".cline/skills/ should not exist");
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
// ── init --global permutations (personal scope) ────────────────────
|
|
330
|
+
|
|
331
|
+
it("init --global --agent claude writes to ~/.claude/skills/, not project", async () => {
|
|
332
|
+
seedTwoSkills();
|
|
333
|
+
const r = await runCli([
|
|
334
|
+
"init",
|
|
335
|
+
"--key", VALID_KEY,
|
|
336
|
+
"--url", `http://127.0.0.1:${port}`,
|
|
337
|
+
"--yes",
|
|
338
|
+
"--global",
|
|
339
|
+
"--agent", "claude",
|
|
340
|
+
"--json",
|
|
341
|
+
]);
|
|
342
|
+
assert.equal(r.status, 0, `stderr: ${r.stderr}`);
|
|
343
|
+
|
|
344
|
+
for (const name of SEEDED_NAMES) {
|
|
345
|
+
assertValidSkillFile(
|
|
346
|
+
join(tempHome, ".claude", "skills", name, "SKILL.md"),
|
|
347
|
+
name,
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
// Global writes do not pollute the project directory
|
|
351
|
+
assert.ok(
|
|
352
|
+
!existsSync(join(tempDir, ".claude", "skills")),
|
|
353
|
+
"project .claude/skills/ should not exist when init runs with --global",
|
|
354
|
+
);
|
|
355
|
+
// Positive: --global --agent claude IS Claude-targeted, so the
|
|
356
|
+
// session-sync flow must run (the action is anything BUT
|
|
357
|
+
// "not-applicable"). Whether the hook merge succeeds depends on
|
|
358
|
+
// whether a global skillrepo is on PATH (environment-dependent —
|
|
359
|
+
// CI runners don't have one; local dev machines often do), so we
|
|
360
|
+
// assert on the attempt, not the file. Pairs with the windsurf
|
|
361
|
+
// negative assertion that locks action === "not-applicable".
|
|
362
|
+
const summary = JSON.parse(r.stdout);
|
|
363
|
+
assert.notEqual(
|
|
364
|
+
summary.sessionSync.action,
|
|
365
|
+
"not-applicable",
|
|
366
|
+
"--global --agent claude is Claude-targeted; sessionSync must attempt the install",
|
|
367
|
+
);
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it("init --global --agent windsurf writes to ~/.codeium/windsurf/skills (vendor-specific)", async () => {
|
|
371
|
+
seedTwoSkills();
|
|
372
|
+
const r = await runCli([
|
|
373
|
+
"init",
|
|
374
|
+
"--key", VALID_KEY,
|
|
375
|
+
"--url", `http://127.0.0.1:${port}`,
|
|
376
|
+
"--yes",
|
|
377
|
+
"--global",
|
|
378
|
+
"--agent", "windsurf",
|
|
379
|
+
]);
|
|
380
|
+
assert.equal(r.status, 0, `stderr: ${r.stderr}`);
|
|
381
|
+
|
|
382
|
+
// Windsurf has a vendor-specific personal skills path.
|
|
383
|
+
for (const name of SEEDED_NAMES) {
|
|
384
|
+
assertValidSkillFile(
|
|
385
|
+
join(tempHome, ".codeium", "windsurf", "skills", name, "SKILL.md"),
|
|
386
|
+
name,
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
// Windsurf's globalTarget is windsurfGlobal, NOT agentsGlobal.
|
|
390
|
+
assert.ok(
|
|
391
|
+
!existsSync(join(tempHome, ".agents", "skills")),
|
|
392
|
+
"~/.agents/skills/ should not exist — Windsurf personal scope is ~/.codeium/windsurf/skills/",
|
|
393
|
+
);
|
|
394
|
+
// The Claude SessionStart hook is Claude Code-specific. A user
|
|
395
|
+
// running `--global --agent windsurf` did NOT pick Claude — the
|
|
396
|
+
// hook must not install. Locks the fix from manual E2E sweep.
|
|
397
|
+
// Assert via --json so the test is environment-independent (file
|
|
398
|
+
// presence depends on whether a global skillrepo is on PATH;
|
|
399
|
+
// sessionSync.action === "not-applicable" is the load-bearing
|
|
400
|
+
// signal that the install path was correctly skipped).
|
|
401
|
+
assert.ok(
|
|
402
|
+
!existsSync(join(tempHome, ".claude", "settings.local.json")),
|
|
403
|
+
"~/.claude/settings.local.json must NOT be installed for --global --agent windsurf",
|
|
404
|
+
);
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
it("init --global --agent copilot rejects (no personal scope)", async () => {
|
|
408
|
+
seedTwoSkills();
|
|
409
|
+
const r = await runCli([
|
|
410
|
+
"init",
|
|
411
|
+
"--key", VALID_KEY,
|
|
412
|
+
"--url", `http://127.0.0.1:${port}`,
|
|
413
|
+
"--yes",
|
|
414
|
+
"--global",
|
|
415
|
+
"--agent", "copilot",
|
|
416
|
+
]);
|
|
417
|
+
// The user passed an invalid combination; init must fail loudly.
|
|
418
|
+
assert.equal(r.status, 5, `expected exit 5 for copilot+global, got ${r.status}; stderr=${r.stderr}; stdout=${r.stdout}`);
|
|
419
|
+
assert.match(
|
|
420
|
+
r.stderr,
|
|
421
|
+
/does not support global scope|copilot/i,
|
|
422
|
+
`stderr should explain copilot has no global scope: ${r.stderr}`,
|
|
423
|
+
);
|
|
424
|
+
// No skill files should land anywhere when the combination is rejected.
|
|
425
|
+
assert.ok(!existsSync(join(tempHome, ".claude", "skills")), "no skills should be written for rejected combination");
|
|
426
|
+
assert.ok(!existsSync(join(tempHome, ".agents", "skills")), "no skills should be written for rejected combination");
|
|
427
|
+
assert.ok(!existsSync(join(tempHome, ".codeium", "windsurf", "skills")), "no skills should be written for rejected combination");
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
// ── update permutations (after a seeded init) ──────────────────────
|
|
431
|
+
|
|
432
|
+
it("update --agent claude is idempotent after init --agent claude", async () => {
|
|
433
|
+
seedTwoSkills();
|
|
434
|
+
const initR = await runCli([
|
|
435
|
+
"init",
|
|
436
|
+
"--key", VALID_KEY,
|
|
437
|
+
"--url", `http://127.0.0.1:${port}`,
|
|
438
|
+
"--yes",
|
|
439
|
+
"--agent", "claude",
|
|
440
|
+
]);
|
|
441
|
+
assert.equal(initR.status, 0, `init stderr: ${initR.stderr}`);
|
|
442
|
+
|
|
443
|
+
// Snapshot the SKILL.md content after init.
|
|
444
|
+
const skillPath = join(tempDir, ".claude", "skills", "pdf-helper", "SKILL.md");
|
|
445
|
+
const before = readFileSync(skillPath, "utf-8");
|
|
446
|
+
|
|
447
|
+
// Re-run update — should be a no-op (or at worst, a re-write with
|
|
448
|
+
// identical bytes). In either case: exit 0 and content unchanged.
|
|
449
|
+
const updateR = await runCli([
|
|
450
|
+
"update",
|
|
451
|
+
"--key", VALID_KEY,
|
|
452
|
+
"--url", `http://127.0.0.1:${port}`,
|
|
453
|
+
"--agent", "claude",
|
|
454
|
+
]);
|
|
455
|
+
assert.equal(updateR.status, 0, `update stderr: ${updateR.stderr}`);
|
|
456
|
+
|
|
457
|
+
const after = readFileSync(skillPath, "utf-8");
|
|
458
|
+
assert.equal(after, before, "SKILL.md content should be unchanged after re-running update");
|
|
459
|
+
assertValidSkillFile(skillPath, "pdf-helper");
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
it("update --agent agents after init --agent claude adds the agents placement without disturbing claude", async () => {
|
|
463
|
+
seedTwoSkills();
|
|
464
|
+
const initR = await runCli([
|
|
465
|
+
"init",
|
|
466
|
+
"--key", VALID_KEY,
|
|
467
|
+
"--url", `http://127.0.0.1:${port}`,
|
|
468
|
+
"--yes",
|
|
469
|
+
"--agent", "claude",
|
|
470
|
+
]);
|
|
471
|
+
assert.equal(initR.status, 0, `init stderr: ${initR.stderr}`);
|
|
472
|
+
|
|
473
|
+
// Pre-state: claude path exists, agents path does not.
|
|
474
|
+
assert.ok(existsSync(join(tempDir, ".claude", "skills", "pdf-helper", "SKILL.md")));
|
|
475
|
+
assert.ok(!existsSync(join(tempDir, ".agents", "skills")));
|
|
476
|
+
|
|
477
|
+
const claudeBefore = readFileSync(
|
|
478
|
+
join(tempDir, ".claude", "skills", "pdf-helper", "SKILL.md"),
|
|
479
|
+
"utf-8",
|
|
480
|
+
);
|
|
481
|
+
|
|
482
|
+
// ETag served from a fresh state may make update return 304;
|
|
483
|
+
// clear it so update fetches fresh and writes to the new target.
|
|
484
|
+
server.setEtag(null);
|
|
485
|
+
|
|
486
|
+
const updateR = await runCli([
|
|
487
|
+
"update",
|
|
488
|
+
"--key", VALID_KEY,
|
|
489
|
+
"--url", `http://127.0.0.1:${port}`,
|
|
490
|
+
"--agent", "agents",
|
|
491
|
+
]);
|
|
492
|
+
assert.equal(updateR.status, 0, `update stderr: ${updateR.stderr}`);
|
|
493
|
+
|
|
494
|
+
// agents placement now exists for both seeded skills.
|
|
495
|
+
for (const name of SEEDED_NAMES) {
|
|
496
|
+
assertValidSkillFile(join(tempDir, ".agents", "skills", name, "SKILL.md"), name);
|
|
497
|
+
}
|
|
498
|
+
// claude placement is unchanged.
|
|
499
|
+
const claudeAfter = readFileSync(
|
|
500
|
+
join(tempDir, ".claude", "skills", "pdf-helper", "SKILL.md"),
|
|
501
|
+
"utf-8",
|
|
502
|
+
);
|
|
503
|
+
assert.equal(
|
|
504
|
+
claudeAfter,
|
|
505
|
+
claudeBefore,
|
|
506
|
+
"claude SKILL.md must remain unchanged when update targets a different cohort",
|
|
507
|
+
);
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
// ── failure-mode tests ─────────────────────────────────────────────
|
|
511
|
+
|
|
512
|
+
it("init --agent foo rejects unknown agent token", async () => {
|
|
513
|
+
seedTwoSkills();
|
|
514
|
+
const r = await runCli([
|
|
515
|
+
"init",
|
|
516
|
+
"--key", VALID_KEY,
|
|
517
|
+
"--url", `http://127.0.0.1:${port}`,
|
|
518
|
+
"--yes",
|
|
519
|
+
"--agent", "foo",
|
|
520
|
+
]);
|
|
521
|
+
assert.equal(r.status, 5, `expected exit 5; stderr=${r.stderr}`);
|
|
522
|
+
assert.match(
|
|
523
|
+
r.stderr,
|
|
524
|
+
/Unknown --agent target|foo/i,
|
|
525
|
+
`stderr should mention the unknown token: ${r.stderr}`,
|
|
526
|
+
);
|
|
527
|
+
// No skill files written.
|
|
528
|
+
assert.ok(!existsSync(join(tempDir, ".claude", "skills")));
|
|
529
|
+
assert.ok(!existsSync(join(tempDir, ".agents", "skills")));
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
it("init --agent claude,none rejects mutually-exclusive tokens", async () => {
|
|
533
|
+
seedTwoSkills();
|
|
534
|
+
const r = await runCli([
|
|
535
|
+
"init",
|
|
536
|
+
"--key", VALID_KEY,
|
|
537
|
+
"--url", `http://127.0.0.1:${port}`,
|
|
538
|
+
"--yes",
|
|
539
|
+
"--agent", "claude,none",
|
|
540
|
+
]);
|
|
541
|
+
assert.equal(r.status, 5, `expected exit 5; stderr=${r.stderr}`);
|
|
542
|
+
assert.match(
|
|
543
|
+
r.stderr,
|
|
544
|
+
/cannot mix.*none|none.*cannot/i,
|
|
545
|
+
`stderr should explain none is mutually exclusive: ${r.stderr}`,
|
|
546
|
+
);
|
|
547
|
+
assert.ok(!existsSync(join(tempDir, ".claude", "skills")));
|
|
548
|
+
assert.ok(!existsSync(join(tempDir, ".agents", "skills")));
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
it("init --agent '' rejects empty value", async () => {
|
|
552
|
+
seedTwoSkills();
|
|
553
|
+
const r = await runCli([
|
|
554
|
+
"init",
|
|
555
|
+
"--key", VALID_KEY,
|
|
556
|
+
"--url", `http://127.0.0.1:${port}`,
|
|
557
|
+
"--yes",
|
|
558
|
+
"--agent", "",
|
|
559
|
+
]);
|
|
560
|
+
assert.equal(r.status, 5, `expected exit 5; stderr=${r.stderr}`);
|
|
561
|
+
// The CLI should call out that --agent received nothing useful.
|
|
562
|
+
assert.match(
|
|
563
|
+
r.stderr,
|
|
564
|
+
/agent/i,
|
|
565
|
+
`stderr should mention --agent: ${r.stderr}`,
|
|
566
|
+
);
|
|
567
|
+
assert.ok(!existsSync(join(tempDir, ".claude", "skills")));
|
|
568
|
+
assert.ok(!existsSync(join(tempDir, ".agents", "skills")));
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
it("init --ide claude rejects the legacy flag with a v2→v3 migration hint", async () => {
|
|
572
|
+
seedTwoSkills();
|
|
573
|
+
const r = await runCli([
|
|
574
|
+
"init",
|
|
575
|
+
"--key", VALID_KEY,
|
|
576
|
+
"--url", `http://127.0.0.1:${port}`,
|
|
577
|
+
"--yes",
|
|
578
|
+
"--ide", "claude",
|
|
579
|
+
]);
|
|
580
|
+
assert.equal(r.status, 5, `expected exit 5; stderr=${r.stderr}`);
|
|
581
|
+
assert.match(
|
|
582
|
+
r.stderr,
|
|
583
|
+
/--ide was renamed to --agent/,
|
|
584
|
+
`stderr should explain the v2→v3 rename: ${r.stderr}`,
|
|
585
|
+
);
|
|
586
|
+
assert.ok(!existsSync(join(tempDir, ".claude", "skills")));
|
|
587
|
+
assert.ok(!existsSync(join(tempDir, ".agents", "skills")));
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
// ── re-run idempotency ─────────────────────────────────────────────
|
|
591
|
+
|
|
592
|
+
it("init --agent claude twice in a row leaves a clean tree on disk", async () => {
|
|
593
|
+
seedTwoSkills();
|
|
594
|
+
|
|
595
|
+
const first = await runCli([
|
|
596
|
+
"init",
|
|
597
|
+
"--key", VALID_KEY,
|
|
598
|
+
"--url", `http://127.0.0.1:${port}`,
|
|
599
|
+
"--yes",
|
|
600
|
+
"--agent", "claude",
|
|
601
|
+
]);
|
|
602
|
+
assert.equal(first.status, 0, `first init stderr: ${first.stderr}`);
|
|
603
|
+
|
|
604
|
+
const skillPath = join(tempDir, ".claude", "skills", "pdf-helper", "SKILL.md");
|
|
605
|
+
const before = readFileSync(skillPath, "utf-8");
|
|
606
|
+
|
|
607
|
+
const second = await runCli([
|
|
608
|
+
"init",
|
|
609
|
+
"--key", VALID_KEY,
|
|
610
|
+
"--url", `http://127.0.0.1:${port}`,
|
|
611
|
+
"--yes",
|
|
612
|
+
"--agent", "claude",
|
|
613
|
+
]);
|
|
614
|
+
assert.equal(second.status, 0, `second init stderr: ${second.stderr}`);
|
|
615
|
+
|
|
616
|
+
// SKILL.md content unchanged after the second run.
|
|
617
|
+
const after = readFileSync(skillPath, "utf-8");
|
|
618
|
+
assert.equal(after, before, "second init must not alter SKILL.md content");
|
|
619
|
+
|
|
620
|
+
// No leftover .tmp / .old siblings under the placement root.
|
|
621
|
+
const placementRoot = join(tempDir, ".claude", "skills");
|
|
622
|
+
const { readdirSync } = await import("node:fs");
|
|
623
|
+
const entries = readdirSync(placementRoot);
|
|
624
|
+
const stale = entries.filter((e) => e.endsWith(".tmp") || e.endsWith(".old"));
|
|
625
|
+
assert.deepEqual(
|
|
626
|
+
stale,
|
|
627
|
+
[],
|
|
628
|
+
`placement root should have no .tmp/.old leftovers; found: ${stale.join(", ")}`,
|
|
629
|
+
);
|
|
630
|
+
});
|
|
631
|
+
});
|