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,350 @@
1
+ /**
2
+ * Unit tests for src/commands/session-sync.mjs (#884).
3
+ *
4
+ * INTENT-based coverage of the `skillrepo session-sync enable|disable`
5
+ * command surface. Lower-level installer correctness (fingerprint,
6
+ * atomic writes, round-trip with #885 remover) is covered in
7
+ * `session-hook.test.mjs` — these tests verify the COMMAND wrapper's
8
+ * behavior: subcommand parsing, flag handling, JSON output, error
9
+ * propagation.
10
+ *
11
+ * HOME isolation enforced in every test. --global paths write to
12
+ * `~/.claude/settings.local.json` and this guard is the only thing
13
+ * preventing a misconfigured test from nuking real user state.
14
+ */
15
+
16
+ import { describe, it, beforeEach, afterEach } from "node:test";
17
+ import assert from "node:assert/strict";
18
+ import {
19
+ mkdtempSync,
20
+ mkdirSync,
21
+ rmSync,
22
+ readFileSync,
23
+ writeFileSync,
24
+ existsSync,
25
+ chmodSync,
26
+ } from "node:fs";
27
+ import { join } from "node:path";
28
+ import { tmpdir } from "node:os";
29
+
30
+ import { runSessionSync } from "../../commands/session-sync.mjs";
31
+ import { buildHookCommand } from "../../lib/mergers/session-hook.mjs";
32
+ import { SESSION_HOOK_FINGERPRINT } from "../../lib/artifact-registry.mjs";
33
+ import { createCaptureStream } from "../helpers/capture-stream.mjs";
34
+ import { CliError, EXIT_VALIDATION } from "../../lib/errors.mjs";
35
+
36
+ let sandbox;
37
+ let originalCwd;
38
+ let originalHome;
39
+ let stdout;
40
+ let stderr;
41
+
42
+ function ASSERT_HOME_ISOLATED() {
43
+ assert.ok(
44
+ process.env.HOME && process.env.HOME.startsWith(tmpdir()),
45
+ `HOME must point inside tmpdir. Current HOME="${process.env.HOME}"`,
46
+ );
47
+ }
48
+
49
+ function setup() {
50
+ sandbox = mkdtempSync(join(tmpdir(), "cli-cmd-session-sync-"));
51
+ mkdirSync(join(sandbox, "project"), { recursive: true });
52
+ mkdirSync(join(sandbox, "home"), { recursive: true });
53
+ originalCwd = process.cwd();
54
+ originalHome = process.env.HOME;
55
+ process.chdir(join(sandbox, "project"));
56
+ process.env.HOME = join(sandbox, "home");
57
+
58
+ // Put a predictable `skillrepo` shim at the front of PATH so
59
+ // mergeSessionHook's `which skillrepo` resolves to it. Saves having
60
+ // to inject a binaryPath at every call site.
61
+ const binDir = join(process.env.HOME, "bin");
62
+ mkdirSync(binDir, { recursive: true });
63
+ const shim = join(binDir, "skillrepo");
64
+ writeFileSync(shim, "#!/bin/sh\nexit 0\n");
65
+ chmodSync(shim, 0o755);
66
+ process.env.PATH = `${binDir}:${process.env.PATH}`;
67
+
68
+ ASSERT_HOME_ISOLATED();
69
+
70
+ stdout = createCaptureStream();
71
+ stderr = createCaptureStream();
72
+ }
73
+
74
+ function teardown() {
75
+ process.chdir(originalCwd);
76
+ process.env.HOME = originalHome;
77
+ if (sandbox) rmSync(sandbox, { recursive: true, force: true });
78
+ // Strip the prepended bin dir from PATH to keep subsequent tests clean
79
+ if (process.env.PATH?.startsWith(join(sandbox ?? "", "home", "bin"))) {
80
+ process.env.PATH = process.env.PATH.slice(
81
+ join(sandbox, "home", "bin").length + 1,
82
+ );
83
+ }
84
+ }
85
+
86
+ // ──────────────────────────────────────────────────────────────────
87
+
88
+ describe("session-sync — subcommand parsing", () => {
89
+ beforeEach(setup);
90
+ afterEach(teardown);
91
+
92
+ it("rejects invocation without a subcommand", async () => {
93
+ // INTENT: ambiguous invocations must fail loudly with guidance,
94
+ // not silently do nothing. A user who types `skillrepo
95
+ // session-sync` expects some clear response.
96
+ await assert.rejects(
97
+ () => runSessionSync([], { stdout, stderr }),
98
+ (err) =>
99
+ err instanceof CliError &&
100
+ err.exitCode === EXIT_VALIDATION &&
101
+ /subcommand/i.test(err.message),
102
+ );
103
+ });
104
+
105
+ it("rejects unknown subcommands", async () => {
106
+ await assert.rejects(
107
+ () => runSessionSync(["status"], { stdout, stderr }),
108
+ (err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
109
+ );
110
+ });
111
+
112
+ it("rejects two subcommands passed together", async () => {
113
+ await assert.rejects(
114
+ () => runSessionSync(["enable", "disable"], { stdout, stderr }),
115
+ (err) =>
116
+ err instanceof CliError &&
117
+ err.exitCode === EXIT_VALIDATION &&
118
+ /exactly one/i.test(err.message),
119
+ );
120
+ });
121
+ });
122
+
123
+ describe("session-sync enable", () => {
124
+ beforeEach(setup);
125
+ afterEach(teardown);
126
+
127
+ it("installs the hook and reports success", async () => {
128
+ // INTENT: the primary success path — user types `session-sync
129
+ // enable`, the hook lands, message confirms.
130
+ ASSERT_HOME_ISOLATED();
131
+ await runSessionSync(["enable"], { stdout, stderr });
132
+
133
+ assert.match(stdout.text(), /installed/i);
134
+ const settingsPath = join(
135
+ process.cwd(),
136
+ ".claude",
137
+ "settings.local.json",
138
+ );
139
+ assert.ok(existsSync(settingsPath), "settings.local.json must exist");
140
+ const parsed = JSON.parse(readFileSync(settingsPath, "utf-8"));
141
+ const hasHook = parsed.hooks.SessionStart.flatMap((g) => g.hooks).some(
142
+ (h) => h.command.includes(SESSION_HOOK_FINGERPRINT),
143
+ );
144
+ assert.ok(hasHook, "SkillRepo hook must be present");
145
+ });
146
+
147
+ it("is idempotent — second enable returns 'unchanged'", async () => {
148
+ // INTENT: users re-running `session-sync enable` must get a clear
149
+ // "nothing to do" response, not a duplicate hook.
150
+ ASSERT_HOME_ISOLATED();
151
+ await runSessionSync(["enable"], { stdout, stderr });
152
+ stdout.clear();
153
+
154
+ await runSessionSync(["enable"], { stdout, stderr });
155
+ assert.match(stdout.text(), /already installed/i);
156
+ });
157
+
158
+ it("--json emits structured output with action + command + path", async () => {
159
+ // INTENT: automation scripts need a deterministic output format.
160
+ // No ambiguity, no ANSI codes, no surrounding prose.
161
+ ASSERT_HOME_ISOLATED();
162
+ await runSessionSync(["enable", "--json"], { stdout, stderr });
163
+
164
+ const json = JSON.parse(stdout.text());
165
+ assert.equal(json.action, "installed");
166
+ assert.equal(json.path, ".claude/settings.local.json");
167
+ assert.ok(typeof json.command === "string");
168
+ assert.ok(json.command.includes(SESSION_HOOK_FINGERPRINT));
169
+ });
170
+
171
+ it("--global writes to the user-wide settings file", async () => {
172
+ // INTENT: `--global` installs the hook at ~/.claude/settings.local.json
173
+ // so it fires in EVERY Claude Code session (not just this project).
174
+ // Used by users who want SkillRepo integration machine-wide.
175
+ ASSERT_HOME_ISOLATED();
176
+ await runSessionSync(["enable", "--global"], { stdout, stderr });
177
+
178
+ const globalSettings = join(
179
+ process.env.HOME,
180
+ ".claude",
181
+ "settings.local.json",
182
+ );
183
+ assert.ok(existsSync(globalSettings));
184
+ // Project-local file must NOT be touched
185
+ assert.ok(
186
+ !existsSync(join(process.cwd(), ".claude", "settings.local.json")),
187
+ "--global must only touch the user-wide file",
188
+ );
189
+ });
190
+ });
191
+
192
+ describe("session-sync disable", () => {
193
+ beforeEach(setup);
194
+ afterEach(teardown);
195
+
196
+ it("removes the hook and reports success", async () => {
197
+ // INTENT: users disabling the hook must see it gone from the
198
+ // file immediately, with a clear success message.
199
+ ASSERT_HOME_ISOLATED();
200
+ await runSessionSync(["enable"], { stdout, stderr });
201
+ stdout.clear();
202
+
203
+ await runSessionSync(["disable"], { stdout, stderr });
204
+
205
+ const settingsPath = join(
206
+ process.cwd(),
207
+ ".claude",
208
+ "settings.local.json",
209
+ );
210
+ if (existsSync(settingsPath)) {
211
+ const parsed = JSON.parse(readFileSync(settingsPath, "utf-8"));
212
+ const hasHook = (parsed.hooks?.SessionStart ?? [])
213
+ .flatMap((g) => g?.hooks ?? [])
214
+ .some((h) => h?.command?.includes(SESSION_HOOK_FINGERPRINT));
215
+ assert.ok(!hasHook, "SkillRepo hook must be gone after disable");
216
+ }
217
+ assert.match(stdout.text(), /removed/i);
218
+ });
219
+
220
+ it("reports cleanly when disable runs on a file without the hook", async () => {
221
+ // INTENT: a user running `session-sync disable` when the hook
222
+ // isn't installed must get a clear "nothing to do" response, not
223
+ // an error. This is how automation scripts confirm the final
224
+ // state is "disabled" regardless of prior state.
225
+ ASSERT_HOME_ISOLATED();
226
+ await runSessionSync(["disable"], { stdout, stderr });
227
+ assert.match(stdout.text(), /not enabled|not installed|nothing to do/i);
228
+ });
229
+
230
+ it("preserves user-authored hooks in the same settings file", async () => {
231
+ // INTENT: disable must only strip SkillRepo's entry — everything
232
+ // the user added must survive.
233
+ ASSERT_HOME_ISOLATED();
234
+ mkdirSync(join(process.cwd(), ".claude"), { recursive: true });
235
+ writeFileSync(
236
+ join(process.cwd(), ".claude", "settings.local.json"),
237
+ JSON.stringify(
238
+ {
239
+ hooks: {
240
+ SessionStart: [
241
+ { hooks: [{ type: "command", command: "echo user-hook" }] },
242
+ {
243
+ hooks: [
244
+ {
245
+ type: "command",
246
+ command: buildHookCommand("/some/skillrepo"),
247
+ },
248
+ ],
249
+ },
250
+ ],
251
+ },
252
+ env: { USER_VAR: "value" },
253
+ },
254
+ null,
255
+ 2,
256
+ ),
257
+ );
258
+
259
+ await runSessionSync(["disable"], { stdout, stderr });
260
+
261
+ const parsed = JSON.parse(
262
+ readFileSync(
263
+ join(process.cwd(), ".claude", "settings.local.json"),
264
+ "utf-8",
265
+ ),
266
+ );
267
+ // User's hook survives
268
+ assert.equal(parsed.hooks.SessionStart.length, 1);
269
+ assert.equal(
270
+ parsed.hooks.SessionStart[0].hooks[0].command,
271
+ "echo user-hook",
272
+ );
273
+ // User's env survives
274
+ assert.deepEqual(parsed.env, { USER_VAR: "value" });
275
+ });
276
+
277
+ it("--json emits structured output for the removed case", async () => {
278
+ ASSERT_HOME_ISOLATED();
279
+ await runSessionSync(["enable"], { stdout, stderr });
280
+ stdout.clear();
281
+
282
+ await runSessionSync(["disable", "--json"], { stdout, stderr });
283
+
284
+ const json = JSON.parse(stdout.text());
285
+ assert.equal(json.action, "removed");
286
+ assert.equal(json.path, ".claude/settings.local.json");
287
+ });
288
+
289
+ it("surfaces the parse error in human output when settings.local.json is corrupt", async () => {
290
+ // Round-2 review gap (both architect + code-reviewer): before
291
+ // this branch, the corrupt-file path silently misdiagnosed a
292
+ // broken settings file as "session sync not enabled." Users
293
+ // had no way to tell from the human output that their file
294
+ // was the problem. The --json path already surfaced the error;
295
+ // this test locks the fix for the human output.
296
+ ASSERT_HOME_ISOLATED();
297
+ mkdirSync(join(process.cwd(), ".claude"), { recursive: true });
298
+ writeFileSync(
299
+ join(process.cwd(), ".claude", "settings.local.json"),
300
+ "{ not valid json",
301
+ );
302
+
303
+ await runSessionSync(["disable"], { stdout, stderr });
304
+
305
+ const out = stdout.text();
306
+ assert.match(
307
+ out,
308
+ /Cannot parse/i,
309
+ "corrupt file must produce a visible 'Cannot parse' message, not the generic 'not enabled' message",
310
+ );
311
+ assert.doesNotMatch(
312
+ out,
313
+ /not enabled/i,
314
+ "must NOT show the 'not enabled' fallback when the file exists but is corrupt",
315
+ );
316
+ });
317
+
318
+ it("still emits the parse error via --json for automation consumers", async () => {
319
+ // Confirms the --json path also surfaces the error (was
320
+ // already working — this locks the contract in).
321
+ ASSERT_HOME_ISOLATED();
322
+ mkdirSync(join(process.cwd(), ".claude"), { recursive: true });
323
+ writeFileSync(
324
+ join(process.cwd(), ".claude", "settings.local.json"),
325
+ "{ broken",
326
+ );
327
+
328
+ await runSessionSync(["disable", "--json"], { stdout, stderr });
329
+
330
+ const json = JSON.parse(stdout.text());
331
+ assert.equal(json.action, "skipped");
332
+ assert.ok(json.error, "error field must be present in JSON output");
333
+ assert.match(json.error, /parse/i);
334
+ });
335
+ });
336
+
337
+ describe("session-sync — flag ordering tolerance", () => {
338
+ beforeEach(setup);
339
+ afterEach(teardown);
340
+
341
+ it("accepts flags before the subcommand", async () => {
342
+ // INTENT: users who type `--json enable` vs `enable --json`
343
+ // expect both to work. Neither the command nor the script it
344
+ // generates should care about order.
345
+ ASSERT_HOME_ISOLATED();
346
+ await runSessionSync(["--json", "enable"], { stdout, stderr });
347
+ const json = JSON.parse(stdout.text());
348
+ assert.equal(json.action, "installed");
349
+ });
350
+ });