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.
- package/README.md +74 -6
- package/bin/skillrepo.mjs +14 -0
- package/package.json +1 -1
- package/src/commands/init.mjs +184 -19
- package/src/commands/remove.mjs +8 -13
- package/src/commands/session-sync.mjs +152 -0
- package/src/commands/uninstall.mjs +484 -0
- package/src/commands/update.mjs +125 -8
- package/src/lib/artifact-registry.mjs +305 -0
- package/src/lib/cli-config.mjs +78 -0
- package/src/lib/config.mjs +6 -3
- package/src/lib/file-write.mjs +8 -3
- package/src/lib/fs-utils.mjs +90 -9
- package/src/lib/mergers/session-hook.mjs +378 -0
- package/src/lib/paths.mjs +21 -0
- package/src/lib/platform.mjs +124 -0
- package/src/lib/removers/claude-mcp.mjs +67 -0
- package/src/lib/removers/cursor-mcp.mjs +60 -0
- package/src/lib/removers/env-local.mjs +55 -0
- package/src/lib/removers/gitignore.mjs +108 -0
- package/src/lib/removers/settings.mjs +183 -0
- package/src/lib/removers/vscode-mcp.mjs +87 -0
- package/src/lib/removers/windsurf-mcp.mjs +65 -0
- package/src/lib/sync.mjs +26 -0
- package/src/test/commands/add.test.mjs +10 -4
- package/src/test/commands/get.test.mjs +10 -4
- package/src/test/commands/init.test.mjs +428 -4
- package/src/test/commands/list.test.mjs +10 -4
- package/src/test/commands/remove.test.mjs +10 -4
- package/src/test/commands/search.test.mjs +10 -4
- package/src/test/commands/session-sync.test.mjs +352 -0
- package/src/test/commands/uninstall.test.mjs +774 -0
- package/src/test/commands/update.test.mjs +168 -4
- package/src/test/helpers/sandbox-home.mjs +161 -0
- package/src/test/helpers/skillrepo-shim.mjs +133 -0
- package/src/test/integration/file-write.integration.test.mjs +10 -4
- package/src/test/lib/artifact-registry.test.mjs +268 -0
- package/src/test/lib/cli-config.test.mjs +126 -5
- package/src/test/lib/config.test.mjs +10 -4
- package/src/test/lib/file-write.test.mjs +24 -10
- package/src/test/lib/mcp-merge.test.mjs +10 -4
- package/src/test/lib/paths.test.mjs +10 -4
- package/src/test/lib/platform.test.mjs +135 -0
- package/src/test/lib/sync.test.mjs +20 -4
- package/src/test/mergers/session-hook.test.mjs +1175 -0
- package/src/test/mergers/uninstall-claude-mcp.test.mjs +145 -0
- package/src/test/mergers/uninstall-cursor-mcp.test.mjs +108 -0
- package/src/test/mergers/uninstall-env-local.test.mjs +144 -0
- package/src/test/mergers/uninstall-gitignore.test.mjs +209 -0
- package/src/test/mergers/uninstall-settings.test.mjs +296 -0
- package/src/test/mergers/uninstall-vscode-mcp.test.mjs +215 -0
- 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
|
+
});
|