skillrepo 4.8.3 → 4.8.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skillrepo",
3
- "version": "4.8.3",
3
+ "version": "4.8.4",
4
4
  "description": "Pull-based CLI for agent skills — init, sync, search, add, remove your library from any IDE",
5
5
  "type": "module",
6
6
  "bin": {
package/src/lib/http.mjs CHANGED
@@ -370,6 +370,25 @@ async function mapErrorResponse(res, url) {
370
370
  if (res.status === 404) {
371
371
  return null; // Caller decides
372
372
  }
373
+ if (res.status === 422 && code === "handle_required") {
374
+ // #2310: the push would CREATE a skill while the account's Author ID
375
+ // is still an unchosen placeholder. Line 1 is the server's message
376
+ // ("Set your Author ID before adding skills."); the hint points at
377
+ // the Settings surface that unblocks it. Derive the base URL from
378
+ // the request URL's origin (same posture as the 401 auth hint above)
379
+ // so staging users get a staging link, with the prod host as the
380
+ // parse-failure fallback.
381
+ let settingsBase = "https://skillrepo.dev";
382
+ try {
383
+ settingsBase = new URL(url).origin;
384
+ } catch {
385
+ // URL parse failure shouldn't happen — `safeFetch` already
386
+ // validated. Fall back to the prod default.
387
+ }
388
+ return validationError(message, {
389
+ hint: `Set it in Settings → Account Profile: ${settingsBase}/app/settings`,
390
+ });
391
+ }
373
392
  if (res.status === 429) {
374
393
  // 429 hits mapErrorResponse in two cases: (1) a read-only
375
394
  // endpoint's retry budget was exhausted (safeFetch with
@@ -220,7 +220,7 @@ describe("runPublish — error paths", () => {
220
220
  server.setPublishResponse("alice", "my-skill", {
221
221
  status: 422,
222
222
  body: {
223
- error: "Choose your Author ID before publishing.",
223
+ error: "Set your Author ID before publishing.",
224
224
  code: "namespace_unset",
225
225
  },
226
226
  });
@@ -453,6 +453,30 @@ describe("runPush — server error mapping", () => {
453
453
  );
454
454
  });
455
455
 
456
+ it("maps server 422 handle_required to EXIT_VALIDATION with the Settings hint (#2310, #2345)", async () => {
457
+ // The dedicated #2310 branch in mapErrorResponse: friendly message
458
+ // plus the Settings pointer. Previously pinned only at the
459
+ // http.test.mjs layer against hand-built JSON — this runs the whole
460
+ // runPush chain against the same wire shape the server emits
461
+ // (route.ts:724: `{ error, code: "handle_required" }`).
462
+ file("SKILL.md", VALID_SKILL_MD);
463
+ server.setPushResponse({
464
+ status: 422,
465
+ body: {
466
+ error: "Set your Author ID before adding skills.",
467
+ code: "handle_required",
468
+ },
469
+ });
470
+ await assert.rejects(
471
+ runPush(["--key", VALID_KEY, "--url", serverUrl, "skill"], { stdout }),
472
+ (err) =>
473
+ err instanceof CliError &&
474
+ err.exitCode === EXIT_VALIDATION &&
475
+ /author id/i.test(err.message) &&
476
+ /settings/i.test(err.hint ?? ""),
477
+ );
478
+ });
479
+
456
480
  it("maps server 413 payload_too_large to EXIT_VALIDATION (generic 4xx)", async () => {
457
481
  // 413 has NO explicit branch in mapErrorResponse (only 401/403/404/
458
482
  // 429/5xx are special-cased). It therefore falls through to the
@@ -1296,6 +1296,93 @@ describe("pushSkill", () => {
1296
1296
  }
1297
1297
  });
1298
1298
 
1299
+ it("maps 422 handle_required to validationError with a Settings hint on the server's own host (#2310)", async () => {
1300
+ const SERVER_MESSAGE = "Set your Author ID before adding skills.";
1301
+ const srv = await makePushServer((req, res) => {
1302
+ jsonRes(res, 422, {
1303
+ error: SERVER_MESSAGE,
1304
+ code: "handle_required",
1305
+ });
1306
+ });
1307
+ try {
1308
+ await assert.rejects(
1309
+ () =>
1310
+ pushSkill(srv.url, VALID_KEY, {
1311
+ files: [{ relativePath: "SKILL.md", content: SKILL_MD }],
1312
+ }),
1313
+ (err) =>
1314
+ err instanceof CliError &&
1315
+ err.exitCode === EXIT_VALIDATION &&
1316
+ // Line 1: the server's message verbatim; line 2 (hint): the
1317
+ // Settings path on the REQUEST host (staging users get a
1318
+ // staging link), not hardcoded prod.
1319
+ err.message === SERVER_MESSAGE &&
1320
+ err.hint === `Set it in Settings → Account Profile: ${srv.url}/app/settings`,
1321
+ );
1322
+ } finally {
1323
+ await srv.close();
1324
+ }
1325
+ });
1326
+
1327
+ it("handle_required hint falls back to the prod host when the request URL is unparseable (#2310)", async () => {
1328
+ // The `new URL(url)` origin-derivation inside the handle_required
1329
+ // mapping can only throw if the URL that reached fetch was itself
1330
+ // unparseable — impossible over a real socket (undici validates the
1331
+ // URL before connecting), so stub fetch to hand back the 422
1332
+ // directly. `normalizeUrl` passes any non-empty string through, so
1333
+ // the raw serverUrl below reaches mapErrorResponse verbatim.
1334
+ const SERVER_MESSAGE = "Set your Author ID before adding skills.";
1335
+ const realFetch = globalThis.fetch;
1336
+ globalThis.fetch = async () =>
1337
+ new Response(
1338
+ JSON.stringify({ error: SERVER_MESSAGE, code: "handle_required" }),
1339
+ { status: 422, headers: { "Content-Type": "application/json" } },
1340
+ );
1341
+ try {
1342
+ await assert.rejects(
1343
+ () =>
1344
+ pushSkill("not a url", VALID_KEY, {
1345
+ files: [{ relativePath: "SKILL.md", content: SKILL_MD }],
1346
+ }),
1347
+ (err) =>
1348
+ err instanceof CliError &&
1349
+ err.exitCode === EXIT_VALIDATION &&
1350
+ err.message === SERVER_MESSAGE &&
1351
+ // Parse-failure fallback: the hint pins the prod host instead
1352
+ // of the (unusable) request origin.
1353
+ err.hint ===
1354
+ "Set it in Settings → Account Profile: https://skillrepo.dev/app/settings",
1355
+ );
1356
+ } finally {
1357
+ globalThis.fetch = realFetch;
1358
+ }
1359
+ });
1360
+
1361
+ it("does NOT special-case a 422 without the handle_required code (generic validation)", async () => {
1362
+ // A 422 carrying a different code (e.g. idempotency_key_reused) must
1363
+ // keep the pre-#2310 generic mapping: validationError, no Settings hint.
1364
+ const srv = await makePushServer((req, res) => {
1365
+ jsonRes(res, 422, {
1366
+ error: "Idempotency-Key was reused with a different request body.",
1367
+ code: "idempotency_key_reused",
1368
+ });
1369
+ });
1370
+ try {
1371
+ await assert.rejects(
1372
+ () =>
1373
+ pushSkill(srv.url, VALID_KEY, {
1374
+ files: [{ relativePath: "SKILL.md", content: SKILL_MD }],
1375
+ }),
1376
+ (err) =>
1377
+ err instanceof CliError &&
1378
+ err.exitCode === EXIT_VALIDATION &&
1379
+ err.hint === undefined,
1380
+ );
1381
+ } finally {
1382
+ await srv.close();
1383
+ }
1384
+ });
1385
+
1299
1386
  it("maps 413 payload_too_large through mapErrorResponse", async () => {
1300
1387
  const srv = await makePushServer((req, res) => {
1301
1388
  jsonRes(res, 413, {