skillrepo 2.0.0 → 3.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.
Files changed (72) hide show
  1. package/README.md +276 -145
  2. package/bin/skillrepo.mjs +224 -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 +589 -143
  7. package/src/commands/list.mjs +176 -0
  8. package/src/commands/remove.mjs +162 -0
  9. package/src/commands/search.mjs +188 -0
  10. package/src/commands/session-sync.mjs +152 -0
  11. package/src/commands/uninstall.mjs +484 -0
  12. package/src/commands/update.mjs +184 -0
  13. package/src/lib/artifact-registry.mjs +265 -0
  14. package/src/lib/cli-config.mjs +230 -0
  15. package/src/lib/config.mjs +238 -0
  16. package/src/lib/detect-ides.mjs +0 -19
  17. package/src/lib/errors.mjs +264 -0
  18. package/src/lib/file-write.mjs +705 -0
  19. package/src/lib/fs-utils.mjs +83 -1
  20. package/src/lib/http.mjs +817 -37
  21. package/src/lib/identifier.mjs +153 -0
  22. package/src/lib/mcp-merge.mjs +275 -0
  23. package/src/lib/mergers/gitignore.mjs +73 -18
  24. package/src/lib/mergers/session-hook.mjs +298 -0
  25. package/src/lib/paths.mjs +67 -17
  26. package/src/lib/prompt.mjs +11 -44
  27. package/src/lib/removers/claude-mcp.mjs +67 -0
  28. package/src/lib/removers/cursor-mcp.mjs +60 -0
  29. package/src/lib/removers/env-local.mjs +55 -0
  30. package/src/lib/removers/gitignore.mjs +108 -0
  31. package/src/lib/removers/settings.mjs +183 -0
  32. package/src/lib/removers/vscode-mcp.mjs +87 -0
  33. package/src/lib/removers/windsurf-mcp.mjs +65 -0
  34. package/src/lib/sync.mjs +305 -0
  35. package/src/test/commands/add.test.mjs +285 -0
  36. package/src/test/commands/get.test.mjs +176 -0
  37. package/src/test/commands/init.test.mjs +697 -0
  38. package/src/test/commands/list.test.mjs +172 -0
  39. package/src/test/commands/remove.test.mjs +234 -0
  40. package/src/test/commands/search.test.mjs +204 -0
  41. package/src/test/commands/session-sync.test.mjs +350 -0
  42. package/src/test/commands/uninstall.test.mjs +768 -0
  43. package/src/test/commands/update.test.mjs +322 -0
  44. package/src/test/detect-ides.test.mjs +9 -14
  45. package/src/test/dispatcher.test.mjs +224 -0
  46. package/src/test/e2e/cli-commands.test.mjs +576 -0
  47. package/src/test/e2e/mock-server.mjs +364 -22
  48. package/src/test/helpers/capture-stream.mjs +48 -0
  49. package/src/test/integration/file-write.integration.test.mjs +279 -0
  50. package/src/test/lib/artifact-registry.test.mjs +268 -0
  51. package/src/test/lib/cli-config.test.mjs +407 -0
  52. package/src/test/lib/config.test.mjs +257 -0
  53. package/src/test/lib/errors.test.mjs +359 -0
  54. package/src/test/lib/file-write.test.mjs +784 -0
  55. package/src/test/lib/http.test.mjs +1198 -0
  56. package/src/test/lib/identifier.test.mjs +157 -0
  57. package/src/test/lib/mcp-merge.test.mjs +345 -0
  58. package/src/test/lib/paths.test.mjs +83 -0
  59. package/src/test/lib/sync.test.mjs +514 -0
  60. package/src/test/mergers/gitignore.test.mjs +145 -20
  61. package/src/test/mergers/session-hook.test.mjs +745 -0
  62. package/src/test/mergers/uninstall-claude-mcp.test.mjs +145 -0
  63. package/src/test/mergers/uninstall-cursor-mcp.test.mjs +108 -0
  64. package/src/test/mergers/uninstall-env-local.test.mjs +144 -0
  65. package/src/test/mergers/uninstall-gitignore.test.mjs +209 -0
  66. package/src/test/mergers/uninstall-settings.test.mjs +285 -0
  67. package/src/test/mergers/uninstall-vscode-mcp.test.mjs +215 -0
  68. package/src/test/mergers/uninstall-windsurf-mcp.test.mjs +122 -0
  69. package/src/lib/write-configs.mjs +0 -202
  70. package/src/test/e2e/HANDOFF.md +0 -223
  71. package/src/test/e2e/cli-init.test.mjs +0 -213
  72. package/src/test/e2e/payload-factory.mjs +0 -22
