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,279 @@
1
+ /**
2
+ * Integration tests for src/lib/file-write.mjs (PR1 of #646).
3
+ *
4
+ * Differs from the unit tests in that we compose the public API against
5
+ * a real temp filesystem and assert on multi-step behaviors:
6
+ * - End-to-end skill round-trip (write → read back → verify)
7
+ * - Update path leaves NO orphan state on success
8
+ * - Sequential rewrites are atomic from the reader's perspective
9
+ * - Recovery from injected partial state (simulated mid-write crash)
10
+ * - Multi-target writes produce identical content
11
+ *
12
+ * These tests touch the real filesystem in a temp directory. They do
13
+ * not require network or a running server.
14
+ */
15
+
16
+ import { describe, it, beforeEach, afterEach } from "node:test";
17
+ import assert from "node:assert/strict";
18
+ import {
19
+ mkdtempSync,
20
+ rmSync,
21
+ mkdirSync,
22
+ writeFileSync,
23
+ existsSync,
24
+ readFileSync,
25
+ readdirSync,
26
+ } from "node:fs";
27
+ import { join } from "node:path";
28
+ import { tmpdir } from "node:os";
29
+
30
+ import {
31
+ writeSkillDir,
32
+ removeSkillDir,
33
+ cleanupOrphans,
34
+ resolvePlacementDir,
35
+ } from "../../lib/file-write.mjs";
36
+
37
+ let sandbox;
38
+ let originalCwd;
39
+ let originalHome;
40
+
41
+ function setupSandbox() {
42
+ sandbox = mkdtempSync(join(tmpdir(), "cli-fw-int-"));
43
+ mkdirSync(join(sandbox, "project"), { recursive: true });
44
+ mkdirSync(join(sandbox, "home"), { recursive: true });
45
+ originalCwd = process.cwd();
46
+ originalHome = process.env.HOME;
47
+ process.chdir(join(sandbox, "project"));
48
+ process.env.HOME = join(sandbox, "home");
49
+ }
50
+
51
+ function teardownSandbox() {
52
+ process.chdir(originalCwd);
53
+ process.env.HOME = originalHome;
54
+ if (sandbox) rmSync(sandbox, { recursive: true, force: true });
55
+ }
56
+
57
+ function multiFileSkill(name = "pdf-helper") {
58
+ return {
59
+ owner: "alice",
60
+ name,
61
+ files: [
62
+ {
63
+ path: "SKILL.md",
64
+ content: `---\nname: ${name}\ndescription: Multi-file integration test skill.\n---\n\nMain body.\n`,
65
+ },
66
+ { path: "scripts/extract.py", content: "import sys\nprint(sys.argv)\n" },
67
+ { path: "scripts/format.sh", content: "#!/bin/bash\necho ok\n" },
68
+ { path: "references/REFERENCE.md", content: "# Reference\n\nDetails.\n" },
69
+ { path: "assets/template.json", content: '{"version": 1}\n' },
70
+ // Path-preserving non-spec dir — proves Defect A fix end-to-end
71
+ { path: "lib/helper.py", content: "def helper():\n return 42\n" },
72
+ ],
73
+ };
74
+ }
75
+
76
+ describe("file-write.mjs integration — round-trip", () => {
77
+ beforeEach(setupSandbox);
78
+ afterEach(teardownSandbox);
79
+
80
+ it("writes a 6-file skill and reads back identical bytes", () => {
81
+ const skill = multiFileSkill();
82
+ const result = writeSkillDir(skill, { vendors: ["claudeCode"] });
83
+ assert.equal(result.written.length, 1);
84
+ const dir = result.written[0];
85
+
86
+ for (const file of skill.files) {
87
+ const onDisk = readFileSync(join(dir, file.path), "utf-8");
88
+ assert.equal(onDisk, file.content, `Mismatch for ${file.path}`);
89
+ }
90
+ });
91
+
92
+ it("writes the same skill identically to multiple targets", () => {
93
+ const skill = multiFileSkill();
94
+ const result = writeSkillDir(skill, { vendors: ["claudeCode", "cursor"] });
95
+ assert.equal(result.written.length, 2);
96
+
97
+ const [firstDir, secondDir] = result.written;
98
+ for (const file of skill.files) {
99
+ const a = readFileSync(join(firstDir, file.path), "utf-8");
100
+ const b = readFileSync(join(secondDir, file.path), "utf-8");
101
+ assert.equal(a, b, `Targets diverged for ${file.path}`);
102
+ }
103
+ });
104
+
105
+ it("writes to global dir under HOME with --global", () => {
106
+ const skill = multiFileSkill();
107
+ const result = writeSkillDir(skill, { global: true });
108
+ assert.equal(result.written.length, 1);
109
+ const dir = result.written[0];
110
+ assert.ok(dir.startsWith(process.env.HOME), "should write under HOME");
111
+ for (const file of skill.files) {
112
+ assert.ok(existsSync(join(dir, file.path)));
113
+ }
114
+ });
115
+ });
116
+
117
+ describe("file-write.mjs integration — update path", () => {
118
+ beforeEach(setupSandbox);
119
+ afterEach(teardownSandbox);
120
+
121
+ it("leaves no .tmp/ or .old/ directories after a successful write", () => {
122
+ writeSkillDir(multiFileSkill(), { vendors: ["claudeCode"] });
123
+ const root = join(process.cwd(), ".claude", "skills");
124
+ const entries = readdirSync(root);
125
+ for (const entry of entries) {
126
+ assert.ok(!entry.endsWith(".tmp"), `Unexpected .tmp leftover: ${entry}`);
127
+ assert.ok(!entry.endsWith(".old"), `Unexpected .old leftover: ${entry}`);
128
+ }
129
+ });
130
+
131
+ it("removes deleted files when overwriting", () => {
132
+ // First write
133
+ writeSkillDir(multiFileSkill(), { vendors: ["claudeCode"] });
134
+
135
+ // Second write removes the lib/ and references/ dirs
136
+ const trimmed = {
137
+ owner: "alice",
138
+ name: "pdf-helper",
139
+ files: [
140
+ { path: "SKILL.md", content: "---\nname: pdf-helper\n---\nTrimmed.\n" },
141
+ { path: "scripts/extract.py", content: "print('only this remains')\n" },
142
+ ],
143
+ };
144
+ const result = writeSkillDir(trimmed, { vendors: ["claudeCode"] });
145
+ const dir = result.written[0];
146
+
147
+ assert.ok(existsSync(join(dir, "SKILL.md")));
148
+ assert.ok(existsSync(join(dir, "scripts/extract.py")));
149
+ assert.ok(!existsSync(join(dir, "lib/helper.py")), "lib/ should be gone");
150
+ assert.ok(!existsSync(join(dir, "references/REFERENCE.md")), "references/ should be gone");
151
+ assert.ok(!existsSync(join(dir, "assets/template.json")), "assets/ should be gone");
152
+ });
153
+
154
+ it("multiple sequential updates each end in a clean state", () => {
155
+ for (let i = 0; i < 5; i++) {
156
+ const skill = {
157
+ owner: "alice",
158
+ name: "pdf-helper",
159
+ files: [
160
+ { path: "SKILL.md", content: `---\nname: pdf-helper\n---\nIteration ${i}.\n` },
161
+ { path: "scripts/iter.py", content: `# iteration ${i}\n` },
162
+ ],
163
+ };
164
+ writeSkillDir(skill, { vendors: ["claudeCode"] });
165
+
166
+ const dir = resolvePlacementDir("claudeProject", "pdf-helper");
167
+ const skillMd = readFileSync(join(dir, "SKILL.md"), "utf-8");
168
+ assert.match(skillMd, new RegExp(`Iteration ${i}`));
169
+
170
+ // No leftovers
171
+ const root = join(process.cwd(), ".claude", "skills");
172
+ const entries = readdirSync(root);
173
+ for (const entry of entries) {
174
+ assert.ok(!entry.endsWith(".tmp"));
175
+ assert.ok(!entry.endsWith(".old"));
176
+ }
177
+ }
178
+ });
179
+ });
180
+
181
+ describe("file-write.mjs integration — orphan recovery", () => {
182
+ beforeEach(setupSandbox);
183
+ afterEach(teardownSandbox);
184
+
185
+ it("cleanupOrphans removes injected .tmp/ (with live siblings) and .old/ across all roots", () => {
186
+ // Inject orphans in claudeProject root, claudeGlobal root, and
187
+ // projectFallback root. .tmp/ entries need a live sibling so the
188
+ // safety invariant doesn't preserve them as recoverable.
189
+ const claudeProjectRoot = join(process.cwd(), ".claude", "skills");
190
+ const fallbackRoot = join(process.cwd(), "skills");
191
+ const globalRoot = join(process.env.HOME, ".claude", "skills");
192
+ mkdirSync(claudeProjectRoot, { recursive: true });
193
+ mkdirSync(fallbackRoot, { recursive: true });
194
+ mkdirSync(globalRoot, { recursive: true });
195
+
196
+ // ghost-1: live sibling + .tmp + .old (.tmp gets cleaned because live exists)
197
+ mkdirSync(join(claudeProjectRoot, "ghost-1"));
198
+ mkdirSync(join(claudeProjectRoot, "ghost-1.tmp"));
199
+ mkdirSync(join(claudeProjectRoot, "ghost-1.old"));
200
+ writeFileSync(join(claudeProjectRoot, "ghost-1.tmp", "garbage.txt"), "leftover");
201
+
202
+ // ghost-2: just a .old/ in the fallback root (.old has no invariant)
203
+ mkdirSync(join(fallbackRoot, "ghost-2.old"));
204
+
205
+ // ghost-3: live sibling + .tmp in the global root
206
+ mkdirSync(join(globalRoot, "ghost-3"));
207
+ mkdirSync(join(globalRoot, "ghost-3.tmp"));
208
+
209
+ const result = cleanupOrphans({});
210
+ assert.equal(result.cleaned.length, 4);
211
+
212
+ assert.ok(!existsSync(join(claudeProjectRoot, "ghost-1.tmp")));
213
+ assert.ok(!existsSync(join(claudeProjectRoot, "ghost-1.old")));
214
+ assert.ok(!existsSync(join(fallbackRoot, "ghost-2.old")));
215
+ assert.ok(!existsSync(join(globalRoot, "ghost-3.tmp")));
216
+ // Live siblings preserved
217
+ assert.ok(existsSync(join(claudeProjectRoot, "ghost-1")));
218
+ assert.ok(existsSync(join(globalRoot, "ghost-3")));
219
+ });
220
+
221
+ it("cleanupOrphans preserves a .tmp/ whose live target is missing (Windows recovery)", () => {
222
+ // This is the safety invariant proof — a .tmp/ with no live
223
+ // sibling is the user's only copy of a skill that crashed
224
+ // mid-rename on Windows, and must not be deleted.
225
+ const root = join(process.cwd(), ".claude", "skills");
226
+ mkdirSync(join(root, "recoverable.tmp"), { recursive: true });
227
+ writeFileSync(join(root, "recoverable.tmp", "SKILL.md"), "---\nname: recoverable\n---\nUser's only copy.\n");
228
+
229
+ const result = cleanupOrphans({});
230
+ assert.equal(result.cleaned.length, 0, "no orphans should be cleaned");
231
+ assert.ok(existsSync(join(root, "recoverable.tmp", "SKILL.md")), "recoverable .tmp must survive");
232
+ });
233
+
234
+ it("write recovers when a stale .tmp/ from a crashed run is present", () => {
235
+ const skill = multiFileSkill();
236
+ // Pre-populate stale .tmp/ with garbage that would conflict
237
+ const targetDir = resolvePlacementDir("claudeProject", "pdf-helper");
238
+ mkdirSync(`${targetDir}.tmp`, { recursive: true });
239
+ writeFileSync(`${targetDir}.tmp/old-garbage.txt`, "x");
240
+
241
+ const result = writeSkillDir(skill, { vendors: ["claudeCode"] });
242
+ const dir = result.written[0];
243
+
244
+ // Old garbage should be gone; new files should be present
245
+ assert.ok(!existsSync(join(dir, "old-garbage.txt")));
246
+ for (const file of skill.files) {
247
+ assert.ok(existsSync(join(dir, file.path)));
248
+ }
249
+ // No .tmp/ leftover
250
+ assert.ok(!existsSync(`${targetDir}.tmp`));
251
+ });
252
+ });
253
+
254
+ describe("file-write.mjs integration — remove + .gitignore", () => {
255
+ beforeEach(setupSandbox);
256
+ afterEach(teardownSandbox);
257
+
258
+ it("write then remove leaves the skills root empty", () => {
259
+ writeSkillDir(multiFileSkill(), { vendors: ["claudeCode"] });
260
+ const result = removeSkillDir("pdf-helper", { vendors: ["claudeCode"] });
261
+ assert.equal(result.removed.length, 1);
262
+ const root = join(process.cwd(), ".claude", "skills");
263
+ if (existsSync(root)) {
264
+ const entries = readdirSync(root);
265
+ assert.equal(entries.length, 0, "skills root should be empty after remove");
266
+ }
267
+ });
268
+
269
+ it("write to fallback creates .gitignore entry; remove does not delete it", () => {
270
+ writeSkillDir(multiFileSkill(), { vendors: ["cursor"] });
271
+ const giBefore = readFileSync(join(process.cwd(), ".gitignore"), "utf-8");
272
+ assert.match(giBefore, /\/skills\//);
273
+
274
+ removeSkillDir("pdf-helper", { vendors: ["cursor"] });
275
+ const giAfter = readFileSync(join(process.cwd(), ".gitignore"), "utf-8");
276
+ // remove should not touch .gitignore
277
+ assert.equal(giAfter, giBefore);
278
+ });
279
+ });
@@ -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
+ });