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/src/lib/http.mjs CHANGED
@@ -368,9 +368,9 @@ async function mapErrorResponse(res, url) {
368
368
  return networkError("Rate limit exceeded — server returned 429.", {
369
369
  hint:
370
370
  "Wait a minute and retry. Read-only commands (update, get, " +
371
- "list, search) retry automatically; write commands (add, " +
372
- "remove) are single-shot. Pass --verbose to see retry " +
373
- "attempts when they happen.",
371
+ "list, search) and idempotent writes (push) retry " +
372
+ "automatically; non-idempotent writes (add, remove) are " +
373
+ "single-shot. Pass --verbose to see retry attempts.",
374
374
  });
375
375
  }
376
376
  if (res.status >= 500) {
@@ -621,7 +621,13 @@ export async function getSkill(serverUrl, apiKey, owner, name) {
621
621
  return body.skill ?? null;
622
622
  }
623
623
 
624
- // ── /api/v1/library (POST — add to library) ────────────────────────────
624
+ // ── /api/v1/library/refs (POST — add catalog skill to library) ─────────
625
+ //
626
+ // Previously POSTed to `/api/v1/library` directly. After epic #1444
627
+ // Release 1 (#1452), `POST /api/v1/library` is the multipart file-push
628
+ // endpoint and the JSON `{owner, name}` catalog-add operation lives at
629
+ // `POST /api/v1/library/refs` (#1451). The handler logic and response
630
+ // shape are unchanged — only the URL moved.
625
631
 
626
632
  /**
627
633
  * @typedef {Object} LibraryAddSuccess
@@ -650,19 +656,21 @@ export async function getSkill(serverUrl, apiKey, owner, name) {
650
656
  */
651
657
 
652
658
  /**
653
- * POST /api/v1/library — add a skill to the authenticated account's
654
- * library.
659
+ * POST /api/v1/library/refs — add an existing catalog skill to the
660
+ * authenticated account's library by `{owner, name}` reference.
655
661
  *
656
- * The server's POST handler distinguishes several outcomes via both
657
- * HTTP status and a `code` field in the response body (see
658
- * src/app/api/v1/library/route.ts:190-237):
662
+ * Distinct from `pushSkill` above, which uploads a SKILL.md + files
663
+ * folder via multipart on `POST /api/v1/library`.
664
+ *
665
+ * The server distinguishes several outcomes via HTTP status + `code`
666
+ * field (see `src/app/api/v1/library/refs/route.ts`):
659
667
  *
660
668
  * 201 → { added: {...} } → "added"
661
669
  * 404 { code: "not_found" } → "not-found"
662
670
  * 409 { code: "already_in_library" } → "already-in-library"
663
671
  * 409 { code: "self_ownership" } → "self-ownership"
664
672
  * 403 { code: "plan_limit" } → mapErrorResponse → validationError
665
- * 403 { code: "scope_required" } (or similar) → mapErrorResponse → scopeError
673
+ * 403 { code: "insufficient_scope" } → mapErrorResponse → scopeError
666
674
  * 401 → mapErrorResponse → authError
667
675
  *
668
676
  * The 404 / 409 cases are documented idempotent-friendly outcomes that
@@ -682,7 +690,7 @@ export async function getSkill(serverUrl, apiKey, owner, name) {
682
690
  * @returns {Promise<LibraryAddResult>}
683
691
  */
684
692
  export async function addSkillToLibrary(serverUrl, apiKey, owner, name) {
685
- const url = `${normalizeUrl(serverUrl)}/api/v1/library`;
693
+ const url = `${normalizeUrl(serverUrl)}/api/v1/library/refs`;
686
694
  const res = await safeFetch(url, {
687
695
  method: "POST",
688
696
  headers: {
@@ -756,6 +764,156 @@ export async function addSkillToLibrary(serverUrl, apiKey, owner, name) {
756
764
  throw err;
757
765
  }
758
766
 
767
+ // ── /api/v1/library (POST — multipart file-push, #1452) ─────────────────
768
+
769
+ /**
770
+ * @typedef {Object} PushSkillFile
771
+ * @property {string} relativePath - POSIX-style skill-relative path (e.g. "references/intro.md").
772
+ * @property {Uint8Array | Buffer} content - Raw file bytes.
773
+ */
774
+
775
+ /**
776
+ * @typedef {Object} PushSkillResultCreated
777
+ * @property {"created"} action
778
+ * @property {null} bump
779
+ * @property {any} skill
780
+ *
781
+ * @typedef {Object} PushSkillResultUpdated
782
+ * @property {"updated"} action
783
+ * @property {"major" | "minor"} bump
784
+ * @property {any} skill
785
+ *
786
+ * @typedef {Object} PushSkillResultUnchanged
787
+ * @property {"unchanged"} action
788
+ * @property {null} bump
789
+ * @property {any} skill
790
+ *
791
+ * @typedef {PushSkillResultCreated | PushSkillResultUpdated | PushSkillResultUnchanged} PushSkillResult
792
+ */
793
+
794
+ /**
795
+ * POST /api/v1/library — multipart file-push / upsert (#1452).
796
+ *
797
+ * Uploads the whole skill folder per the agentskills.io spec. Every file
798
+ * (including the root `SKILL.md`) goes up as a `files[]` part with its
799
+ * in-skill relative path carried via the `Content-Disposition: filename`
800
+ * parameter (RFC 7578). The server identifies SKILL.md by exact-match
801
+ * `filename === "SKILL.md"` (case-sensitive, no path prefix).
802
+ *
803
+ * The `Idempotency-Key` header is set on every call (auto-generated if
804
+ * not supplied) so transient network failures can be retried safely. The
805
+ * server replays the cached response on a same-key + same-body retry
806
+ * within 24h.
807
+ *
808
+ * Server outcomes (see `src/app/api/v1/library/route.ts`):
809
+ * 201 { action: "created", bump: null, skill: SyncSkill }
810
+ * 200 { action: "updated", bump: "major"|"minor", skill: SyncSkill }
811
+ * 200 { action: "unchanged", bump: null, skill: SyncSkill }
812
+ * 400 { code: "missing_skill_md" | "invalid_skill_md" | "invalid_path" | "invalid_multipart" }
813
+ * 403 { code: "plan_limit" | "scope_required" }
814
+ * 409 { code: "source_conflict" | "idempotency_key_in_progress" }
815
+ * 413 { code: "payload_too_large" }
816
+ * 422 { code: "idempotency_key_reused" }
817
+ * 429 — rate-limited
818
+ *
819
+ * Documented 4xx/2xx outcomes return a discriminated union. Anything
820
+ * else is routed through mapErrorResponse for typed-error throwing.
821
+ *
822
+ * @param {string} serverUrl
823
+ * @param {string} apiKey
824
+ * @param {Object} input
825
+ * @param {PushSkillFile[]} input.files - The skill folder (SKILL.md plus supporting files).
826
+ * @param {string} [input.idempotencyKey] - Override the auto-generated key. Pass to retry idempotently.
827
+ * @returns {Promise<PushSkillResult>}
828
+ */
829
+ export async function pushSkill(serverUrl, apiKey, input) {
830
+ const url = `${normalizeUrl(serverUrl)}/api/v1/library`;
831
+ const idempotencyKey = input.idempotencyKey ?? generateIdempotencyKey();
832
+
833
+ const fd = new FormData();
834
+ for (const file of input.files) {
835
+ // Per RFC 7578 §4.2, `filename` carries the per-part relative path.
836
+ // The `File` constructor's second arg sets the `Content-Disposition:
837
+ // filename` parameter via undici's native FormData encoding. `File`
838
+ // accepts a `BlobPart[]` directly (Uint8Array / Buffer / Blob /
839
+ // string), so the previous `new Blob([content])` round-trip was
840
+ // unnecessary allocation.
841
+ fd.append("files", new File([file.content], file.relativePath));
842
+ }
843
+
844
+ // `retry: true` is safe here despite this being a write endpoint —
845
+ // every request carries an `Idempotency-Key` header (auto-generated
846
+ // above if not provided), and the server (#1465 middleware) replays
847
+ // the cached response on same-key + same-body retries within 24h.
848
+ // Without retry, a transient 503 from a cold-start Vercel function
849
+ // surfaces as an immediate `networkError` to the user even though
850
+ // the operation could safely re-attempt.
851
+ const res = await safeFetch(url, {
852
+ method: "POST",
853
+ headers: {
854
+ ...(await authHeaders(apiKey)),
855
+ "Idempotency-Key": idempotencyKey,
856
+ // Note: do NOT set Content-Type for FormData — undici will set
857
+ // `multipart/form-data; boundary=...` automatically. Setting it
858
+ // here would omit the boundary parameter and the server's parse
859
+ // would fail.
860
+ },
861
+ body: fd,
862
+ retry: true,
863
+ });
864
+
865
+ if (res.status === 200 || res.status === 201) {
866
+ const body = await parseJsonOrThrow(res, url);
867
+ if (
868
+ body?.action === "created" ||
869
+ body?.action === "updated" ||
870
+ body?.action === "unchanged"
871
+ ) {
872
+ return {
873
+ action: body.action,
874
+ bump: body.bump ?? null,
875
+ skill: body.skill,
876
+ };
877
+ }
878
+ throw networkError(
879
+ `Library push returned ${res.status} with unexpected action "${body?.action}" from ${url}`,
880
+ );
881
+ }
882
+
883
+ // Documented 4xx outcomes flow through mapErrorResponse so the CLI
884
+ // surfaces them with the same `code`-discriminated handling pattern
885
+ // used for catalog-add. The `*_too_large`, `*_in_progress`, and
886
+ // `*_reused` codes share a 4xx envelope; mapErrorResponse maps them
887
+ // to validationError/scopeError/authError consistently.
888
+ const err = await mapErrorResponse(res, url);
889
+ if (err === null) {
890
+ throw validationError(
891
+ `Library push returned ${res.status} with no documented meaning.`,
892
+ );
893
+ }
894
+ throw err;
895
+ }
896
+
897
+ /**
898
+ * Generate a random Idempotency-Key. UUID v4 if `crypto.randomUUID` is
899
+ * available; falls back to a hex random for older Node runtimes (Node
900
+ * 19+ ships randomUUID at the top level, so this is defensive).
901
+ */
902
+ function generateIdempotencyKey() {
903
+ if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
904
+ return crypto.randomUUID();
905
+ }
906
+ const bytes = new Uint8Array(16);
907
+ if (typeof crypto !== "undefined" && typeof crypto.getRandomValues === "function") {
908
+ crypto.getRandomValues(bytes);
909
+ } else {
910
+ for (let i = 0; i < bytes.length; i++) bytes[i] = Math.floor(Math.random() * 256);
911
+ }
912
+ return Array.from(bytes)
913
+ .map((b) => b.toString(16).padStart(2, "0"))
914
+ .join("");
915
+ }
916
+
759
917
  // ── /api/v1/library/[owner]/[name] (DELETE — remove from library) ──────
760
918
 
761
919
  /**
@@ -897,3 +1055,192 @@ export async function searchSkills(serverUrl, apiKey, opts = {}) {
897
1055
  pagination: body.pagination ?? { total: 0, limit: opts.limit ?? 20, offset: opts.offset ?? 0 },
898
1056
  };
899
1057
  }
1058
+
1059
+ // ── /api/v1/library/[owner]/[name]/publish + /unpublish (#1444 R3 #1449) ──
1060
+
1061
+ /**
1062
+ * @typedef {Object} VisibilityResultPublished
1063
+ * @property {"published"} action
1064
+ * @property {any} skill
1065
+ *
1066
+ * @typedef {Object} VisibilityResultUnpublished
1067
+ * @property {"unpublished"} action
1068
+ * @property {number} notifiedSubscriberCount
1069
+ * @property {any} skill
1070
+ *
1071
+ * @typedef {Object} VisibilityResultUnchanged
1072
+ * @property {"unchanged"} action
1073
+ * @property {any} skill
1074
+ *
1075
+ * @typedef {Object} VisibilityResultForbidden
1076
+ * @property {"forbidden"} action
1077
+ * @property {"publish_not_permitted" | "unpublish_not_permitted"} code
1078
+ * @property {string} reason
1079
+ *
1080
+ * @typedef {Object} VisibilityResultBlocked
1081
+ * @property {"publish-blocked"} action
1082
+ * @property {"namespace_unset" | "analysis_pending" | "safety_grade_too_low"} code
1083
+ * @property {string} reason
1084
+ *
1085
+ * @typedef {Object} VisibilityResultNotFound
1086
+ * @property {"not-found"} action
1087
+ * @property {string} owner
1088
+ * @property {string} name
1089
+ *
1090
+ * @typedef {VisibilityResultPublished | VisibilityResultUnpublished | VisibilityResultUnchanged
1091
+ * | VisibilityResultForbidden | VisibilityResultBlocked | VisibilityResultNotFound} VisibilityResult
1092
+ */
1093
+
1094
+ /**
1095
+ * Shared helper used by `publishLibrarySkill` and `unpublishLibrarySkill`.
1096
+ * The two endpoints differ only in:
1097
+ * - URL suffix (/publish vs /unpublish)
1098
+ * - 200 `action` value (published vs unpublished)
1099
+ * - 200 response includes `notifiedSubscriberCount` only on unpublish
1100
+ * - 403 `code` (publish_not_permitted vs unpublish_not_permitted)
1101
+ * - 422 `code` set differs (publish has product-rule blocked codes;
1102
+ * unpublish has only `idempotency_key_reused` which routes
1103
+ * through `mapErrorResponse`)
1104
+ *
1105
+ * Both return a discriminated `VisibilityResult` so the command layer
1106
+ * can map outcomes to user-facing exit codes without duplicating the
1107
+ * status-code dispatch.
1108
+ *
1109
+ * Idempotency-Key: auto-generated unless supplied via `options.idempotencyKey`.
1110
+ * `retry: true` is safe because the server caches responses for 24h
1111
+ * keyed by `(accountId, key, route)`.
1112
+ */
1113
+ async function setSkillVisibility(serverUrl, apiKey, owner, name, target, options = {}) {
1114
+ const action = target === "global" ? "publish" : "unpublish";
1115
+ const url = `${normalizeUrl(serverUrl)}/api/v1/library/${encodeURIComponent(
1116
+ owner,
1117
+ )}/${encodeURIComponent(name)}/${action}`;
1118
+ const idempotencyKey = options.idempotencyKey ?? generateIdempotencyKey();
1119
+
1120
+ const res = await safeFetch(url, {
1121
+ method: "POST",
1122
+ headers: {
1123
+ ...(await authHeaders(apiKey)),
1124
+ "Idempotency-Key": idempotencyKey,
1125
+ },
1126
+ retry: true,
1127
+ });
1128
+
1129
+ if (res.status === 200) {
1130
+ const body = await parseJsonOrThrow(res, url);
1131
+ if (body?.action === "published") {
1132
+ return { action: "published", skill: body.skill };
1133
+ }
1134
+ if (body?.action === "unpublished") {
1135
+ return {
1136
+ action: "unpublished",
1137
+ notifiedSubscriberCount: body.notifiedSubscriberCount ?? 0,
1138
+ skill: body.skill,
1139
+ };
1140
+ }
1141
+ if (body?.action === "unchanged") {
1142
+ return { action: "unchanged", skill: body.skill };
1143
+ }
1144
+ throw networkError(
1145
+ `Library ${action} returned 200 with unexpected action "${body?.action}" from ${url}`,
1146
+ );
1147
+ }
1148
+
1149
+ if (res.status === 404) {
1150
+ await res.json().catch(() => undefined);
1151
+ return { action: "not-found", owner, name };
1152
+ }
1153
+
1154
+ // 403 / 422 require body inspection. Once we read the stream we
1155
+ // CANNOT fall through to `mapErrorResponse` (which re-reads the
1156
+ // body and would get an empty object — a `plan_limit` 403 would
1157
+ // mis-classify as authError exit 2 instead of validationError
1158
+ // exit 5 with billing hint). Pre-parse once, then dispatch every
1159
+ // code path inline mirroring `mapErrorResponse`'s shape so the
1160
+ // CliError types stay consistent across endpoints.
1161
+ if (res.status === 403 || res.status === 422) {
1162
+ let body = null;
1163
+ try {
1164
+ body = await res.json();
1165
+ } catch {
1166
+ // body stays null — fall through to the no-code branches below
1167
+ }
1168
+ const code = typeof body?.code === "string" ? body.code : null;
1169
+ const message = body?.error || `${res.status} ${res.statusText}`;
1170
+
1171
+ if (res.status === 403) {
1172
+ // Our endpoint-specific permission failure.
1173
+ if (code === "publish_not_permitted" || code === "unpublish_not_permitted") {
1174
+ return { action: "forbidden", code, reason: message };
1175
+ }
1176
+ // Mirror `mapErrorResponse`'s 403 taxonomy so a 403 here behaves
1177
+ // identically to a 403 on any other endpoint:
1178
+ // - `plan_limit` → validationError (exit 5) with billing hint
1179
+ // - any `*scope*` code → scopeError (exit 4)
1180
+ // - everything else → authError (exit 2, "suspended" hint)
1181
+ if (code === "plan_limit") {
1182
+ throw validationError(message, {
1183
+ hint: "Upgrade your plan at /app/settings/billing or remove an existing skill from your library.",
1184
+ });
1185
+ }
1186
+ if (code && code.toLowerCase().includes("scope")) {
1187
+ throw scopeError(message, {
1188
+ hint: "Create a write-scoped key at /app/settings/access-keys.",
1189
+ });
1190
+ }
1191
+ throw authError(message, {
1192
+ hint: "If your account is suspended, contact support.",
1193
+ });
1194
+ }
1195
+
1196
+ // res.status === 422
1197
+ if (target === "global") {
1198
+ if (
1199
+ code === "namespace_unset" ||
1200
+ code === "analysis_pending" ||
1201
+ code === "safety_grade_too_low"
1202
+ ) {
1203
+ return { action: "publish-blocked", code, reason: message };
1204
+ }
1205
+ }
1206
+ // `idempotency_key_reused` and any other 422 code surface as
1207
+ // validationError so the exit code matches the rest of v1.
1208
+ throw validationError(message);
1209
+ }
1210
+
1211
+ const err = await mapErrorResponse(res, url);
1212
+ if (err === null) {
1213
+ throw validationError(
1214
+ `Library ${action} returned ${res.status} with no documented meaning.`,
1215
+ );
1216
+ }
1217
+ throw err;
1218
+ }
1219
+
1220
+ /**
1221
+ * POST /api/v1/library/{owner}/{name}/publish (#1449).
1222
+ *
1223
+ * @param {string} serverUrl
1224
+ * @param {string} apiKey
1225
+ * @param {string} owner
1226
+ * @param {string} name
1227
+ * @param {{ idempotencyKey?: string }} [options]
1228
+ * @returns {Promise<VisibilityResult>}
1229
+ */
1230
+ export async function publishLibrarySkill(serverUrl, apiKey, owner, name, options = {}) {
1231
+ return setSkillVisibility(serverUrl, apiKey, owner, name, "global", options);
1232
+ }
1233
+
1234
+ /**
1235
+ * POST /api/v1/library/{owner}/{name}/unpublish (#1449).
1236
+ *
1237
+ * @param {string} serverUrl
1238
+ * @param {string} apiKey
1239
+ * @param {string} owner
1240
+ * @param {string} name
1241
+ * @param {{ idempotencyKey?: string }} [options]
1242
+ * @returns {Promise<VisibilityResult>}
1243
+ */
1244
+ export async function unpublishLibrarySkill(serverUrl, apiKey, owner, name, options = {}) {
1245
+ return setSkillVisibility(serverUrl, apiKey, owner, name, "private", options);
1246
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Directory walker for `skillrepo push` (#1455).
3
+ *
4
+ * Walks a local skill directory and collects files for upload — the
5
+ * whole skill folder uniformly, including the root `SKILL.md`, per the
6
+ * agentskills.io specification (a skill is a directory containing
7
+ * `SKILL.md` at the root plus optional supporting files).
8
+ *
9
+ * Excludes:
10
+ * - Any path component starting with `.` (e.g. `.git`, `.DS_Store`)
11
+ * so `skillrepo push .` from a repo root doesn't accidentally
12
+ * upload git internals.
13
+ * - `node_modules` directories anywhere in the tree.
14
+ *
15
+ * Paths returned are relative to the skill directory and use forward
16
+ * slashes regardless of platform — that's what the server's
17
+ * `validateFilePath` expects, and what RFC 7578 `Content-Disposition:
18
+ * filename` carries on the wire.
19
+ */
20
+
21
+ import { promises as fs } from "node:fs";
22
+ import path from "node:path";
23
+
24
+ const EXCLUDED_DIRS = new Set(["node_modules"]);
25
+
26
+ /**
27
+ * @typedef {Object} WalkedFile
28
+ * @property {string} relativePath - POSIX-style path within the skill directory.
29
+ * @property {string} absolutePath - Absolute filesystem path on local disk.
30
+ * @property {number} size - File size in bytes (from stat).
31
+ */
32
+
33
+ /**
34
+ * Walk a skill directory and yield every file that should be sent as
35
+ * a multipart `files` part.
36
+ *
37
+ * @param {string} skillDir - Absolute path to the skill directory.
38
+ * @returns {Promise<WalkedFile[]>}
39
+ */
40
+ export async function walkSkillFiles(skillDir) {
41
+ const absRoot = path.resolve(skillDir);
42
+ const out = [];
43
+ await walkRecursive(absRoot, absRoot, out);
44
+ // Deterministic order so SHA fingerprints (and tests that assert on
45
+ // ordered output) are reproducible.
46
+ out.sort((a, b) => a.relativePath.localeCompare(b.relativePath));
47
+ return out;
48
+ }
49
+
50
+ /**
51
+ * @param {string} absRoot
52
+ * @param {string} currentDir
53
+ * @param {WalkedFile[]} out
54
+ */
55
+ async function walkRecursive(absRoot, currentDir, out) {
56
+ let entries;
57
+ try {
58
+ entries = await fs.readdir(currentDir, { withFileTypes: true });
59
+ } catch (err) {
60
+ if (err && /** @type {NodeJS.ErrnoException} */ (err).code === "ENOENT") {
61
+ return;
62
+ }
63
+ throw err;
64
+ }
65
+
66
+ for (const entry of entries) {
67
+ const name = entry.name;
68
+
69
+ // Hidden files / directories (anything starting with `.`).
70
+ if (name.startsWith(".")) continue;
71
+
72
+ // Excluded directory names.
73
+ if (entry.isDirectory() && EXCLUDED_DIRS.has(name)) continue;
74
+
75
+ const absChild = path.join(currentDir, name);
76
+
77
+ if (entry.isDirectory()) {
78
+ await walkRecursive(absRoot, absChild, out);
79
+ continue;
80
+ }
81
+
82
+ if (!entry.isFile()) continue; // skip symlinks / sockets / etc.
83
+
84
+ // POSIX-style relative path from the skill root.
85
+ const relative = path
86
+ .relative(absRoot, absChild)
87
+ .split(path.sep)
88
+ .join("/");
89
+
90
+ const stat = await fs.stat(absChild);
91
+ out.push({
92
+ relativePath: relative,
93
+ absolutePath: absChild,
94
+ size: stat.size,
95
+ });
96
+ }
97
+ }