skillrepo 4.0.0 → 4.2.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 +49 -2
- package/bin/skillrepo.mjs +8 -0
- package/package.json +10 -4
- package/src/commands/init-cohort-hooks.mjs +127 -0
- package/src/commands/init.mjs +45 -6
- package/src/commands/push.mjs +187 -0
- package/src/commands/uninstall.mjs +12 -1
- package/src/commands/update.mjs +97 -16
- package/src/lib/agent-hook-merge.mjs +203 -0
- package/src/lib/agent-registry.mjs +186 -2
- package/src/lib/artifact-registry.mjs +111 -2
- package/src/lib/fs-utils.mjs +16 -1
- package/src/lib/http.mjs +169 -11
- 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/removers/agent-hooks.mjs +83 -0
- package/src/lib/skill-walk.mjs +97 -0
- package/src/test/commands/init.test.mjs +281 -0
- package/src/test/commands/push.test.mjs +289 -0
- package/src/test/commands/update.test.mjs +135 -0
- package/src/test/dispatcher.test.mjs +10 -2
- package/src/test/e2e/cli-cohort-hooks.test.mjs +393 -0
- package/src/test/e2e/mock-server.mjs +92 -10
- package/src/test/integration/agent-hooks.integration.test.mjs +340 -0
- package/src/test/lib/agent-hook-merge.test.mjs +172 -0
- package/src/test/lib/artifact-registry.test.mjs +39 -0
- package/src/test/lib/http.test.mjs +242 -1
- package/src/test/lib/skill-walk.test.mjs +127 -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
|
@@ -336,3 +336,138 @@ describe("runUpdate — --session-hook contract", () => {
|
|
|
336
336
|
assert.equal(stdout.text(), "", "zero-delta 200 is silent");
|
|
337
337
|
});
|
|
338
338
|
});
|
|
339
|
+
|
|
340
|
+
// ── --silent mode (#1240) ─────────────────────────────────────────────
|
|
341
|
+
//
|
|
342
|
+
// Silent mode is what the cohort SessionStart hooks Cursor / Gemini CLI /
|
|
343
|
+
// Codex CLI / VS Code + Copilot install via the universal command
|
|
344
|
+
// `npx --yes skillrepo update --silent`. Contract:
|
|
345
|
+
//
|
|
346
|
+
// - stdout produces ONE line on success: `{}` (Gemini's hook stdout
|
|
347
|
+
// must be valid JSON; the others tolerate empty JSON).
|
|
348
|
+
// - On failure, stdout writes nothing extra; the typed CliError
|
|
349
|
+
// propagates and the dispatcher exits non-zero with an stderr
|
|
350
|
+
// message.
|
|
351
|
+
// - sync.mjs's non-fatal warnings (e.g. "failed to persist last-sync
|
|
352
|
+
// state") still go to stderr — operators running `update --silent`
|
|
353
|
+
// in a terminal still see them.
|
|
354
|
+
//
|
|
355
|
+
// Distinct from `--session-hook`. That mode is exit-0-on-everything
|
|
356
|
+
// because Claude Code blocks session start on hook failures. Cohort
|
|
357
|
+
// vendors do not block, so `--silent` propagates real exit codes.
|
|
358
|
+
|
|
359
|
+
describe("runUpdate — --silent contract", () => {
|
|
360
|
+
beforeEach(setup);
|
|
361
|
+
afterEach(teardown);
|
|
362
|
+
|
|
363
|
+
it("accepts the --silent flag without throwing Unknown argument", async () => {
|
|
364
|
+
// Same regression-guard pattern as the --session-hook test —
|
|
365
|
+
// resolveFlags would normally reject the flag, so an
|
|
366
|
+
// acceptPositional callback must consume it.
|
|
367
|
+
server.setLibraryResponse({ skills: [], removals: [], syncedAt: "x" });
|
|
368
|
+
await assert.doesNotReject(
|
|
369
|
+
() =>
|
|
370
|
+
runUpdate(
|
|
371
|
+
["--key", VALID_KEY, "--url", serverUrl, "--silent"],
|
|
372
|
+
{ stdout },
|
|
373
|
+
),
|
|
374
|
+
"update --silent must NOT throw Unknown argument",
|
|
375
|
+
);
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
it("emits exactly `{}\\n` on a successful empty sync (Gemini contract)", async () => {
|
|
379
|
+
// INTENT: Gemini specifically requires hook stdout to be valid JSON.
|
|
380
|
+
// The minimal valid value `{}` injects no model context.
|
|
381
|
+
server.setLibraryResponse({ skills: [], removals: [], syncedAt: "x" });
|
|
382
|
+
await runUpdate(
|
|
383
|
+
["--key", VALID_KEY, "--url", serverUrl, "--silent"],
|
|
384
|
+
{ stdout },
|
|
385
|
+
);
|
|
386
|
+
assert.equal(stdout.text(), "{}\n");
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
it("emits `{}\\n` even when the sync produced changes (no progress lines on stdout)", async () => {
|
|
390
|
+
server.setLibraryResponse({
|
|
391
|
+
skills: [makeSkill("silent-test")],
|
|
392
|
+
removals: [],
|
|
393
|
+
syncedAt: "x",
|
|
394
|
+
});
|
|
395
|
+
await runUpdate(
|
|
396
|
+
["--key", VALID_KEY, "--url", serverUrl, "--silent"],
|
|
397
|
+
{ stdout },
|
|
398
|
+
);
|
|
399
|
+
// Stdout still ONLY `{}` — progress is not part of the hook
|
|
400
|
+
// contract. The skill files themselves still landed on disk
|
|
401
|
+
// (verified separately in the integration tests).
|
|
402
|
+
assert.equal(stdout.text(), "{}\n");
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it("propagates a real exit code on failure (does NOT silently swallow errors)", async () => {
|
|
406
|
+
// INTENT: distinct from --session-hook. Cohort vendors do not
|
|
407
|
+
// block session start on non-zero hook exit, so a failure should
|
|
408
|
+
// surface as a typed CliError that the dispatcher maps to the
|
|
409
|
+
// documented exit code. Silent-on-success does NOT mean
|
|
410
|
+
// silent-on-failure.
|
|
411
|
+
server.setForcedStatus(401, { error: "Invalid access key" });
|
|
412
|
+
await assert.rejects(
|
|
413
|
+
() =>
|
|
414
|
+
runUpdate(
|
|
415
|
+
["--key", VALID_KEY, "--url", serverUrl, "--silent"],
|
|
416
|
+
{ stdout },
|
|
417
|
+
),
|
|
418
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_AUTH,
|
|
419
|
+
"auth error must propagate as CliError under --silent",
|
|
420
|
+
);
|
|
421
|
+
// No `{}` was written — failure surfaces via the throw, not a
|
|
422
|
+
// half-success stdout line.
|
|
423
|
+
assert.equal(stdout.text(), "", "no stdout on failure");
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
it("propagates validation error (e.g. missing --agent value) under --silent", async () => {
|
|
427
|
+
// resolveFlags throws validationError on a malformed --agent.
|
|
428
|
+
// Silent mode must NOT catch that — same rationale as the auth
|
|
429
|
+
// error case.
|
|
430
|
+
await assert.rejects(
|
|
431
|
+
() =>
|
|
432
|
+
runUpdate(
|
|
433
|
+
["--key", VALID_KEY, "--url", serverUrl, "--silent", "--agent"],
|
|
434
|
+
{ stdout },
|
|
435
|
+
),
|
|
436
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
|
|
437
|
+
);
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
it("--silent + --session-hook simultaneously: session-hook wins (defensive precedence) (#1239 QA)", async () => {
|
|
441
|
+
// INTENT: defensive precedence test. The two flags have different
|
|
442
|
+
// exit-code contracts: --session-hook is exit-0-on-everything
|
|
443
|
+
// (Claude blocks session start on non-zero), --silent propagates
|
|
444
|
+
// real exit codes. Today no installer writes a hook command
|
|
445
|
+
// containing both flags, but a future code path or manual user
|
|
446
|
+
// edit could. The branches in update.mjs check sessionHook FIRST,
|
|
447
|
+
// so when both are present, the session-hook contract wins.
|
|
448
|
+
//
|
|
449
|
+
// We force a failure (unreachable server) and assert the
|
|
450
|
+
// resolveFlags-doesnt-throw + emit-failure-line-on-stdout pattern
|
|
451
|
+
// of session-hook mode, NOT silent mode's exit-non-zero pattern.
|
|
452
|
+
server.setForcedStatus(401, { error: "bad key" });
|
|
453
|
+
await assert.doesNotReject(
|
|
454
|
+
() =>
|
|
455
|
+
runUpdate(
|
|
456
|
+
[
|
|
457
|
+
"--key", VALID_KEY,
|
|
458
|
+
"--url", serverUrl,
|
|
459
|
+
"--silent",
|
|
460
|
+
"--session-hook",
|
|
461
|
+
],
|
|
462
|
+
{ stdout },
|
|
463
|
+
),
|
|
464
|
+
"session-hook precedence over --silent must NOT throw on auth failure",
|
|
465
|
+
);
|
|
466
|
+
// Session-hook mode emits the one-line failure marker on stdout
|
|
467
|
+
assert.match(
|
|
468
|
+
stdout.text(),
|
|
469
|
+
/\[SkillRepo\] Sync failed:/,
|
|
470
|
+
"session-hook precedence must produce the session-hook failure line, not silent's empty stdout",
|
|
471
|
+
);
|
|
472
|
+
});
|
|
473
|
+
});
|
|
@@ -71,7 +71,7 @@ describe("dispatcher — top-level help", () => {
|
|
|
71
71
|
assert.equal(r.status, 0);
|
|
72
72
|
assert.match(r.stdout, /SkillRepo CLI/);
|
|
73
73
|
// All 7 commands listed
|
|
74
|
-
for (const cmd of ["init", "update", "get", "add", "remove", "list", "search"]) {
|
|
74
|
+
for (const cmd of ["init", "update", "get", "add", "push", "remove", "list", "search"]) {
|
|
75
75
|
assert.match(r.stdout, new RegExp(`\\b${cmd}\\b`), `expected to see "${cmd}" in help`);
|
|
76
76
|
}
|
|
77
77
|
});
|
|
@@ -123,7 +123,7 @@ describe("dispatcher — unknown command", () => {
|
|
|
123
123
|
describe("dispatcher — per-command help", () => {
|
|
124
124
|
// PR1 only ships init for real; the other 6 are stubs but still
|
|
125
125
|
// route --help correctly.
|
|
126
|
-
for (const cmd of ["init", "update", "get", "add", "remove", "list", "search"]) {
|
|
126
|
+
for (const cmd of ["init", "update", "get", "add", "push", "remove", "list", "search"]) {
|
|
127
127
|
it(`\`skillrepo ${cmd} --help\` prints command-specific help`, async () => {
|
|
128
128
|
const r = await runCli([cmd, "--help"]);
|
|
129
129
|
assert.equal(r.status, 0);
|
|
@@ -194,6 +194,14 @@ describe("dispatcher — implemented commands route to their modules", () => {
|
|
|
194
194
|
assert.ok([1, 2, 5].includes(r.status));
|
|
195
195
|
});
|
|
196
196
|
|
|
197
|
+
it("`skillrepo push` is wired to the real module (not a stub)", async () => {
|
|
198
|
+
// Missing path exits validation (5); missing credentials would
|
|
199
|
+
// exit auth (2). Either proves the stub is gone.
|
|
200
|
+
const r = await runCli(["push"], { env: { HOME: "/tmp/no-skillrepo-config" } });
|
|
201
|
+
assert.doesNotMatch(r.stderr, /Not yet implemented/);
|
|
202
|
+
assert.ok([2, 5].includes(r.status));
|
|
203
|
+
});
|
|
204
|
+
|
|
197
205
|
it("`skillrepo remove` is wired to the real module (not a stub)", async () => {
|
|
198
206
|
const r = await runCli(["remove", "@alice/pdf-helper"], { env: { HOME: "/tmp/no-skillrepo-config" } });
|
|
199
207
|
assert.doesNotMatch(r.stderr, /Not yet implemented/);
|
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* E2E permutation matrix for the cohort SessionStart hooks (#1240).
|
|
3
|
+
*
|
|
4
|
+
* Spawns the real CLI binary and asserts on the user-visible result —
|
|
5
|
+
* what files land at what paths, what JSON shape they have, what the
|
|
6
|
+
* `--json` summary reports, and whether `uninstall --global` reverses
|
|
7
|
+
* everything cleanly.
|
|
8
|
+
*
|
|
9
|
+
* The harness mirrors `cli-agent-permutations.test.mjs` exactly:
|
|
10
|
+
* shared mock server for the library endpoint, per-test mkdtemp for
|
|
11
|
+
* cwd + HOME, and a `runCli` helper that always resolves with
|
|
12
|
+
* stdout / stderr / status.
|
|
13
|
+
*
|
|
14
|
+
* The gap this file closes is the user-layer: "after I run init with
|
|
15
|
+
* --agent cursor,gemini, what's actually in ~/.cursor/hooks.json and
|
|
16
|
+
* ~/.gemini/settings.json on disk?" The unit tests prove the
|
|
17
|
+
* mergers' shape contracts; the integration tests prove the
|
|
18
|
+
* dispatcher's filesystem behavior; this test proves the binary
|
|
19
|
+
* actually exercises both end-to-end.
|
|
20
|
+
*
|
|
21
|
+
* NOTE on `npx --yes` and the hook command: the universal hook
|
|
22
|
+
* command is `npx --yes skillrepo update --silent`. Invoking `npx`
|
|
23
|
+
* inside the test would either fetch from npm (slow, network-
|
|
24
|
+
* dependent) or fail in offline CI. We do NOT execute the hook
|
|
25
|
+
* command in this test — we only verify it was WRITTEN correctly to
|
|
26
|
+
* disk. Verifying execution end-to-end requires a global install,
|
|
27
|
+
* which the smoke-test script (`scripts/smoke-cohort-hooks.sh`)
|
|
28
|
+
* exercises out-of-band.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { describe, it, before, after, beforeEach, afterEach } from "node:test";
|
|
32
|
+
import assert from "node:assert/strict";
|
|
33
|
+
import {
|
|
34
|
+
mkdtempSync,
|
|
35
|
+
rmSync,
|
|
36
|
+
existsSync,
|
|
37
|
+
readFileSync,
|
|
38
|
+
writeFileSync,
|
|
39
|
+
mkdirSync,
|
|
40
|
+
} from "node:fs";
|
|
41
|
+
import { join, dirname, resolve } from "node:path";
|
|
42
|
+
import { tmpdir } from "node:os";
|
|
43
|
+
import { execFile } from "node:child_process";
|
|
44
|
+
import { fileURLToPath } from "node:url";
|
|
45
|
+
|
|
46
|
+
import { createMockServer } from "./mock-server.mjs";
|
|
47
|
+
import { AGENT_HOOK_COMMAND } from "../../lib/artifact-registry.mjs";
|
|
48
|
+
|
|
49
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
50
|
+
const CLI_BIN = resolve(__dirname, "../../../bin/skillrepo.mjs");
|
|
51
|
+
const VALID_KEY = "sk_live_test_e2e";
|
|
52
|
+
|
|
53
|
+
let server;
|
|
54
|
+
let port;
|
|
55
|
+
let tempDir;
|
|
56
|
+
let tempHome;
|
|
57
|
+
|
|
58
|
+
function makeSkill(owner, name) {
|
|
59
|
+
return {
|
|
60
|
+
owner,
|
|
61
|
+
name,
|
|
62
|
+
version: "1.0.0",
|
|
63
|
+
description: `${name} description`,
|
|
64
|
+
files: [
|
|
65
|
+
{
|
|
66
|
+
path: "SKILL.md",
|
|
67
|
+
content: `---\nname: ${name}\ndescription: ${name} description\n---\n\nbody\n`,
|
|
68
|
+
sha256: "x",
|
|
69
|
+
size: 100,
|
|
70
|
+
contentType: "text/markdown",
|
|
71
|
+
},
|
|
72
|
+
],
|
|
73
|
+
updatedAt: "2025-01-01T00:00:00Z",
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function runCli(args, extraEnv = {}) {
|
|
78
|
+
return new Promise((resolve) => {
|
|
79
|
+
execFile(
|
|
80
|
+
process.execPath,
|
|
81
|
+
[CLI_BIN, ...args],
|
|
82
|
+
{
|
|
83
|
+
cwd: tempDir,
|
|
84
|
+
encoding: "utf-8",
|
|
85
|
+
timeout: 15_000,
|
|
86
|
+
env: {
|
|
87
|
+
...process.env,
|
|
88
|
+
HOME: tempHome,
|
|
89
|
+
USERPROFILE: tempHome, // Windows isolation
|
|
90
|
+
NO_COLOR: "1",
|
|
91
|
+
NODE_NO_WARNINGS: "1",
|
|
92
|
+
SKILLREPO_ACCESS_KEY: "",
|
|
93
|
+
SKILLREPO_TIMEOUT_MS: "5000",
|
|
94
|
+
...extraEnv,
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
(err, stdout, stderr) => {
|
|
98
|
+
resolve({
|
|
99
|
+
stdout: stdout ?? "",
|
|
100
|
+
stderr: stderr ?? "",
|
|
101
|
+
status: err ? err.code ?? 1 : 0,
|
|
102
|
+
});
|
|
103
|
+
},
|
|
104
|
+
);
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
describe("CLI E2E — cohort SessionStart hooks (#1240)", () => {
|
|
109
|
+
before(async () => {
|
|
110
|
+
server = createMockServer({});
|
|
111
|
+
port = await server.start();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
after(async () => {
|
|
115
|
+
if (server) await server.stop();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
beforeEach(() => {
|
|
119
|
+
tempDir = mkdtempSync(join(tmpdir(), "cli-e2e-cohort-"));
|
|
120
|
+
tempHome = mkdtempSync(join(tmpdir(), "cli-e2e-cohort-home-"));
|
|
121
|
+
server.setLibraryResponse({
|
|
122
|
+
skills: [makeSkill("alice", "demo-skill")],
|
|
123
|
+
removals: [],
|
|
124
|
+
syncedAt: "x",
|
|
125
|
+
});
|
|
126
|
+
server.setEtag(null);
|
|
127
|
+
server.clearSkillResponses();
|
|
128
|
+
server.setSearchResponse({
|
|
129
|
+
skills: [],
|
|
130
|
+
pagination: { total: 0, limit: 20, offset: 0 },
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
afterEach(() => {
|
|
135
|
+
if (tempDir) rmSync(tempDir, { recursive: true, force: true });
|
|
136
|
+
if (tempHome) rmSync(tempHome, { recursive: true, force: true });
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// ── Per-vendor file shape verification ─────────────────────────
|
|
140
|
+
|
|
141
|
+
it("init --agent cursor writes ~/.cursor/hooks.json with cursor-shape JSON", async () => {
|
|
142
|
+
const r = await runCli([
|
|
143
|
+
"init",
|
|
144
|
+
"--key", VALID_KEY,
|
|
145
|
+
"--url", `http://127.0.0.1:${port}`,
|
|
146
|
+
"--yes",
|
|
147
|
+
"--agent", "cursor",
|
|
148
|
+
"--json",
|
|
149
|
+
]);
|
|
150
|
+
assert.equal(r.status, 0, `stderr: ${r.stderr}`);
|
|
151
|
+
|
|
152
|
+
const filePath = join(tempHome, ".cursor", "hooks.json");
|
|
153
|
+
assert.ok(existsSync(filePath));
|
|
154
|
+
const parsed = JSON.parse(readFileSync(filePath, "utf-8"));
|
|
155
|
+
assert.equal(parsed.version, 1);
|
|
156
|
+
assert.equal(parsed.hooks.sessionStart[0].command, AGENT_HOOK_COMMAND);
|
|
157
|
+
|
|
158
|
+
const json = JSON.parse(r.stdout);
|
|
159
|
+
assert.equal(json.sessionSync.cohortHooks.length, 1);
|
|
160
|
+
assert.equal(json.sessionSync.cohortHooks[0].vendorKey, "cursor");
|
|
161
|
+
assert.equal(json.sessionSync.cohortHooks[0].action, "installed");
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("init --agent gemini writes ~/.gemini/settings.json with claude-shape + timeout: 60000", async () => {
|
|
165
|
+
const r = await runCli([
|
|
166
|
+
"init",
|
|
167
|
+
"--key", VALID_KEY,
|
|
168
|
+
"--url", `http://127.0.0.1:${port}`,
|
|
169
|
+
"--yes",
|
|
170
|
+
"--agent", "gemini",
|
|
171
|
+
"--json",
|
|
172
|
+
]);
|
|
173
|
+
assert.equal(r.status, 0, `stderr: ${r.stderr}`);
|
|
174
|
+
|
|
175
|
+
const filePath = join(tempHome, ".gemini", "settings.json");
|
|
176
|
+
assert.ok(existsSync(filePath));
|
|
177
|
+
const parsed = JSON.parse(readFileSync(filePath, "utf-8"));
|
|
178
|
+
const inner = parsed.hooks.SessionStart[0].hooks[0];
|
|
179
|
+
assert.equal(inner.command, AGENT_HOOK_COMMAND);
|
|
180
|
+
assert.equal(inner.type, "command");
|
|
181
|
+
assert.equal(inner.timeout, 60000);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("init --agent codex writes ~/.codex/hooks.json with timeout: 60 seconds (#1243)", async () => {
|
|
185
|
+
// Codex's timeout is in SECONDS per the Codex hooks source.
|
|
186
|
+
// Distinct from Gemini's milliseconds. Verified against
|
|
187
|
+
// codex-rs/hooks/.
|
|
188
|
+
const r = await runCli([
|
|
189
|
+
"init",
|
|
190
|
+
"--key", VALID_KEY,
|
|
191
|
+
"--url", `http://127.0.0.1:${port}`,
|
|
192
|
+
"--yes",
|
|
193
|
+
"--agent", "codex",
|
|
194
|
+
"--json",
|
|
195
|
+
]);
|
|
196
|
+
assert.equal(r.status, 0, `stderr: ${r.stderr}`);
|
|
197
|
+
|
|
198
|
+
const filePath = join(tempHome, ".codex", "hooks.json");
|
|
199
|
+
assert.ok(existsSync(filePath));
|
|
200
|
+
const parsed = JSON.parse(readFileSync(filePath, "utf-8"));
|
|
201
|
+
const inner = parsed.hooks.SessionStart[0].hooks[0];
|
|
202
|
+
assert.equal(inner.command, AGENT_HOOK_COMMAND);
|
|
203
|
+
assert.equal(inner.type, "command");
|
|
204
|
+
assert.equal(inner.timeout, 60);
|
|
205
|
+
// No matcher on Codex group — that's Gemini-specific (#1242)
|
|
206
|
+
assert.equal(parsed.hooks.SessionStart[0].matcher, undefined);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("init --agent copilot writes ~/.copilot/hooks/skillrepo-update.json with lowercase sessionStart event (#1244)", async () => {
|
|
210
|
+
// Copilot's spec uses lowercase `sessionStart` per VS Code +
|
|
211
|
+
// Copilot's hook docs. Distinct from Gemini and Codex's
|
|
212
|
+
// uppercase. Per-tool single file (no merge concerns within).
|
|
213
|
+
const r = await runCli([
|
|
214
|
+
"init",
|
|
215
|
+
"--key", VALID_KEY,
|
|
216
|
+
"--url", `http://127.0.0.1:${port}`,
|
|
217
|
+
"--yes",
|
|
218
|
+
"--agent", "copilot",
|
|
219
|
+
"--json",
|
|
220
|
+
]);
|
|
221
|
+
assert.equal(r.status, 0, `stderr: ${r.stderr}`);
|
|
222
|
+
|
|
223
|
+
const filePath = join(tempHome, ".copilot", "hooks", "skillrepo-update.json");
|
|
224
|
+
assert.ok(existsSync(filePath), "Copilot per-tool hook file must exist");
|
|
225
|
+
const parsed = JSON.parse(readFileSync(filePath, "utf-8"));
|
|
226
|
+
// Lowercase event name (Phase 4 fix)
|
|
227
|
+
assert.ok(Array.isArray(parsed.hooks.sessionStart));
|
|
228
|
+
assert.equal(parsed.hooks.SessionStart, undefined);
|
|
229
|
+
const inner = parsed.hooks.sessionStart[0].hooks[0];
|
|
230
|
+
assert.equal(inner.command, AGENT_HOOK_COMMAND);
|
|
231
|
+
assert.equal(inner.type, "command");
|
|
232
|
+
assert.equal(inner.timeout, 60);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
// ── Multi-vendor combined run ──────────────────────────────────
|
|
236
|
+
|
|
237
|
+
it("init --agent cursor,gemini,codex,copilot writes all four hook files", async () => {
|
|
238
|
+
const r = await runCli([
|
|
239
|
+
"init",
|
|
240
|
+
"--key", VALID_KEY,
|
|
241
|
+
"--url", `http://127.0.0.1:${port}`,
|
|
242
|
+
"--yes",
|
|
243
|
+
"--agent", "cursor,gemini,codex,copilot",
|
|
244
|
+
"--json",
|
|
245
|
+
]);
|
|
246
|
+
assert.equal(r.status, 0, `stderr: ${r.stderr}`);
|
|
247
|
+
|
|
248
|
+
assert.ok(existsSync(join(tempHome, ".cursor", "hooks.json")));
|
|
249
|
+
assert.ok(existsSync(join(tempHome, ".gemini", "settings.json")));
|
|
250
|
+
assert.ok(existsSync(join(tempHome, ".codex", "hooks.json")));
|
|
251
|
+
assert.ok(
|
|
252
|
+
existsSync(join(tempHome, ".copilot", "hooks", "skillrepo-update.json")),
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
const json = JSON.parse(r.stdout);
|
|
256
|
+
assert.equal(json.sessionSync.cohortHooks.length, 4);
|
|
257
|
+
for (const h of json.sessionSync.cohortHooks) {
|
|
258
|
+
assert.equal(h.action, "installed");
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
// ── --no-session-sync escape hatch ─────────────────────────────
|
|
263
|
+
|
|
264
|
+
it("init --no-session-sync skips ALL cohort hooks (semantics widened in #1240)", async () => {
|
|
265
|
+
const r = await runCli([
|
|
266
|
+
"init",
|
|
267
|
+
"--key", VALID_KEY,
|
|
268
|
+
"--url", `http://127.0.0.1:${port}`,
|
|
269
|
+
"--yes",
|
|
270
|
+
"--no-session-sync",
|
|
271
|
+
"--agent", "cursor,gemini,codex,copilot",
|
|
272
|
+
"--json",
|
|
273
|
+
]);
|
|
274
|
+
assert.equal(r.status, 0, `stderr: ${r.stderr}`);
|
|
275
|
+
|
|
276
|
+
// None of the hook files should exist
|
|
277
|
+
assert.ok(!existsSync(join(tempHome, ".cursor", "hooks.json")));
|
|
278
|
+
assert.ok(!existsSync(join(tempHome, ".gemini", "settings.json")));
|
|
279
|
+
assert.ok(!existsSync(join(tempHome, ".codex", "hooks.json")));
|
|
280
|
+
assert.ok(
|
|
281
|
+
!existsSync(join(tempHome, ".copilot", "hooks", "skillrepo-update.json")),
|
|
282
|
+
);
|
|
283
|
+
|
|
284
|
+
const json = JSON.parse(r.stdout);
|
|
285
|
+
assert.equal(json.sessionSync.cohortHooks.length, 0);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
// ── Idempotency on re-run ──────────────────────────────────────
|
|
289
|
+
|
|
290
|
+
it("re-running init with the same flags reports unchanged for every cohort vendor", async () => {
|
|
291
|
+
const baseArgs = [
|
|
292
|
+
"init",
|
|
293
|
+
"--key", VALID_KEY,
|
|
294
|
+
"--url", `http://127.0.0.1:${port}`,
|
|
295
|
+
"--yes",
|
|
296
|
+
"--agent", "cursor,gemini",
|
|
297
|
+
];
|
|
298
|
+
const r1 = await runCli(baseArgs);
|
|
299
|
+
assert.equal(r1.status, 0, `first init stderr: ${r1.stderr}`);
|
|
300
|
+
|
|
301
|
+
const r2 = await runCli([...baseArgs, "--json"]);
|
|
302
|
+
assert.equal(r2.status, 0, `second init stderr: ${r2.stderr}`);
|
|
303
|
+
const json = JSON.parse(r2.stdout);
|
|
304
|
+
for (const h of json.sessionSync.cohortHooks) {
|
|
305
|
+
assert.equal(h.action, "unchanged", `${h.vendorKey} should be unchanged`);
|
|
306
|
+
}
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
// ── Multi-tool merge surface ───────────────────────────────────
|
|
310
|
+
|
|
311
|
+
it("init --agent cursor preserves a pre-existing 1Password entry in ~/.cursor/hooks.json", async () => {
|
|
312
|
+
// Seed the hook file with a "1Password" entry before init runs.
|
|
313
|
+
// The cohort installer must add the SkillRepo entry alongside
|
|
314
|
+
// without removing the pre-existing one.
|
|
315
|
+
mkdirSync(join(tempHome, ".cursor"), { recursive: true });
|
|
316
|
+
writeFileSync(
|
|
317
|
+
join(tempHome, ".cursor", "hooks.json"),
|
|
318
|
+
JSON.stringify(
|
|
319
|
+
{
|
|
320
|
+
version: 1,
|
|
321
|
+
hooks: { sessionStart: [{ command: "1password-agent-helper" }] },
|
|
322
|
+
},
|
|
323
|
+
null,
|
|
324
|
+
2,
|
|
325
|
+
),
|
|
326
|
+
);
|
|
327
|
+
|
|
328
|
+
const r = await runCli([
|
|
329
|
+
"init",
|
|
330
|
+
"--key", VALID_KEY,
|
|
331
|
+
"--url", `http://127.0.0.1:${port}`,
|
|
332
|
+
"--yes",
|
|
333
|
+
"--agent", "cursor",
|
|
334
|
+
]);
|
|
335
|
+
assert.equal(r.status, 0, `stderr: ${r.stderr}`);
|
|
336
|
+
|
|
337
|
+
const parsed = JSON.parse(
|
|
338
|
+
readFileSync(join(tempHome, ".cursor", "hooks.json"), "utf-8"),
|
|
339
|
+
);
|
|
340
|
+
assert.equal(parsed.hooks.sessionStart.length, 2);
|
|
341
|
+
assert.equal(
|
|
342
|
+
parsed.hooks.sessionStart[0].command,
|
|
343
|
+
"1password-agent-helper",
|
|
344
|
+
);
|
|
345
|
+
assert.equal(parsed.hooks.sessionStart[1].command, AGENT_HOOK_COMMAND);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
// ── Uninstall round-trip ──────────────────────────────────────
|
|
349
|
+
|
|
350
|
+
it("uninstall --global --yes removes every cohort hook the previous init wrote", async () => {
|
|
351
|
+
// Install all four
|
|
352
|
+
const init = await runCli([
|
|
353
|
+
"init",
|
|
354
|
+
"--key", VALID_KEY,
|
|
355
|
+
"--url", `http://127.0.0.1:${port}`,
|
|
356
|
+
"--yes",
|
|
357
|
+
"--agent", "cursor,gemini,codex,copilot",
|
|
358
|
+
]);
|
|
359
|
+
assert.equal(init.status, 0, `init stderr: ${init.stderr}`);
|
|
360
|
+
|
|
361
|
+
// Confirm files exist
|
|
362
|
+
const files = [
|
|
363
|
+
join(tempHome, ".cursor", "hooks.json"),
|
|
364
|
+
join(tempHome, ".gemini", "settings.json"),
|
|
365
|
+
join(tempHome, ".codex", "hooks.json"),
|
|
366
|
+
join(tempHome, ".copilot", "hooks", "skillrepo-update.json"),
|
|
367
|
+
];
|
|
368
|
+
for (const f of files) assert.ok(existsSync(f), `${f} must exist before uninstall`);
|
|
369
|
+
|
|
370
|
+
// Run uninstall --global --yes
|
|
371
|
+
const uninstall = await runCli([
|
|
372
|
+
"uninstall",
|
|
373
|
+
"--global",
|
|
374
|
+
"--yes",
|
|
375
|
+
"--json",
|
|
376
|
+
]);
|
|
377
|
+
assert.equal(uninstall.status, 0, `uninstall stderr: ${uninstall.stderr}`);
|
|
378
|
+
|
|
379
|
+
// Each cohort hook is no longer present in its file.
|
|
380
|
+
// The file may or may not exist depending on whether uninstall
|
|
381
|
+
// walked it down to {} or left an empty container — both are
|
|
382
|
+
// valid post-uninstall states. What matters is no SkillRepo
|
|
383
|
+
// command remains.
|
|
384
|
+
for (const f of files) {
|
|
385
|
+
if (!existsSync(f)) continue;
|
|
386
|
+
const content = readFileSync(f, "utf-8");
|
|
387
|
+
assert.ok(
|
|
388
|
+
!content.includes("skillrepo update --silent"),
|
|
389
|
+
`${f} still contains a SkillRepo command after uninstall`,
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
});
|
|
393
|
+
});
|