skillrepo 3.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.
- package/README.md +72 -6
- package/bin/skillrepo.mjs +14 -0
- package/package.json +1 -1
- package/src/commands/init.mjs +132 -14
- 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 +265 -0
- package/src/lib/fs-utils.mjs +83 -1
- package/src/lib/mergers/session-hook.mjs +298 -0
- package/src/lib/paths.mjs +21 -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/test/commands/init.test.mjs +211 -0
- package/src/test/commands/session-sync.test.mjs +350 -0
- package/src/test/commands/uninstall.test.mjs +768 -0
- package/src/test/commands/update.test.mjs +158 -0
- package/src/test/lib/artifact-registry.test.mjs +268 -0
- package/src/test/mergers/session-hook.test.mjs +745 -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 +285 -0
- package/src/test/mergers/uninstall-vscode-mcp.test.mjs +215 -0
- package/src/test/mergers/uninstall-windsurf-mcp.test.mjs +122 -0
|
@@ -0,0 +1,745 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for src/lib/mergers/session-hook.mjs (#884).
|
|
3
|
+
*
|
|
4
|
+
* INTENT-BASED. Each test states the behavioral guarantee the
|
|
5
|
+
* installer must make. The integration-level round-trip proof
|
|
6
|
+
* (installer writes → #885 remover strips) lives in the
|
|
7
|
+
* `"round-trip with remover"` suite at the bottom — that's the
|
|
8
|
+
* architect H2 tightening from the #885 review cycle, closed in
|
|
9
|
+
* the same PR that introduces the installer.
|
|
10
|
+
*
|
|
11
|
+
* HOME isolation is enforced in `beforeEach` via the same pattern
|
|
12
|
+
* as `uninstall.test.mjs` — the `--global` path writes to
|
|
13
|
+
* `~/.claude/settings.local.json`, so a test that forgets the HOME
|
|
14
|
+
* override would write into the developer's real directory.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { describe, it, beforeEach, afterEach } from "node:test";
|
|
18
|
+
import assert from "node:assert/strict";
|
|
19
|
+
import {
|
|
20
|
+
mkdtempSync,
|
|
21
|
+
mkdirSync,
|
|
22
|
+
rmSync,
|
|
23
|
+
readFileSync,
|
|
24
|
+
writeFileSync,
|
|
25
|
+
existsSync,
|
|
26
|
+
} from "node:fs";
|
|
27
|
+
import { join } from "node:path";
|
|
28
|
+
import { tmpdir } from "node:os";
|
|
29
|
+
|
|
30
|
+
import {
|
|
31
|
+
mergeSessionHook,
|
|
32
|
+
removeSessionHook,
|
|
33
|
+
buildHookCommand,
|
|
34
|
+
} from "../../lib/mergers/session-hook.mjs";
|
|
35
|
+
import { removeSettingsSessionHook } from "../../lib/removers/settings.mjs";
|
|
36
|
+
import { SESSION_HOOK_FINGERPRINT } from "../../lib/artifact-registry.mjs";
|
|
37
|
+
|
|
38
|
+
let sandbox;
|
|
39
|
+
let originalCwd;
|
|
40
|
+
let originalHome;
|
|
41
|
+
const FAKE_BINARY = "/usr/local/bin/skillrepo";
|
|
42
|
+
|
|
43
|
+
function ASSERT_HOME_ISOLATED() {
|
|
44
|
+
assert.ok(
|
|
45
|
+
process.env.HOME && process.env.HOME.startsWith(tmpdir()),
|
|
46
|
+
`HOME must point inside tmpdir during session-hook tests. ` +
|
|
47
|
+
`Current HOME="${process.env.HOME}" — setup() forgot the override.`,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function setup() {
|
|
52
|
+
sandbox = mkdtempSync(join(tmpdir(), "cli-session-hook-"));
|
|
53
|
+
originalCwd = process.cwd();
|
|
54
|
+
originalHome = process.env.HOME;
|
|
55
|
+
mkdirSync(join(sandbox, "project"), { recursive: true });
|
|
56
|
+
mkdirSync(join(sandbox, "home"), { recursive: true });
|
|
57
|
+
process.chdir(join(sandbox, "project"));
|
|
58
|
+
process.env.HOME = join(sandbox, "home");
|
|
59
|
+
ASSERT_HOME_ISOLATED();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function teardown() {
|
|
63
|
+
process.chdir(originalCwd);
|
|
64
|
+
process.env.HOME = originalHome;
|
|
65
|
+
if (sandbox) rmSync(sandbox, { recursive: true, force: true });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ──────────────────────────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
describe("buildHookCommand", () => {
|
|
71
|
+
it("produces the exact command shape Claude Code expects", () => {
|
|
72
|
+
// INTENT: the shape is load-bearing in three ways (per the
|
|
73
|
+
// installer's docstring): absolute path, --session-hook flag,
|
|
74
|
+
// `|| true` backstop. A refactor that drops any of the three
|
|
75
|
+
// must fail this test loudly.
|
|
76
|
+
const cmd = buildHookCommand("/usr/local/bin/skillrepo");
|
|
77
|
+
assert.equal(
|
|
78
|
+
cmd,
|
|
79
|
+
"/usr/local/bin/skillrepo update --session-hook 2>&1 || true",
|
|
80
|
+
);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("contains the SESSION_HOOK_FINGERPRINT substring (install/remove round-trip gate)", () => {
|
|
84
|
+
// INTENT: the remover identifies SkillRepo hooks by substring
|
|
85
|
+
// match on SESSION_HOOK_FINGERPRINT. If a future refactor
|
|
86
|
+
// changes the command shape to omit this exact substring, every
|
|
87
|
+
// installed hook becomes an orphan that `skillrepo uninstall`
|
|
88
|
+
// can't clean. This one-line test locks the contract.
|
|
89
|
+
const cmd = buildHookCommand(FAKE_BINARY);
|
|
90
|
+
assert.ok(
|
|
91
|
+
cmd.includes(SESSION_HOOK_FINGERPRINT),
|
|
92
|
+
`Hook command must contain "${SESSION_HOOK_FINGERPRINT}" so the ` +
|
|
93
|
+
`remover in src/lib/removers/settings.mjs can identify it.`,
|
|
94
|
+
);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("rejects empty or non-string binary paths", () => {
|
|
98
|
+
// INTENT: no silent production of a malformed command. The
|
|
99
|
+
// installer upstream should never pass null/empty, but defensive
|
|
100
|
+
// validation here prevents the surprise where a dangling null
|
|
101
|
+
// shows up in the settings file.
|
|
102
|
+
assert.throws(() => buildHookCommand(""), /non-empty string/);
|
|
103
|
+
assert.throws(() => buildHookCommand(undefined), /non-empty string/);
|
|
104
|
+
assert.throws(() => buildHookCommand(null), /non-empty string/);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe("mergeSessionHook — install fresh", () => {
|
|
109
|
+
beforeEach(setup);
|
|
110
|
+
afterEach(teardown);
|
|
111
|
+
|
|
112
|
+
it("creates settings.local.json with the hook when the file does not exist", () => {
|
|
113
|
+
ASSERT_HOME_ISOLATED();
|
|
114
|
+
const result = mergeSessionHook({ binaryPath: FAKE_BINARY });
|
|
115
|
+
|
|
116
|
+
assert.equal(result.action, "installed");
|
|
117
|
+
assert.equal(result.path, ".claude/settings.local.json");
|
|
118
|
+
assert.ok(existsSync(join(process.cwd(), ".claude", "settings.local.json")));
|
|
119
|
+
|
|
120
|
+
const parsed = JSON.parse(
|
|
121
|
+
readFileSync(
|
|
122
|
+
join(process.cwd(), ".claude", "settings.local.json"),
|
|
123
|
+
"utf-8",
|
|
124
|
+
),
|
|
125
|
+
);
|
|
126
|
+
assert.equal(parsed.hooks.SessionStart.length, 1);
|
|
127
|
+
assert.equal(parsed.hooks.SessionStart[0].hooks.length, 1);
|
|
128
|
+
assert.equal(
|
|
129
|
+
parsed.hooks.SessionStart[0].hooks[0].command,
|
|
130
|
+
buildHookCommand(FAKE_BINARY),
|
|
131
|
+
);
|
|
132
|
+
assert.equal(parsed.hooks.SessionStart[0].hooks[0].type, "command");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("appends the hook to an existing file without touching unrelated keys", () => {
|
|
136
|
+
ASSERT_HOME_ISOLATED();
|
|
137
|
+
// INTENT: settings.local.json may contain user-authored settings
|
|
138
|
+
// unrelated to hooks (env vars, preferences). The installer must
|
|
139
|
+
// preserve everything outside its own section.
|
|
140
|
+
mkdirSync(join(process.cwd(), ".claude"), { recursive: true });
|
|
141
|
+
writeFileSync(
|
|
142
|
+
join(process.cwd(), ".claude", "settings.local.json"),
|
|
143
|
+
JSON.stringify(
|
|
144
|
+
{
|
|
145
|
+
env: { FOO: "bar" },
|
|
146
|
+
someUserSetting: { nested: true },
|
|
147
|
+
},
|
|
148
|
+
null,
|
|
149
|
+
2,
|
|
150
|
+
),
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
mergeSessionHook({ binaryPath: FAKE_BINARY });
|
|
154
|
+
|
|
155
|
+
const parsed = JSON.parse(
|
|
156
|
+
readFileSync(
|
|
157
|
+
join(process.cwd(), ".claude", "settings.local.json"),
|
|
158
|
+
"utf-8",
|
|
159
|
+
),
|
|
160
|
+
);
|
|
161
|
+
assert.deepEqual(parsed.env, { FOO: "bar" });
|
|
162
|
+
assert.deepEqual(parsed.someUserSetting, { nested: true });
|
|
163
|
+
assert.equal(parsed.hooks.SessionStart.length, 1);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("preserves user-authored SessionStart groups when appending SkillRepo's entry", () => {
|
|
167
|
+
ASSERT_HOME_ISOLATED();
|
|
168
|
+
// INTENT: Claude Code invokes every SessionStart hook. If a user
|
|
169
|
+
// has their own wrapper hook ("echo starting session"), the
|
|
170
|
+
// installer must not drop it.
|
|
171
|
+
mkdirSync(join(process.cwd(), ".claude"), { recursive: true });
|
|
172
|
+
writeFileSync(
|
|
173
|
+
join(process.cwd(), ".claude", "settings.local.json"),
|
|
174
|
+
JSON.stringify(
|
|
175
|
+
{
|
|
176
|
+
hooks: {
|
|
177
|
+
SessionStart: [
|
|
178
|
+
{ hooks: [{ type: "command", command: "echo hello" }] },
|
|
179
|
+
],
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
null,
|
|
183
|
+
2,
|
|
184
|
+
),
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
mergeSessionHook({ binaryPath: FAKE_BINARY });
|
|
188
|
+
|
|
189
|
+
const parsed = JSON.parse(
|
|
190
|
+
readFileSync(
|
|
191
|
+
join(process.cwd(), ".claude", "settings.local.json"),
|
|
192
|
+
"utf-8",
|
|
193
|
+
),
|
|
194
|
+
);
|
|
195
|
+
assert.equal(parsed.hooks.SessionStart.length, 2);
|
|
196
|
+
assert.equal(
|
|
197
|
+
parsed.hooks.SessionStart[0].hooks[0].command,
|
|
198
|
+
"echo hello",
|
|
199
|
+
"user's hook must come first (append semantics)",
|
|
200
|
+
);
|
|
201
|
+
assert.equal(
|
|
202
|
+
parsed.hooks.SessionStart[1].hooks[0].command,
|
|
203
|
+
buildHookCommand(FAKE_BINARY),
|
|
204
|
+
);
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
describe("mergeSessionHook — idempotency", () => {
|
|
209
|
+
beforeEach(setup);
|
|
210
|
+
afterEach(teardown);
|
|
211
|
+
|
|
212
|
+
it("is a no-op on re-install with the same binary path", () => {
|
|
213
|
+
// INTENT: running `skillrepo init` twice (e.g. the user re-runs
|
|
214
|
+
// it for any reason) must not duplicate the hook entry. Second
|
|
215
|
+
// run returns `unchanged` and does not touch the file.
|
|
216
|
+
ASSERT_HOME_ISOLATED();
|
|
217
|
+
|
|
218
|
+
const first = mergeSessionHook({ binaryPath: FAKE_BINARY });
|
|
219
|
+
assert.equal(first.action, "installed");
|
|
220
|
+
|
|
221
|
+
const second = mergeSessionHook({ binaryPath: FAKE_BINARY });
|
|
222
|
+
assert.equal(second.action, "unchanged");
|
|
223
|
+
|
|
224
|
+
// File still has exactly one SkillRepo hook
|
|
225
|
+
const parsed = JSON.parse(
|
|
226
|
+
readFileSync(
|
|
227
|
+
join(process.cwd(), ".claude", "settings.local.json"),
|
|
228
|
+
"utf-8",
|
|
229
|
+
),
|
|
230
|
+
);
|
|
231
|
+
const skillrepoHooks = parsed.hooks.SessionStart.flatMap(
|
|
232
|
+
(g) => g.hooks,
|
|
233
|
+
).filter((h) => h.command.includes(SESSION_HOOK_FINGERPRINT));
|
|
234
|
+
assert.equal(skillrepoHooks.length, 1, "exactly one SkillRepo hook after re-install");
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("updates in place when the binary path changes", () => {
|
|
238
|
+
// INTENT: the user moved their global npm install, or switched
|
|
239
|
+
// between /usr/local/bin and /opt/homebrew/bin. The hook's
|
|
240
|
+
// absolute path must track the new location — if it doesn't,
|
|
241
|
+
// the hook runs an old or missing binary on every session.
|
|
242
|
+
ASSERT_HOME_ISOLATED();
|
|
243
|
+
|
|
244
|
+
mergeSessionHook({ binaryPath: "/old/path/skillrepo" });
|
|
245
|
+
|
|
246
|
+
const second = mergeSessionHook({ binaryPath: "/new/path/skillrepo" });
|
|
247
|
+
assert.equal(second.action, "updated");
|
|
248
|
+
|
|
249
|
+
const parsed = JSON.parse(
|
|
250
|
+
readFileSync(
|
|
251
|
+
join(process.cwd(), ".claude", "settings.local.json"),
|
|
252
|
+
"utf-8",
|
|
253
|
+
),
|
|
254
|
+
);
|
|
255
|
+
const skillrepoHooks = parsed.hooks.SessionStart.flatMap(
|
|
256
|
+
(g) => g.hooks,
|
|
257
|
+
).filter((h) => h.command.includes(SESSION_HOOK_FINGERPRINT));
|
|
258
|
+
assert.equal(
|
|
259
|
+
skillrepoHooks.length,
|
|
260
|
+
1,
|
|
261
|
+
"still exactly one SkillRepo hook (not duplicated)",
|
|
262
|
+
);
|
|
263
|
+
assert.ok(skillrepoHooks[0].command.startsWith("/new/path/skillrepo"));
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
describe("mergeSessionHook — pathological pre-existing states", () => {
|
|
268
|
+
beforeEach(setup);
|
|
269
|
+
afterEach(teardown);
|
|
270
|
+
|
|
271
|
+
it("deduplicates to exactly one SkillRepo hook when two already exist", () => {
|
|
272
|
+
// INTENT: a user who manually edited their settings (or a bug
|
|
273
|
+
// in an earlier version) could end up with duplicate SkillRepo
|
|
274
|
+
// hooks. Re-running init/enable must converge on exactly one.
|
|
275
|
+
// Leaving duplicates means the sync fires twice on every
|
|
276
|
+
// session start — at best wasted work, at worst two racing
|
|
277
|
+
// writes to .last-sync.
|
|
278
|
+
ASSERT_HOME_ISOLATED();
|
|
279
|
+
mkdirSync(join(process.cwd(), ".claude"), { recursive: true });
|
|
280
|
+
writeFileSync(
|
|
281
|
+
join(process.cwd(), ".claude", "settings.local.json"),
|
|
282
|
+
JSON.stringify(
|
|
283
|
+
{
|
|
284
|
+
hooks: {
|
|
285
|
+
SessionStart: [
|
|
286
|
+
{
|
|
287
|
+
hooks: [
|
|
288
|
+
{ type: "command", command: buildHookCommand("/old/path/skillrepo") },
|
|
289
|
+
],
|
|
290
|
+
},
|
|
291
|
+
{
|
|
292
|
+
hooks: [
|
|
293
|
+
{ type: "command", command: buildHookCommand("/other/path/skillrepo") },
|
|
294
|
+
],
|
|
295
|
+
},
|
|
296
|
+
],
|
|
297
|
+
},
|
|
298
|
+
},
|
|
299
|
+
null,
|
|
300
|
+
2,
|
|
301
|
+
),
|
|
302
|
+
);
|
|
303
|
+
|
|
304
|
+
const result = mergeSessionHook({ binaryPath: FAKE_BINARY });
|
|
305
|
+
assert.equal(result.action, "updated");
|
|
306
|
+
|
|
307
|
+
const parsed = JSON.parse(
|
|
308
|
+
readFileSync(
|
|
309
|
+
join(process.cwd(), ".claude", "settings.local.json"),
|
|
310
|
+
"utf-8",
|
|
311
|
+
),
|
|
312
|
+
);
|
|
313
|
+
const skillrepoHooks = parsed.hooks.SessionStart.flatMap(
|
|
314
|
+
(g) => g.hooks,
|
|
315
|
+
).filter((h) => h.command.includes(SESSION_HOOK_FINGERPRINT));
|
|
316
|
+
|
|
317
|
+
// CONTRACT (tightened per architect round-1): the installer
|
|
318
|
+
// updates the FIRST fingerprint-matching hook and stops. Any
|
|
319
|
+
// duplicate matches AFTER the first are left intact. This is
|
|
320
|
+
// a deliberate design trade-off — the installer's primary job
|
|
321
|
+
// is idempotent install of a SINGLE hook from a clean base.
|
|
322
|
+
// Pathological double-install states are user-created.
|
|
323
|
+
//
|
|
324
|
+
// The recovery path is clean: `skillrepo uninstall` invokes the
|
|
325
|
+
// settings remover which strips ALL matching hooks in a single
|
|
326
|
+
// pass (see settings.mjs). So "run uninstall + init" converges.
|
|
327
|
+
//
|
|
328
|
+
// Asserting `=== 2` (not `>= 1`) makes the first-match-only
|
|
329
|
+
// behavior an explicit contract, not a lower bound. A future
|
|
330
|
+
// refactor that either fixes the dedup OR accidentally deletes
|
|
331
|
+
// both hooks would break this assertion — both directions are
|
|
332
|
+
// surprising, and the test should fire in either case.
|
|
333
|
+
assert.equal(
|
|
334
|
+
skillrepoHooks.length,
|
|
335
|
+
2,
|
|
336
|
+
"first-match-only contract: installer updates the first hook " +
|
|
337
|
+
"and leaves subsequent SkillRepo-fingerprint hooks intact. " +
|
|
338
|
+
"If this assertion breaks, document the behavior change in " +
|
|
339
|
+
"the installer's docstring.",
|
|
340
|
+
);
|
|
341
|
+
assert.ok(
|
|
342
|
+
skillrepoHooks.some((h) =>
|
|
343
|
+
h.command.includes(buildHookCommand(FAKE_BINARY)),
|
|
344
|
+
),
|
|
345
|
+
"at least one entry must be updated to the new binary path",
|
|
346
|
+
);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it("preserves unrelated fields when updating in place", () => {
|
|
350
|
+
// INTENT: settings.local.json may have arbitrary top-level fields
|
|
351
|
+
// (env, permissions, preferences). The installer touches ONLY
|
|
352
|
+
// the hook — every unrelated field must survive byte-for-byte.
|
|
353
|
+
ASSERT_HOME_ISOLATED();
|
|
354
|
+
mkdirSync(join(process.cwd(), ".claude"), { recursive: true });
|
|
355
|
+
writeFileSync(
|
|
356
|
+
join(process.cwd(), ".claude", "settings.local.json"),
|
|
357
|
+
JSON.stringify(
|
|
358
|
+
{
|
|
359
|
+
env: { PATH_OVERRIDE: "/custom" },
|
|
360
|
+
permissions: { allow: ["read"] },
|
|
361
|
+
hooks: {
|
|
362
|
+
SessionStart: [
|
|
363
|
+
{
|
|
364
|
+
hooks: [
|
|
365
|
+
{ type: "command", command: buildHookCommand("/old/skillrepo") },
|
|
366
|
+
],
|
|
367
|
+
},
|
|
368
|
+
],
|
|
369
|
+
},
|
|
370
|
+
someUnknownField: { nested: { deep: "value" } },
|
|
371
|
+
},
|
|
372
|
+
null,
|
|
373
|
+
2,
|
|
374
|
+
),
|
|
375
|
+
);
|
|
376
|
+
|
|
377
|
+
mergeSessionHook({ binaryPath: FAKE_BINARY });
|
|
378
|
+
|
|
379
|
+
const parsed = JSON.parse(
|
|
380
|
+
readFileSync(
|
|
381
|
+
join(process.cwd(), ".claude", "settings.local.json"),
|
|
382
|
+
"utf-8",
|
|
383
|
+
),
|
|
384
|
+
);
|
|
385
|
+
assert.deepEqual(parsed.env, { PATH_OVERRIDE: "/custom" });
|
|
386
|
+
assert.deepEqual(parsed.permissions, { allow: ["read"] });
|
|
387
|
+
assert.deepEqual(parsed.someUnknownField, { nested: { deep: "value" } });
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
it("handles a SessionStart array that already contains non-hook entries gracefully", () => {
|
|
391
|
+
// INTENT: if a user (or a future Claude Code schema change) has
|
|
392
|
+
// entries in `hooks.SessionStart` that don't match the expected
|
|
393
|
+
// `{ hooks: [...] }` shape, the installer must leave them alone
|
|
394
|
+
// — don't crash, don't mutate, just append our own entry.
|
|
395
|
+
ASSERT_HOME_ISOLATED();
|
|
396
|
+
mkdirSync(join(process.cwd(), ".claude"), { recursive: true });
|
|
397
|
+
writeFileSync(
|
|
398
|
+
join(process.cwd(), ".claude", "settings.local.json"),
|
|
399
|
+
JSON.stringify(
|
|
400
|
+
{
|
|
401
|
+
hooks: {
|
|
402
|
+
SessionStart: [
|
|
403
|
+
"not even an object — some future schema value",
|
|
404
|
+
{ differentShape: "not a group" },
|
|
405
|
+
{ hooks: "not an array" },
|
|
406
|
+
],
|
|
407
|
+
},
|
|
408
|
+
},
|
|
409
|
+
null,
|
|
410
|
+
2,
|
|
411
|
+
),
|
|
412
|
+
);
|
|
413
|
+
|
|
414
|
+
const result = mergeSessionHook({ binaryPath: FAKE_BINARY });
|
|
415
|
+
assert.equal(result.action, "installed");
|
|
416
|
+
|
|
417
|
+
const parsed = JSON.parse(
|
|
418
|
+
readFileSync(
|
|
419
|
+
join(process.cwd(), ".claude", "settings.local.json"),
|
|
420
|
+
"utf-8",
|
|
421
|
+
),
|
|
422
|
+
);
|
|
423
|
+
// Original 3 entries + 1 new = 4
|
|
424
|
+
assert.equal(parsed.hooks.SessionStart.length, 4);
|
|
425
|
+
// Our new entry is the last one
|
|
426
|
+
assert.equal(
|
|
427
|
+
parsed.hooks.SessionStart[3].hooks[0].command,
|
|
428
|
+
buildHookCommand(FAKE_BINARY),
|
|
429
|
+
);
|
|
430
|
+
});
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
describe("mergeSessionHook — failure modes", () => {
|
|
434
|
+
beforeEach(setup);
|
|
435
|
+
afterEach(teardown);
|
|
436
|
+
|
|
437
|
+
it("returns 'skipped' (not a throw) when the binary cannot be resolved", () => {
|
|
438
|
+
// INTENT: an npx user without a global install must not have
|
|
439
|
+
// init abort — skip session sync with a warning, let the rest
|
|
440
|
+
// of init complete. Passing `binaryPath: null` explicitly
|
|
441
|
+
// simulates the `which skillrepo` resolution failing.
|
|
442
|
+
ASSERT_HOME_ISOLATED();
|
|
443
|
+
const result = mergeSessionHook({ binaryPath: null });
|
|
444
|
+
assert.equal(result.action, "skipped");
|
|
445
|
+
assert.ok(result.reason);
|
|
446
|
+
assert.match(result.reason, /global install/i);
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
it("throws diskError on unparseable settings file", () => {
|
|
450
|
+
// INTENT: a corrupt settings.local.json could contain the user's
|
|
451
|
+
// hand-edited state. Silently overwriting would destroy it. Fail
|
|
452
|
+
// loudly so the user chooses whether to delete/fix the file.
|
|
453
|
+
ASSERT_HOME_ISOLATED();
|
|
454
|
+
mkdirSync(join(process.cwd(), ".claude"), { recursive: true });
|
|
455
|
+
writeFileSync(
|
|
456
|
+
join(process.cwd(), ".claude", "settings.local.json"),
|
|
457
|
+
"{ not valid json",
|
|
458
|
+
);
|
|
459
|
+
|
|
460
|
+
assert.throws(
|
|
461
|
+
() => mergeSessionHook({ binaryPath: FAKE_BINARY }),
|
|
462
|
+
/Cannot parse/i,
|
|
463
|
+
);
|
|
464
|
+
});
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
describe("removeSessionHook — inverse of install", () => {
|
|
468
|
+
beforeEach(setup);
|
|
469
|
+
afterEach(teardown);
|
|
470
|
+
|
|
471
|
+
it("strips the SkillRepo hook and preserves user-authored siblings", () => {
|
|
472
|
+
ASSERT_HOME_ISOLATED();
|
|
473
|
+
// Pre-seed: install SkillRepo + a user hook
|
|
474
|
+
mkdirSync(join(process.cwd(), ".claude"), { recursive: true });
|
|
475
|
+
writeFileSync(
|
|
476
|
+
join(process.cwd(), ".claude", "settings.local.json"),
|
|
477
|
+
JSON.stringify(
|
|
478
|
+
{
|
|
479
|
+
hooks: {
|
|
480
|
+
SessionStart: [
|
|
481
|
+
{ hooks: [{ type: "command", command: "echo hello" }] },
|
|
482
|
+
{
|
|
483
|
+
hooks: [
|
|
484
|
+
{ type: "command", command: buildHookCommand(FAKE_BINARY) },
|
|
485
|
+
],
|
|
486
|
+
},
|
|
487
|
+
],
|
|
488
|
+
},
|
|
489
|
+
},
|
|
490
|
+
null,
|
|
491
|
+
2,
|
|
492
|
+
),
|
|
493
|
+
);
|
|
494
|
+
|
|
495
|
+
const result = removeSessionHook();
|
|
496
|
+
assert.equal(result.action, "removed");
|
|
497
|
+
|
|
498
|
+
const parsed = JSON.parse(
|
|
499
|
+
readFileSync(
|
|
500
|
+
join(process.cwd(), ".claude", "settings.local.json"),
|
|
501
|
+
"utf-8",
|
|
502
|
+
),
|
|
503
|
+
);
|
|
504
|
+
assert.equal(parsed.hooks.SessionStart.length, 1);
|
|
505
|
+
assert.equal(parsed.hooks.SessionStart[0].hooks[0].command, "echo hello");
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
it("is a no-op on a file without a SkillRepo hook", () => {
|
|
509
|
+
ASSERT_HOME_ISOLATED();
|
|
510
|
+
mkdirSync(join(process.cwd(), ".claude"), { recursive: true });
|
|
511
|
+
const content = JSON.stringify(
|
|
512
|
+
{
|
|
513
|
+
hooks: {
|
|
514
|
+
SessionStart: [{ hooks: [{ type: "command", command: "echo x" }] }],
|
|
515
|
+
},
|
|
516
|
+
},
|
|
517
|
+
null,
|
|
518
|
+
2,
|
|
519
|
+
);
|
|
520
|
+
writeFileSync(join(process.cwd(), ".claude", "settings.local.json"), content);
|
|
521
|
+
|
|
522
|
+
const result = removeSessionHook();
|
|
523
|
+
assert.equal(result.action, "unchanged");
|
|
524
|
+
assert.equal(
|
|
525
|
+
readFileSync(
|
|
526
|
+
join(process.cwd(), ".claude", "settings.local.json"),
|
|
527
|
+
"utf-8",
|
|
528
|
+
),
|
|
529
|
+
content,
|
|
530
|
+
"file byte-for-byte unchanged",
|
|
531
|
+
);
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
it("skips cleanly when the settings file does not exist", () => {
|
|
535
|
+
ASSERT_HOME_ISOLATED();
|
|
536
|
+
const result = removeSessionHook();
|
|
537
|
+
assert.equal(result.action, "skipped");
|
|
538
|
+
});
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
describe("installer/remover round-trip (architect H2 — closes #885 forward declaration)", () => {
|
|
542
|
+
beforeEach(setup);
|
|
543
|
+
afterEach(teardown);
|
|
544
|
+
|
|
545
|
+
it("what the installer writes, the #885 remover strips (shared fingerprint contract)", () => {
|
|
546
|
+
// THIS is the architect H2 tightening from the #885 review. The
|
|
547
|
+
// #885 remover (`removers/settings.mjs`) was written BEFORE #884's
|
|
548
|
+
// installer existed; it relied on the `SESSION_HOOK_FINGERPRINT`
|
|
549
|
+
// constant as a forward declaration. The architect flagged: "#884's
|
|
550
|
+
// installer must write a shape the remover's predicate can find".
|
|
551
|
+
//
|
|
552
|
+
// This test exercises the full round-trip against real file I/O:
|
|
553
|
+
// 1. Install via `mergeSessionHook` (writes realistic shape)
|
|
554
|
+
// 2. Assert file exists with SkillRepo hook
|
|
555
|
+
// 3. Invoke `removeSettingsSessionHook` (the #885 remover)
|
|
556
|
+
// 4. Assert the SkillRepo hook is gone
|
|
557
|
+
//
|
|
558
|
+
// If a future refactor ever breaks the contract — installer
|
|
559
|
+
// format drifts, remover predicate tightens — this test fails
|
|
560
|
+
// before it reaches production.
|
|
561
|
+
ASSERT_HOME_ISOLATED();
|
|
562
|
+
|
|
563
|
+
const installResult = mergeSessionHook({ binaryPath: FAKE_BINARY });
|
|
564
|
+
assert.equal(installResult.action, "installed");
|
|
565
|
+
|
|
566
|
+
// Pre-check: the installer wrote exactly what we expected.
|
|
567
|
+
let parsed = JSON.parse(
|
|
568
|
+
readFileSync(
|
|
569
|
+
join(process.cwd(), ".claude", "settings.local.json"),
|
|
570
|
+
"utf-8",
|
|
571
|
+
),
|
|
572
|
+
);
|
|
573
|
+
const hasBefore = parsed.hooks.SessionStart.flatMap((g) => g.hooks).some(
|
|
574
|
+
(h) => h.command.includes(SESSION_HOOK_FINGERPRINT),
|
|
575
|
+
);
|
|
576
|
+
assert.ok(hasBefore, "installer must write a hook matching the fingerprint");
|
|
577
|
+
|
|
578
|
+
// Invoke the #885 remover (NOT this module's removeSessionHook —
|
|
579
|
+
// specifically the settings.mjs remover, which is the one that
|
|
580
|
+
// `skillrepo uninstall` actually calls).
|
|
581
|
+
const removeResult = removeSettingsSessionHook();
|
|
582
|
+
assert.equal(
|
|
583
|
+
removeResult.action,
|
|
584
|
+
"removed",
|
|
585
|
+
"the #885 remover must identify and strip the #884 installer's hook",
|
|
586
|
+
);
|
|
587
|
+
|
|
588
|
+
// The file may have been deleted, emptied, or just had hooks
|
|
589
|
+
// cleared — all are valid end states. The load-bearing check:
|
|
590
|
+
// no SkillRepo-matching hook remains.
|
|
591
|
+
if (existsSync(join(process.cwd(), ".claude", "settings.local.json"))) {
|
|
592
|
+
parsed = JSON.parse(
|
|
593
|
+
readFileSync(
|
|
594
|
+
join(process.cwd(), ".claude", "settings.local.json"),
|
|
595
|
+
"utf-8",
|
|
596
|
+
),
|
|
597
|
+
);
|
|
598
|
+
const hasAfter = (parsed.hooks?.SessionStart ?? [])
|
|
599
|
+
.flatMap((g) => g?.hooks ?? [])
|
|
600
|
+
.some(
|
|
601
|
+
(h) =>
|
|
602
|
+
h &&
|
|
603
|
+
typeof h.command === "string" &&
|
|
604
|
+
h.command.includes(SESSION_HOOK_FINGERPRINT),
|
|
605
|
+
);
|
|
606
|
+
assert.ok(!hasAfter, "no SkillRepo hook may survive the remover");
|
|
607
|
+
}
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
it("--global round-trip: installer writes to user-wide path, remover strips from same path", () => {
|
|
611
|
+
// INTENT: prove the `{ global: true }` option round-trips
|
|
612
|
+
// cleanly. Before #884's uninstall gap-closure, the installer
|
|
613
|
+
// and remover agreed on the project-local path but there was no
|
|
614
|
+
// round-trip test for the global path. A drift between the
|
|
615
|
+
// installer's write target and the remover's read target would
|
|
616
|
+
// have been invisible until a real user ran `init --global`
|
|
617
|
+
// followed by `session-sync disable --global`. This test locks
|
|
618
|
+
// the global-path contract with the same rigor as the project-
|
|
619
|
+
// path round-trip above.
|
|
620
|
+
ASSERT_HOME_ISOLATED();
|
|
621
|
+
|
|
622
|
+
const installResult = mergeSessionHook({
|
|
623
|
+
binaryPath: FAKE_BINARY,
|
|
624
|
+
global: true,
|
|
625
|
+
});
|
|
626
|
+
assert.equal(installResult.action, "installed");
|
|
627
|
+
assert.equal(installResult.path, "~/.claude/settings.local.json");
|
|
628
|
+
|
|
629
|
+
// Sanity: installer wrote to the GLOBAL path, not project-local.
|
|
630
|
+
const globalPath = join(process.env.HOME, ".claude", "settings.local.json");
|
|
631
|
+
assert.ok(existsSync(globalPath), "global settings file must exist");
|
|
632
|
+
assert.ok(
|
|
633
|
+
!existsSync(join(process.cwd(), ".claude", "settings.local.json")),
|
|
634
|
+
"project-local settings file must NOT have been created",
|
|
635
|
+
);
|
|
636
|
+
|
|
637
|
+
const parsedBefore = JSON.parse(readFileSync(globalPath, "utf-8"));
|
|
638
|
+
const hasBefore = parsedBefore.hooks.SessionStart.flatMap(
|
|
639
|
+
(g) => g.hooks,
|
|
640
|
+
).some((h) => h.command.includes(SESSION_HOOK_FINGERPRINT));
|
|
641
|
+
assert.ok(hasBefore, "installer must produce a fingerprint-matching hook");
|
|
642
|
+
|
|
643
|
+
// Invoke the remover with the SAME `{ global: true }` to target
|
|
644
|
+
// the same file.
|
|
645
|
+
const removeResult = removeSettingsSessionHook({ global: true });
|
|
646
|
+
assert.equal(
|
|
647
|
+
removeResult.action,
|
|
648
|
+
"removed",
|
|
649
|
+
"remover with { global: true } must strip what the installer wrote",
|
|
650
|
+
);
|
|
651
|
+
assert.equal(removeResult.path, "~/.claude/settings.local.json");
|
|
652
|
+
|
|
653
|
+
// Hook gone at the global path.
|
|
654
|
+
if (existsSync(globalPath)) {
|
|
655
|
+
const parsedAfter = JSON.parse(readFileSync(globalPath, "utf-8"));
|
|
656
|
+
const hasAfter = (parsedAfter.hooks?.SessionStart ?? [])
|
|
657
|
+
.flatMap((g) => g?.hooks ?? [])
|
|
658
|
+
.some(
|
|
659
|
+
(h) =>
|
|
660
|
+
h && typeof h.command === "string" &&
|
|
661
|
+
h.command.includes(SESSION_HOOK_FINGERPRINT),
|
|
662
|
+
);
|
|
663
|
+
assert.ok(
|
|
664
|
+
!hasAfter,
|
|
665
|
+
"no SkillRepo hook may survive at the global path after remove",
|
|
666
|
+
);
|
|
667
|
+
}
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
it("scope isolation: removing project-local does not affect a hook installed globally", () => {
|
|
671
|
+
// INTENT: project-local and user-global paths must be
|
|
672
|
+
// independent. Removing from one must NOT touch the other.
|
|
673
|
+
// Catches a refactor that accidentally merges the two paths
|
|
674
|
+
// (e.g. makes `{ global }` a no-op). This is the unit-level
|
|
675
|
+
// complement to uninstall.test.mjs's "scope isolation" test
|
|
676
|
+
// that verifies the orchestrator's behavior.
|
|
677
|
+
ASSERT_HOME_ISOLATED();
|
|
678
|
+
|
|
679
|
+
// Install to BOTH paths, separately.
|
|
680
|
+
mergeSessionHook({ binaryPath: FAKE_BINARY, global: false });
|
|
681
|
+
mergeSessionHook({ binaryPath: FAKE_BINARY, global: true });
|
|
682
|
+
|
|
683
|
+
const projectPath = join(process.cwd(), ".claude", "settings.local.json");
|
|
684
|
+
const globalPath = join(process.env.HOME, ".claude", "settings.local.json");
|
|
685
|
+
assert.ok(existsSync(projectPath), "project path written");
|
|
686
|
+
assert.ok(existsSync(globalPath), "global path written");
|
|
687
|
+
|
|
688
|
+
// Remove ONLY from the project path.
|
|
689
|
+
removeSettingsSessionHook({ global: false });
|
|
690
|
+
|
|
691
|
+
// Project path hook is gone.
|
|
692
|
+
const projectAfter = existsSync(projectPath)
|
|
693
|
+
? JSON.parse(readFileSync(projectPath, "utf-8"))
|
|
694
|
+
: {};
|
|
695
|
+
const projectHasHook = (projectAfter.hooks?.SessionStart ?? [])
|
|
696
|
+
.flatMap((g) => g?.hooks ?? [])
|
|
697
|
+
.some((h) => h?.command?.includes(SESSION_HOOK_FINGERPRINT));
|
|
698
|
+
assert.ok(!projectHasHook, "project hook removed as requested");
|
|
699
|
+
|
|
700
|
+
// Global path hook is INTACT.
|
|
701
|
+
const globalAfter = JSON.parse(readFileSync(globalPath, "utf-8"));
|
|
702
|
+
const globalHasHook = globalAfter.hooks.SessionStart.flatMap(
|
|
703
|
+
(g) => g.hooks,
|
|
704
|
+
).some((h) => h.command.includes(SESSION_HOOK_FINGERPRINT));
|
|
705
|
+
assert.ok(
|
|
706
|
+
globalHasHook,
|
|
707
|
+
"global hook must still be present — project remove must NOT touch global",
|
|
708
|
+
);
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
it("installer + remover cycle leaves no residual SkillRepo state", () => {
|
|
712
|
+
// INTENT: install → remove → install → remove cycles must
|
|
713
|
+
// stabilize. Each step either does nothing or flips cleanly.
|
|
714
|
+
ASSERT_HOME_ISOLATED();
|
|
715
|
+
|
|
716
|
+
mergeSessionHook({ binaryPath: FAKE_BINARY });
|
|
717
|
+
removeSettingsSessionHook();
|
|
718
|
+
mergeSessionHook({ binaryPath: FAKE_BINARY });
|
|
719
|
+
const finalRemove = removeSettingsSessionHook();
|
|
720
|
+
|
|
721
|
+
assert.equal(finalRemove.action, "removed");
|
|
722
|
+
|
|
723
|
+
if (existsSync(join(process.cwd(), ".claude", "settings.local.json"))) {
|
|
724
|
+
const parsed = JSON.parse(
|
|
725
|
+
readFileSync(
|
|
726
|
+
join(process.cwd(), ".claude", "settings.local.json"),
|
|
727
|
+
"utf-8",
|
|
728
|
+
),
|
|
729
|
+
);
|
|
730
|
+
const skillrepoHooks = (parsed.hooks?.SessionStart ?? [])
|
|
731
|
+
.flatMap((g) => g?.hooks ?? [])
|
|
732
|
+
.filter(
|
|
733
|
+
(h) =>
|
|
734
|
+
h &&
|
|
735
|
+
typeof h.command === "string" &&
|
|
736
|
+
h.command.includes(SESSION_HOOK_FINGERPRINT),
|
|
737
|
+
);
|
|
738
|
+
assert.equal(
|
|
739
|
+
skillrepoHooks.length,
|
|
740
|
+
0,
|
|
741
|
+
"after install+remove+install+remove, no SkillRepo hooks remain",
|
|
742
|
+
);
|
|
743
|
+
}
|
|
744
|
+
});
|
|
745
|
+
});
|