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,407 @@
1
+ /**
2
+ * Unit tests for src/lib/cli-config.mjs (PR2 of #646).
3
+ *
4
+ * Covers:
5
+ * • resolveFlags — every flag, the priority order (CLI flag > config
6
+ * file > env var > default), error paths
7
+ * • effectiveVendors — defaults, --global override, explicit list
8
+ * • parseVendorList edge cases (alias, all, empty, unknown)
9
+ *
10
+ * The cli-config helper is shared by all four PR2 commands and PR3a's
11
+ * write commands, so coverage here is load-bearing.
12
+ */
13
+
14
+ import { describe, it, beforeEach, afterEach } from "node:test";
15
+ import assert from "node:assert/strict";
16
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
17
+ import { join } from "node:path";
18
+ import { tmpdir } from "node:os";
19
+
20
+ import { resolveFlags, effectiveVendors } from "../../lib/cli-config.mjs";
21
+ import { CliError, EXIT_AUTH, EXIT_VALIDATION } from "../../lib/errors.mjs";
22
+
23
+ let sandbox;
24
+ let originalHome;
25
+ let originalEnv;
26
+
27
+ function setupSandbox() {
28
+ sandbox = mkdtempSync(join(tmpdir(), "cli-config-"));
29
+ mkdirSync(join(sandbox, "home", ".claude", "skillrepo"), { recursive: true });
30
+ originalHome = process.env.HOME;
31
+ // Snapshot the env vars we mutate so tests don't bleed into each other
32
+ originalEnv = {
33
+ SKILLREPO_ACCESS_KEY: process.env.SKILLREPO_ACCESS_KEY,
34
+ SKILLREPO_URL: process.env.SKILLREPO_URL,
35
+ };
36
+ process.env.HOME = join(sandbox, "home");
37
+ delete process.env.SKILLREPO_ACCESS_KEY;
38
+ delete process.env.SKILLREPO_URL;
39
+ }
40
+
41
+ function teardownSandbox() {
42
+ process.env.HOME = originalHome;
43
+ if (originalEnv.SKILLREPO_ACCESS_KEY === undefined) {
44
+ delete process.env.SKILLREPO_ACCESS_KEY;
45
+ } else {
46
+ process.env.SKILLREPO_ACCESS_KEY = originalEnv.SKILLREPO_ACCESS_KEY;
47
+ }
48
+ if (originalEnv.SKILLREPO_URL === undefined) {
49
+ delete process.env.SKILLREPO_URL;
50
+ } else {
51
+ process.env.SKILLREPO_URL = originalEnv.SKILLREPO_URL;
52
+ }
53
+ if (sandbox) rmSync(sandbox, { recursive: true, force: true });
54
+ }
55
+
56
+ function writeConfig(obj) {
57
+ writeFileSync(
58
+ join(sandbox, "home", ".claude", "skillrepo", "config.json"),
59
+ JSON.stringify(obj),
60
+ );
61
+ }
62
+
63
+ // ── resolveFlags — priority order ──────────────────────────────────────
64
+
65
+ describe("resolveFlags — credential priority", () => {
66
+ beforeEach(setupSandbox);
67
+ afterEach(teardownSandbox);
68
+
69
+ it("uses --key and --url when provided (highest priority)", () => {
70
+ writeConfig({ apiKey: "config_key", serverUrl: "https://config.example" });
71
+ process.env.SKILLREPO_ACCESS_KEY = "env_key";
72
+ process.env.SKILLREPO_URL = "https://env.example";
73
+
74
+ const flags = resolveFlags(["--key", "flag_key", "--url", "https://flag.example"]);
75
+ assert.equal(flags.apiKey, "flag_key");
76
+ assert.equal(flags.serverUrl, "https://flag.example");
77
+ });
78
+
79
+ it("falls back to config file when CLI flags absent", () => {
80
+ writeConfig({ apiKey: "config_key", serverUrl: "https://config.example" });
81
+ process.env.SKILLREPO_ACCESS_KEY = "env_key";
82
+ process.env.SKILLREPO_URL = "https://env.example";
83
+
84
+ const flags = resolveFlags([]);
85
+ assert.equal(flags.apiKey, "config_key");
86
+ assert.equal(flags.serverUrl, "https://config.example");
87
+ });
88
+
89
+ it("falls back to env vars when no config file", () => {
90
+ process.env.SKILLREPO_ACCESS_KEY = "env_key";
91
+ process.env.SKILLREPO_URL = "https://env.example";
92
+
93
+ const flags = resolveFlags([]);
94
+ assert.equal(flags.apiKey, "env_key");
95
+ assert.equal(flags.serverUrl, "https://env.example");
96
+ });
97
+
98
+ it("falls back to DEFAULT_URL when nothing provides a URL", () => {
99
+ process.env.SKILLREPO_ACCESS_KEY = "env_key";
100
+ const flags = resolveFlags([]);
101
+ assert.equal(flags.serverUrl, "https://skillrepo.dev");
102
+ });
103
+
104
+ it("throws authError when no key is configured anywhere", () => {
105
+ assert.throws(
106
+ () => resolveFlags([]),
107
+ (err) => err instanceof CliError && err.exitCode === EXIT_AUTH && /No access key/.test(err.message),
108
+ );
109
+ });
110
+
111
+ it("requireAuth: false skips the no-key error", () => {
112
+ const flags = resolveFlags([], { requireAuth: false });
113
+ assert.equal(flags.apiKey, null);
114
+ });
115
+
116
+ it("ignores corrupt config file gracefully", () => {
117
+ writeFileSync(
118
+ join(sandbox, "home", ".claude", "skillrepo", "config.json"),
119
+ "this is not json {{{",
120
+ );
121
+ process.env.SKILLREPO_ACCESS_KEY = "env_key";
122
+ const flags = resolveFlags([]);
123
+ assert.equal(flags.apiKey, "env_key");
124
+ });
125
+
126
+ it("merges flag override with config-file fallback (mixed)", () => {
127
+ writeConfig({ apiKey: "config_key", serverUrl: "https://config.example" });
128
+ // Override only the URL via flag; key still comes from config
129
+ const flags = resolveFlags(["--url", "https://flag.example"]);
130
+ assert.equal(flags.apiKey, "config_key");
131
+ assert.equal(flags.serverUrl, "https://flag.example");
132
+ });
133
+ });
134
+
135
+ // ── resolveFlags — skipConfig (round-1 review fix) ────────────────────
136
+
137
+ describe("resolveFlags — skipConfig", () => {
138
+ beforeEach(setupSandbox);
139
+ afterEach(teardownSandbox);
140
+
141
+ // This option was introduced in the round-1 PR3b fix because `init`
142
+ // needs to own its credential lifecycle end-to-end. Before this
143
+ // option, `resolveFlags` would silently inject the cached config key
144
+ // AND eagerly default `serverUrl` to the production URL — making
145
+ // init's `--force` and stale-key branches dead code. These tests
146
+ // lock the skipConfig contract: (1) config file is ignored,
147
+ // (2) production-URL default does NOT apply (caller must supply its
148
+ // own), (3) env vars still apply because they're explicit runtime
149
+ // state, not a cached credential.
150
+
151
+ it("skipConfig: true ignores the config file entirely", () => {
152
+ writeConfig({ apiKey: "config_key", serverUrl: "https://config.example" });
153
+ const flags = resolveFlags([], { requireAuth: false, skipConfig: true });
154
+ assert.equal(
155
+ flags.apiKey,
156
+ null,
157
+ "config-file key must NOT leak through when skipConfig is set",
158
+ );
159
+ assert.equal(
160
+ flags.serverUrl,
161
+ null,
162
+ "config-file URL must NOT leak through when skipConfig is set",
163
+ );
164
+ });
165
+
166
+ it("skipConfig: true does NOT apply the https://skillrepo.dev default", () => {
167
+ // Without skipConfig, an empty argv would return the production
168
+ // URL. With skipConfig, serverUrl stays null so the caller can
169
+ // decide what to do. This is the specific bug that made init's
170
+ // `!serverUrl && existingConfig` branch dead code in round 0.
171
+ const flags = resolveFlags([], { requireAuth: false, skipConfig: true });
172
+ assert.equal(flags.serverUrl, null);
173
+ assert.notEqual(flags.serverUrl, "https://skillrepo.dev");
174
+ });
175
+
176
+ it("skipConfig: true still consumes SKILLREPO_URL / SKILLREPO_ACCESS_KEY env vars", () => {
177
+ // Env vars are explicit runtime state, not cached credentials —
178
+ // they should still resolve under skipConfig. This matches the
179
+ // init.mjs flow: `init` reads the env directly in its own
180
+ // credential collection step too, so the behavior is consistent
181
+ // whether init is called via `resolveFlags` or not.
182
+ process.env.SKILLREPO_ACCESS_KEY = "env_key";
183
+ process.env.SKILLREPO_URL = "https://env.example";
184
+ const flags = resolveFlags([], { requireAuth: false, skipConfig: true });
185
+ assert.equal(flags.apiKey, "env_key");
186
+ assert.equal(flags.serverUrl, "https://env.example");
187
+ });
188
+
189
+ it("skipConfig: true still honors --key and --url flags (highest priority)", () => {
190
+ writeConfig({ apiKey: "config_key", serverUrl: "https://config.example" });
191
+ process.env.SKILLREPO_ACCESS_KEY = "env_key";
192
+ const flags = resolveFlags(
193
+ ["--key", "flag_key", "--url", "https://flag.example"],
194
+ { requireAuth: false, skipConfig: true },
195
+ );
196
+ // Flags still win — the option only suppresses the config-file
197
+ // fallback, it doesn't disable flag parsing.
198
+ assert.equal(flags.apiKey, "flag_key");
199
+ assert.equal(flags.serverUrl, "https://flag.example");
200
+ });
201
+ });
202
+
203
+ // ── resolveFlags — flag parsing ────────────────────────────────────────
204
+
205
+ describe("resolveFlags — flag parsing", () => {
206
+ beforeEach(setupSandbox);
207
+ afterEach(teardownSandbox);
208
+
209
+ it("parses --global", () => {
210
+ process.env.SKILLREPO_ACCESS_KEY = "k";
211
+ const flags = resolveFlags(["--global"]);
212
+ assert.equal(flags.global, true);
213
+ });
214
+
215
+ it("parses --json", () => {
216
+ process.env.SKILLREPO_ACCESS_KEY = "k";
217
+ const flags = resolveFlags(["--json"]);
218
+ assert.equal(flags.json, true);
219
+ });
220
+
221
+ it("parses -k as alias for --key", () => {
222
+ const flags = resolveFlags(["-k", "k1"]);
223
+ assert.equal(flags.apiKey, "k1");
224
+ });
225
+
226
+ it("parses -u as alias for --url", () => {
227
+ process.env.SKILLREPO_ACCESS_KEY = "k";
228
+ const flags = resolveFlags(["-u", "https://x"]);
229
+ assert.equal(flags.serverUrl, "https://x");
230
+ });
231
+
232
+ it("parses --ide claudeCode", () => {
233
+ process.env.SKILLREPO_ACCESS_KEY = "k";
234
+ const flags = resolveFlags(["--ide", "claudeCode"]);
235
+ assert.deepEqual(flags.vendors, ["claudeCode"]);
236
+ });
237
+
238
+ it("parses --ide alias 'claude' as claudeCode", () => {
239
+ process.env.SKILLREPO_ACCESS_KEY = "k";
240
+ const flags = resolveFlags(["--ide", "claude"]);
241
+ assert.deepEqual(flags.vendors, ["claudeCode"]);
242
+ });
243
+
244
+ it("parses --ide multi-vendor", () => {
245
+ process.env.SKILLREPO_ACCESS_KEY = "k";
246
+ const flags = resolveFlags(["--ide", "claude,cursor,windsurf"]);
247
+ assert.deepEqual(flags.vendors, ["claudeCode", "cursor", "windsurf"]);
248
+ });
249
+
250
+ it("parses --ide all as the full vendor list", () => {
251
+ process.env.SKILLREPO_ACCESS_KEY = "k";
252
+ const flags = resolveFlags(["--ide", "all"]);
253
+ assert.deepEqual(flags.vendors, ["claudeCode", "cursor", "windsurf", "vscode"]);
254
+ });
255
+
256
+ it("rejects --ide cursor,all (mixing all with explicit vendors is ambiguous)", () => {
257
+ process.env.SKILLREPO_ACCESS_KEY = "k";
258
+ assert.throws(
259
+ () => resolveFlags(["--ide", "cursor,all"]),
260
+ (err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION && /cannot mix/.test(err.message),
261
+ );
262
+ });
263
+
264
+ it("rejects --ide all,cursor in the other order too", () => {
265
+ process.env.SKILLREPO_ACCESS_KEY = "k";
266
+ assert.throws(
267
+ () => resolveFlags(["--ide", "all,cursor"]),
268
+ (err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
269
+ );
270
+ });
271
+
272
+ it("accepts --ide all,all (degenerate case dedupes to the full set)", () => {
273
+ // The `all` token is hard-coded to mean the full vendor list. A
274
+ // user passing it twice is a degenerate input but unambiguous —
275
+ // both `all`s expand to the same set, so we accept it. This test
276
+ // locks the behavior so a future tightening of parseVendorList
277
+ // doesn't accidentally start rejecting it.
278
+ process.env.SKILLREPO_ACCESS_KEY = "k";
279
+ const flags = resolveFlags(["--ide", "all,all"]);
280
+ assert.deepEqual(flags.vendors, ["claudeCode", "cursor", "windsurf", "vscode"]);
281
+ });
282
+
283
+ it("rejects unknown --ide vendor", () => {
284
+ process.env.SKILLREPO_ACCESS_KEY = "k";
285
+ assert.throws(
286
+ () => resolveFlags(["--ide", "jetbrains"]),
287
+ (err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
288
+ );
289
+ });
290
+
291
+ it("rejects empty --ide list", () => {
292
+ process.env.SKILLREPO_ACCESS_KEY = "k";
293
+ assert.throws(
294
+ () => resolveFlags(["--ide", ","]),
295
+ (err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
296
+ );
297
+ });
298
+
299
+ it("rejects unknown flag without acceptPositional", () => {
300
+ process.env.SKILLREPO_ACCESS_KEY = "k";
301
+ assert.throws(
302
+ () => resolveFlags(["--bogus"]),
303
+ (err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
304
+ );
305
+ });
306
+ });
307
+
308
+ // ── resolveFlags — positional handling ─────────────────────────────────
309
+
310
+ describe("resolveFlags — positional callback", () => {
311
+ beforeEach(setupSandbox);
312
+ afterEach(teardownSandbox);
313
+
314
+ it("invokes acceptPositional for non-flag args", () => {
315
+ process.env.SKILLREPO_ACCESS_KEY = "k";
316
+ const captured = [];
317
+ resolveFlags(["@alice/skill"], {
318
+ acceptPositional(arg) {
319
+ captured.push(arg);
320
+ return 1;
321
+ },
322
+ });
323
+ assert.deepEqual(captured, ["@alice/skill"]);
324
+ });
325
+
326
+ it("respects the consume count from acceptPositional", () => {
327
+ process.env.SKILLREPO_ACCESS_KEY = "k";
328
+ const captured = [];
329
+ resolveFlags(["--limit", "50", "@alice/skill"], {
330
+ acceptPositional(arg, i, all) {
331
+ if (arg === "--limit") {
332
+ captured.push(["limit", all[i + 1]]);
333
+ return 2;
334
+ }
335
+ captured.push(["query", arg]);
336
+ return 1;
337
+ },
338
+ });
339
+ assert.deepEqual(captured, [["limit", "50"], ["query", "@alice/skill"]]);
340
+ });
341
+
342
+ it("treats positional as unknown when acceptPositional returns false", () => {
343
+ process.env.SKILLREPO_ACCESS_KEY = "k";
344
+ assert.throws(
345
+ () => resolveFlags(["random"], { acceptPositional: () => false }),
346
+ (err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
347
+ );
348
+ });
349
+
350
+ it("rejects positional when acceptPositional returns 0 (would loop forever)", () => {
351
+ process.env.SKILLREPO_ACCESS_KEY = "k";
352
+ assert.throws(
353
+ () => resolveFlags(["random"], { acceptPositional: () => 0 }),
354
+ (err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
355
+ );
356
+ });
357
+
358
+ it("rejects positional when acceptPositional returns a negative number", () => {
359
+ process.env.SKILLREPO_ACCESS_KEY = "k";
360
+ assert.throws(
361
+ () => resolveFlags(["random"], { acceptPositional: () => -1 }),
362
+ (err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
363
+ );
364
+ });
365
+
366
+ it("rejects positional when acceptPositional returns a non-integer", () => {
367
+ process.env.SKILLREPO_ACCESS_KEY = "k";
368
+ assert.throws(
369
+ () => resolveFlags(["random"], { acceptPositional: () => 1.5 }),
370
+ (err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
371
+ );
372
+ });
373
+
374
+ it("treats positional as unknown when no callback provided", () => {
375
+ process.env.SKILLREPO_ACCESS_KEY = "k";
376
+ assert.throws(
377
+ () => resolveFlags(["random"]),
378
+ (err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
379
+ );
380
+ });
381
+ });
382
+
383
+ // ── effectiveVendors ───────────────────────────────────────────────────
384
+
385
+ describe("effectiveVendors", () => {
386
+ it("returns ['claudeCode'] by default", () => {
387
+ assert.deepEqual(effectiveVendors({ vendors: null, global: false }), ["claudeCode"]);
388
+ });
389
+
390
+ it("returns undefined for global mode", () => {
391
+ assert.equal(effectiveVendors({ vendors: null, global: true }), undefined);
392
+ });
393
+
394
+ it("global overrides explicit vendors", () => {
395
+ assert.equal(
396
+ effectiveVendors({ vendors: ["cursor"], global: true }),
397
+ undefined,
398
+ );
399
+ });
400
+
401
+ it("returns explicit vendors when set", () => {
402
+ assert.deepEqual(
403
+ effectiveVendors({ vendors: ["claudeCode", "cursor"], global: false }),
404
+ ["claudeCode", "cursor"],
405
+ );
406
+ });
407
+ });
@@ -0,0 +1,257 @@
1
+ /**
2
+ * Unit tests for src/lib/config.mjs (PR3b of #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
+ writeFileSync,
12
+ existsSync,
13
+ readFileSync,
14
+ statSync,
15
+ chmodSync,
16
+ } from "node:fs";
17
+ import { join } from "node:path";
18
+ import { tmpdir } from "node:os";
19
+
20
+ import {
21
+ readConfig,
22
+ writeConfig,
23
+ clearConfig,
24
+ CONFIG_SCHEMA_VERSION,
25
+ } from "../../lib/config.mjs";
26
+ import { globalConfigPath } from "../../lib/paths.mjs";
27
+ import { CliError, EXIT_VALIDATION, EXIT_DISK } from "../../lib/errors.mjs";
28
+
29
+ let sandbox;
30
+ let originalHome;
31
+
32
+ function setupSandbox() {
33
+ sandbox = mkdtempSync(join(tmpdir(), "cli-config-mjs-"));
34
+ mkdirSync(join(sandbox, "home"), { recursive: true });
35
+ originalHome = process.env.HOME;
36
+ process.env.HOME = join(sandbox, "home");
37
+ }
38
+
39
+ function teardownSandbox() {
40
+ process.env.HOME = originalHome;
41
+ if (sandbox) rmSync(sandbox, { recursive: true, force: true });
42
+ }
43
+
44
+ // ── readConfig ─────────────────────────────────────────────────────────
45
+
46
+ describe("readConfig", () => {
47
+ beforeEach(setupSandbox);
48
+ afterEach(teardownSandbox);
49
+
50
+ it("returns null when the file does not exist", () => {
51
+ assert.equal(readConfig(), null);
52
+ });
53
+
54
+ it("reads a valid config file", () => {
55
+ const path = globalConfigPath();
56
+ mkdirSync(join(process.env.HOME, ".claude", "skillrepo"), { recursive: true });
57
+ writeFileSync(
58
+ path,
59
+ JSON.stringify({
60
+ schemaVersion: CONFIG_SCHEMA_VERSION,
61
+ apiKey: "sk_live_abc",
62
+ serverUrl: "https://example.com",
63
+ accountSlug: "alice",
64
+ }),
65
+ );
66
+ const result = readConfig();
67
+ assert.equal(result.apiKey, "sk_live_abc");
68
+ assert.equal(result.serverUrl, "https://example.com");
69
+ assert.equal(result.accountSlug, "alice");
70
+ });
71
+
72
+ it("accepts a legacy v2.0.0 config file with no schemaVersion", () => {
73
+ const path = globalConfigPath();
74
+ mkdirSync(join(process.env.HOME, ".claude", "skillrepo"), { recursive: true });
75
+ writeFileSync(
76
+ path,
77
+ JSON.stringify({
78
+ apiKey: "sk_live_abc",
79
+ serverUrl: "https://example.com",
80
+ }),
81
+ );
82
+ const result = readConfig();
83
+ assert.equal(result.apiKey, "sk_live_abc");
84
+ assert.equal(result.schemaVersion, CONFIG_SCHEMA_VERSION);
85
+ });
86
+
87
+ it("returns null on corrupt JSON", () => {
88
+ const path = globalConfigPath();
89
+ mkdirSync(join(process.env.HOME, ".claude", "skillrepo"), { recursive: true });
90
+ writeFileSync(path, "not json {{{");
91
+ assert.equal(readConfig(), null);
92
+ });
93
+
94
+ it("returns null on unknown future schema version", () => {
95
+ const path = globalConfigPath();
96
+ mkdirSync(join(process.env.HOME, ".claude", "skillrepo"), { recursive: true });
97
+ writeFileSync(
98
+ path,
99
+ JSON.stringify({
100
+ schemaVersion: 999,
101
+ apiKey: "sk_live_abc",
102
+ serverUrl: "https://example.com",
103
+ }),
104
+ );
105
+ assert.equal(readConfig(), null);
106
+ });
107
+
108
+ it("returns null when apiKey is missing", () => {
109
+ const path = globalConfigPath();
110
+ mkdirSync(join(process.env.HOME, ".claude", "skillrepo"), { recursive: true });
111
+ writeFileSync(path, JSON.stringify({ serverUrl: "https://example.com" }));
112
+ assert.equal(readConfig(), null);
113
+ });
114
+
115
+ it("returns null when serverUrl is missing", () => {
116
+ const path = globalConfigPath();
117
+ mkdirSync(join(process.env.HOME, ".claude", "skillrepo"), { recursive: true });
118
+ writeFileSync(path, JSON.stringify({ apiKey: "sk_live_abc" }));
119
+ assert.equal(readConfig(), null);
120
+ });
121
+
122
+ it("returns null when file is an empty object", () => {
123
+ const path = globalConfigPath();
124
+ mkdirSync(join(process.env.HOME, ".claude", "skillrepo"), { recursive: true });
125
+ writeFileSync(path, "{}");
126
+ assert.equal(readConfig(), null);
127
+ });
128
+
129
+ it("returns null when file is a JSON array (not object)", () => {
130
+ const path = globalConfigPath();
131
+ mkdirSync(join(process.env.HOME, ".claude", "skillrepo"), { recursive: true });
132
+ writeFileSync(path, "[]");
133
+ assert.equal(readConfig(), null);
134
+ });
135
+ });
136
+
137
+ // ── writeConfig ────────────────────────────────────────────────────────
138
+
139
+ describe("writeConfig", () => {
140
+ beforeEach(setupSandbox);
141
+ afterEach(teardownSandbox);
142
+
143
+ it("creates a new config file", () => {
144
+ const action = writeConfig({
145
+ apiKey: "sk_live_abc",
146
+ serverUrl: "https://example.com",
147
+ accountSlug: "alice",
148
+ });
149
+ assert.equal(action, "created");
150
+ const result = readConfig();
151
+ assert.equal(result.apiKey, "sk_live_abc");
152
+ assert.equal(result.accountSlug, "alice");
153
+ assert.equal(result.schemaVersion, CONFIG_SCHEMA_VERSION);
154
+ assert.ok(result.writtenAt); // timestamp present
155
+ });
156
+
157
+ it("updates an existing config file", () => {
158
+ writeConfig({ apiKey: "sk_live_old", serverUrl: "https://old.com" });
159
+ const action = writeConfig({
160
+ apiKey: "sk_live_new",
161
+ serverUrl: "https://new.com",
162
+ });
163
+ assert.equal(action, "updated");
164
+ const result = readConfig();
165
+ assert.equal(result.apiKey, "sk_live_new");
166
+ assert.equal(result.serverUrl, "https://new.com");
167
+ });
168
+
169
+ it("creates parent directory if missing", () => {
170
+ // sandbox HOME has no .claude/skillrepo/ yet
171
+ writeConfig({ apiKey: "sk_live_abc", serverUrl: "https://example.com" });
172
+ assert.ok(existsSync(globalConfigPath()));
173
+ });
174
+
175
+ it("preserves unknown fields from an existing config", () => {
176
+ const path = globalConfigPath();
177
+ mkdirSync(join(process.env.HOME, ".claude", "skillrepo"), { recursive: true });
178
+ writeFileSync(
179
+ path,
180
+ JSON.stringify({
181
+ schemaVersion: CONFIG_SCHEMA_VERSION,
182
+ apiKey: "sk_live_old",
183
+ serverUrl: "https://old.com",
184
+ futureField: "preserve me",
185
+ }),
186
+ );
187
+ writeConfig({
188
+ apiKey: "sk_live_new",
189
+ serverUrl: "https://new.com",
190
+ });
191
+ const raw = JSON.parse(readFileSync(path, "utf-8"));
192
+ assert.equal(raw.futureField, "preserve me");
193
+ assert.equal(raw.apiKey, "sk_live_new");
194
+ });
195
+
196
+ it("sets 0600 permissions on POSIX", { skip: process.platform === "win32" || process.getuid?.() === 0 }, () => {
197
+ writeConfig({ apiKey: "sk_live_abc", serverUrl: "https://example.com" });
198
+ const path = globalConfigPath();
199
+ const mode = statSync(path).mode & 0o777;
200
+ assert.equal(mode, 0o600, `Expected 0o600, got ${mode.toString(8)}`);
201
+ });
202
+
203
+ it("throws validationError on missing apiKey", () => {
204
+ assert.throws(
205
+ () => writeConfig({ serverUrl: "https://example.com" }),
206
+ (err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
207
+ );
208
+ });
209
+
210
+ it("throws validationError on missing serverUrl", () => {
211
+ assert.throws(
212
+ () => writeConfig({ apiKey: "sk_live_abc" }),
213
+ (err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
214
+ );
215
+ });
216
+
217
+ it("throws validationError on null config", () => {
218
+ assert.throws(
219
+ () => writeConfig(null),
220
+ (err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
221
+ );
222
+ });
223
+
224
+ it("throws diskError when parent dir is read-only", { skip: process.platform === "win32" || process.getuid?.() === 0 }, () => {
225
+ // Create the grandparent dir and make the parent path non-writable
226
+ const grandParent = join(process.env.HOME, ".claude");
227
+ mkdirSync(grandParent, { recursive: true });
228
+ chmodSync(grandParent, 0o555);
229
+
230
+ try {
231
+ assert.throws(
232
+ () => writeConfig({ apiKey: "sk_live_abc", serverUrl: "https://example.com" }),
233
+ (err) => err instanceof CliError && err.exitCode === EXIT_DISK,
234
+ );
235
+ } finally {
236
+ chmodSync(grandParent, 0o755);
237
+ }
238
+ });
239
+ });
240
+
241
+ // ── clearConfig ────────────────────────────────────────────────────────
242
+
243
+ describe("clearConfig", () => {
244
+ beforeEach(setupSandbox);
245
+ afterEach(teardownSandbox);
246
+
247
+ it("returns false when no config exists", () => {
248
+ assert.equal(clearConfig(), false);
249
+ });
250
+
251
+ it("removes an existing config file", () => {
252
+ writeConfig({ apiKey: "sk_live_abc", serverUrl: "https://example.com" });
253
+ assert.ok(existsSync(globalConfigPath()));
254
+ assert.equal(clearConfig(), true);
255
+ assert.ok(!existsSync(globalConfigPath()));
256
+ });
257
+ });