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.
@@ -908,6 +908,287 @@ describe("runInit — session sync (#884)", () => {
908
908
  });
909
909
  });
910
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,
1186
+ "installed",
1187
+ "Gemini install must succeed even when Cursor's failed (per-vendor isolation)",
1188
+ );
1189
+ });
1190
+ });
1191
+
911
1192
  // ── v3.1.1 patch fixes: init UX bugs surfaced by real-world npx use ──
912
1193
  //
913
1194
  // Real-user `npx skillrepo@latest init` session surfaced four bugs in
@@ -0,0 +1,289 @@
1
+ /**
2
+ * Unit / integration tests for `src/commands/push.mjs` (#1455).
3
+ *
4
+ * Runs against the mock server which exposes `POST /api/v1/library`
5
+ * (multipart upsert). The mock doesn't actually parse multipart — it
6
+ * just checks the Content-Type and serves either a configured response
7
+ * (via `setPushResponse`) or a default 201 LibraryPushResponse.
8
+ *
9
+ * Coverage: happy paths (created / updated / unchanged), JSON output,
10
+ * missing-path / missing-SKILL.md errors, invalid frontmatter,
11
+ * idempotency-key flag.
12
+ */
13
+
14
+ import { describe, it, beforeEach, afterEach } from "node:test";
15
+ import assert from "node:assert/strict";
16
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
17
+ import { join } from "node:path";
18
+ import { tmpdir } from "node:os";
19
+
20
+ import { runPush } from "../../commands/push.mjs";
21
+ import { CliError, EXIT_VALIDATION } from "../../lib/errors.mjs";
22
+ import { createMockServer } from "../e2e/mock-server.mjs";
23
+ import { createCaptureStream } from "../helpers/capture-stream.mjs";
24
+ import {
25
+ captureHome,
26
+ setSandboxHome,
27
+ restoreHome,
28
+ } from "../helpers/sandbox-home.mjs";
29
+
30
+ let sandbox;
31
+ let server;
32
+ let serverUrl;
33
+ let originalCwd;
34
+ let originalHomeEnv;
35
+ let stdout;
36
+ const VALID_KEY = "sk_live_test";
37
+
38
+ const VALID_SKILL_MD = `---
39
+ name: my-skill
40
+ description: A skill exercising the push command
41
+ ---
42
+
43
+ # my-skill
44
+
45
+ body
46
+ `;
47
+
48
+ function file(rel, content = "x") {
49
+ const abs = join(sandbox, "skill", rel);
50
+ mkdirSync(join(abs, ".."), { recursive: true });
51
+ writeFileSync(abs, content);
52
+ }
53
+
54
+ async function setup() {
55
+ sandbox = mkdtempSync(join(tmpdir(), "cli-cmd-push-"));
56
+ mkdirSync(join(sandbox, "skill"), { recursive: true });
57
+ mkdirSync(join(sandbox, "home"), { recursive: true });
58
+ originalCwd = process.cwd();
59
+ originalHomeEnv = captureHome();
60
+ process.chdir(sandbox);
61
+ setSandboxHome(join(sandbox, "home"));
62
+ delete process.env.SKILLREPO_ACCESS_KEY;
63
+
64
+ server = createMockServer({});
65
+ const port = await server.start();
66
+ serverUrl = `http://127.0.0.1:${port}`;
67
+
68
+ stdout = createCaptureStream();
69
+ }
70
+
71
+ async function teardown() {
72
+ if (server) await server.stop();
73
+ process.chdir(originalCwd);
74
+ restoreHome(originalHomeEnv);
75
+ if (sandbox) rmSync(sandbox, { recursive: true, force: true });
76
+ server = null;
77
+ }
78
+
79
+ describe("runPush — happy paths", () => {
80
+ beforeEach(setup);
81
+ afterEach(teardown);
82
+
83
+ it("pushes a fresh skill (default response: 201 created)", async () => {
84
+ file("SKILL.md", VALID_SKILL_MD);
85
+ file("references/intro.md", "intro");
86
+
87
+ await runPush(
88
+ ["--key", VALID_KEY, "--url", serverUrl, "skill"],
89
+ { stdout },
90
+ );
91
+
92
+ assert.match(stdout.text(), /Created @mock\/test-skill/);
93
+ });
94
+
95
+ it("--json prints action / bump / owner / name / version / filesUploaded", async () => {
96
+ file("SKILL.md", VALID_SKILL_MD);
97
+ file("references/intro.md", "intro");
98
+
99
+ await runPush(
100
+ ["--key", VALID_KEY, "--url", serverUrl, "--json", "skill"],
101
+ { stdout },
102
+ );
103
+
104
+ const json = JSON.parse(stdout.text());
105
+ assert.equal(json.action, "created");
106
+ assert.equal(json.owner, "mock");
107
+ assert.equal(json.name, "test-skill");
108
+ assert.equal(json.version, "1.0");
109
+ // SKILL.md + 1 supporting file = 2 (the walker includes SKILL.md as
110
+ // a regular file per the agentskills.io spec).
111
+ assert.equal(json.filesUploaded, 2);
112
+ assert.equal(json.bump, null);
113
+ });
114
+
115
+ it("reports 'updated' with bump when server returns action=updated", async () => {
116
+ file("SKILL.md", VALID_SKILL_MD);
117
+ server.setPushResponse({
118
+ status: 200,
119
+ body: {
120
+ action: "updated",
121
+ bump: "minor",
122
+ skill: {
123
+ owner: "alice",
124
+ name: "my-skill",
125
+ version: "1.1",
126
+ description: "A skill exercising the push command",
127
+ keywords: [],
128
+ updatedAt: new Date().toISOString(),
129
+ etag: '"alice/my-skill@0"',
130
+ contextSignals: null,
131
+ files: [],
132
+ filesIncomplete: false,
133
+ },
134
+ },
135
+ });
136
+
137
+ await runPush(
138
+ ["--key", VALID_KEY, "--url", serverUrl, "skill"],
139
+ { stdout },
140
+ );
141
+
142
+ assert.match(stdout.text(), /Released @alice\/my-skill v1\.1.*minor bump/);
143
+ });
144
+
145
+ it("reports 'no changes' when server returns action=unchanged", async () => {
146
+ file("SKILL.md", VALID_SKILL_MD);
147
+ server.setPushResponse({
148
+ status: 200,
149
+ body: {
150
+ action: "unchanged",
151
+ bump: null,
152
+ skill: {
153
+ owner: "alice",
154
+ name: "my-skill",
155
+ version: "1.0",
156
+ description: "A skill exercising the push command",
157
+ keywords: [],
158
+ updatedAt: new Date().toISOString(),
159
+ etag: '"alice/my-skill@0"',
160
+ contextSignals: null,
161
+ files: [],
162
+ filesIncomplete: false,
163
+ },
164
+ },
165
+ });
166
+
167
+ await runPush(
168
+ ["--key", VALID_KEY, "--url", serverUrl, "skill"],
169
+ { stdout },
170
+ );
171
+
172
+ assert.match(stdout.text(), /No changes — @alice\/my-skill is already at v1\.0/);
173
+ });
174
+ });
175
+
176
+ describe("runPush — input validation", () => {
177
+ beforeEach(setup);
178
+ afterEach(teardown);
179
+
180
+ it("errors when no path is provided", async () => {
181
+ await assert.rejects(
182
+ runPush(["--key", VALID_KEY, "--url", serverUrl], { stdout }),
183
+ (err) =>
184
+ err instanceof CliError &&
185
+ err.exitCode === EXIT_VALIDATION &&
186
+ /Missing skill directory path/.test(err.message),
187
+ );
188
+ });
189
+
190
+ it("errors when the path doesn't exist", async () => {
191
+ await assert.rejects(
192
+ runPush(
193
+ ["--key", VALID_KEY, "--url", serverUrl, "./does-not-exist"],
194
+ { stdout },
195
+ ),
196
+ (err) =>
197
+ err instanceof CliError &&
198
+ err.exitCode === EXIT_VALIDATION &&
199
+ /Path not found/.test(err.message),
200
+ );
201
+ });
202
+
203
+ it("errors when the path is a file, not a directory", async () => {
204
+ file("SKILL.md", VALID_SKILL_MD);
205
+ await assert.rejects(
206
+ runPush(
207
+ ["--key", VALID_KEY, "--url", serverUrl, "skill/SKILL.md"],
208
+ { stdout },
209
+ ),
210
+ (err) =>
211
+ err instanceof CliError &&
212
+ err.exitCode === EXIT_VALIDATION &&
213
+ /Not a directory/.test(err.message),
214
+ );
215
+ });
216
+
217
+ it("errors when SKILL.md is missing", async () => {
218
+ // Empty `skill/` directory — no SKILL.md.
219
+ await assert.rejects(
220
+ runPush(
221
+ ["--key", VALID_KEY, "--url", serverUrl, "skill"],
222
+ { stdout },
223
+ ),
224
+ (err) =>
225
+ err instanceof CliError &&
226
+ err.exitCode === EXIT_VALIDATION &&
227
+ /No SKILL\.md at skill\/SKILL\.md/.test(err.message),
228
+ );
229
+ });
230
+
231
+ it("errors when SKILL.md frontmatter is missing the `name` field", async () => {
232
+ file("SKILL.md", "---\ndescription: only description\n---\n\nbody\n");
233
+ await assert.rejects(
234
+ runPush(
235
+ ["--key", VALID_KEY, "--url", serverUrl, "skill"],
236
+ { stdout },
237
+ ),
238
+ (err) =>
239
+ err instanceof CliError &&
240
+ err.exitCode === EXIT_VALIDATION &&
241
+ /SKILL\.md is missing the required `name` field/.test(err.message),
242
+ );
243
+ });
244
+
245
+ it("errors when SKILL.md frontmatter is malformed YAML", async () => {
246
+ file("SKILL.md", "---\nname: [unclosed\n---\n");
247
+ await assert.rejects(
248
+ runPush(
249
+ ["--key", VALID_KEY, "--url", serverUrl, "skill"],
250
+ { stdout },
251
+ ),
252
+ (err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
253
+ );
254
+ });
255
+ });
256
+
257
+ describe("runPush — flags", () => {
258
+ beforeEach(setup);
259
+ afterEach(teardown);
260
+
261
+ it("--idempotency-key requires a value", async () => {
262
+ file("SKILL.md", VALID_SKILL_MD);
263
+ await assert.rejects(
264
+ runPush(
265
+ ["--key", VALID_KEY, "--url", serverUrl, "--idempotency-key"],
266
+ { stdout },
267
+ ),
268
+ (err) => /Missing value for --idempotency-key/.test(err.message),
269
+ );
270
+ });
271
+
272
+ it("accepts --idempotency-key + path together", async () => {
273
+ file("SKILL.md", VALID_SKILL_MD);
274
+ await runPush(
275
+ [
276
+ "--key",
277
+ VALID_KEY,
278
+ "--url",
279
+ serverUrl,
280
+ "--idempotency-key",
281
+ "test-key-123",
282
+ "skill",
283
+ ],
284
+ { stdout },
285
+ );
286
+ // Default server response is 201 created.
287
+ assert.match(stdout.text(), /Created @mock\/test-skill/);
288
+ });
289
+ });