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,322 @@
1
+ /**
2
+ * Unit/integration tests for src/commands/update.mjs (PR2 of #646).
3
+ *
4
+ * Tests run the runUpdate function directly against an in-process mock
5
+ * server. Captures stdout via a stream override.
6
+ */
7
+
8
+ import { describe, it, beforeEach, afterEach } from "node:test";
9
+ import assert from "node:assert/strict";
10
+ import { mkdtempSync, mkdirSync, rmSync, existsSync, writeFileSync } from "node:fs";
11
+ import { join } from "node:path";
12
+ import { tmpdir } from "node:os";
13
+
14
+ import { runUpdate } from "../../commands/update.mjs";
15
+ import { resolvePlacementDir } from "../../lib/file-write.mjs";
16
+ import { CliError, EXIT_AUTH, EXIT_VALIDATION } from "../../lib/errors.mjs";
17
+ import { createMockServer } from "../e2e/mock-server.mjs";
18
+ import { createCaptureStream } from "../helpers/capture-stream.mjs";
19
+
20
+ let sandbox;
21
+ let server;
22
+ let serverUrl;
23
+ let originalCwd;
24
+ let originalHome;
25
+ let stdout;
26
+ const VALID_KEY = "sk_live_test";
27
+
28
+ function makeSkill(name) {
29
+ return {
30
+ owner: "alice",
31
+ name,
32
+ version: "1.0.0",
33
+ description: `${name} description`,
34
+ files: [
35
+ {
36
+ path: "SKILL.md",
37
+ content: `---\nname: ${name}\ndescription: ${name} description\n---\n\nbody\n`,
38
+ sha256: "x",
39
+ size: 50,
40
+ contentType: "text/markdown",
41
+ },
42
+ ],
43
+ updatedAt: new Date().toISOString(),
44
+ };
45
+ }
46
+
47
+ async function setup() {
48
+ sandbox = mkdtempSync(join(tmpdir(), "cli-cmd-update-"));
49
+ mkdirSync(join(sandbox, "project"), { recursive: true });
50
+ mkdirSync(join(sandbox, "home"), { recursive: true });
51
+ originalCwd = process.cwd();
52
+ originalHome = process.env.HOME;
53
+ process.chdir(join(sandbox, "project"));
54
+ process.env.HOME = join(sandbox, "home");
55
+ delete process.env.SKILLREPO_ACCESS_KEY;
56
+ delete process.env.SKILLREPO_URL;
57
+
58
+ server = createMockServer({});
59
+ const port = await server.start();
60
+ serverUrl = `http://127.0.0.1:${port}`;
61
+
62
+ stdout = createCaptureStream();
63
+ }
64
+
65
+ async function teardown() {
66
+ if (server) await server.stop();
67
+ process.chdir(originalCwd);
68
+ process.env.HOME = originalHome;
69
+ if (sandbox) rmSync(sandbox, { recursive: true, force: true });
70
+ server = null;
71
+ }
72
+
73
+ describe("runUpdate — happy path", () => {
74
+ beforeEach(setup);
75
+ afterEach(teardown);
76
+
77
+ it("syncs an empty library and prints up-to-date", async () => {
78
+ server.setLibraryResponse({ skills: [], removals: [], syncedAt: "x" });
79
+ await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
80
+ assert.match(stdout.text(), /up to date/);
81
+ });
82
+
83
+ it("syncs a single skill and prints add count", async () => {
84
+ server.setLibraryResponse({
85
+ skills: [makeSkill("first")],
86
+ removals: [],
87
+ syncedAt: "x",
88
+ });
89
+ await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
90
+ assert.match(stdout.text(), /1 added/);
91
+ assert.ok(existsSync(resolvePlacementDir("claudeProject", "first")));
92
+ });
93
+
94
+ it("--json outputs structured summary", async () => {
95
+ server.setLibraryResponse({
96
+ skills: [makeSkill("first"), makeSkill("second")],
97
+ removals: [],
98
+ syncedAt: "x",
99
+ });
100
+ await runUpdate(["--key", VALID_KEY, "--url", serverUrl, "--json"], { stdout });
101
+ const json = JSON.parse(stdout.text());
102
+ assert.equal(json.added, 2);
103
+ assert.equal(json.removed, 0);
104
+ assert.equal(json.notModified, false);
105
+ });
106
+ });
107
+
108
+ describe("runUpdate — credential resolution", () => {
109
+ beforeEach(setup);
110
+ afterEach(teardown);
111
+
112
+ it("reads from the global config file when no flags", async () => {
113
+ mkdirSync(join(process.env.HOME, ".claude", "skillrepo"), { recursive: true });
114
+ writeFileSync(
115
+ join(process.env.HOME, ".claude", "skillrepo", "config.json"),
116
+ JSON.stringify({ apiKey: VALID_KEY, serverUrl }),
117
+ );
118
+ server.setLibraryResponse({ skills: [], removals: [], syncedAt: "x" });
119
+ await runUpdate([], { stdout });
120
+ assert.match(stdout.text(), /up to date/);
121
+ });
122
+
123
+ it("throws authError when no key and no config", async () => {
124
+ await assert.rejects(
125
+ () => runUpdate([]),
126
+ (err) => err instanceof CliError && err.exitCode === EXIT_AUTH,
127
+ );
128
+ });
129
+ });
130
+
131
+ describe("runUpdate — flag handling", () => {
132
+ beforeEach(setup);
133
+ afterEach(teardown);
134
+
135
+ it("rejects unknown flag", async () => {
136
+ await assert.rejects(
137
+ () => runUpdate(["--key", VALID_KEY, "--url", serverUrl, "--bogus"]),
138
+ (err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
139
+ );
140
+ });
141
+
142
+ it("--global writes to the global skills dir", async () => {
143
+ server.setLibraryResponse({
144
+ skills: [makeSkill("global-test")],
145
+ removals: [],
146
+ syncedAt: "x",
147
+ });
148
+ await runUpdate(["--key", VALID_KEY, "--url", serverUrl, "--global"]);
149
+ const dir = resolvePlacementDir("claudeGlobal", "global-test");
150
+ assert.ok(existsSync(dir));
151
+ assert.ok(dir.startsWith(process.env.HOME));
152
+ });
153
+
154
+ it("--ide cursor writes to the project /skills/ fallback", async () => {
155
+ server.setLibraryResponse({
156
+ skills: [makeSkill("cursor-test")],
157
+ removals: [],
158
+ syncedAt: "x",
159
+ });
160
+ await runUpdate(["--key", VALID_KEY, "--url", serverUrl, "--ide", "cursor"]);
161
+ const dir = resolvePlacementDir("projectFallback", "cursor-test");
162
+ assert.ok(existsSync(dir));
163
+ });
164
+ });
165
+
166
+ // ── --session-hook mode (#884) ────────────────────────────────────────
167
+ //
168
+ // Session-hook mode is what the Claude Code SessionStart hook #884's
169
+ // installer writes invokes: `skillrepo update --session-hook`. The
170
+ // contract: exit 0 on ALL errors, silent on 304, one-line summary on
171
+ // changes, one-line failure message on error. A sync failure must
172
+ // NEVER block a session start.
173
+ //
174
+ // Architect pre-flight review flagged the flag-acceptance bug as
175
+ // "most likely silent-failure bug" — if `resolveFlags` rejects the
176
+ // flag before the exit-0 contract activates, every session-start
177
+ // hook silently fails with a symptom indistinguishable from 304.
178
+ // Tests in this suite specifically guard against that.
179
+
180
+ describe("runUpdate — --session-hook contract", () => {
181
+ beforeEach(setup);
182
+ afterEach(teardown);
183
+
184
+ it("accepts the --session-hook flag without throwing a validation error", async () => {
185
+ // THE architect-flagged regression guard. If a future refactor
186
+ // removes the acceptPositional callback from update.mjs,
187
+ // resolveFlags throws `Unknown argument: --session-hook` before
188
+ // the exit-0 contract has a chance to fire. The `|| true` shell
189
+ // backstop would catch it, but the user would lose the failure
190
+ // message AND every sync would silently no-op. This test makes
191
+ // that class of regression fail loudly at unit-test time.
192
+ server.setLibraryResponse({ skills: [], removals: [], syncedAt: "x" });
193
+ await assert.doesNotReject(
194
+ () =>
195
+ runUpdate(
196
+ ["--key", VALID_KEY, "--url", serverUrl, "--session-hook"],
197
+ { stdout },
198
+ ),
199
+ "update --session-hook must NOT throw Unknown argument",
200
+ );
201
+ });
202
+
203
+ it("is silent on 304 / up-to-date (no 'Syncing' output every session)", async () => {
204
+ // INTENT: 304 means nothing changed. Printing "Syncing..." on
205
+ // every session start with no value to show would clutter the
206
+ // system-message surface. The contract is SILENCE on 304.
207
+ server.setLibraryResponse({ skills: [], removals: [], syncedAt: "x" });
208
+ await runUpdate(
209
+ ["--key", VALID_KEY, "--url", serverUrl, "--session-hook"],
210
+ { stdout },
211
+ );
212
+ assert.equal(
213
+ stdout.text(),
214
+ "",
215
+ "session-hook mode must emit zero output when there's nothing to sync",
216
+ );
217
+ });
218
+
219
+ it("prints one line on successful sync with changes", async () => {
220
+ // INTENT: when content CHANGES, surface exactly one actionable
221
+ // line so the user knows their library updated. No banners, no
222
+ // multi-line output — a single line the hook runner can render
223
+ // as a system message.
224
+ server.setLibraryResponse({
225
+ skills: [makeSkill("sync-hook-test")],
226
+ removals: [],
227
+ syncedAt: "x",
228
+ });
229
+ await runUpdate(
230
+ ["--key", VALID_KEY, "--url", serverUrl, "--session-hook"],
231
+ { stdout },
232
+ );
233
+ const out = stdout.text();
234
+ assert.match(out, /^\[SkillRepo\] Library synced: \d+ added, \d+ updated, \d+ removed\.\n$/);
235
+ });
236
+
237
+ it("exits 0 with a failure message on a server error instead of throwing", async () => {
238
+ // THE load-bearing contract. A sync failure must NEVER block a
239
+ // session start. runUpdate called with --session-hook against an
240
+ // unreachable server must return normally (no throw) and emit a
241
+ // one-line failure message.
242
+ //
243
+ // We simulate an unreachable server by pointing at an invalid
244
+ // port. Without the exit-0 contract, this would throw
245
+ // networkError from runSync; WITH the contract, the catch block
246
+ // swallows it and prints a failure message.
247
+ await assert.doesNotReject(
248
+ () =>
249
+ runUpdate(
250
+ ["--key", VALID_KEY, "--url", "http://127.0.0.1:1", "--session-hook"],
251
+ { stdout },
252
+ ),
253
+ "session-hook mode must NEVER throw — session start must proceed",
254
+ );
255
+ const out = stdout.text();
256
+ assert.match(out, /^\[SkillRepo\] Sync failed: .+\n$/);
257
+ });
258
+
259
+ it("exits 0 with a failure message on auth error (invalid key)", async () => {
260
+ // Auth error is non-retryable AND non-blocking for session start.
261
+ // If the user's key was rotated, we want them to see "access key
262
+ // invalid" in their session system message, but the session must
263
+ // still open normally.
264
+ server.setForcedStatus(401, { error: "Invalid access key" });
265
+ await assert.doesNotReject(
266
+ () =>
267
+ runUpdate(
268
+ ["--key", VALID_KEY, "--url", serverUrl, "--session-hook"],
269
+ { stdout },
270
+ ),
271
+ );
272
+ const out = stdout.text();
273
+ assert.match(out, /Sync failed/);
274
+ });
275
+
276
+ it("exits 0 with a failure message when no access key is configured", async () => {
277
+ // INTENT: the VERY first session after installing skillrepo, if
278
+ // init hasn't run yet, has no key. The hook must not block the
279
+ // session. This is a genuine first-run scenario, not a synthetic
280
+ // edge case — users who set up Claude Code before running
281
+ // `skillrepo init` will hit exactly this.
282
+ //
283
+ // No --key flag, no cached config, no env var. resolveFlags
284
+ // normally throws authError in this case. Session-hook mode
285
+ // must catch that and exit 0 anyway.
286
+ await assert.doesNotReject(
287
+ () =>
288
+ runUpdate(["--url", serverUrl, "--session-hook"], { stdout }),
289
+ "session-hook mode with no key must NEVER throw",
290
+ );
291
+ const out = stdout.text();
292
+ assert.match(out, /Sync failed/, "failure message must still surface");
293
+ });
294
+
295
+ it("exits 0 when the --session-hook flag appears after other flags", async () => {
296
+ // INTENT: flag ordering is not part of the contract. The installer
297
+ // writes a specific order today, but a user hand-editing their
298
+ // settings (or a future refactor moving flags around) must not
299
+ // break the exit-0 behavior.
300
+ server.setLibraryResponse({ skills: [], removals: [], syncedAt: "x" });
301
+ await assert.doesNotReject(
302
+ () =>
303
+ runUpdate(
304
+ ["--session-hook", "--key", VALID_KEY, "--url", serverUrl],
305
+ { stdout },
306
+ ),
307
+ );
308
+ assert.equal(stdout.text(), "", "still silent on 304 regardless of flag order");
309
+ });
310
+
311
+ it("exits 0 silently when the server returns zero deltas (empty success, not 304)", async () => {
312
+ // INTENT: 304 isn't the only silent-success case. A 200 response
313
+ // with zero added/updated/removed (a fresh sync that happened to
314
+ // produce no work) is also silent — same UX reasoning as 304.
315
+ server.setLibraryResponse({ skills: [], removals: [], syncedAt: "x" });
316
+ await runUpdate(
317
+ ["--key", VALID_KEY, "--url", serverUrl, "--session-hook"],
318
+ { stdout },
319
+ );
320
+ assert.equal(stdout.text(), "", "zero-delta 200 is silent");
321
+ });
322
+ });
@@ -53,18 +53,13 @@ describe("IDE detection", () => {
53
53
  // windsurf depends on home dir, skip assertion
54
54
  });
55
55
 
56
- it("defaults to claudeCode + cursor when nothing detected", async () => {
57
- const { getDetectedIdeKeys } = await import("../lib/detect-ides.mjs");
58
-
59
- // Override windsurf detection too
60
- const keys = getDetectedIdeKeys({ claudeCode: false, cursor: false, windsurf: false, vscode: false });
61
- assert.deepEqual(keys, ["claudeCode", "cursor"]);
62
- });
63
-
64
- it("returns only detected IDEs", async () => {
65
- const { getDetectedIdeKeys } = await import("../lib/detect-ides.mjs");
66
-
67
- const keys = getDetectedIdeKeys({ claudeCode: true, cursor: false, windsurf: false, vscode: true });
68
- assert.deepEqual(keys, ["claudeCode", "vscode"]);
69
- });
56
+ // PR4 cross-review cleanup: the old v2.0.0 `getDetectedIdeKeys`
57
+ // helper with a "default to claudeCode + cursor when nothing
58
+ // detected" fallback was dead code — the plan explicitly
59
+ // removed the silent fallback in PR3b, and no command called
60
+ // this helper. It was exported and had tests asserting the
61
+ // v2.0.0 behavior, which contradicted the actual v3.0.0 code
62
+ // path. Deleted; init now refuses with a --ide hint when
63
+ // nothing is detected (see the "refuses with clear error when
64
+ // no IDE detected and no --ide flag" test in init.test.mjs).
70
65
  });
