skillrepo 3.0.0 → 3.1.1

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