skillrepo 4.1.0 → 4.3.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/bin/skillrepo.mjs +22 -1
- package/package.json +10 -4
- package/src/commands/publish.mjs +125 -0
- package/src/commands/push.mjs +187 -0
- package/src/commands/unpublish.mjs +129 -0
- package/src/lib/http.mjs +358 -11
- package/src/lib/skill-walk.mjs +97 -0
- package/src/test/commands/publish.test.mjs +420 -0
- package/src/test/commands/push.test.mjs +289 -0
- package/src/test/dispatcher.test.mjs +10 -2
- package/src/test/e2e/mock-server.mjs +202 -10
- package/src/test/lib/http.test.mjs +242 -1
- package/src/test/lib/skill-walk.test.mjs +127 -0
|
@@ -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,8 +6,11 @@
|
|
|
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 —
|
|
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)
|
|
12
|
+
* POST /api/v1/library/[owner]/[name]/publish — make global (#1449)
|
|
13
|
+
* POST /api/v1/library/[owner]/[name]/unpublish — make private (#1449)
|
|
11
14
|
* GET /api/v1/skills/[owner]/[name] — single skill fetch (PR2)
|
|
12
15
|
* GET /api/v1/skills/search — keyword search (PR2)
|
|
13
16
|
*
|
|
@@ -24,10 +27,15 @@
|
|
|
24
27
|
* setEtag(etag) — what library responses include
|
|
25
28
|
*
|
|
26
29
|
* (PR3a additions)
|
|
27
|
-
* setAddResponse(owner, name, resp) — per-skill response for POST /library
|
|
30
|
+
* setAddResponse(owner, name, resp) — per-skill response for POST /library/refs (#1451)
|
|
28
31
|
* setRemoveResponse(owner, name, resp) — per-skill response for DELETE /library/<owner>/<name>
|
|
32
|
+
* setPushResponse(resp) — response for POST /library multipart push (#1452)
|
|
29
33
|
* getLastPostBody() — inspect the most recent POST body (JSON-parsed)
|
|
30
34
|
*
|
|
35
|
+
* (Epic #1444 R3 #1449 additions)
|
|
36
|
+
* setPublishResponse(owner, name, resp) — per-skill response for POST /library/<owner>/<name>/publish
|
|
37
|
+
* setUnpublishResponse(owner, name, resp) — per-skill response for POST /library/<owner>/<name>/unpublish
|
|
38
|
+
*
|
|
31
39
|
* The slot APIs let unit/integration tests configure each scenario
|
|
32
40
|
* without spinning up multiple servers per test. Default behavior
|
|
33
41
|
* for an unconfigured slot is a sensible empty payload.
|
|
@@ -84,6 +92,18 @@ export function createMockServer(initialPayload, options = {}) {
|
|
|
84
92
|
// matches any owner/name when no exact match is registered.
|
|
85
93
|
let addResponses = new Map(); // key: "owner/name" or "*" → { status, body }
|
|
86
94
|
let removeResponses = new Map(); // key: "owner/name" or "*" → { status, body }
|
|
95
|
+
// Epic #1444 R3 #1456 — POST /api/v1/library/[owner]/[name]/publish
|
|
96
|
+
// and POST /api/v1/library/[owner]/[name]/unpublish (#1449).
|
|
97
|
+
let publishResponses = new Map(); // key: "owner/name" or "*" → { status, body }
|
|
98
|
+
let unpublishResponses = new Map(); // key: "owner/name" or "*" → { status, body }
|
|
99
|
+
|
|
100
|
+
// #1452 — POST /api/v1/library (multipart file-push) response slot.
|
|
101
|
+
// Tests set this with `setPushResponse({ status, body })`; when null,
|
|
102
|
+
// a default 201 LibraryPushResponse is served. The test server does
|
|
103
|
+
// not parse multipart bodies — verifying the wire format is the
|
|
104
|
+
// client's responsibility.
|
|
105
|
+
/** @type {{ status: number, body: any } | null} */
|
|
106
|
+
let pushResponse = null;
|
|
87
107
|
|
|
88
108
|
// Most recent POST body (JSON-parsed) for test inspection.
|
|
89
109
|
//
|
|
@@ -138,19 +158,25 @@ export function createMockServer(initialPayload, options = {}) {
|
|
|
138
158
|
// command sends a JSON body to POST /api/v1/library, so we
|
|
139
159
|
// now collect chunks and re-dispatch once the stream ends.
|
|
140
160
|
if (req.method === "POST" || req.method === "DELETE" || req.method === "PUT" || req.method === "PATCH") {
|
|
141
|
-
|
|
161
|
+
// Buffer as Buffer chunks, not string concatenation. JSON routes
|
|
162
|
+
// re-interpret as UTF-8; binary multipart bodies (#1452 push) keep
|
|
163
|
+
// their bytes intact. Concatenating `chunk.toString("utf-8")`
|
|
164
|
+
// would silently corrupt non-UTF-8 file content.
|
|
165
|
+
const chunks = [];
|
|
142
166
|
req.on("data", (chunk) => {
|
|
143
|
-
|
|
167
|
+
chunks.push(chunk);
|
|
144
168
|
});
|
|
145
169
|
req.on("end", () => {
|
|
146
|
-
|
|
170
|
+
const rawBody = Buffer.concat(chunks);
|
|
171
|
+
const rawBodyText = rawBody.toString("utf-8");
|
|
172
|
+
if (req.method === "POST" && rawBodyText.length > 0) {
|
|
147
173
|
try {
|
|
148
|
-
lastPostBody = JSON.parse(
|
|
174
|
+
lastPostBody = JSON.parse(rawBodyText);
|
|
149
175
|
} catch {
|
|
150
|
-
lastPostBody =
|
|
176
|
+
lastPostBody = rawBodyText; // preserve as string for tests
|
|
151
177
|
}
|
|
152
178
|
}
|
|
153
|
-
handleRequest(req, res,
|
|
179
|
+
handleRequest(req, res, rawBodyText);
|
|
154
180
|
});
|
|
155
181
|
req.on("error", () => {
|
|
156
182
|
// Connection dropped — nothing to do, client is gone
|
|
@@ -189,8 +215,11 @@ export function createMockServer(initialPayload, options = {}) {
|
|
|
189
215
|
return;
|
|
190
216
|
}
|
|
191
217
|
|
|
192
|
-
// ──
|
|
193
|
-
|
|
218
|
+
// ── #1451: POST /api/v1/library/refs (add catalog skill) ───────
|
|
219
|
+
//
|
|
220
|
+
// Was at /api/v1/library until #1452 rebound that URL to multipart
|
|
221
|
+
// file-push. Same handler logic; only the URL moved.
|
|
222
|
+
if (url.pathname === "/api/v1/library/refs" && req.method === "POST") {
|
|
194
223
|
if (!checkAuth(req, res)) return;
|
|
195
224
|
let body;
|
|
196
225
|
try {
|
|
@@ -233,6 +262,141 @@ export function createMockServer(initialPayload, options = {}) {
|
|
|
233
262
|
return;
|
|
234
263
|
}
|
|
235
264
|
|
|
265
|
+
// ── #1452: POST /api/v1/library (multipart file-push) ──────────
|
|
266
|
+
//
|
|
267
|
+
// Accepts multipart/form-data and returns a synthesized
|
|
268
|
+
// `LibraryPushResponse` shape. The handler does not actually
|
|
269
|
+
// parse the multipart body — it just inspects the Content-Type
|
|
270
|
+
// header to confirm the client sent multipart, and serves either
|
|
271
|
+
// a `pushResponse` slot value (set via `setPushResponse`) or a
|
|
272
|
+
// default 201 with `action: "created"` shape.
|
|
273
|
+
if (url.pathname === "/api/v1/library" && req.method === "POST") {
|
|
274
|
+
if (!checkAuth(req, res)) return;
|
|
275
|
+
const contentType = req.headers["content-type"] ?? "";
|
|
276
|
+
if (!contentType.includes("multipart/form-data")) {
|
|
277
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
278
|
+
res.end(
|
|
279
|
+
JSON.stringify({
|
|
280
|
+
error:
|
|
281
|
+
"Invalid multipart body. Set Content-Type: multipart/form-data.",
|
|
282
|
+
code: "invalid_multipart",
|
|
283
|
+
}),
|
|
284
|
+
);
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
if (pushResponse) {
|
|
288
|
+
res.writeHead(pushResponse.status, {
|
|
289
|
+
"Content-Type": "application/json",
|
|
290
|
+
});
|
|
291
|
+
res.end(JSON.stringify(pushResponse.body));
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
// Default: 201 with a synthesized SyncSkill response.
|
|
295
|
+
res.writeHead(201, {
|
|
296
|
+
"Content-Type": "application/json",
|
|
297
|
+
Location: "/api/v1/library/mock/test-skill",
|
|
298
|
+
});
|
|
299
|
+
res.end(
|
|
300
|
+
JSON.stringify({
|
|
301
|
+
action: "created",
|
|
302
|
+
bump: null,
|
|
303
|
+
skill: {
|
|
304
|
+
owner: "mock",
|
|
305
|
+
name: "test-skill",
|
|
306
|
+
version: "1.0",
|
|
307
|
+
description: "mock",
|
|
308
|
+
keywords: [],
|
|
309
|
+
updatedAt: new Date().toISOString(),
|
|
310
|
+
etag: '"mock/test-skill@0"',
|
|
311
|
+
contextSignals: null,
|
|
312
|
+
files: [],
|
|
313
|
+
filesIncomplete: false,
|
|
314
|
+
},
|
|
315
|
+
}),
|
|
316
|
+
);
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// ── #1449: POST /api/v1/library/[owner]/[name]/publish ─────────
|
|
321
|
+
{
|
|
322
|
+
const m = url.pathname.match(
|
|
323
|
+
/^\/api\/v1\/library\/([^\/]+)\/([^\/]+)\/publish$/,
|
|
324
|
+
);
|
|
325
|
+
if (m && req.method === "POST") {
|
|
326
|
+
if (!checkAuth(req, res)) return;
|
|
327
|
+
const owner = decodeURIComponent(m[1]);
|
|
328
|
+
const name = decodeURIComponent(m[2]);
|
|
329
|
+
const configured =
|
|
330
|
+
publishResponses.get(`${owner}/${name}`) ?? publishResponses.get("*");
|
|
331
|
+
if (configured) {
|
|
332
|
+
res.writeHead(configured.status, { "Content-Type": "application/json" });
|
|
333
|
+
res.end(JSON.stringify(configured.body));
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
// Default: 200 published with synthesized SyncSkill.
|
|
337
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
338
|
+
res.end(
|
|
339
|
+
JSON.stringify({
|
|
340
|
+
action: "published",
|
|
341
|
+
skill: {
|
|
342
|
+
owner,
|
|
343
|
+
name,
|
|
344
|
+
version: "1.0.0",
|
|
345
|
+
description: "mock",
|
|
346
|
+
keywords: [],
|
|
347
|
+
updatedAt: new Date().toISOString(),
|
|
348
|
+
etag: `"${owner}/${name}@0"`,
|
|
349
|
+
contextSignals: null,
|
|
350
|
+
files: [],
|
|
351
|
+
filesIncomplete: false,
|
|
352
|
+
},
|
|
353
|
+
}),
|
|
354
|
+
);
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// ── #1449: POST /api/v1/library/[owner]/[name]/unpublish ───────
|
|
360
|
+
{
|
|
361
|
+
const m = url.pathname.match(
|
|
362
|
+
/^\/api\/v1\/library\/([^\/]+)\/([^\/]+)\/unpublish$/,
|
|
363
|
+
);
|
|
364
|
+
if (m && req.method === "POST") {
|
|
365
|
+
if (!checkAuth(req, res)) return;
|
|
366
|
+
const owner = decodeURIComponent(m[1]);
|
|
367
|
+
const name = decodeURIComponent(m[2]);
|
|
368
|
+
const configured =
|
|
369
|
+
unpublishResponses.get(`${owner}/${name}`) ??
|
|
370
|
+
unpublishResponses.get("*");
|
|
371
|
+
if (configured) {
|
|
372
|
+
res.writeHead(configured.status, { "Content-Type": "application/json" });
|
|
373
|
+
res.end(JSON.stringify(configured.body));
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
// Default: 200 unpublished, zero subscribers notified.
|
|
377
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
378
|
+
res.end(
|
|
379
|
+
JSON.stringify({
|
|
380
|
+
action: "unpublished",
|
|
381
|
+
notifiedSubscriberCount: 0,
|
|
382
|
+
skill: {
|
|
383
|
+
owner,
|
|
384
|
+
name,
|
|
385
|
+
version: "1.0.0",
|
|
386
|
+
description: "mock",
|
|
387
|
+
keywords: [],
|
|
388
|
+
updatedAt: new Date().toISOString(),
|
|
389
|
+
etag: `"${owner}/${name}@0"`,
|
|
390
|
+
contextSignals: null,
|
|
391
|
+
files: [],
|
|
392
|
+
filesIncomplete: false,
|
|
393
|
+
},
|
|
394
|
+
}),
|
|
395
|
+
);
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
236
400
|
// ── PR3a: DELETE /api/v1/library/[owner]/[name] (remove) ────────
|
|
237
401
|
{
|
|
238
402
|
const m = url.pathname.match(/^\/api\/v1\/library\/([^\/]+)\/([^\/]+)$/);
|
|
@@ -489,6 +653,34 @@ export function createMockServer(initialPayload, options = {}) {
|
|
|
489
653
|
removeResponses = new Map();
|
|
490
654
|
},
|
|
491
655
|
|
|
656
|
+
// Epic #1444 R3 #1456 — publish / unpublish slot setters.
|
|
657
|
+
setPublishResponse(owner, name, response) {
|
|
658
|
+
publishResponses.set(`${owner}/${name}`, response);
|
|
659
|
+
},
|
|
660
|
+
setPublishResponseForAny(response) {
|
|
661
|
+
publishResponses.set("*", response);
|
|
662
|
+
},
|
|
663
|
+
clearPublishResponses() {
|
|
664
|
+
publishResponses = new Map();
|
|
665
|
+
},
|
|
666
|
+
setUnpublishResponse(owner, name, response) {
|
|
667
|
+
unpublishResponses.set(`${owner}/${name}`, response);
|
|
668
|
+
},
|
|
669
|
+
setUnpublishResponseForAny(response) {
|
|
670
|
+
unpublishResponses.set("*", response);
|
|
671
|
+
},
|
|
672
|
+
clearUnpublishResponses() {
|
|
673
|
+
unpublishResponses = new Map();
|
|
674
|
+
},
|
|
675
|
+
|
|
676
|
+
/**
|
|
677
|
+
* #1452 — register the next POST /api/v1/library (multipart file-push)
|
|
678
|
+
* response. Pass `null` to restore the default 201 LibraryPushResponse.
|
|
679
|
+
*/
|
|
680
|
+
setPushResponse(response) {
|
|
681
|
+
pushResponse = response;
|
|
682
|
+
},
|
|
683
|
+
|
|
492
684
|
/**
|
|
493
685
|
* Return the most recent POST body the server received, or null
|
|
494
686
|
* if no POST has been made yet. Body is JSON-parsed when
|
|
@@ -39,6 +39,7 @@ import {
|
|
|
39
39
|
searchSkills,
|
|
40
40
|
addSkillToLibrary,
|
|
41
41
|
removeSkillFromLibrary,
|
|
42
|
+
pushSkill,
|
|
42
43
|
isCliSource,
|
|
43
44
|
CLI_SOURCE_VALUES,
|
|
44
45
|
} from "../../lib/http.mjs";
|
|
@@ -795,7 +796,7 @@ describe("addSkillToLibrary", () => {
|
|
|
795
796
|
it("returns { status: 'added' } on 201", async () => {
|
|
796
797
|
const srv = await startServer((req, res) => {
|
|
797
798
|
assert.equal(req.method, "POST");
|
|
798
|
-
assert.match(req.url, /^\/api\/v1\/library$/);
|
|
799
|
+
assert.match(req.url, /^\/api\/v1\/library\/refs$/);
|
|
799
800
|
assert.equal(req.headers["content-type"], "application/json");
|
|
800
801
|
jsonRes(res, 201, {
|
|
801
802
|
added: {
|
|
@@ -988,6 +989,246 @@ describe("addSkillToLibrary", () => {
|
|
|
988
989
|
});
|
|
989
990
|
});
|
|
990
991
|
|
|
992
|
+
// ── pushSkill (#1455 multipart upsert) ─────────────────────────────────
|
|
993
|
+
|
|
994
|
+
describe("pushSkill", () => {
|
|
995
|
+
const SKILL_MD = "---\nname: my-skill\ndescription: t\n---\n\nbody\n";
|
|
996
|
+
|
|
997
|
+
/**
|
|
998
|
+
* Helper: read the request's Content-Type and verify it's multipart;
|
|
999
|
+
* accept the body without parsing. Tests assert on the response shape
|
|
1000
|
+
* the helper returns and on headers / URL.
|
|
1001
|
+
*/
|
|
1002
|
+
function makePushServer(handler) {
|
|
1003
|
+
return startServer(handler);
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
it("hits POST /api/v1/library with multipart Content-Type", async () => {
|
|
1007
|
+
const srv = await makePushServer((req, res) => {
|
|
1008
|
+
assert.equal(req.method, "POST");
|
|
1009
|
+
assert.match(req.url, /^\/api\/v1\/library$/);
|
|
1010
|
+
assert.match(req.headers["content-type"] ?? "", /multipart\/form-data/);
|
|
1011
|
+
jsonRes(res, 201, {
|
|
1012
|
+
action: "created",
|
|
1013
|
+
bump: null,
|
|
1014
|
+
skill: { owner: "mock", name: "my-skill", version: "1.0" },
|
|
1015
|
+
});
|
|
1016
|
+
});
|
|
1017
|
+
try {
|
|
1018
|
+
await pushSkill(srv.url, VALID_KEY, {
|
|
1019
|
+
files: [{ relativePath: "SKILL.md", content: SKILL_MD }],
|
|
1020
|
+
});
|
|
1021
|
+
} finally {
|
|
1022
|
+
await srv.close();
|
|
1023
|
+
}
|
|
1024
|
+
});
|
|
1025
|
+
|
|
1026
|
+
it("sends an Idempotency-Key header on every request (auto-generated)", async () => {
|
|
1027
|
+
let observedKey;
|
|
1028
|
+
const srv = await makePushServer((req, res) => {
|
|
1029
|
+
observedKey = req.headers["idempotency-key"];
|
|
1030
|
+
jsonRes(res, 201, {
|
|
1031
|
+
action: "created",
|
|
1032
|
+
bump: null,
|
|
1033
|
+
skill: { owner: "mock", name: "my-skill", version: "1.0" },
|
|
1034
|
+
});
|
|
1035
|
+
});
|
|
1036
|
+
try {
|
|
1037
|
+
await pushSkill(srv.url, VALID_KEY, {
|
|
1038
|
+
files: [{ relativePath: "SKILL.md", content: SKILL_MD }],
|
|
1039
|
+
});
|
|
1040
|
+
assert.ok(observedKey, "Idempotency-Key header should be present");
|
|
1041
|
+
assert.ok(observedKey.length >= 16, "Auto-generated key should be reasonably long");
|
|
1042
|
+
} finally {
|
|
1043
|
+
await srv.close();
|
|
1044
|
+
}
|
|
1045
|
+
});
|
|
1046
|
+
|
|
1047
|
+
it("threads an explicit idempotencyKey through to the header", async () => {
|
|
1048
|
+
let observedKey;
|
|
1049
|
+
const srv = await makePushServer((req, res) => {
|
|
1050
|
+
observedKey = req.headers["idempotency-key"];
|
|
1051
|
+
jsonRes(res, 201, {
|
|
1052
|
+
action: "created",
|
|
1053
|
+
bump: null,
|
|
1054
|
+
skill: { owner: "mock", name: "my-skill", version: "1.0" },
|
|
1055
|
+
});
|
|
1056
|
+
});
|
|
1057
|
+
try {
|
|
1058
|
+
await pushSkill(srv.url, VALID_KEY, {
|
|
1059
|
+
files: [{ relativePath: "SKILL.md", content: SKILL_MD }],
|
|
1060
|
+
idempotencyKey: "custom-key-abc",
|
|
1061
|
+
});
|
|
1062
|
+
assert.equal(observedKey, "custom-key-abc");
|
|
1063
|
+
} finally {
|
|
1064
|
+
await srv.close();
|
|
1065
|
+
}
|
|
1066
|
+
});
|
|
1067
|
+
|
|
1068
|
+
it("returns { action: 'created', bump: null, skill } on 201", async () => {
|
|
1069
|
+
const srv = await makePushServer((req, res) => {
|
|
1070
|
+
jsonRes(res, 201, {
|
|
1071
|
+
action: "created",
|
|
1072
|
+
bump: null,
|
|
1073
|
+
skill: { owner: "mock", name: "my-skill", version: "1.0" },
|
|
1074
|
+
});
|
|
1075
|
+
});
|
|
1076
|
+
try {
|
|
1077
|
+
const result = await pushSkill(srv.url, VALID_KEY, {
|
|
1078
|
+
files: [{ relativePath: "SKILL.md", content: SKILL_MD }],
|
|
1079
|
+
});
|
|
1080
|
+
assert.equal(result.action, "created");
|
|
1081
|
+
assert.equal(result.bump, null);
|
|
1082
|
+
assert.equal(result.skill.version, "1.0");
|
|
1083
|
+
} finally {
|
|
1084
|
+
await srv.close();
|
|
1085
|
+
}
|
|
1086
|
+
});
|
|
1087
|
+
|
|
1088
|
+
it("returns { action: 'updated', bump: 'minor' | 'major' } on 200 update", async () => {
|
|
1089
|
+
const srv = await makePushServer((req, res) => {
|
|
1090
|
+
jsonRes(res, 200, {
|
|
1091
|
+
action: "updated",
|
|
1092
|
+
bump: "minor",
|
|
1093
|
+
skill: { owner: "mock", name: "my-skill", version: "1.1" },
|
|
1094
|
+
});
|
|
1095
|
+
});
|
|
1096
|
+
try {
|
|
1097
|
+
const result = await pushSkill(srv.url, VALID_KEY, {
|
|
1098
|
+
files: [{ relativePath: "SKILL.md", content: SKILL_MD }],
|
|
1099
|
+
});
|
|
1100
|
+
assert.equal(result.action, "updated");
|
|
1101
|
+
assert.equal(result.bump, "minor");
|
|
1102
|
+
assert.equal(result.skill.version, "1.1");
|
|
1103
|
+
} finally {
|
|
1104
|
+
await srv.close();
|
|
1105
|
+
}
|
|
1106
|
+
});
|
|
1107
|
+
|
|
1108
|
+
it("returns { action: 'unchanged', bump: null } on 200 no-op", async () => {
|
|
1109
|
+
const srv = await makePushServer((req, res) => {
|
|
1110
|
+
jsonRes(res, 200, {
|
|
1111
|
+
action: "unchanged",
|
|
1112
|
+
bump: null,
|
|
1113
|
+
skill: { owner: "mock", name: "my-skill", version: "1.0" },
|
|
1114
|
+
});
|
|
1115
|
+
});
|
|
1116
|
+
try {
|
|
1117
|
+
const result = await pushSkill(srv.url, VALID_KEY, {
|
|
1118
|
+
files: [{ relativePath: "SKILL.md", content: SKILL_MD }],
|
|
1119
|
+
});
|
|
1120
|
+
assert.equal(result.action, "unchanged");
|
|
1121
|
+
assert.equal(result.bump, null);
|
|
1122
|
+
} finally {
|
|
1123
|
+
await srv.close();
|
|
1124
|
+
}
|
|
1125
|
+
});
|
|
1126
|
+
|
|
1127
|
+
it("throws networkError on 2xx with unknown `action` (server contract violation)", async () => {
|
|
1128
|
+
const srv = await makePushServer((req, res) => {
|
|
1129
|
+
jsonRes(res, 200, {
|
|
1130
|
+
action: "weird",
|
|
1131
|
+
skill: { owner: "x", name: "x", version: "1.0" },
|
|
1132
|
+
});
|
|
1133
|
+
});
|
|
1134
|
+
try {
|
|
1135
|
+
await assert.rejects(
|
|
1136
|
+
() =>
|
|
1137
|
+
pushSkill(srv.url, VALID_KEY, {
|
|
1138
|
+
files: [{ relativePath: "SKILL.md", content: SKILL_MD }],
|
|
1139
|
+
}),
|
|
1140
|
+
(err) =>
|
|
1141
|
+
err instanceof CliError && err.exitCode === EXIT_NETWORK,
|
|
1142
|
+
);
|
|
1143
|
+
} finally {
|
|
1144
|
+
await srv.close();
|
|
1145
|
+
}
|
|
1146
|
+
});
|
|
1147
|
+
|
|
1148
|
+
it("maps 403 plan_limit through mapErrorResponse to validationError", async () => {
|
|
1149
|
+
const srv = await makePushServer((req, res) => {
|
|
1150
|
+
jsonRes(res, 403, {
|
|
1151
|
+
error: "Plan limit reached",
|
|
1152
|
+
code: "plan_limit",
|
|
1153
|
+
});
|
|
1154
|
+
});
|
|
1155
|
+
try {
|
|
1156
|
+
await assert.rejects(
|
|
1157
|
+
() =>
|
|
1158
|
+
pushSkill(srv.url, VALID_KEY, {
|
|
1159
|
+
files: [{ relativePath: "SKILL.md", content: SKILL_MD }],
|
|
1160
|
+
}),
|
|
1161
|
+
(err) =>
|
|
1162
|
+
err instanceof CliError && err.exitCode === EXIT_VALIDATION,
|
|
1163
|
+
);
|
|
1164
|
+
} finally {
|
|
1165
|
+
await srv.close();
|
|
1166
|
+
}
|
|
1167
|
+
});
|
|
1168
|
+
|
|
1169
|
+
it("maps 413 payload_too_large through mapErrorResponse", async () => {
|
|
1170
|
+
const srv = await makePushServer((req, res) => {
|
|
1171
|
+
jsonRes(res, 413, {
|
|
1172
|
+
error: "Payload exceeds limit",
|
|
1173
|
+
code: "payload_too_large",
|
|
1174
|
+
});
|
|
1175
|
+
});
|
|
1176
|
+
try {
|
|
1177
|
+
await assert.rejects(
|
|
1178
|
+
() =>
|
|
1179
|
+
pushSkill(srv.url, VALID_KEY, {
|
|
1180
|
+
files: [{ relativePath: "SKILL.md", content: SKILL_MD }],
|
|
1181
|
+
}),
|
|
1182
|
+
(err) => err instanceof CliError,
|
|
1183
|
+
);
|
|
1184
|
+
} finally {
|
|
1185
|
+
await srv.close();
|
|
1186
|
+
}
|
|
1187
|
+
});
|
|
1188
|
+
|
|
1189
|
+
it("retries on transient 5xx (Idempotency-Key makes retry safe)", async () => {
|
|
1190
|
+
let calls = 0;
|
|
1191
|
+
const srv = await makePushServer((req, res) => {
|
|
1192
|
+
calls++;
|
|
1193
|
+
if (calls === 1) {
|
|
1194
|
+
jsonRes(res, 503, { error: "transient" });
|
|
1195
|
+
return;
|
|
1196
|
+
}
|
|
1197
|
+
jsonRes(res, 201, {
|
|
1198
|
+
action: "created",
|
|
1199
|
+
bump: null,
|
|
1200
|
+
skill: { owner: "mock", name: "my-skill", version: "1.0" },
|
|
1201
|
+
});
|
|
1202
|
+
});
|
|
1203
|
+
try {
|
|
1204
|
+
const result = await pushSkill(srv.url, VALID_KEY, {
|
|
1205
|
+
files: [{ relativePath: "SKILL.md", content: SKILL_MD }],
|
|
1206
|
+
});
|
|
1207
|
+
assert.equal(result.action, "created");
|
|
1208
|
+
assert.ok(calls >= 2, "Should have retried at least once");
|
|
1209
|
+
} finally {
|
|
1210
|
+
await srv.close();
|
|
1211
|
+
}
|
|
1212
|
+
});
|
|
1213
|
+
|
|
1214
|
+
it("guards against empty serverUrl / apiKey via the cross-cutting check", async () => {
|
|
1215
|
+
await assert.rejects(
|
|
1216
|
+
() =>
|
|
1217
|
+
pushSkill("", VALID_KEY, {
|
|
1218
|
+
files: [{ relativePath: "SKILL.md", content: SKILL_MD }],
|
|
1219
|
+
}),
|
|
1220
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
|
|
1221
|
+
);
|
|
1222
|
+
await assert.rejects(
|
|
1223
|
+
() =>
|
|
1224
|
+
pushSkill("http://x", "", {
|
|
1225
|
+
files: [{ relativePath: "SKILL.md", content: SKILL_MD }],
|
|
1226
|
+
}),
|
|
1227
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_AUTH,
|
|
1228
|
+
);
|
|
1229
|
+
});
|
|
1230
|
+
});
|
|
1231
|
+
|
|
991
1232
|
// ── removeSkillFromLibrary (PR3a) ──────────────────────────────────────
|
|
992
1233
|
|
|
993
1234
|
describe("removeSkillFromLibrary", () => {
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for `walkSkillFiles` (#1455).
|
|
3
|
+
*
|
|
4
|
+
* Verifies the walker's exclusion + path-normalization rules:
|
|
5
|
+
* - Hidden files / dirs (anything starting with `.`) are excluded
|
|
6
|
+
* - `node_modules` directories are excluded
|
|
7
|
+
* - Root `SKILL.md` is INCLUDED (uploaded uniformly with other files
|
|
8
|
+
* per the agentskills.io spec)
|
|
9
|
+
* - Returned paths are POSIX-style + sorted
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, it, beforeEach, afterEach } from "node:test";
|
|
13
|
+
import assert from "node:assert/strict";
|
|
14
|
+
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import { tmpdir } from "node:os";
|
|
17
|
+
|
|
18
|
+
import { walkSkillFiles } from "../../lib/skill-walk.mjs";
|
|
19
|
+
|
|
20
|
+
let sandbox;
|
|
21
|
+
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
sandbox = mkdtempSync(join(tmpdir(), "skillrepo-walk-"));
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
rmSync(sandbox, { recursive: true, force: true });
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
function file(rel, content = "x") {
|
|
31
|
+
const abs = join(sandbox, rel);
|
|
32
|
+
mkdirSync(join(abs, ".."), { recursive: true });
|
|
33
|
+
writeFileSync(abs, content);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe("walkSkillFiles", () => {
|
|
37
|
+
it("returns all skill files in deterministic (sorted) order", async () => {
|
|
38
|
+
file("SKILL.md", "---\nname: x\n---\n");
|
|
39
|
+
file("references/intro.md");
|
|
40
|
+
file("scripts/run.sh");
|
|
41
|
+
file("assets/logo.png");
|
|
42
|
+
|
|
43
|
+
const walked = await walkSkillFiles(sandbox);
|
|
44
|
+
const paths = walked.map((f) => f.relativePath);
|
|
45
|
+
// `localeCompare` sorts uppercase after lowercase, so SKILL.md
|
|
46
|
+
// lands last among entries with no subdir prefix.
|
|
47
|
+
assert.deepEqual(paths, [
|
|
48
|
+
"assets/logo.png",
|
|
49
|
+
"references/intro.md",
|
|
50
|
+
"scripts/run.sh",
|
|
51
|
+
"SKILL.md",
|
|
52
|
+
]);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("includes root SKILL.md as a regular file (agentskills.io spec)", async () => {
|
|
56
|
+
file("SKILL.md", "---\nname: x\n---\n");
|
|
57
|
+
file("references/a.md");
|
|
58
|
+
|
|
59
|
+
const walked = await walkSkillFiles(sandbox);
|
|
60
|
+
const paths = walked.map((f) => f.relativePath);
|
|
61
|
+
assert.ok(paths.includes("SKILL.md"));
|
|
62
|
+
assert.deepEqual(paths, ["references/a.md", "SKILL.md"]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("excludes hidden files and dirs at every depth", async () => {
|
|
66
|
+
file("SKILL.md");
|
|
67
|
+
file(".env");
|
|
68
|
+
file(".git/HEAD");
|
|
69
|
+
file(".vscode/settings.json");
|
|
70
|
+
file("references/.DS_Store");
|
|
71
|
+
file("references/.hidden/secret");
|
|
72
|
+
file("references/foo.md");
|
|
73
|
+
|
|
74
|
+
const walked = await walkSkillFiles(sandbox);
|
|
75
|
+
const paths = walked.map((f) => f.relativePath);
|
|
76
|
+
assert.deepEqual(paths, ["references/foo.md", "SKILL.md"]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("excludes node_modules directories", async () => {
|
|
80
|
+
file("SKILL.md");
|
|
81
|
+
file("node_modules/foo/index.js");
|
|
82
|
+
file("scripts/node_modules/bar/index.js");
|
|
83
|
+
file("references/intro.md");
|
|
84
|
+
|
|
85
|
+
const walked = await walkSkillFiles(sandbox);
|
|
86
|
+
const paths = walked.map((f) => f.relativePath);
|
|
87
|
+
assert.deepEqual(paths, ["references/intro.md", "SKILL.md"]);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("uses POSIX forward-slash paths even on Windows-style separators", async () => {
|
|
91
|
+
file("SKILL.md");
|
|
92
|
+
file("references/nested/deep.md");
|
|
93
|
+
|
|
94
|
+
const walked = await walkSkillFiles(sandbox);
|
|
95
|
+
const paths = walked.map((f) => f.relativePath);
|
|
96
|
+
for (const p of paths) {
|
|
97
|
+
assert.ok(!p.includes("\\"), `Path ${p} contains backslash`);
|
|
98
|
+
}
|
|
99
|
+
assert.ok(paths.includes("SKILL.md"));
|
|
100
|
+
assert.ok(paths.includes("references/nested/deep.md"));
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("returns just SKILL.md when the directory has only SKILL.md + hidden files", async () => {
|
|
104
|
+
file("SKILL.md");
|
|
105
|
+
file(".env");
|
|
106
|
+
file(".git/HEAD");
|
|
107
|
+
|
|
108
|
+
const walked = await walkSkillFiles(sandbox);
|
|
109
|
+
const paths = walked.map((f) => f.relativePath);
|
|
110
|
+
assert.deepEqual(paths, ["SKILL.md"]);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("returns absolute paths + sizes for each file", async () => {
|
|
114
|
+
file("SKILL.md", "ab");
|
|
115
|
+
file("references/a.md", "abc");
|
|
116
|
+
|
|
117
|
+
const walked = await walkSkillFiles(sandbox);
|
|
118
|
+
assert.equal(walked.length, 2);
|
|
119
|
+
const refEntry = walked.find((f) => f.relativePath === "references/a.md");
|
|
120
|
+
assert.ok(refEntry);
|
|
121
|
+
assert.ok(refEntry.absolutePath.startsWith(sandbox));
|
|
122
|
+
assert.equal(refEntry.size, 3);
|
|
123
|
+
const skillEntry = walked.find((f) => f.relativePath === "SKILL.md");
|
|
124
|
+
assert.ok(skillEntry);
|
|
125
|
+
assert.equal(skillEntry.size, 2);
|
|
126
|
+
});
|
|
127
|
+
});
|