skillrepo 2.0.0 → 3.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 (49) hide show
  1. package/README.md +215 -150
  2. package/bin/skillrepo.mjs +210 -36
  3. package/package.json +6 -3
  4. package/src/commands/add.mjs +176 -0
  5. package/src/commands/get.mjs +116 -0
  6. package/src/commands/init.mjs +471 -143
  7. package/src/commands/list.mjs +176 -0
  8. package/src/commands/remove.mjs +167 -0
  9. package/src/commands/search.mjs +188 -0
  10. package/src/commands/update.mjs +67 -0
  11. package/src/lib/cli-config.mjs +230 -0
  12. package/src/lib/config.mjs +238 -0
  13. package/src/lib/detect-ides.mjs +0 -19
  14. package/src/lib/errors.mjs +264 -0
  15. package/src/lib/file-write.mjs +705 -0
  16. package/src/lib/http.mjs +817 -37
  17. package/src/lib/identifier.mjs +153 -0
  18. package/src/lib/mcp-merge.mjs +275 -0
  19. package/src/lib/mergers/gitignore.mjs +73 -18
  20. package/src/lib/paths.mjs +46 -17
  21. package/src/lib/prompt.mjs +11 -44
  22. package/src/lib/sync.mjs +305 -0
  23. package/src/test/commands/add.test.mjs +285 -0
  24. package/src/test/commands/get.test.mjs +176 -0
  25. package/src/test/commands/init.test.mjs +486 -0
  26. package/src/test/commands/list.test.mjs +172 -0
  27. package/src/test/commands/remove.test.mjs +234 -0
  28. package/src/test/commands/search.test.mjs +204 -0
  29. package/src/test/commands/update.test.mjs +164 -0
  30. package/src/test/detect-ides.test.mjs +9 -14
  31. package/src/test/dispatcher.test.mjs +224 -0
  32. package/src/test/e2e/cli-commands.test.mjs +576 -0
  33. package/src/test/e2e/mock-server.mjs +364 -22
  34. package/src/test/helpers/capture-stream.mjs +48 -0
  35. package/src/test/integration/file-write.integration.test.mjs +279 -0
  36. package/src/test/lib/cli-config.test.mjs +407 -0
  37. package/src/test/lib/config.test.mjs +257 -0
  38. package/src/test/lib/errors.test.mjs +359 -0
  39. package/src/test/lib/file-write.test.mjs +784 -0
  40. package/src/test/lib/http.test.mjs +1198 -0
  41. package/src/test/lib/identifier.test.mjs +157 -0
  42. package/src/test/lib/mcp-merge.test.mjs +345 -0
  43. package/src/test/lib/paths.test.mjs +83 -0
  44. package/src/test/lib/sync.test.mjs +514 -0
  45. package/src/test/mergers/gitignore.test.mjs +145 -20
  46. package/src/lib/write-configs.mjs +0 -202
  47. package/src/test/e2e/HANDOFF.md +0 -223
  48. package/src/test/e2e/cli-init.test.mjs +0 -213
  49. package/src/test/e2e/payload-factory.mjs +0 -22
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Unit/integration tests for src/commands/get.mjs (PR2 of #646).
3
+ */
4
+
5
+ import { describe, it, beforeEach, afterEach } from "node:test";
6
+ import assert from "node:assert/strict";
7
+ import { mkdtempSync, mkdirSync, rmSync, existsSync } from "node:fs";
8
+ import { join } from "node:path";
9
+ import { tmpdir } from "node:os";
10
+
11
+ import { runGet } from "../../commands/get.mjs";
12
+ import { resolvePlacementDir } from "../../lib/file-write.mjs";
13
+ import { CliError, EXIT_VALIDATION } from "../../lib/errors.mjs";
14
+ import { createMockServer } from "../e2e/mock-server.mjs";
15
+ import { createCaptureStream } from "../helpers/capture-stream.mjs";
16
+
17
+ let sandbox;
18
+ let server;
19
+ let serverUrl;
20
+ let originalCwd;
21
+ let originalHome;
22
+ let stdout;
23
+ const VALID_KEY = "sk_live_test";
24
+
25
+ function makeSkill(owner, name) {
26
+ return {
27
+ owner,
28
+ name,
29
+ version: "1.0.0",
30
+ description: `${name} description`,
31
+ files: [
32
+ {
33
+ path: "SKILL.md",
34
+ content: `---\nname: ${name}\ndescription: ${name} description\n---\n\nbody\n`,
35
+ sha256: "x",
36
+ size: 50,
37
+ contentType: "text/markdown",
38
+ },
39
+ {
40
+ path: "scripts/run.py",
41
+ content: "print('hi')\n",
42
+ sha256: "y",
43
+ size: 12,
44
+ contentType: "text/x-python",
45
+ },
46
+ ],
47
+ updatedAt: new Date().toISOString(),
48
+ };
49
+ }
50
+
51
+ async function setup() {
52
+ sandbox = mkdtempSync(join(tmpdir(), "cli-cmd-get-"));
53
+ mkdirSync(join(sandbox, "project"), { recursive: true });
54
+ mkdirSync(join(sandbox, "home"), { recursive: true });
55
+ originalCwd = process.cwd();
56
+ originalHome = process.env.HOME;
57
+ process.chdir(join(sandbox, "project"));
58
+ process.env.HOME = join(sandbox, "home");
59
+ delete process.env.SKILLREPO_ACCESS_KEY;
60
+ delete process.env.SKILLREPO_URL;
61
+
62
+ server = createMockServer({});
63
+ const port = await server.start();
64
+ serverUrl = `http://127.0.0.1:${port}`;
65
+
66
+ stdout = createCaptureStream();
67
+ }
68
+
69
+ async function teardown() {
70
+ if (server) await server.stop();
71
+ process.chdir(originalCwd);
72
+ process.env.HOME = originalHome;
73
+ if (sandbox) rmSync(sandbox, { recursive: true, force: true });
74
+ server = null;
75
+ }
76
+
77
+ describe("runGet — happy path", () => {
78
+ beforeEach(setup);
79
+ afterEach(teardown);
80
+
81
+ it("fetches and writes a single skill", async () => {
82
+ const skill = makeSkill("alice", "pdf-helper");
83
+ server.setSkillResponse("alice", "pdf-helper", skill);
84
+
85
+ await runGet(["--key", VALID_KEY, "--url", serverUrl, "@alice/pdf-helper"], { stdout });
86
+
87
+ const dir = resolvePlacementDir("claudeProject", "pdf-helper");
88
+ assert.ok(existsSync(join(dir, "SKILL.md")));
89
+ assert.ok(existsSync(join(dir, "scripts/run.py")));
90
+ assert.match(stdout.text(), /Fetched/);
91
+ });
92
+
93
+ it("accepts identifier without leading @", async () => {
94
+ server.setSkillResponse("alice", "pdf-helper", makeSkill("alice", "pdf-helper"));
95
+ await runGet(["--key", VALID_KEY, "--url", serverUrl, "alice/pdf-helper"], { stdout });
96
+ assert.ok(existsSync(resolvePlacementDir("claudeProject", "pdf-helper")));
97
+ });
98
+
99
+ it("--global writes to home dir", async () => {
100
+ server.setSkillResponse("alice", "pdf-helper", makeSkill("alice", "pdf-helper"));
101
+ await runGet(["--key", VALID_KEY, "--url", serverUrl, "--global", "@alice/pdf-helper"], { stdout });
102
+ const dir = resolvePlacementDir("claudeGlobal", "pdf-helper");
103
+ assert.ok(existsSync(dir));
104
+ assert.ok(dir.startsWith(process.env.HOME));
105
+ });
106
+
107
+ it("--json outputs structured result", async () => {
108
+ server.setSkillResponse("alice", "pdf-helper", makeSkill("alice", "pdf-helper"));
109
+ await runGet(["--key", VALID_KEY, "--url", serverUrl, "--json", "@alice/pdf-helper"], { stdout });
110
+ const json = JSON.parse(stdout.text());
111
+ assert.equal(json.action, "fetched");
112
+ assert.equal(json.owner, "alice");
113
+ assert.equal(json.name, "pdf-helper");
114
+ assert.equal(json.filesWritten, 2);
115
+ });
116
+ });
117
+
118
+ describe("runGet — error paths", () => {
119
+ beforeEach(setup);
120
+ afterEach(teardown);
121
+
122
+ it("rejects missing identifier", async () => {
123
+ await assert.rejects(
124
+ () => runGet(["--key", VALID_KEY, "--url", serverUrl]),
125
+ (err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION && /Missing/i.test(err.message),
126
+ );
127
+ });
128
+
129
+ it("rejects malformed identifier", async () => {
130
+ await assert.rejects(
131
+ () => runGet(["--key", VALID_KEY, "--url", serverUrl, "not-an-identifier"]),
132
+ (err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
133
+ );
134
+ });
135
+
136
+ it("rejects extra positional after identifier", async () => {
137
+ await assert.rejects(
138
+ () => runGet(["--key", VALID_KEY, "--url", serverUrl, "@a/b", "@c/d"]),
139
+ (err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
140
+ );
141
+ });
142
+
143
+ it("404 on the skill returns a clean validation error (not a network error)", async () => {
144
+ // Server has no registered skill — getSkill returns null
145
+ await assert.rejects(
146
+ () => runGet(["--key", VALID_KEY, "--url", serverUrl, "@alice/missing"]),
147
+ (err) =>
148
+ err instanceof CliError &&
149
+ err.exitCode === EXIT_VALIDATION &&
150
+ /not found/i.test(err.message),
151
+ );
152
+ });
153
+
154
+ it("rejects skill with mismatched owner/name from server", async () => {
155
+ // Defense in depth: server says it's @alice/pdf-helper but returns
156
+ // @bob/wrong. The command should refuse the response.
157
+ server.setSkillResponse("alice", "pdf-helper", makeSkill("bob", "wrong"));
158
+ await assert.rejects(
159
+ () => runGet(["--key", VALID_KEY, "--url", serverUrl, "@alice/pdf-helper"]),
160
+ (err) =>
161
+ err instanceof CliError &&
162
+ err.exitCode === EXIT_VALIDATION &&
163
+ /wrong skill/i.test(err.message),
164
+ );
165
+ });
166
+
167
+ it("rejects filesIncomplete skill", async () => {
168
+ const incomplete = makeSkill("alice", "incomplete");
169
+ incomplete.filesIncomplete = true;
170
+ server.setSkillResponse("alice", "incomplete", incomplete);
171
+ await assert.rejects(
172
+ () => runGet(["--key", VALID_KEY, "--url", serverUrl, "@alice/incomplete"]),
173
+ (err) => err instanceof CliError && /incomplete/i.test(err.message),
174
+ );
175
+ });
176
+ });
@@ -0,0 +1,486 @@
1
+ /**
2
+ * Unit/integration tests for src/commands/init.mjs (PR3b rewrite, #646).
3
+ */
4
+
5
+ import { describe, it, beforeEach, afterEach } from "node:test";
6
+ import assert from "node:assert/strict";
7
+ import {
8
+ mkdtempSync,
9
+ mkdirSync,
10
+ rmSync,
11
+ existsSync,
12
+ readFileSync,
13
+ writeFileSync,
14
+ } from "node:fs";
15
+ import { join } from "node:path";
16
+ import { tmpdir } from "node:os";
17
+
18
+ import { runInit } from "../../commands/init.mjs";
19
+ import { readConfig } from "../../lib/config.mjs";
20
+ import { CliError, EXIT_AUTH, EXIT_VALIDATION } from "../../lib/errors.mjs";
21
+ import { createMockServer } from "../e2e/mock-server.mjs";
22
+ import { createCaptureStream } from "../helpers/capture-stream.mjs";
23
+
24
+ let sandbox;
25
+ let server;
26
+ let serverUrl;
27
+ let originalCwd;
28
+ let originalHome;
29
+ let stdout;
30
+ let stderr;
31
+ const VALID_KEY = "sk_live_init_test";
32
+
33
+ async function setup() {
34
+ sandbox = mkdtempSync(join(tmpdir(), "cli-cmd-init-"));
35
+ // init defaults to detecting IDEs in cwd. Create a `.claude/`
36
+ // marker so detection finds claudeCode and the command doesn't
37
+ // refuse for "no IDEs detected".
38
+ mkdirSync(join(sandbox, "project", ".claude"), { recursive: true });
39
+ mkdirSync(join(sandbox, "home"), { recursive: true });
40
+ originalCwd = process.cwd();
41
+ originalHome = process.env.HOME;
42
+ process.chdir(join(sandbox, "project"));
43
+ process.env.HOME = join(sandbox, "home");
44
+ delete process.env.SKILLREPO_ACCESS_KEY;
45
+ delete process.env.SKILLREPO_URL;
46
+
47
+ server = createMockServer({});
48
+ const port = await server.start();
49
+ serverUrl = `http://127.0.0.1:${port}`;
50
+
51
+ stdout = createCaptureStream();
52
+ stderr = createCaptureStream();
53
+ }
54
+
55
+ async function teardown() {
56
+ if (server) await server.stop();
57
+ process.chdir(originalCwd);
58
+ process.env.HOME = originalHome;
59
+ if (sandbox) rmSync(sandbox, { recursive: true, force: true });
60
+ server = null;
61
+ }
62
+
63
+ // ── Happy path ─────────────────────────────────────────────────────────
64
+
65
+ describe("runInit — happy path", () => {
66
+ beforeEach(setup);
67
+ afterEach(teardown);
68
+
69
+ it("writes config + MCP + runs first sync with --yes", async () => {
70
+ await runInit(
71
+ ["--key", VALID_KEY, "--url", serverUrl, "--yes"],
72
+ { stdout, stderr },
73
+ );
74
+
75
+ // Config file persisted
76
+ const cfg = readConfig();
77
+ assert.ok(cfg, "config should be readable after init");
78
+ assert.equal(cfg.apiKey, VALID_KEY);
79
+ assert.equal(cfg.serverUrl, serverUrl);
80
+ assert.equal(cfg.accountSlug, "mock");
81
+
82
+ // MCP config created in project
83
+ assert.ok(existsSync(join(process.cwd(), ".mcp.json")));
84
+ const mcp = JSON.parse(readFileSync(join(process.cwd(), ".mcp.json"), "utf-8"));
85
+ assert.ok(mcp.mcpServers?.skillrepo);
86
+
87
+ // .env.local written for agent env var consumers
88
+ assert.ok(existsSync(join(process.cwd(), ".env.local")));
89
+ const envContent = readFileSync(join(process.cwd(), ".env.local"), "utf-8");
90
+ assert.match(envContent, new RegExp(`SKILLREPO_ACCESS_KEY=${VALID_KEY}`));
91
+
92
+ // .gitignore has the three init-required entries.
93
+ // Round-3 architect + code-reviewer caught that this gitignore
94
+ // management was documented in the README but never actually
95
+ // implemented — this assertion locks the fix so a future
96
+ // regression that removes the mergeGitignore call from init
97
+ // fails loudly.
98
+ assert.ok(existsSync(join(process.cwd(), ".gitignore")));
99
+ const gi = readFileSync(join(process.cwd(), ".gitignore"), "utf-8");
100
+ assert.match(gi, /^\.env\.local$/m, ".env.local must be gitignored (contains access key)");
101
+ assert.match(gi, /^\.claude\/skills\/$/m, ".claude/skills/ must be gitignored");
102
+ assert.match(gi, /^\.claude\/settings\.local\.json$/m, ".claude/settings.local.json must be gitignored");
103
+
104
+ // Human summary
105
+ assert.match(stdout.text(), /SkillRepo is ready/);
106
+ });
107
+
108
+ it("--json outputs structured summary", async () => {
109
+ await runInit(
110
+ ["--key", VALID_KEY, "--url", serverUrl, "--yes", "--json"],
111
+ { stdout, stderr },
112
+ );
113
+ const json = JSON.parse(stdout.text());
114
+ assert.equal(json.action, "initialized");
115
+ assert.equal(json.account.slug, "mock");
116
+ assert.ok(Array.isArray(json.vendors));
117
+ assert.ok(Array.isArray(json.mcp.merged));
118
+ });
119
+
120
+ it("respects --ide flag to override detection", async () => {
121
+ await runInit(
122
+ ["--key", VALID_KEY, "--url", serverUrl, "--yes", "--ide", "claude"],
123
+ { stdout, stderr },
124
+ );
125
+ const mcp = JSON.parse(readFileSync(join(process.cwd(), ".mcp.json"), "utf-8"));
126
+ assert.ok(mcp.mcpServers?.skillrepo);
127
+ });
128
+
129
+ it("detects multiple IDEs when both .claude/ and .cursor/ exist", async () => {
130
+ mkdirSync(join(process.cwd(), ".cursor"), { recursive: true });
131
+ await runInit(
132
+ ["--key", VALID_KEY, "--url", serverUrl, "--yes"],
133
+ { stdout, stderr },
134
+ );
135
+ assert.ok(existsSync(join(process.cwd(), ".mcp.json")));
136
+ assert.ok(existsSync(join(process.cwd(), ".cursor", "mcp.json")));
137
+ });
138
+ });
139
+
140
+ // ── Credential resolution ─────────────────────────────────────────────
141
+
142
+ describe("runInit — credential resolution", () => {
143
+ beforeEach(setup);
144
+ afterEach(teardown);
145
+
146
+ it("reads existing config when no --key provided and server validates OK", async () => {
147
+ // Pre-seed the config file
148
+ mkdirSync(join(process.env.HOME, ".claude", "skillrepo"), { recursive: true });
149
+ writeFileSync(
150
+ join(process.env.HOME, ".claude", "skillrepo", "config.json"),
151
+ JSON.stringify({
152
+ schemaVersion: 1,
153
+ apiKey: VALID_KEY,
154
+ serverUrl,
155
+ }),
156
+ );
157
+ // No --key passed; init should pick up the config and succeed
158
+ await runInit(["--yes"], { stdout, stderr });
159
+ assert.match(stdout.text(), /SkillRepo is ready/);
160
+ });
161
+
162
+ it("reads SKILLREPO_ACCESS_KEY env var when no flag or config", async () => {
163
+ process.env.SKILLREPO_ACCESS_KEY = VALID_KEY;
164
+ process.env.SKILLREPO_URL = serverUrl;
165
+ await runInit(["--yes"], { stdout, stderr });
166
+ assert.match(stdout.text(), /SkillRepo is ready/);
167
+ });
168
+
169
+ it("refuses to run under --yes when no key is configured anywhere", async () => {
170
+ // No --key, no config, no env var, non-interactive → hard fail
171
+ await assert.rejects(
172
+ () => runInit(["--yes"], { stdout, stderr }),
173
+ (err) => err instanceof CliError && err.exitCode === EXIT_AUTH,
174
+ );
175
+ });
176
+ });
177
+
178
+ // ── Error paths ────────────────────────────────────────────────────────
179
+
180
+ describe("runInit — error paths", () => {
181
+ beforeEach(setup);
182
+ afterEach(teardown);
183
+
184
+ it("rejects invalid key format (not sk_live_ prefix)", async () => {
185
+ await assert.rejects(
186
+ () => runInit(
187
+ ["--key", "not_a_valid_key", "--url", serverUrl, "--yes"],
188
+ { stdout, stderr },
189
+ ),
190
+ (err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
191
+ );
192
+ });
193
+
194
+ it("401 from validate surfaces as authError", async () => {
195
+ server.setForcedStatus(401, { error: "Invalid access key" });
196
+ await assert.rejects(
197
+ () => runInit(
198
+ ["--key", VALID_KEY, "--url", serverUrl, "--yes"],
199
+ { stdout, stderr },
200
+ ),
201
+ (err) => err instanceof CliError && err.exitCode === EXIT_AUTH,
202
+ );
203
+ });
204
+
205
+ it("refuses with clear error when no IDE detected and no --ide flag", async () => {
206
+ // Remove the .claude marker that setup() created
207
+ rmSync(join(process.cwd(), ".claude"), { recursive: true, force: true });
208
+ await assert.rejects(
209
+ () => runInit(
210
+ ["--key", VALID_KEY, "--url", serverUrl, "--yes"],
211
+ { stdout, stderr },
212
+ ),
213
+ (err) =>
214
+ err instanceof CliError &&
215
+ err.exitCode === EXIT_VALIDATION &&
216
+ /No IDEs detected/.test(err.message),
217
+ );
218
+ });
219
+
220
+ it("headless CI scenario: explicit --ide claude works in empty project", async () => {
221
+ // Remove the .claude marker — empty dir
222
+ rmSync(join(process.cwd(), ".claude"), { recursive: true, force: true });
223
+ // With --ide claude, init should proceed even in an empty dir
224
+ await runInit(
225
+ ["--key", VALID_KEY, "--url", serverUrl, "--yes", "--ide", "claude"],
226
+ { stdout, stderr },
227
+ );
228
+ // And write the MCP config
229
+ assert.ok(existsSync(join(process.cwd(), ".mcp.json")));
230
+ });
231
+ });
232
+
233
+ // ── Idempotency ────────────────────────────────────────────────────────
234
+
235
+ describe("runInit — idempotency", () => {
236
+ beforeEach(setup);
237
+ afterEach(teardown);
238
+
239
+ it("running init twice with valid existing config is a no-op refresh", async () => {
240
+ // First init
241
+ await runInit(
242
+ ["--key", VALID_KEY, "--url", serverUrl, "--yes"],
243
+ { stdout, stderr },
244
+ );
245
+ const firstConfig = readConfig();
246
+
247
+ // Reset captures for the second run
248
+ stdout = createCaptureStream();
249
+ stderr = createCaptureStream();
250
+
251
+ // Second init WITHOUT --key — should pick up from config
252
+ await runInit(["--yes"], { stdout, stderr });
253
+ const secondConfig = readConfig();
254
+ assert.equal(secondConfig.apiKey, firstConfig.apiKey);
255
+ assert.match(stdout.text(), /SkillRepo is ready/);
256
+ });
257
+ });
258
+
259
+ // ── --force flag (round-1 review fix) ──────────────────────────────────
260
+
261
+ describe("runInit — --force flag", () => {
262
+ beforeEach(setup);
263
+ afterEach(teardown);
264
+
265
+ it("--force ignores existing config key (requires explicit new key)", async () => {
266
+ // Pre-seed a valid config
267
+ mkdirSync(join(process.env.HOME, ".claude", "skillrepo"), { recursive: true });
268
+ writeFileSync(
269
+ join(process.env.HOME, ".claude", "skillrepo", "config.json"),
270
+ JSON.stringify({
271
+ schemaVersion: 1,
272
+ apiKey: VALID_KEY,
273
+ serverUrl,
274
+ }),
275
+ );
276
+
277
+ // --force WITHOUT --key AND WITHOUT env var under --yes should
278
+ // hard-fail with EXIT_AUTH. This proves --force actually
279
+ // invalidates the cached credential rather than silently
280
+ // inheriting from the config. Before the round-1 fix, --force
281
+ // only cleared the key but still inherited serverUrl — this
282
+ // test also locks that the cached URL is ignored (the --url
283
+ // flag is required alongside --force for a full reset).
284
+ await assert.rejects(
285
+ () =>
286
+ runInit(["--force", "--yes"], { stdout, stderr }),
287
+ (err) => err instanceof CliError && err.exitCode === EXIT_AUTH,
288
+ );
289
+ });
290
+
291
+ it("--force + --key + --url re-runs validation against new credentials", async () => {
292
+ // Pre-seed with one server URL
293
+ mkdirSync(join(process.env.HOME, ".claude", "skillrepo"), { recursive: true });
294
+ writeFileSync(
295
+ join(process.env.HOME, ".claude", "skillrepo", "config.json"),
296
+ JSON.stringify({
297
+ schemaVersion: 1,
298
+ apiKey: "sk_live_old_key",
299
+ serverUrl: "https://old.example",
300
+ }),
301
+ );
302
+
303
+ // --force + explicit new credentials should succeed and OVERWRITE
304
+ // the config with the new ones (not merge).
305
+ await runInit(
306
+ ["--force", "--key", VALID_KEY, "--url", serverUrl, "--yes"],
307
+ { stdout, stderr },
308
+ );
309
+ const cfg = readConfig();
310
+ assert.equal(cfg.apiKey, VALID_KEY);
311
+ assert.equal(cfg.serverUrl, serverUrl);
312
+ });
313
+
314
+ it("--force + SKILLREPO_ACCESS_KEY env var (no --key flag) uses the env var", async () => {
315
+ // Cross-review coverage gap: the README explicitly documents
316
+ // this scenario ("init --force with SKILLREPO_ACCESS_KEY set:
317
+ // uses the env var (step 2). --force only clears the cached
318
+ // credentials, not the runtime env."), but no existing test
319
+ // locked it. Reviewers (correctly) pointed out that missing
320
+ // coverage on this path would let a regression silently change
321
+ // the priority order between env vars and the cached config.
322
+ //
323
+ // Pre-seed a cached config with a DIFFERENT key than the env
324
+ // var so we can tell which one init actually used.
325
+ mkdirSync(join(process.env.HOME, ".claude", "skillrepo"), { recursive: true });
326
+ writeFileSync(
327
+ join(process.env.HOME, ".claude", "skillrepo", "config.json"),
328
+ JSON.stringify({
329
+ schemaVersion: 1,
330
+ apiKey: "sk_live_CONFIG_KEY",
331
+ serverUrl: "https://old.example",
332
+ }),
333
+ );
334
+
335
+ process.env.SKILLREPO_ACCESS_KEY = VALID_KEY;
336
+ process.env.SKILLREPO_URL = serverUrl;
337
+
338
+ await runInit(["--force", "--yes"], { stdout, stderr });
339
+
340
+ // The config file should now contain the env var's key, NOT
341
+ // the seeded config key. --force cleared the cache; the env
342
+ // var won over the interactive-prompt fallback (which would
343
+ // also have fired since no --key was given).
344
+ const cfg = readConfig();
345
+ assert.equal(cfg.apiKey, VALID_KEY);
346
+ assert.notEqual(cfg.apiKey, "sk_live_CONFIG_KEY");
347
+ assert.equal(cfg.serverUrl, serverUrl);
348
+ });
349
+ });
350
+
351
+ // ── Non-fatal sync failure (round-2 review fix) ──────────────────────
352
+
353
+ describe("runInit — non-fatal sync failure", () => {
354
+ beforeEach(setup);
355
+ afterEach(teardown);
356
+
357
+ it("sync failure during init does NOT abort the command", async () => {
358
+ // Round-2 review caught the prior behavior: sync failure warned
359
+ // "run skillrepo update later to retry" but then rethrew the
360
+ // error, so the init command exited non-zero — contradicting
361
+ // its own message. The fix: swallow the sync failure, print
362
+ // the warning, and exit 0 with a synthesized zero-delta summary.
363
+ // The user's config and MCP setup are already persisted; only
364
+ // the skill fetch failed, and `skillrepo update` is the
365
+ // documented recovery path.
366
+ //
367
+ // We simulate the failure by forcing the mock server to return
368
+ // 500 on the library sync endpoint AFTER validate succeeds.
369
+ // setForcedStatus fires once then clears, so the POST /validate
370
+ // in step 2 succeeds with a 200, and the subsequent GET /library
371
+ // hits normal routing — which we break by setting the status
372
+ // mid-run. The simplest approach: set the forced status to a
373
+ // non-200 BEFORE calling init and let it fire on the first
374
+ // non-auth request (the library GET comes after validate).
375
+ //
376
+ // But that would hit validate first. Instead we use a
377
+ // purpose-built "always 500 on /library" by overriding the
378
+ // library-sync response via the mock's setLibraryResponse slot.
379
+ server.setLibraryStatus({ status: 500, body: { error: "Upstream exploded" } });
380
+
381
+ // Init should complete without throwing
382
+ await runInit(
383
+ ["--key", VALID_KEY, "--url", serverUrl, "--yes"],
384
+ { stdout, stderr },
385
+ );
386
+
387
+ // Config IS persisted despite the sync failure
388
+ const cfg = readConfig();
389
+ assert.ok(cfg, "config must be written even when sync fails");
390
+ assert.equal(cfg.apiKey, VALID_KEY);
391
+
392
+ // MCP IS configured despite the sync failure
393
+ assert.ok(existsSync(join(process.cwd(), ".mcp.json")));
394
+
395
+ // Warning is surfaced
396
+ assert.match(stdout.text(), /first sync failed/i);
397
+ assert.match(stdout.text(), /skillrepo update/);
398
+
399
+ // Final "ready" line STILL prints — the init completed
400
+ assert.match(stdout.text(), /SkillRepo is ready/);
401
+ });
402
+
403
+ it("--json sync failure includes failureReason in the JSON payload", async () => {
404
+ server.setLibraryStatus({ status: 500, body: { error: "Upstream exploded" } });
405
+ await runInit(
406
+ ["--key", VALID_KEY, "--url", serverUrl, "--yes", "--json"],
407
+ { stdout, stderr },
408
+ );
409
+ const json = JSON.parse(stdout.text());
410
+ assert.equal(json.action, "initialized");
411
+ // `failureReason` is the sentinel — downstream scripts detect a
412
+ // partial init by `sync.failureReason != null`. The synthesized
413
+ // SyncSummary no longer carries a `failed` field because that
414
+ // wasn't part of the documented SyncSummary typedef; the
415
+ // failure is signalled exclusively via `failureReason`.
416
+ assert.ok(json.sync.failureReason, "sync.failureReason should be present on failure");
417
+ assert.equal(json.sync.added, 0);
418
+ assert.equal(json.sync.updated, 0);
419
+ assert.equal(json.sync.removed, 0);
420
+ assert.equal(json.sync.notModified, false);
421
+ });
422
+ });
423
+
424
+ // ── Stale-key handling (round-1 review fix) ───────────────────────────
425
+
426
+ describe("runInit — stale-key handling", () => {
427
+ beforeEach(setup);
428
+ afterEach(teardown);
429
+
430
+ it("existing config + 401 from validate + --yes → hard failure (no re-prompt)", async () => {
431
+ // Pre-seed an existing config
432
+ mkdirSync(join(process.env.HOME, ".claude", "skillrepo"), { recursive: true });
433
+ writeFileSync(
434
+ join(process.env.HOME, ".claude", "skillrepo", "config.json"),
435
+ JSON.stringify({
436
+ schemaVersion: 1,
437
+ apiKey: VALID_KEY,
438
+ serverUrl,
439
+ }),
440
+ );
441
+ // Force the server to reject the stale key
442
+ server.setForcedStatus(401, { error: "Invalid access key" });
443
+
444
+ // Under --yes (non-interactive), init MUST NOT fall back to
445
+ // promptSecret because there's no interactive stdin. It must
446
+ // surface the auth error directly. This test locks the guard
447
+ // in init.mjs's catch block: the re-prompt path is gated on
448
+ // `!yes` specifically so non-interactive callers (CI, scripts)
449
+ // fail loudly instead of hanging on stdin.
450
+ await assert.rejects(
451
+ () =>
452
+ runInit(
453
+ ["--url", serverUrl, "--yes"],
454
+ { stdout, stderr },
455
+ ),
456
+ (err) => err instanceof CliError && err.exitCode === EXIT_AUTH,
457
+ );
458
+ });
459
+
460
+ it("existing config + 401 + --force + --yes still hard-fails (force + yes both gate)", async () => {
461
+ // Pre-seed config
462
+ mkdirSync(join(process.env.HOME, ".claude", "skillrepo"), { recursive: true });
463
+ writeFileSync(
464
+ join(process.env.HOME, ".claude", "skillrepo", "config.json"),
465
+ JSON.stringify({
466
+ schemaVersion: 1,
467
+ apiKey: VALID_KEY,
468
+ serverUrl,
469
+ }),
470
+ );
471
+ server.setForcedStatus(401, { error: "Invalid access key" });
472
+
473
+ // With --force AND --yes, the re-prompt path is gated twice:
474
+ // once by `!force` (the intent of --force is "use exactly what I
475
+ // passed, no fallbacks") and once by `!yes` (non-interactive).
476
+ // Either alone would block re-prompt; this test locks both.
477
+ await assert.rejects(
478
+ () =>
479
+ runInit(
480
+ ["--force", "--key", VALID_KEY, "--url", serverUrl, "--yes"],
481
+ { stdout, stderr },
482
+ ),
483
+ (err) => err instanceof CliError && err.exitCode === EXIT_AUTH,
484
+ );
485
+ });
486
+ });