@@ -0,0 +1,697 @@
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
+ });
487
+
488
+ // ── Session-sync step 6 (#884) ────────────────────────────────────────
489
+ //
490
+ // INTENT-based coverage of the new step 6 added in v3.1.0. Tests use
491
+ // the PATH-shim trick from session-sync.test.mjs to make
492
+ // `which skillrepo` resolve deterministically: a fake `skillrepo`
493
+ // executable is dropped into `$HOME/bin` and prepended to PATH.
494
+ // Without this, the behavior of these tests would depend on whether
495
+ // a global install exists on the developer's machine.
496
+ //
497
+ // Lower-level installer correctness (hook shape, idempotency,
498
+ // round-trip with remover) is covered in session-hook.test.mjs. These
499
+ // init tests verify the ORCHESTRATION: --yes path, --no-session-sync
500
+ // opt-out, --json output shape, and the non-fatal disk-error path.
501
+
502
+ import { chmodSync as _chmodSync } from "node:fs";
503
+ import { SESSION_HOOK_FINGERPRINT as _FINGERPRINT } from "../../lib/artifact-registry.mjs";
504
+
505
+ async function setupWithShim() {
506
+ await setup();
507
+ // Drop a deterministic `skillrepo` shim into HOME/bin so
508
+ // `which skillrepo` resolves to it rather than a possibly-missing
509
+ // global install.
510
+ const binDir = join(process.env.HOME, "bin");
511
+ mkdirSync(binDir, { recursive: true });
512
+ const shim = join(binDir, "skillrepo");
513
+ writeFileSync(shim, "#!/bin/sh\nexit 0\n");
514
+ _chmodSync(shim, 0o755);
515
+ process.env.PATH = `${binDir}:${process.env.PATH}`;
516
+ }
517
+
518
+ describe("runInit — session sync (#884)", () => {
519
+ beforeEach(setupWithShim);
520
+ afterEach(teardown);
521
+
522
+ it("--yes installs the hook at step 6 by default", async () => {
523
+ // INTENT: the architect-designed default for --yes mode is
524
+ // "install the hook." CI/onboarding scripts passing --yes should
525
+ // get a fully-configured project including session sync.
526
+ await runInit(
527
+ ["--key", VALID_KEY, "--url", serverUrl, "--yes"],
528
+ { stdout, stderr },
529
+ );
530
+
531
+ const settingsPath = join(process.cwd(), ".claude", "settings.local.json");
532
+ assert.ok(existsSync(settingsPath), "settings.local.json must exist");
533
+
534
+ const parsed = JSON.parse(readFileSync(settingsPath, "utf-8"));
535
+ const hasHook = parsed.hooks.SessionStart.flatMap((g) => g.hooks).some(
536
+ (h) => h.command.includes(_FINGERPRINT),
537
+ );
538
+ assert.ok(hasHook, "SkillRepo SessionStart hook must be installed");
539
+ assert.match(stdout.text(), /SessionStart hook installed/);
540
+ });
541
+
542
+ it("--no-session-sync skips the hook install even with --yes", async () => {
543
+ // INTENT: the only way CI scripts that bootstrap a project
544
+ // without starting Claude Code sessions can opt out. Must work
545
+ // alongside --yes (otherwise --yes would force hook install).
546
+ await runInit(
547
+ ["--key", VALID_KEY, "--url", serverUrl, "--yes", "--no-session-sync"],
548
+ { stdout, stderr },
549
+ );
550
+
551
+ const settingsPath = join(process.cwd(), ".claude", "settings.local.json");
552
+ assert.ok(
553
+ !existsSync(settingsPath),
554
+ "settings.local.json must NOT be written under --no-session-sync",
555
+ );
556
+ assert.match(stdout.text(), /Session sync skipped \(--no-session-sync\)/);
557
+ });
558
+
559
+ it("re-running init is idempotent — exactly one hook entry", async () => {
560
+ // INTENT: users re-run init for many reasons (switching keys,
561
+ // updating the config). A duplicate hook would fire sync twice
562
+ // per session — waste at best, race at worst.
563
+ await runInit(
564
+ ["--key", VALID_KEY, "--url", serverUrl, "--yes"],
565
+ { stdout, stderr },
566
+ );
567
+ stdout.clear();
568
+ await runInit(
569
+ ["--key", VALID_KEY, "--url", serverUrl, "--yes"],
570
+ { stdout, stderr },
571
+ );
572
+
573
+ const parsed = JSON.parse(
574
+ readFileSync(
575
+ join(process.cwd(), ".claude", "settings.local.json"),
576
+ "utf-8",
577
+ ),
578
+ );
579
+ const skillrepoHooks = parsed.hooks.SessionStart.flatMap(
580
+ (g) => g.hooks,
581
+ ).filter((h) => h.command.includes(_FINGERPRINT));
582
+ assert.equal(skillrepoHooks.length, 1, "exactly one SkillRepo hook");
583
+ });
584
+
585
+ it("--json includes a sessionSync block with action + path", async () => {
586
+ // INTENT: automation scripts need to know whether session sync
587
+ // was installed, opted out, or failed. The JSON contract is the
588
+ // machine-readable channel for that.
589
+ await runInit(
590
+ ["--key", VALID_KEY, "--url", serverUrl, "--yes", "--json"],
591
+ { stdout, stderr },
592
+ );
593
+ const json = JSON.parse(stdout.text());
594
+ assert.ok(json.sessionSync, "sessionSync must be in --json output");
595
+ assert.equal(json.sessionSync.action, "installed");
596
+ assert.equal(json.sessionSync.path, ".claude/settings.local.json");
597
+ });
598
+
599
+ it("--json with --no-session-sync reports action: 'opted-out'", async () => {
600
+ await runInit(
601
+ [
602
+ "--key",
603
+ VALID_KEY,
604
+ "--url",
605
+ serverUrl,
606
+ "--yes",
607
+ "--no-session-sync",
608
+ "--json",
609
+ ],
610
+ { stdout, stderr },
611
+ );
612
+ const json = JSON.parse(stdout.text());
613
+ assert.equal(json.sessionSync.action, "opted-out");
614
+ assert.equal(json.sessionSync.path, null);
615
+ });
616
+
617
+ it("skips session sync entirely when only non-Claude-Code IDEs are targeted (cross-PR review fix)", async () => {
618
+ // Cross-PR review flagged: before this guard, a user running
619
+ // `skillrepo init --ide cursor` would get a Claude Code-specific
620
+ // SessionStart hook written to `.claude/settings.local.json`.
621
+ // Cursor never reads that file, so the hook was silent useless
622
+ // state that `skillrepo uninstall` later had to clean up.
623
+ //
624
+ // The guard in init.mjs step 6 now skips the install when
625
+ // `claudeCode` is not in the resolved vendors list AND
626
+ // `--global` is not passed. This test proves the skip fires.
627
+ //
628
+ // Use --ide cursor to force vendors = ["cursor"]. Bypass the
629
+ // .claude/ auto-detection by creating .cursor/ instead.
630
+ mkdirSync(join(process.cwd(), ".cursor"), { recursive: true });
631
+ rmSync(join(process.cwd(), ".claude"), { recursive: true, force: true });
632
+
633
+ await runInit(
634
+ [
635
+ "--key",
636
+ VALID_KEY,
637
+ "--url",
638
+ serverUrl,
639
+ "--yes",
640
+ "--ide",
641
+ "cursor",
642
+ "--json",
643
+ ],
644
+ { stdout, stderr },
645
+ );
646
+
647
+ const json = JSON.parse(stdout.text());
648
+ assert.equal(
649
+ json.sessionSync.action,
650
+ "not-applicable",
651
+ "session sync must report 'not-applicable' for non-Claude-Code targets",
652
+ );
653
+ assert.equal(json.sessionSync.path, null);
654
+ // Critical: the settings.local.json file must NOT have been
655
+ // written. A Cursor user should never see this Claude-specific
656
+ // file materialize from `skillrepo init`.
657
+ assert.ok(
658
+ !existsSync(join(process.cwd(), ".claude", "settings.local.json")),
659
+ ".claude/settings.local.json must NOT be written for Cursor-only init",
660
+ );
661
+ });
662
+
663
+ it("still installs session sync under --global even without claudeCode in vendors", async () => {
664
+ // INTENT: `--global` writes to `~/.claude/settings.local.json`,
665
+ // which IS Claude Code's user-wide settings path. A user who
666
+ // runs `skillrepo init --global` (even without `--ide claude`)
667
+ // is implicitly targeting Claude Code. The guard must allow
668
+ // this path so `--global` users still get auto-sync.
669
+ //
670
+ // Note: the setup() helper already creates `.claude/` in the
671
+ // project, which would normally push vendors to include
672
+ // claudeCode. Force vendors = ["cursor"] via --ide to exercise
673
+ // the "--global overrides vendors" branch.
674
+ await runInit(
675
+ [
676
+ "--key",
677
+ VALID_KEY,
678
+ "--url",
679
+ serverUrl,
680
+ "--yes",
681
+ "--global",
682
+ "--ide",
683
+ "cursor",
684
+ "--json",
685
+ ],
686
+ { stdout, stderr },
687
+ );
688
+
689
+ const json = JSON.parse(stdout.text());
690
+ assert.equal(
691
+ json.sessionSync.action,
692
+ "installed",
693
+ "--global must install the hook even when vendors doesn't include claudeCode",
694
+ );
695
+ assert.equal(json.sessionSync.path, "~/.claude/settings.local.json");
696
+ });
697
+ });