@@ -0,0 +1,224 @@
1
+ /**
2
+ * Unit tests for bin/skillrepo.mjs (PR1 of #646).
3
+ *
4
+ * Spawns the real binary as a subprocess and asserts on stdout, stderr,
5
+ * and exit code for each documented routing path. Intentionally thin —
6
+ * the dispatcher should be a pure routing layer, so most logic lives in
7
+ * the command modules tested elsewhere.
8
+ *
9
+ * IMPORTANT: keep these tests fast — they run as part of `npm run check`
10
+ * and a slow dispatcher suite slows down every commit.
11
+ */
12
+
13
+ import { describe, it } from "node:test";
14
+ import assert from "node:assert/strict";
15
+ import { execFile } from "node:child_process";
16
+ import { fileURLToPath } from "node:url";
17
+ import { dirname, resolve } from "node:path";
18
+ import { readFileSync } from "node:fs";
19
+
20
+ const __dirname = dirname(fileURLToPath(import.meta.url));
21
+ const CLI_BIN = resolve(__dirname, "../../bin/skillrepo.mjs");
22
+ const PKG_PATH = resolve(__dirname, "../../package.json");
23
+
24
+ /**
25
+ * Run the CLI dispatcher and capture stdout, stderr, and exit code.
26
+ * Always resolves — never rejects — so tests can assert on failures.
27
+ *
28
+ * @param {string[]} args
29
+ * @param {object} [opts]
30
+ * @param {object} [opts.env] - Extra env vars (merged onto process.env).
31
+ * Useful for tests that need to point HOME at an empty dir to
32
+ * prove "no global config" code paths fire correctly. Pass
33
+ * SKILLREPO_ACCESS_KEY: "" explicitly to override an inherited
34
+ * env var from the developer's shell.
35
+ */
36
+ function runCli(args = [], opts = {}) {
37
+ return new Promise((resolve) => {
38
+ const baseEnv = {
39
+ ...process.env,
40
+ NO_COLOR: "1",
41
+ // Default tests to "no key in env" so the no-config code paths
42
+ // are exercised reliably across developer machines that may
43
+ // have SKILLREPO_ACCESS_KEY set in their shell.
44
+ SKILLREPO_ACCESS_KEY: "",
45
+ // Short timeout so the network-failure case fails fast rather
46
+ // than waiting 30s on the default safeFetch timeout.
47
+ SKILLREPO_TIMEOUT_MS: "2000",
48
+ };
49
+ execFile(
50
+ process.execPath,
51
+ [CLI_BIN, ...args],
52
+ {
53
+ encoding: "utf-8",
54
+ timeout: 10_000,
55
+ env: { ...baseEnv, ...(opts.env || {}) },
56
+ },
57
+ (err, stdout, stderr) => {
58
+ resolve({
59
+ stdout: stdout ?? "",
60
+ stderr: stderr ?? "",
61
+ status: err ? err.code ?? 1 : 0,
62
+ });
63
+ },
64
+ );
65
+ });
66
+ }
67
+
68
+ describe("dispatcher — top-level help", () => {
69
+ it("`skillrepo` with no args prints top-level help and exits 0", async () => {
70
+ const r = await runCli([]);
71
+ assert.equal(r.status, 0);
72
+ assert.match(r.stdout, /SkillRepo CLI/);
73
+ // All 7 commands listed
74
+ for (const cmd of ["init", "update", "get", "add", "remove", "list", "search"]) {
75
+ assert.match(r.stdout, new RegExp(`\\b${cmd}\\b`), `expected to see "${cmd}" in help`);
76
+ }
77
+ });
78
+
79
+ it("`skillrepo --help` is the same as no args", async () => {
80
+ const r = await runCli(["--help"]);
81
+ assert.equal(r.status, 0);
82
+ assert.match(r.stdout, /SkillRepo CLI/);
83
+ });
84
+
85
+ it("`skillrepo -h` is the same as --help", async () => {
86
+ const r = await runCli(["-h"]);
87
+ assert.equal(r.status, 0);
88
+ assert.match(r.stdout, /SkillRepo CLI/);
89
+ });
90
+ });
91
+
92
+ describe("dispatcher — version", () => {
93
+ it("`skillrepo --version` prints the package.json version", async () => {
94
+ const r = await runCli(["--version"]);
95
+ assert.equal(r.status, 0);
96
+ const pkg = JSON.parse(readFileSync(PKG_PATH, "utf-8"));
97
+ assert.equal(r.stdout.trim(), pkg.version);
98
+ });
99
+
100
+ it("`skillrepo -v` is the same as --version", async () => {
101
+ const r = await runCli(["-v"]);
102
+ assert.equal(r.status, 0);
103
+ const pkg = JSON.parse(readFileSync(PKG_PATH, "utf-8"));
104
+ assert.equal(r.stdout.trim(), pkg.version);
105
+ });
106
+ });
107
+
108
+ describe("dispatcher — unknown command", () => {
109
+ it("exits with EXIT_VALIDATION (5) and prints a hint", async () => {
110
+ const r = await runCli(["nonsense"]);
111
+ assert.equal(r.status, 5);
112
+ assert.match(r.stderr, /Unknown command/);
113
+ assert.match(r.stderr, /skillrepo --help/);
114
+ });
115
+
116
+ it("rejects unknown flags as commands too", async () => {
117
+ const r = await runCli(["--bogus"]);
118
+ assert.equal(r.status, 5);
119
+ assert.match(r.stderr, /Unknown command/);
120
+ });
121
+ });
122
+
123
+ describe("dispatcher — per-command help", () => {
124
+ // PR1 only ships init for real; the other 6 are stubs but still
125
+ // route --help correctly.
126
+ for (const cmd of ["init", "update", "get", "add", "remove", "list", "search"]) {
127
+ it(`\`skillrepo ${cmd} --help\` prints command-specific help`, async () => {
128
+ const r = await runCli([cmd, "--help"]);
129
+ assert.equal(r.status, 0);
130
+ assert.match(r.stdout, new RegExp(`skillrepo ${cmd}`));
131
+ assert.match(r.stdout, /Usage:/);
132
+ });
133
+ }
134
+ });
135
+
136
+ // All 7 commands are implemented as of PR3a. No stubs remain.
137
+ // (PR1 shipped init; PR2 shipped update/get/list/search; PR3a ships add/remove.)
138
+
139
+ describe("dispatcher — implemented commands route to their modules", () => {
140
+ // These commands have real implementations as of PR2. The dispatcher
141
+ // routing test verifies the binary doesn't fall through to the stub
142
+ // factory — we expect a "real" failure (validation/auth/network)
143
+ // rather than the "Not yet implemented" stub message.
144
+ //
145
+ // Each command is invoked WITHOUT credentials in a directory where
146
+ // there is no global config, so the expected outcome is an authError
147
+ // (exit 2, "No access key configured") OR a validationError (exit 5)
148
+ // for commands that require a positional argument.
149
+ it("`skillrepo update` is wired to the real module (not a stub)", async () => {
150
+ const r = await runCli(["update"], { env: { HOME: "/tmp/no-skillrepo-config" } });
151
+ assert.doesNotMatch(r.stderr, /Not yet implemented/);
152
+ // Either auth (no key) or network (no server) — both prove routing worked
153
+ assert.ok([1, 2, 5].includes(r.status), `expected 1/2/5, got ${r.status}`);
154
+ });
155
+
156
+ it("`skillrepo get` is wired to the real module (not a stub)", async () => {
157
+ const r = await runCli(["get", "@alice/pdf-helper"], { env: { HOME: "/tmp/no-skillrepo-config" } });
158
+ assert.doesNotMatch(r.stderr, /Not yet implemented/);
159
+ assert.ok([1, 2, 5].includes(r.status));
160
+ });
161
+
162
+ it("`skillrepo get` without an identifier exits 5 with a usage hint", async () => {
163
+ // Missing positional should fail validation BEFORE attempting
164
+ // credential resolution — proves the dispatcher passes argv
165
+ // through and the command's positional check fires.
166
+ const r = await runCli(["get"], { env: { HOME: "/tmp/no-skillrepo-config" } });
167
+ assert.doesNotMatch(r.stderr, /Not yet implemented/);
168
+ // Either validation (missing positional) or auth (no key, depending
169
+ // on which check fires first) — both prove the stub is gone.
170
+ assert.ok([2, 5].includes(r.status));
171
+ });
172
+
173
+ it("`skillrepo list` is wired to the real module (not a stub)", async () => {
174
+ const r = await runCli(["list"], { env: { HOME: "/tmp/no-skillrepo-config" } });
175
+ assert.doesNotMatch(r.stderr, /Not yet implemented/);
176
+ assert.ok([1, 2, 5].includes(r.status));
177
+ });
178
+
179
+ it("`skillrepo search` is wired to the real module (not a stub)", async () => {
180
+ const r = await runCli(["search", "test"], { env: { HOME: "/tmp/no-skillrepo-config" } });
181
+ assert.doesNotMatch(r.stderr, /Not yet implemented/);
182
+ assert.ok([1, 2, 5].includes(r.status));
183
+ });
184
+
185
+ it("`skillrepo search` without a query exits with a usage hint", async () => {
186
+ const r = await runCli(["search"], { env: { HOME: "/tmp/no-skillrepo-config" } });
187
+ assert.doesNotMatch(r.stderr, /Not yet implemented/);
188
+ assert.ok([2, 5].includes(r.status));
189
+ });
190
+
191
+ it("`skillrepo add` is wired to the real module (not a stub)", async () => {
192
+ const r = await runCli(["add", "@alice/pdf-helper"], { env: { HOME: "/tmp/no-skillrepo-config" } });
193
+ assert.doesNotMatch(r.stderr, /Not yet implemented/);
194
+ assert.ok([1, 2, 5].includes(r.status));
195
+ });
196
+
197
+ it("`skillrepo remove` is wired to the real module (not a stub)", async () => {
198
+ const r = await runCli(["remove", "@alice/pdf-helper"], { env: { HOME: "/tmp/no-skillrepo-config" } });
199
+ assert.doesNotMatch(r.stderr, /Not yet implemented/);
200
+ assert.ok([1, 2, 5].includes(r.status));
201
+ });
202
+
203
+ it("`skillrepo add` without identifier exits 2 or 5 (usage hint)", async () => {
204
+ const r = await runCli(["add"], { env: { HOME: "/tmp/no-skillrepo-config" } });
205
+ assert.doesNotMatch(r.stderr, /Not yet implemented/);
206
+ assert.ok([2, 5].includes(r.status));
207
+ });
208
+
209
+ it("`skillrepo remove` without identifier exits 2 or 5 (usage hint)", async () => {
210
+ const r = await runCli(["remove"], { env: { HOME: "/tmp/no-skillrepo-config" } });
211
+ assert.doesNotMatch(r.stderr, /Not yet implemented/);
212
+ assert.ok([2, 5].includes(r.status));
213
+ });
214
+ });
215
+
216
+ describe("dispatcher — init still works (PR1 keeps existing init untouched)", () => {
217
+ it("`skillrepo init --help` prints init help", async () => {
218
+ const r = await runCli(["init", "--help"]);
219
+ assert.equal(r.status, 0);
220
+ assert.match(r.stdout, /skillrepo init/);
221
+ });
222
+ // Real init flow is exercised by src/test/e2e/cli-init.test.mjs
223
+ // against the mock server. We don't duplicate that here.
224
+ });