skillrepo 3.2.0 → 4.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 +137 -27
- package/bin/skillrepo.mjs +5 -5
- package/package.json +1 -1
- package/src/commands/add.mjs +21 -6
- package/src/commands/get.mjs +20 -4
- package/src/commands/init-cohort-hooks.mjs +127 -0
- package/src/commands/init-session-sync.mjs +1 -1
- package/src/commands/init.mjs +480 -117
- package/src/commands/list.mjs +1 -1
- package/src/commands/remove.mjs +10 -2
- package/src/commands/uninstall.mjs +13 -2
- package/src/commands/update.mjs +112 -19
- package/src/lib/agent-hook-merge.mjs +203 -0
- package/src/lib/agent-registry.mjs +399 -0
- package/src/lib/artifact-registry.mjs +111 -2
- package/src/lib/cli-config.mjs +146 -44
- package/src/lib/detect-agents.mjs +112 -0
- package/src/lib/file-write.mjs +162 -77
- package/src/lib/fs-utils.mjs +16 -1
- package/src/lib/mcp-merge.mjs +17 -36
- package/src/lib/mergers/agent-hook-claude-shape.mjs +519 -0
- package/src/lib/mergers/agent-hook-cursor-shape.mjs +318 -0
- package/src/lib/mergers/gitignore.mjs +55 -28
- package/src/lib/paths.mjs +27 -25
- package/src/lib/prompt-multiselect.mjs +324 -0
- package/src/lib/removers/agent-hooks.mjs +83 -0
- package/src/lib/sync.mjs +18 -19
- package/src/test/commands/add.test.mjs +18 -3
- package/src/test/commands/init-picker.test.mjs +144 -0
- package/src/test/commands/init.test.mjs +508 -41
- package/src/test/commands/remove.test.mjs +4 -1
- package/src/test/commands/update.test.mjs +148 -3
- package/src/test/e2e/cli-agent-permutations.test.mjs +631 -0
- package/src/test/e2e/cli-cohort-hooks.test.mjs +393 -0
- package/src/test/e2e/cli-commands.test.mjs +39 -13
- package/src/test/integration/agent-hooks.integration.test.mjs +340 -0
- package/src/test/integration/file-write.integration.test.mjs +31 -10
- package/src/test/lib/agent-hook-merge.test.mjs +172 -0
- package/src/test/lib/agent-registry.test.mjs +215 -0
- package/src/test/lib/artifact-registry.test.mjs +39 -0
- package/src/test/lib/cli-config.test.mjs +222 -38
- package/src/test/lib/detect-agents.test.mjs +336 -0
- package/src/test/lib/file-write-placement.test.mjs +264 -0
- package/src/test/lib/file-write.test.mjs +231 -30
- package/src/test/lib/mcp-merge.test.mjs +23 -15
- package/src/test/lib/paths.test.mjs +53 -17
- package/src/test/lib/prompt-multiselect.test.mjs +448 -0
- package/src/test/lib/sync.test.mjs +157 -0
- package/src/test/mergers/agent-hook-claude-shape.test.mjs +518 -0
- package/src/test/mergers/agent-hook-cursor-shape.test.mjs +306 -0
- package/src/test/removers/agent-hooks.test.mjs +206 -0
- package/src/lib/detect-ides.mjs +0 -44
- package/src/test/detect-ides.test.mjs +0 -65
|
@@ -18,6 +18,7 @@ import { tmpdir } from "node:os";
|
|
|
18
18
|
import { runInit } from "../../commands/init.mjs";
|
|
19
19
|
import { readConfig } from "../../lib/config.mjs";
|
|
20
20
|
import { CliError, EXIT_AUTH, EXIT_VALIDATION } from "../../lib/errors.mjs";
|
|
21
|
+
import { AGENT_REGISTRY } from "../../lib/agent-registry.mjs";
|
|
21
22
|
import { createMockServer } from "../e2e/mock-server.mjs";
|
|
22
23
|
import { createCaptureStream } from "../helpers/capture-stream.mjs";
|
|
23
24
|
import {
|
|
@@ -33,19 +34,48 @@ let serverUrl;
|
|
|
33
34
|
let originalCwd;
|
|
34
35
|
/** @type {import("../helpers/sandbox-home.mjs").HomeEnvSnapshot} */
|
|
35
36
|
let originalHomeEnv;
|
|
37
|
+
/** @type {Record<string, string | undefined>} */
|
|
38
|
+
let originalDetectionEnv;
|
|
36
39
|
let stdout;
|
|
37
40
|
let stderr;
|
|
38
41
|
const VALID_KEY = "sk_live_init_test";
|
|
39
42
|
|
|
43
|
+
// Every env var the agent registry uses as a detection signal. The
|
|
44
|
+
// init test runner inherits the developer's shell env, which on
|
|
45
|
+
// machines running an agent (e.g., CLAUDECODE=1 inside Claude Code)
|
|
46
|
+
// would otherwise pre-fire detection and silently change which
|
|
47
|
+
// targets the picker auto-selects. Clearing these in setup() makes
|
|
48
|
+
// every test deterministic.
|
|
49
|
+
const DETECTION_ENV_VARS = Array.from(
|
|
50
|
+
new Set(
|
|
51
|
+
AGENT_REGISTRY.flatMap((entry) =>
|
|
52
|
+
entry.detectionSignals
|
|
53
|
+
.filter((s) => s.type === "env")
|
|
54
|
+
.map((s) => s.value),
|
|
55
|
+
),
|
|
56
|
+
),
|
|
57
|
+
);
|
|
58
|
+
|
|
40
59
|
async function setup() {
|
|
41
60
|
sandbox = mkdtempSync(join(tmpdir(), "cli-cmd-init-"));
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
61
|
+
// The Phase 3 picker (#1236) pre-checks the Claude Code row when
|
|
62
|
+
// detection finds a Claude signal. Most existing tests rely on
|
|
63
|
+
// that pre-check landing → vendors include claudeCode → MCP /
|
|
64
|
+
// session-sync hooks fire. Create the `.claude/` marker so the
|
|
65
|
+
// existing tests' assumptions still hold; tests that need the
|
|
66
|
+
// empty-detection state strip it explicitly.
|
|
45
67
|
mkdirSync(join(sandbox, "project", ".claude"), { recursive: true });
|
|
46
68
|
mkdirSync(join(sandbox, "home"), { recursive: true });
|
|
47
69
|
originalCwd = process.cwd();
|
|
48
70
|
originalHomeEnv = captureHome();
|
|
71
|
+
// Snapshot every detection env var so a stray host-env var
|
|
72
|
+
// (e.g. CLAUDECODE=1 inside a Claude Code session) doesn't
|
|
73
|
+
// pre-fire a signal and skew the picker's auto-selection.
|
|
74
|
+
originalDetectionEnv = {};
|
|
75
|
+
for (const name of DETECTION_ENV_VARS) {
|
|
76
|
+
originalDetectionEnv[name] = process.env[name];
|
|
77
|
+
delete process.env[name];
|
|
78
|
+
}
|
|
49
79
|
process.chdir(join(sandbox, "project"));
|
|
50
80
|
setSandboxHome(join(sandbox, "home"));
|
|
51
81
|
delete process.env.SKILLREPO_ACCESS_KEY;
|
|
@@ -63,6 +93,12 @@ async function teardown() {
|
|
|
63
93
|
if (server) await server.stop();
|
|
64
94
|
process.chdir(originalCwd);
|
|
65
95
|
restoreHome(originalHomeEnv);
|
|
96
|
+
if (originalDetectionEnv) {
|
|
97
|
+
for (const name of DETECTION_ENV_VARS) {
|
|
98
|
+
if (originalDetectionEnv[name] === undefined) delete process.env[name];
|
|
99
|
+
else process.env[name] = originalDetectionEnv[name];
|
|
100
|
+
}
|
|
101
|
+
}
|
|
66
102
|
if (sandbox) rmSync(sandbox, { recursive: true, force: true });
|
|
67
103
|
server = null;
|
|
68
104
|
}
|
|
@@ -139,9 +175,9 @@ describe("runInit — happy path", () => {
|
|
|
139
175
|
assert.ok(Array.isArray(json.mcp.merged));
|
|
140
176
|
});
|
|
141
177
|
|
|
142
|
-
it("respects --
|
|
178
|
+
it("respects --agent flag to override detection", async () => {
|
|
143
179
|
await runInit(
|
|
144
|
-
["--key", VALID_KEY, "--url", serverUrl, "--yes", "--
|
|
180
|
+
["--key", VALID_KEY, "--url", serverUrl, "--yes", "--agent", "claude"],
|
|
145
181
|
{ stdout, stderr },
|
|
146
182
|
);
|
|
147
183
|
const mcp = JSON.parse(readFileSync(join(process.cwd(), ".mcp.json"), "utf-8"));
|
|
@@ -224,31 +260,176 @@ describe("runInit — error paths", () => {
|
|
|
224
260
|
);
|
|
225
261
|
});
|
|
226
262
|
|
|
227
|
-
it("
|
|
228
|
-
// Remove the .claude marker
|
|
263
|
+
it("non-interactive scenario: explicit --agent claude works in empty project", async () => {
|
|
264
|
+
// Remove the .claude marker — empty dir
|
|
229
265
|
rmSync(join(process.cwd(), ".claude"), { recursive: true, force: true });
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
266
|
+
// With --agent claude, init should proceed even in an empty dir
|
|
267
|
+
await runInit(
|
|
268
|
+
["--key", VALID_KEY, "--url", serverUrl, "--yes", "--agent", "claude"],
|
|
269
|
+
{ stdout, stderr },
|
|
270
|
+
);
|
|
271
|
+
// And write the MCP config
|
|
272
|
+
assert.ok(existsSync(join(process.cwd(), ".mcp.json")));
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
// ── Phase 3 picker (#1236) ────────────────────────────────────────────
|
|
277
|
+
|
|
278
|
+
describe("runInit — Phase 3 detection picker (#1236)", () => {
|
|
279
|
+
beforeEach(setup);
|
|
280
|
+
afterEach(teardown);
|
|
281
|
+
|
|
282
|
+
it("--yes + no detection signals → both default rows pre-checked → both targets configured", async () => {
|
|
283
|
+
// Spec rationale: "writing a few KB the user didn't strictly
|
|
284
|
+
// need is trivial; CI running init --yes on a fresh clone and
|
|
285
|
+
// writing nothing is broken automation." A fresh-clone --yes run
|
|
286
|
+
// must configure BOTH targets. Strip the .claude marker setup()
|
|
287
|
+
// creates so we land in the genuine no-detection state.
|
|
288
|
+
rmSync(join(process.cwd(), ".claude"), { recursive: true, force: true });
|
|
289
|
+
await runInit(
|
|
290
|
+
["--key", VALID_KEY, "--url", serverUrl, "--yes", "--json"],
|
|
291
|
+
{ stdout, stderr },
|
|
292
|
+
);
|
|
293
|
+
const json = JSON.parse(stdout.text());
|
|
294
|
+
assert.equal(json.action, "initialized");
|
|
295
|
+
assert.ok(
|
|
296
|
+
json.vendors.includes("claudeCode"),
|
|
297
|
+
"Claude Code must be configured on a fresh-clone --yes run",
|
|
298
|
+
);
|
|
299
|
+
// Cohort vendors (anything that lives at .agents/skills/) must
|
|
300
|
+
// also be configured. We don't assert the full list because the
|
|
301
|
+
// registry is the source of truth — instead we assert at least
|
|
302
|
+
// one cohort vendor besides claudeCode.
|
|
303
|
+
const nonClaude = json.vendors.filter((v) => v !== "claudeCode");
|
|
304
|
+
assert.ok(
|
|
305
|
+
nonClaude.length > 0,
|
|
306
|
+
"cohort vendors must also be configured on a fresh-clone --yes run",
|
|
307
|
+
);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it("--yes + only Claude signal → only Claude row pre-checked → only claudeCode configured", async () => {
|
|
311
|
+
// setup() already creates .claude/, so the Claude row pre-checks
|
|
312
|
+
// and the cohort row does not. The picker auto-selects only the
|
|
313
|
+
// pre-checked rows under --yes.
|
|
314
|
+
await runInit(
|
|
315
|
+
["--key", VALID_KEY, "--url", serverUrl, "--yes", "--json"],
|
|
316
|
+
{ stdout, stderr },
|
|
317
|
+
);
|
|
318
|
+
const json = JSON.parse(stdout.text());
|
|
319
|
+
assert.deepEqual(
|
|
320
|
+
json.vendors,
|
|
321
|
+
["claudeCode"],
|
|
322
|
+
"only the Claude Code target must be configured when only its signal fires",
|
|
323
|
+
);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it("--yes + only cohort signal → only cohort row pre-checked → cohort configured (no claudeCode)", async () => {
|
|
327
|
+
// Strip the .claude marker setup() creates and add a cohort
|
|
328
|
+
// signal instead — .cursor/ should fire the cohort row.
|
|
329
|
+
rmSync(join(process.cwd(), ".claude"), { recursive: true, force: true });
|
|
330
|
+
mkdirSync(join(process.cwd(), ".cursor"), { recursive: true });
|
|
331
|
+
await runInit(
|
|
332
|
+
["--key", VALID_KEY, "--url", serverUrl, "--yes", "--json"],
|
|
333
|
+
{ stdout, stderr },
|
|
334
|
+
);
|
|
335
|
+
const json = JSON.parse(stdout.text());
|
|
336
|
+
assert.ok(
|
|
337
|
+
!json.vendors.includes("claudeCode"),
|
|
338
|
+
"Claude Code must NOT be configured when only the cohort signal fires",
|
|
339
|
+
);
|
|
340
|
+
assert.ok(
|
|
341
|
+
json.vendors.includes("cursor"),
|
|
342
|
+
"cohort vendors must be configured when a cohort signal fires",
|
|
343
|
+
);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it("--yes + both signals → both rows pre-checked → both targets configured", async () => {
|
|
347
|
+
// setup() creates .claude/, add .cursor/ for cohort signal.
|
|
348
|
+
mkdirSync(join(process.cwd(), ".cursor"), { recursive: true });
|
|
349
|
+
await runInit(
|
|
350
|
+
["--key", VALID_KEY, "--url", serverUrl, "--yes", "--json"],
|
|
351
|
+
{ stdout, stderr },
|
|
352
|
+
);
|
|
353
|
+
const json = JSON.parse(stdout.text());
|
|
354
|
+
assert.ok(json.vendors.includes("claudeCode"));
|
|
355
|
+
assert.ok(json.vendors.includes("cursor"));
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it("does not throw 'No agent targets detected' (the old refuse-branch is gone)", async () => {
|
|
359
|
+
// The pre-Phase-3 init refused with this error when no signal
|
|
360
|
+
// fired. Phase 3 deletes that branch entirely — fresh clones
|
|
361
|
+
// configure both targets. This test locks the deletion: even
|
|
362
|
+
// with no detection markers, init must NOT throw a validation
|
|
363
|
+
// error about agent detection.
|
|
364
|
+
rmSync(join(process.cwd(), ".claude"), { recursive: true, force: true });
|
|
365
|
+
await assert.doesNotReject(() =>
|
|
366
|
+
runInit(
|
|
367
|
+
["--key", VALID_KEY, "--url", serverUrl, "--yes", "--json"],
|
|
233
368
|
{ stdout, stderr },
|
|
234
369
|
),
|
|
235
|
-
(err) =>
|
|
236
|
-
err instanceof CliError &&
|
|
237
|
-
err.exitCode === EXIT_VALIDATION &&
|
|
238
|
-
/No IDEs detected/.test(err.message),
|
|
239
370
|
);
|
|
371
|
+
const json = JSON.parse(stdout.text());
|
|
372
|
+
assert.equal(json.action, "initialized");
|
|
240
373
|
});
|
|
374
|
+
});
|
|
241
375
|
|
|
242
|
-
|
|
243
|
-
|
|
376
|
+
// ── --agent none (skip placement) ─────────────────────────────────────
|
|
377
|
+
|
|
378
|
+
describe("runInit — --agent none", () => {
|
|
379
|
+
beforeEach(setup);
|
|
380
|
+
afterEach(teardown);
|
|
381
|
+
|
|
382
|
+
it("skips first sync but still writes config + JSON summary", async () => {
|
|
383
|
+
// `--agent none` is the explicit "no placement writes" sentinel.
|
|
384
|
+
// Init's credential write, gitignore management, and MCP merge
|
|
385
|
+
// (which is a no-op for empty vendors via mergeMcpForVendors)
|
|
386
|
+
// still run; only step 7 is skipped. The library sync is the
|
|
387
|
+
// expensive network call — skipping it is the user's intent.
|
|
388
|
+
//
|
|
389
|
+
// We DON'T preconfigure a mock library response. If init were
|
|
390
|
+
// (incorrectly) calling the library endpoint, the mock server's
|
|
391
|
+
// default empty response would still let the test pass — that's
|
|
392
|
+
// why we also assert the readable success line.
|
|
393
|
+
await runInit(
|
|
394
|
+
["--key", VALID_KEY, "--url", serverUrl, "--yes", "--agent", "none", "--json"],
|
|
395
|
+
{ stdout, stderr },
|
|
396
|
+
);
|
|
397
|
+
const json = JSON.parse(stdout.text());
|
|
398
|
+
assert.equal(json.action, "initialized");
|
|
399
|
+
assert.deepEqual(json.vendors, [], "vendors must be empty under --agent none");
|
|
400
|
+
// The synthesized sync summary uses fullSync=null to signal
|
|
401
|
+
// "the network call never ran" — same shape as the failure
|
|
402
|
+
// recovery path. Locking it here keeps that contract explicit.
|
|
403
|
+
assert.equal(json.sync.added, 0);
|
|
404
|
+
assert.equal(json.sync.updated, 0);
|
|
405
|
+
assert.equal(json.sync.removed, 0);
|
|
406
|
+
assert.equal(json.sync.fullSync, null);
|
|
407
|
+
// The session-sync hook is Claude-specific, so under --agent
|
|
408
|
+
// none (no Claude target, no --global) it must be skipped.
|
|
409
|
+
assert.equal(json.sessionSync.action, "not-applicable");
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
it("does NOT throw 'No agent targets detected' when --agent none is passed in an empty dir", async () => {
|
|
413
|
+
// Without --agent none, an empty dir would refuse with the
|
|
414
|
+
// "No agent targets detected" error. The `none` sentinel skips
|
|
415
|
+
// detection entirely — the user already told us what they want.
|
|
244
416
|
rmSync(join(process.cwd(), ".claude"), { recursive: true, force: true });
|
|
245
|
-
// With --ide claude, init should proceed even in an empty dir
|
|
246
417
|
await runInit(
|
|
247
|
-
["--key", VALID_KEY, "--url", serverUrl, "--yes", "--
|
|
418
|
+
["--key", VALID_KEY, "--url", serverUrl, "--yes", "--agent", "none", "--json"],
|
|
248
419
|
{ stdout, stderr },
|
|
249
420
|
);
|
|
250
|
-
|
|
251
|
-
assert.
|
|
421
|
+
const json = JSON.parse(stdout.text());
|
|
422
|
+
assert.equal(json.action, "initialized");
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
it("config is persisted on disk after --agent none init", async () => {
|
|
426
|
+
await runInit(
|
|
427
|
+
["--key", VALID_KEY, "--url", serverUrl, "--yes", "--agent", "none"],
|
|
428
|
+
{ stdout, stderr },
|
|
429
|
+
);
|
|
430
|
+
const cfg = readConfig();
|
|
431
|
+
assert.equal(cfg.apiKey, VALID_KEY);
|
|
432
|
+
assert.equal(cfg.serverUrl, serverUrl);
|
|
252
433
|
});
|
|
253
434
|
});
|
|
254
435
|
|
|
@@ -640,9 +821,9 @@ describe("runInit — session sync (#884)", () => {
|
|
|
640
821
|
assert.equal(json.sessionSync.path, null);
|
|
641
822
|
});
|
|
642
823
|
|
|
643
|
-
it("skips session sync entirely when only non-Claude-Code
|
|
824
|
+
it("skips session sync entirely when only non-Claude-Code agents are targeted (cross-PR review fix)", async () => {
|
|
644
825
|
// Cross-PR review flagged: before this guard, a user running
|
|
645
|
-
// `skillrepo init --
|
|
826
|
+
// `skillrepo init --agent cursor` would get a Claude Code-specific
|
|
646
827
|
// SessionStart hook written to `.claude/settings.local.json`.
|
|
647
828
|
// Cursor never reads that file, so the hook was silent useless
|
|
648
829
|
// state that `skillrepo uninstall` later had to clean up.
|
|
@@ -651,7 +832,7 @@ describe("runInit — session sync (#884)", () => {
|
|
|
651
832
|
// `claudeCode` is not in the resolved vendors list AND
|
|
652
833
|
// `--global` is not passed. This test proves the skip fires.
|
|
653
834
|
//
|
|
654
|
-
// Use --
|
|
835
|
+
// Use --agent cursor to force vendors = ["cursor"]. Bypass the
|
|
655
836
|
// .claude/ auto-detection by creating .cursor/ instead.
|
|
656
837
|
mkdirSync(join(process.cwd(), ".cursor"), { recursive: true });
|
|
657
838
|
rmSync(join(process.cwd(), ".claude"), { recursive: true, force: true });
|
|
@@ -663,7 +844,7 @@ describe("runInit — session sync (#884)", () => {
|
|
|
663
844
|
"--url",
|
|
664
845
|
serverUrl,
|
|
665
846
|
"--yes",
|
|
666
|
-
"--
|
|
847
|
+
"--agent",
|
|
667
848
|
"cursor",
|
|
668
849
|
"--json",
|
|
669
850
|
],
|
|
@@ -686,17 +867,15 @@ describe("runInit — session sync (#884)", () => {
|
|
|
686
867
|
);
|
|
687
868
|
});
|
|
688
869
|
|
|
689
|
-
it("
|
|
690
|
-
// INTENT:
|
|
691
|
-
//
|
|
692
|
-
//
|
|
693
|
-
//
|
|
694
|
-
//
|
|
695
|
-
//
|
|
696
|
-
//
|
|
697
|
-
//
|
|
698
|
-
// claudeCode. Force vendors = ["cursor"] via --ide to exercise
|
|
699
|
-
// the "--global overrides vendors" branch.
|
|
870
|
+
it("does NOT install Claude SessionStart hook for --global --agent cursor", async () => {
|
|
871
|
+
// INTENT: the SessionStart hook is Claude Code-specific. Pre-#1249,
|
|
872
|
+
// `claudeTargeted` was `Boolean(flags.global) || vendors.includes("claudeCode")`
|
|
873
|
+
// because bare `--global` historically routed to Claude. After
|
|
874
|
+
// #1249's effectiveVendors fix preserves --agent under --global,
|
|
875
|
+
// `--global --agent cursor` legitimately targets Cursor only —
|
|
876
|
+
// installing a Claude hook the user never asked for is a bug.
|
|
877
|
+
// Manual E2E sweep on staging caught this regression; this test
|
|
878
|
+
// pins the corrected behavior.
|
|
700
879
|
await runInit(
|
|
701
880
|
[
|
|
702
881
|
"--key",
|
|
@@ -705,7 +884,7 @@ describe("runInit — session sync (#884)", () => {
|
|
|
705
884
|
serverUrl,
|
|
706
885
|
"--yes",
|
|
707
886
|
"--global",
|
|
708
|
-
"--
|
|
887
|
+
"--agent",
|
|
709
888
|
"cursor",
|
|
710
889
|
"--json",
|
|
711
890
|
],
|
|
@@ -715,10 +894,298 @@ describe("runInit — session sync (#884)", () => {
|
|
|
715
894
|
const json = JSON.parse(stdout.text());
|
|
716
895
|
assert.equal(
|
|
717
896
|
json.sessionSync.action,
|
|
897
|
+
"not-applicable",
|
|
898
|
+
"Claude SessionStart hook must NOT install for --global --agent cursor",
|
|
899
|
+
);
|
|
900
|
+
assert.equal(json.sessionSync.path, null);
|
|
901
|
+
// Critical: the user-wide settings.local.json file must NOT have
|
|
902
|
+
// been written. A Cursor-only --global user should never see a
|
|
903
|
+
// Claude-specific file materialize.
|
|
904
|
+
assert.ok(
|
|
905
|
+
!existsSync(join(sandbox, "home", ".claude", "settings.local.json")),
|
|
906
|
+
"~/.claude/settings.local.json must NOT be written for Cursor-only --global init",
|
|
907
|
+
);
|
|
908
|
+
});
|
|
909
|
+
});
|
|
910
|
+
|
|
911
|
+
// ── runInit — cohort SessionStart hooks (#1240) ────────────────────
|
|
912
|
+
//
|
|
913
|
+
// Sibling step to the Claude session-hook block above. These tests
|
|
914
|
+
// exercise the cohort installer wired in init.mjs step 6 alongside
|
|
915
|
+
// `installSessionSyncHook`. The cohort installer writes user-scope
|
|
916
|
+
// hook configs for Cursor / Gemini CLI / Codex CLI / VS Code +
|
|
917
|
+
// Copilot — every selected vendor with a non-null `agentHook` spec.
|
|
918
|
+
//
|
|
919
|
+
// HOME isolation is the load-bearing safety property: the cohort
|
|
920
|
+
// hooks live at `~/.<vendor>/...` so a sandbox HOME leak would
|
|
921
|
+
// pollute the developer's real home. The setupWithShim helper
|
|
922
|
+
// already overrides HOME / USERPROFILE; that's the gate.
|
|
923
|
+
|
|
924
|
+
describe("runInit — cohort SessionStart hooks (#1240)", () => {
|
|
925
|
+
beforeEach(setupWithShim);
|
|
926
|
+
afterEach(teardownWithShim);
|
|
927
|
+
|
|
928
|
+
it("writes cohort hooks for every selected non-Claude vendor with --yes --agent", async () => {
|
|
929
|
+
await runInit(
|
|
930
|
+
[
|
|
931
|
+
"--key", VALID_KEY,
|
|
932
|
+
"--url", serverUrl,
|
|
933
|
+
"--yes",
|
|
934
|
+
"--agent", "cursor,gemini,codex,copilot",
|
|
935
|
+
"--json",
|
|
936
|
+
],
|
|
937
|
+
{ stdout, stderr },
|
|
938
|
+
);
|
|
939
|
+
|
|
940
|
+
const json = JSON.parse(stdout.text());
|
|
941
|
+
assert.ok(
|
|
942
|
+
Array.isArray(json.sessionSync.cohortHooks),
|
|
943
|
+
"sessionSync.cohortHooks must be an array",
|
|
944
|
+
);
|
|
945
|
+
assert.equal(json.sessionSync.cohortHooks.length, 4);
|
|
946
|
+
for (const r of json.sessionSync.cohortHooks) {
|
|
947
|
+
assert.equal(
|
|
948
|
+
r.action,
|
|
949
|
+
"installed",
|
|
950
|
+
`expected install for ${r.vendorKey}, got ${r.action}`,
|
|
951
|
+
);
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
// Each vendor's file landed on disk under the sandbox HOME
|
|
955
|
+
const home = join(sandbox, "home");
|
|
956
|
+
assert.ok(existsSync(join(home, ".cursor", "hooks.json")));
|
|
957
|
+
assert.ok(existsSync(join(home, ".gemini", "settings.json")));
|
|
958
|
+
assert.ok(existsSync(join(home, ".codex", "hooks.json")));
|
|
959
|
+
assert.ok(
|
|
960
|
+
existsSync(
|
|
961
|
+
join(home, ".copilot", "hooks", "skillrepo-update.json"),
|
|
962
|
+
),
|
|
963
|
+
);
|
|
964
|
+
});
|
|
965
|
+
|
|
966
|
+
it("each cohort hook command is `npx --yes skillrepo update --silent`", async () => {
|
|
967
|
+
// INTENT: the universal command is the single source of truth.
|
|
968
|
+
// A future refactor that drifts one vendor's command out of sync
|
|
969
|
+
// breaks the round-trip uninstall via the shared fingerprint.
|
|
970
|
+
await runInit(
|
|
971
|
+
[
|
|
972
|
+
"--key", VALID_KEY,
|
|
973
|
+
"--url", serverUrl,
|
|
974
|
+
"--yes",
|
|
975
|
+
"--agent", "cursor,gemini",
|
|
976
|
+
],
|
|
977
|
+
{ stdout, stderr },
|
|
978
|
+
);
|
|
979
|
+
|
|
980
|
+
const home = join(sandbox, "home");
|
|
981
|
+
|
|
982
|
+
const cursor = JSON.parse(
|
|
983
|
+
readFileSync(join(home, ".cursor", "hooks.json"), "utf-8"),
|
|
984
|
+
);
|
|
985
|
+
assert.equal(
|
|
986
|
+
cursor.hooks.sessionStart[0].command,
|
|
987
|
+
"npx --yes skillrepo update --silent",
|
|
988
|
+
);
|
|
989
|
+
assert.equal(cursor.version, 1);
|
|
990
|
+
|
|
991
|
+
const gemini = JSON.parse(
|
|
992
|
+
readFileSync(join(home, ".gemini", "settings.json"), "utf-8"),
|
|
993
|
+
);
|
|
994
|
+
const geminiHook = gemini.hooks.SessionStart[0].hooks[0];
|
|
995
|
+
assert.equal(geminiHook.command, "npx --yes skillrepo update --silent");
|
|
996
|
+
assert.equal(geminiHook.type, "command");
|
|
997
|
+
assert.equal(geminiHook.timeout, 60000);
|
|
998
|
+
});
|
|
999
|
+
|
|
1000
|
+
it("--no-session-sync skips ALL cohort hooks (semantics widened in #1240)", async () => {
|
|
1001
|
+
await runInit(
|
|
1002
|
+
[
|
|
1003
|
+
"--key", VALID_KEY,
|
|
1004
|
+
"--url", serverUrl,
|
|
1005
|
+
"--yes",
|
|
1006
|
+
"--agent", "cursor,gemini",
|
|
1007
|
+
"--no-session-sync",
|
|
1008
|
+
"--json",
|
|
1009
|
+
],
|
|
1010
|
+
{ stdout, stderr },
|
|
1011
|
+
);
|
|
1012
|
+
|
|
1013
|
+
const json = JSON.parse(stdout.text());
|
|
1014
|
+
// No cohort hooks installed
|
|
1015
|
+
assert.equal(json.sessionSync.cohortHooks.length, 0);
|
|
1016
|
+
|
|
1017
|
+
// Files do not exist
|
|
1018
|
+
const home = join(sandbox, "home");
|
|
1019
|
+
assert.ok(!existsSync(join(home, ".cursor", "hooks.json")));
|
|
1020
|
+
assert.ok(!existsSync(join(home, ".gemini", "settings.json")));
|
|
1021
|
+
});
|
|
1022
|
+
|
|
1023
|
+
it("re-running init is idempotent — exactly one cohort entry per vendor file", async () => {
|
|
1024
|
+
const args = [
|
|
1025
|
+
"--key", VALID_KEY,
|
|
1026
|
+
"--url", serverUrl,
|
|
1027
|
+
"--yes",
|
|
1028
|
+
"--agent", "cursor,gemini",
|
|
1029
|
+
];
|
|
1030
|
+
await runInit(args, { stdout, stderr });
|
|
1031
|
+
stdout.clear();
|
|
1032
|
+
await runInit([...args, "--json"], { stdout, stderr });
|
|
1033
|
+
|
|
1034
|
+
// Second run reports unchanged for every cohort vendor
|
|
1035
|
+
const json = JSON.parse(stdout.text());
|
|
1036
|
+
for (const r of json.sessionSync.cohortHooks) {
|
|
1037
|
+
assert.equal(r.action, "unchanged", `${r.vendorKey} should be unchanged`);
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
// Cursor file has exactly one SkillRepo entry, not two
|
|
1041
|
+
const home = join(sandbox, "home");
|
|
1042
|
+
const cursor = JSON.parse(
|
|
1043
|
+
readFileSync(join(home, ".cursor", "hooks.json"), "utf-8"),
|
|
1044
|
+
);
|
|
1045
|
+
const skillrepoEntries = cursor.hooks.sessionStart.filter((h) =>
|
|
1046
|
+
h.command?.includes("skillrepo update --silent"),
|
|
1047
|
+
);
|
|
1048
|
+
assert.equal(skillrepoEntries.length, 1);
|
|
1049
|
+
});
|
|
1050
|
+
|
|
1051
|
+
it("does NOT install cohort hooks for vendors not in the selected list", async () => {
|
|
1052
|
+
// --agent cursor only → gemini/codex/copilot files must NOT exist
|
|
1053
|
+
await runInit(
|
|
1054
|
+
[
|
|
1055
|
+
"--key", VALID_KEY,
|
|
1056
|
+
"--url", serverUrl,
|
|
1057
|
+
"--yes",
|
|
1058
|
+
"--agent", "cursor",
|
|
1059
|
+
"--json",
|
|
1060
|
+
],
|
|
1061
|
+
{ stdout, stderr },
|
|
1062
|
+
);
|
|
1063
|
+
|
|
1064
|
+
const home = join(sandbox, "home");
|
|
1065
|
+
assert.ok(existsSync(join(home, ".cursor", "hooks.json")));
|
|
1066
|
+
assert.ok(!existsSync(join(home, ".gemini", "settings.json")));
|
|
1067
|
+
assert.ok(!existsSync(join(home, ".codex", "hooks.json")));
|
|
1068
|
+
assert.ok(
|
|
1069
|
+
!existsSync(join(home, ".copilot", "hooks", "skillrepo-update.json")),
|
|
1070
|
+
);
|
|
1071
|
+
|
|
1072
|
+
const json = JSON.parse(stdout.text());
|
|
1073
|
+
assert.equal(json.sessionSync.cohortHooks.length, 1);
|
|
1074
|
+
assert.equal(json.sessionSync.cohortHooks[0].vendorKey, "cursor");
|
|
1075
|
+
});
|
|
1076
|
+
|
|
1077
|
+
it("does NOT install cohort hooks for windsurf or cline (deferred per agent registry)", async () => {
|
|
1078
|
+
// Windsurf and Cline are deliberately excluded from the cohort
|
|
1079
|
+
// installer per the registry's `agentHook: null`. A user
|
|
1080
|
+
// selecting them should get file-based skill placement but NO
|
|
1081
|
+
// hook-config writes.
|
|
1082
|
+
await runInit(
|
|
1083
|
+
[
|
|
1084
|
+
"--key", VALID_KEY,
|
|
1085
|
+
"--url", serverUrl,
|
|
1086
|
+
"--yes",
|
|
1087
|
+
"--agent", "windsurf,cline",
|
|
1088
|
+
"--json",
|
|
1089
|
+
],
|
|
1090
|
+
{ stdout, stderr },
|
|
1091
|
+
);
|
|
1092
|
+
|
|
1093
|
+
const home = join(sandbox, "home");
|
|
1094
|
+
assert.ok(!existsSync(join(home, ".cline", "hooks.json")));
|
|
1095
|
+
// Windsurf has no hook config file by spec
|
|
1096
|
+
const json = JSON.parse(stdout.text());
|
|
1097
|
+
assert.equal(json.sessionSync.cohortHooks.length, 0);
|
|
1098
|
+
});
|
|
1099
|
+
|
|
1100
|
+
it("Copilot install surfaces a Preview-status warning to the user (#1244)", async () => {
|
|
1101
|
+
// INTENT: GitHub currently labels Copilot's hook system as
|
|
1102
|
+
// Preview. The init step-6 cohort flow adds a one-time `p.warning`
|
|
1103
|
+
// when Copilot was among the installed vendors so a user knows
|
|
1104
|
+
// the hook schema may shift before GA. This test locks the warning
|
|
1105
|
+
// to the installed-Copilot path.
|
|
1106
|
+
await runInit(
|
|
1107
|
+
[
|
|
1108
|
+
"--key", VALID_KEY,
|
|
1109
|
+
"--url", serverUrl,
|
|
1110
|
+
"--yes",
|
|
1111
|
+
"--agent", "copilot",
|
|
1112
|
+
],
|
|
1113
|
+
{ stdout, stderr },
|
|
1114
|
+
);
|
|
1115
|
+
assert.match(
|
|
1116
|
+
stdout.text(),
|
|
1117
|
+
/Copilot.*Preview/,
|
|
1118
|
+
"Copilot's Preview-status caveat must surface on a successful copilot install",
|
|
1119
|
+
);
|
|
1120
|
+
});
|
|
1121
|
+
|
|
1122
|
+
it("Preview warning does NOT fire when Copilot is not selected", async () => {
|
|
1123
|
+
// INTENT: the warning is Copilot-specific. Installing for cohort
|
|
1124
|
+
// vendors WITHOUT Copilot must not surface the Preview note.
|
|
1125
|
+
await runInit(
|
|
1126
|
+
[
|
|
1127
|
+
"--key", VALID_KEY,
|
|
1128
|
+
"--url", serverUrl,
|
|
1129
|
+
"--yes",
|
|
1130
|
+
"--agent", "cursor,gemini",
|
|
1131
|
+
],
|
|
1132
|
+
{ stdout, stderr },
|
|
1133
|
+
);
|
|
1134
|
+
assert.doesNotMatch(
|
|
1135
|
+
stdout.text(),
|
|
1136
|
+
/Preview/,
|
|
1137
|
+
"Preview warning is Copilot-specific and must NOT fire for cursor/gemini installs",
|
|
1138
|
+
);
|
|
1139
|
+
});
|
|
1140
|
+
|
|
1141
|
+
it("init JSON output's cohortHooks array reports per-vendor failures with reason (#1239 coverage gap 44)", async () => {
|
|
1142
|
+
// INTENT: when a per-vendor cohort install fails (e.g. corrupt
|
|
1143
|
+
// existing hook config), the failure must surface in
|
|
1144
|
+
// `sessionSync.cohortHooks[].action === "failed"` with a `reason`
|
|
1145
|
+
// field. Force a failure by pre-seeding Cursor's hook file with
|
|
1146
|
+
// invalid JSON so the merger throws diskError.
|
|
1147
|
+
const home = join(sandbox, "home");
|
|
1148
|
+
mkdirSync(join(home, ".cursor"), { recursive: true });
|
|
1149
|
+
writeFileSync(
|
|
1150
|
+
join(home, ".cursor", "hooks.json"),
|
|
1151
|
+
"{not valid json",
|
|
1152
|
+
);
|
|
1153
|
+
|
|
1154
|
+
await runInit(
|
|
1155
|
+
[
|
|
1156
|
+
"--key", VALID_KEY,
|
|
1157
|
+
"--url", serverUrl,
|
|
1158
|
+
"--yes",
|
|
1159
|
+
"--agent", "cursor,gemini",
|
|
1160
|
+
"--json",
|
|
1161
|
+
],
|
|
1162
|
+
{ stdout, stderr },
|
|
1163
|
+
);
|
|
1164
|
+
const json = JSON.parse(stdout.text());
|
|
1165
|
+
|
|
1166
|
+
const cursorResult = json.sessionSync.cohortHooks.find(
|
|
1167
|
+
(h) => h.vendorKey === "cursor",
|
|
1168
|
+
);
|
|
1169
|
+
assert.equal(
|
|
1170
|
+
cursorResult.action,
|
|
1171
|
+
"failed",
|
|
1172
|
+
"cursor install must report failed when its config is corrupt",
|
|
1173
|
+
);
|
|
1174
|
+
assert.match(
|
|
1175
|
+
cursorResult.reason,
|
|
1176
|
+
/Cannot parse/,
|
|
1177
|
+
"failure reason must be actionable (parse error mentions the file)",
|
|
1178
|
+
);
|
|
1179
|
+
|
|
1180
|
+
// Critical: sibling vendor still installed despite cursor's failure
|
|
1181
|
+
const geminiResult = json.sessionSync.cohortHooks.find(
|
|
1182
|
+
(h) => h.vendorKey === "gemini",
|
|
1183
|
+
);
|
|
1184
|
+
assert.equal(
|
|
1185
|
+
geminiResult.action,
|
|
718
1186
|
"installed",
|
|
719
|
-
"
|
|
1187
|
+
"Gemini install must succeed even when Cursor's failed (per-vendor isolation)",
|
|
720
1188
|
);
|
|
721
|
-
assert.equal(json.sessionSync.path, "~/.claude/settings.local.json");
|
|
722
1189
|
});
|
|
723
1190
|
});
|
|
724
1191
|
|
|
@@ -1433,9 +1900,9 @@ describe("runInit — v3.1.2 step 6 auto-install (npx)", () => {
|
|
|
1433
1900
|
assert.equal(parsed.sessionSync.action, "skipped");
|
|
1434
1901
|
});
|
|
1435
1902
|
|
|
1436
|
-
it("Branch 2: --
|
|
1903
|
+
it("Branch 2: --agent cursor (non-Claude target) → spawn never called, action = not-applicable", async () => {
|
|
1437
1904
|
// QA gap fix: previously had no test for the non-Claude branch.
|
|
1438
|
-
// Even under npx, if the user targets a non-Claude
|
|
1905
|
+
// Even under npx, if the user targets a non-Claude agent, the
|
|
1439
1906
|
// SessionStart hook (Claude-specific) is skipped without an
|
|
1440
1907
|
// install offer.
|
|
1441
1908
|
makeNpxArgv();
|
|
@@ -1451,7 +1918,7 @@ describe("runInit — v3.1.2 step 6 auto-install (npx)", () => {
|
|
|
1451
1918
|
"--url",
|
|
1452
1919
|
serverUrl,
|
|
1453
1920
|
"--yes",
|
|
1454
|
-
"--
|
|
1921
|
+
"--agent",
|
|
1455
1922
|
"cursor",
|
|
1456
1923
|
"--json",
|
|
1457
1924
|
],
|
|
@@ -178,7 +178,10 @@ describe("runRemove — happy path", () => {
|
|
|
178
178
|
});
|
|
179
179
|
|
|
180
180
|
it("--global removes from home dir", async () => {
|
|
181
|
-
writeSkillDir(makeSkill("alice", "pdf-helper"), {
|
|
181
|
+
writeSkillDir(makeSkill("alice", "pdf-helper"), {
|
|
182
|
+
global: true,
|
|
183
|
+
vendors: ["claudeCode"],
|
|
184
|
+
});
|
|
182
185
|
const dir = resolvePlacementDir("claudeGlobal", "pdf-helper");
|
|
183
186
|
assert.ok(existsSync(dir));
|
|
184
187
|
|