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
|
@@ -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
|
+
});
|