skillrepo 4.1.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.
@@ -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
+ });
@@ -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/);
@@ -6,7 +6,8 @@
6
6
  * GET /api/v1/skill-content — legacy skill content
7
7
  * POST /api/v1/auth/validate — credential check (PR2)
8
8
  * GET /api/v1/library — library sync with ETag + since (PR2)
9
- * POST /api/v1/library — add to library (PR3a)
9
+ * POST /api/v1/library — multipart file-push (#1452)
10
+ * POST /api/v1/library/refs — add catalog skill to library (#1451)
10
11
  * DELETE /api/v1/library/[owner]/[name] — remove from library (PR3a)
11
12
  * GET /api/v1/skills/[owner]/[name] — single skill fetch (PR2)
12
13
  * GET /api/v1/skills/search — keyword search (PR2)
@@ -24,8 +25,9 @@
24
25
  * setEtag(etag) — what library responses include
25
26
  *
26
27
  * (PR3a additions)
27
- * setAddResponse(owner, name, resp) — per-skill response for POST /library
28
+ * setAddResponse(owner, name, resp) — per-skill response for POST /library/refs (#1451)
28
29
  * setRemoveResponse(owner, name, resp) — per-skill response for DELETE /library/<owner>/<name>
30
+ * setPushResponse(resp) — response for POST /library multipart push (#1452)
29
31
  * getLastPostBody() — inspect the most recent POST body (JSON-parsed)
30
32
  *
31
33
  * The slot APIs let unit/integration tests configure each scenario
@@ -85,6 +87,14 @@ export function createMockServer(initialPayload, options = {}) {
85
87
  let addResponses = new Map(); // key: "owner/name" or "*" → { status, body }
86
88
  let removeResponses = new Map(); // key: "owner/name" or "*" → { status, body }
87
89
 
90
+ // #1452 — POST /api/v1/library (multipart file-push) response slot.
91
+ // Tests set this with `setPushResponse({ status, body })`; when null,
92
+ // a default 201 LibraryPushResponse is served. The test server does
93
+ // not parse multipart bodies — verifying the wire format is the
94
+ // client's responsibility.
95
+ /** @type {{ status: number, body: any } | null} */
96
+ let pushResponse = null;
97
+
88
98
  // Most recent POST body (JSON-parsed) for test inspection.
89
99
  //
90
100
  // IMPORTANT:
@@ -138,19 +148,25 @@ export function createMockServer(initialPayload, options = {}) {
138
148
  // command sends a JSON body to POST /api/v1/library, so we
139
149
  // now collect chunks and re-dispatch once the stream ends.
140
150
  if (req.method === "POST" || req.method === "DELETE" || req.method === "PUT" || req.method === "PATCH") {
141
- let bodyChunks = "";
151
+ // Buffer as Buffer chunks, not string concatenation. JSON routes
152
+ // re-interpret as UTF-8; binary multipart bodies (#1452 push) keep
153
+ // their bytes intact. Concatenating `chunk.toString("utf-8")`
154
+ // would silently corrupt non-UTF-8 file content.
155
+ const chunks = [];
142
156
  req.on("data", (chunk) => {
143
- bodyChunks += chunk.toString("utf-8");
157
+ chunks.push(chunk);
144
158
  });
145
159
  req.on("end", () => {
146
- if (req.method === "POST" && bodyChunks.length > 0) {
160
+ const rawBody = Buffer.concat(chunks);
161
+ const rawBodyText = rawBody.toString("utf-8");
162
+ if (req.method === "POST" && rawBodyText.length > 0) {
147
163
  try {
148
- lastPostBody = JSON.parse(bodyChunks);
164
+ lastPostBody = JSON.parse(rawBodyText);
149
165
  } catch {
150
- lastPostBody = bodyChunks; // preserve as string for tests
166
+ lastPostBody = rawBodyText; // preserve as string for tests
151
167
  }
152
168
  }
153
- handleRequest(req, res, bodyChunks);
169
+ handleRequest(req, res, rawBodyText);
154
170
  });
155
171
  req.on("error", () => {
156
172
  // Connection dropped — nothing to do, client is gone
@@ -189,8 +205,11 @@ export function createMockServer(initialPayload, options = {}) {
189
205
  return;
190
206
  }
191
207
 
192
- // ── PR3a: POST /api/v1/library (add to library) ─────────────────
193
- if (url.pathname === "/api/v1/library" && req.method === "POST") {
208
+ // ── #1451: POST /api/v1/library/refs (add catalog skill) ───────
209
+ //
210
+ // Was at /api/v1/library until #1452 rebound that URL to multipart
211
+ // file-push. Same handler logic; only the URL moved.
212
+ if (url.pathname === "/api/v1/library/refs" && req.method === "POST") {
194
213
  if (!checkAuth(req, res)) return;
195
214
  let body;
196
215
  try {
@@ -233,6 +252,61 @@ export function createMockServer(initialPayload, options = {}) {
233
252
  return;
234
253
  }
235
254
 
255
+ // ── #1452: POST /api/v1/library (multipart file-push) ──────────
256
+ //
257
+ // Accepts multipart/form-data and returns a synthesized
258
+ // `LibraryPushResponse` shape. The handler does not actually
259
+ // parse the multipart body — it just inspects the Content-Type
260
+ // header to confirm the client sent multipart, and serves either
261
+ // a `pushResponse` slot value (set via `setPushResponse`) or a
262
+ // default 201 with `action: "created"` shape.
263
+ if (url.pathname === "/api/v1/library" && req.method === "POST") {
264
+ if (!checkAuth(req, res)) return;
265
+ const contentType = req.headers["content-type"] ?? "";
266
+ if (!contentType.includes("multipart/form-data")) {
267
+ res.writeHead(400, { "Content-Type": "application/json" });
268
+ res.end(
269
+ JSON.stringify({
270
+ error:
271
+ "Invalid multipart body. Set Content-Type: multipart/form-data.",
272
+ code: "invalid_multipart",
273
+ }),
274
+ );
275
+ return;
276
+ }
277
+ if (pushResponse) {
278
+ res.writeHead(pushResponse.status, {
279
+ "Content-Type": "application/json",
280
+ });
281
+ res.end(JSON.stringify(pushResponse.body));
282
+ return;
283
+ }
284
+ // Default: 201 with a synthesized SyncSkill response.
285
+ res.writeHead(201, {
286
+ "Content-Type": "application/json",
287
+ Location: "/api/v1/library/mock/test-skill",
288
+ });
289
+ res.end(
290
+ JSON.stringify({
291
+ action: "created",
292
+ bump: null,
293
+ skill: {
294
+ owner: "mock",
295
+ name: "test-skill",
296
+ version: "1.0",
297
+ description: "mock",
298
+ keywords: [],
299
+ updatedAt: new Date().toISOString(),
300
+ etag: '"mock/test-skill@0"',
301
+ contextSignals: null,
302
+ files: [],
303
+ filesIncomplete: false,
304
+ },
305
+ }),
306
+ );
307
+ return;
308
+ }
309
+
236
310
  // ── PR3a: DELETE /api/v1/library/[owner]/[name] (remove) ────────
237
311
  {
238
312
  const m = url.pathname.match(/^\/api\/v1\/library\/([^\/]+)\/([^\/]+)$/);
@@ -489,6 +563,14 @@ export function createMockServer(initialPayload, options = {}) {
489
563
  removeResponses = new Map();
490
564
  },
491
565
 
566
+ /**
567
+ * #1452 — register the next POST /api/v1/library (multipart file-push)
568
+ * response. Pass `null` to restore the default 201 LibraryPushResponse.
569
+ */
570
+ setPushResponse(response) {
571
+ pushResponse = response;
572
+ },
573
+
492
574
  /**
493
575
  * Return the most recent POST body the server received, or null
494
576
  * if no POST has been made yet. Body is JSON-parsed when