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
|
@@ -42,6 +42,13 @@ import {
|
|
|
42
42
|
} from "./paths.mjs";
|
|
43
43
|
import { join } from "node:path";
|
|
44
44
|
import { homedir } from "node:os";
|
|
45
|
+
// Cohort agent-hook descriptors derive from the agent registry so the
|
|
46
|
+
// catalog never drifts from the registry's `agentHook` data. Pure data
|
|
47
|
+
// composition: no fs, no dynamic behavior beyond reading frozen
|
|
48
|
+
// constants. The DATA-ONLY rule for this module forbids fs access and
|
|
49
|
+
// runtime mutation; reading another data-only module's exports is in
|
|
50
|
+
// scope.
|
|
51
|
+
import { AGENT_REGISTRY } from "./agent-registry.mjs";
|
|
45
52
|
|
|
46
53
|
// ── Fingerprint constants exported for cross-module consumption ───────
|
|
47
54
|
|
|
@@ -139,13 +146,84 @@ export const VSCODE_INPUT_ID = "skillrepo-api-key";
|
|
|
139
146
|
*/
|
|
140
147
|
export const SESSION_HOOK_FINGERPRINT = " update --session-hook";
|
|
141
148
|
|
|
149
|
+
/**
|
|
150
|
+
* The canonical SessionStart-hook command the CLI installs into every
|
|
151
|
+
* cohort agent's hook config (#1240). Cohort agents = Cursor, Gemini
|
|
152
|
+
* CLI, Codex CLI, VS Code + Copilot — every cohort vendor whose
|
|
153
|
+
* placement target is `agentsProject` AND whose vendor docs publish a
|
|
154
|
+
* SessionStart-equivalent hook surface.
|
|
155
|
+
*
|
|
156
|
+
* The command is intentionally short and shell-portable:
|
|
157
|
+
*
|
|
158
|
+
* - `npx --yes` works on Windows (cmd.exe + PowerShell) and POSIX
|
|
159
|
+
* shells with no shell-builtin dependency. The session-hook
|
|
160
|
+
* installer's `<absolute-path>` + `|| true` design is
|
|
161
|
+
* Claude-Code-specific (Claude doesn't load PATH); the cohort
|
|
162
|
+
* vendors all spawn hooks through the user's normal shell where
|
|
163
|
+
* `npx` resolves naturally.
|
|
164
|
+
* - `--yes` suppresses npx's interactive "Need to install …" prompt
|
|
165
|
+
* so the hook never blocks waiting for stdin.
|
|
166
|
+
* - `--silent` (#1240) suppresses stdout to a single `{}` line on
|
|
167
|
+
* success. Gemini CLI specifically requires hook stdout to be
|
|
168
|
+
* valid JSON; the empty object is the minimal valid value that
|
|
169
|
+
* injects no model context. Other vendors tolerate it.
|
|
170
|
+
*
|
|
171
|
+
* Distinct from `--session-hook` (Claude-Code-specific, exit-0 on
|
|
172
|
+
* every error). See `commands/update.mjs`'s docstring for the
|
|
173
|
+
* cross-flag contract table.
|
|
174
|
+
*/
|
|
175
|
+
export const AGENT_HOOK_COMMAND = "npx --yes skillrepo update --silent";
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Substring that identifies a cohort SessionStart-hook entry as
|
|
179
|
+
* SkillRepo-owned (#1240). The cohort installers write a `command`
|
|
180
|
+
* field equal to `AGENT_HOOK_COMMAND`; the remover walks each cohort
|
|
181
|
+
* agent's hook config and filters out any entry whose command
|
|
182
|
+
* contains this substring.
|
|
183
|
+
*
|
|
184
|
+
* Same word-boundary rationale as `SESSION_HOOK_FINGERPRINT`: the
|
|
185
|
+
* leading space requires `skillrepo` to be preceded by whitespace
|
|
186
|
+
* (i.e., its own argv token), so a hypothetical command like
|
|
187
|
+
* `myapp-skillrepo update --silent` cannot false-match.
|
|
188
|
+
*
|
|
189
|
+
* Both ends of the fingerprint are deliberately permissive — the
|
|
190
|
+
* fingerprint is shorter than the full command at BOTH the prefix
|
|
191
|
+
* and suffix:
|
|
192
|
+
* - PREFIX tolerance: a future change to the wrapper invocation
|
|
193
|
+
* (e.g., switching from `npx --yes` to `bunx`) preserves this
|
|
194
|
+
* substring and stays round-trip compatible with installed
|
|
195
|
+
* hook entries.
|
|
196
|
+
* - SUFFIX tolerance: a future suffix addition (e.g., piping to
|
|
197
|
+
* a logger, or adding a `--quiet`-style alias flag) ALSO
|
|
198
|
+
* preserves the substring. This is intentional: the remover
|
|
199
|
+
* must match every legitimate evolution of the SkillRepo hook
|
|
200
|
+
* command without forcing a fingerprint bump.
|
|
201
|
+
*
|
|
202
|
+
* The trade-off is that an unrelated tool's hook command containing
|
|
203
|
+
* the literal sequence ` skillrepo update --silent` somewhere in its
|
|
204
|
+
* argv would false-match. The two-token combination
|
|
205
|
+
* `skillrepo update --silent` is specific enough that no real-world
|
|
206
|
+
* non-SkillRepo command realistically produces it; the fingerprint
|
|
207
|
+
* test in `agent-hook-{claude,cursor}-shape.test.mjs` covers the
|
|
208
|
+
* round-trip but does not enumerate every possible false-positive.
|
|
209
|
+
* If a real-world collision ever surfaces, the right fix is to bump
|
|
210
|
+
* the fingerprint to a more specific marker (e.g., a version-pinned
|
|
211
|
+
* sentinel) and gate the change with a backward-compat test that
|
|
212
|
+
* proves both old and new shapes are matched.
|
|
213
|
+
*
|
|
214
|
+
* Any change to the substring itself MUST be coordinated with the
|
|
215
|
+
* installer mergers (agent-hook-{claude,cursor}-shape.mjs) and the
|
|
216
|
+
* remover (removers/agent-hooks.mjs).
|
|
217
|
+
*/
|
|
218
|
+
export const AGENT_HOOK_FINGERPRINT = " skillrepo update --silent";
|
|
219
|
+
|
|
142
220
|
// ── Artifact descriptors ────────────────────────────────────────────
|
|
143
221
|
|
|
144
222
|
/**
|
|
145
223
|
* @typedef {Object} ArtifactDescriptor
|
|
146
224
|
* @property {string} id - Stable identifier. Shows up in --json output.
|
|
147
225
|
* @property {"project" | "global"} scope - Which teardown pass owns this.
|
|
148
|
-
* @property {"json-key" | "json-input" | "line" | "section" | "directory"} kind
|
|
226
|
+
* @property {"json-key" | "json-input" | "line" | "section" | "directory" | "agent-hook"} kind
|
|
149
227
|
* How the remover deletes this artifact.
|
|
150
228
|
* @property {() => string} pathFn - Resolves the on-disk path (absolute).
|
|
151
229
|
* @property {string} displayPath - Human-readable path for output (e.g.
|
|
@@ -158,7 +236,13 @@ export const SESSION_HOOK_FINGERPRINT = " update --session-hook";
|
|
|
158
236
|
* @property {string} [sectionHeader] - For section: the header line;
|
|
159
237
|
* every line under it up to a blank-line sentinel is owned.
|
|
160
238
|
* @property {string} [commandFingerprint] - For the SessionStart hook
|
|
161
|
-
* entry: `command` field
|
|
239
|
+
* entry (kind="json-key" or "agent-hook"): `command` field
|
|
240
|
+
* contains this substring.
|
|
241
|
+
* @property {string} [vendorKey] - For kind="agent-hook" (#1240):
|
|
242
|
+
* AGENT_REGISTRY key whose `agentHook` spec drives the walk.
|
|
243
|
+
* The remover dispatches to the per-shape merger via this key
|
|
244
|
+
* rather than inlining the shape data here, so the registry
|
|
245
|
+
* stays static even as the per-shape merger logic evolves.
|
|
162
246
|
*/
|
|
163
247
|
|
|
164
248
|
/**
|
|
@@ -284,6 +368,31 @@ export const ARTIFACT_REGISTRY = Object.freeze([
|
|
|
284
368
|
displayPath: "~/.claude/settings.local.json",
|
|
285
369
|
commandFingerprint: SESSION_HOOK_FINGERPRINT,
|
|
286
370
|
}),
|
|
371
|
+
|
|
372
|
+
// ── Cohort agent-hook artifacts (#1240) ────────────────────────────
|
|
373
|
+
//
|
|
374
|
+
// One descriptor per cohort vendor whose `agentHook` spec is non-null
|
|
375
|
+
// in the agent registry. Each descriptor's `kind: "agent-hook"`
|
|
376
|
+
// routes the uninstaller to `removers/agent-hooks.mjs`, which uses
|
|
377
|
+
// the `vendorKey` to look up the per-shape walk in the agent
|
|
378
|
+
// registry. The cohort hooks live under `~/.<vendor>/...` (user
|
|
379
|
+
// scope), so all four are scope: "global".
|
|
380
|
+
//
|
|
381
|
+
// These descriptors are derived (filter+map) rather than hand-typed
|
|
382
|
+
// so the catalog cannot drift from `agent-registry.mjs`'s `agentHook`
|
|
383
|
+
// data. The agent registry is the single source of truth for
|
|
384
|
+
// per-vendor hook configuration; this is just its uninstall index.
|
|
385
|
+
...AGENT_REGISTRY.filter((entry) => entry.agentHook !== null).map((entry) =>
|
|
386
|
+
Object.freeze({
|
|
387
|
+
id: `agent-hook-${entry.key}`,
|
|
388
|
+
scope: "global",
|
|
389
|
+
kind: "agent-hook",
|
|
390
|
+
pathFn: entry.agentHook.pathFn,
|
|
391
|
+
displayPath: entry.agentHook.displayPath,
|
|
392
|
+
commandFingerprint: AGENT_HOOK_FINGERPRINT,
|
|
393
|
+
vendorKey: entry.key,
|
|
394
|
+
}),
|
|
395
|
+
),
|
|
287
396
|
]);
|
|
288
397
|
|
|
289
398
|
/**
|
package/src/lib/fs-utils.mjs
CHANGED
|
@@ -87,7 +87,22 @@ export function writeFileAtomic(filePath, content, { mode } = {}) {
|
|
|
87
87
|
throw new Error(`${filePath} is a directory, expected a file`);
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
-
|
|
90
|
+
// Per-process unique temp suffix prevents two concurrent writers
|
|
91
|
+
// (e.g. a SessionStart hook firing `skillrepo update --silent` while
|
|
92
|
+
// the user runs `skillrepo uninstall --global`) from writing to the
|
|
93
|
+
// same `.tmp` path and producing non-deterministic final content.
|
|
94
|
+
// The cohort SessionStart hooks (#1240) made this race observable
|
|
95
|
+
// in production: hook configs at user-scope can be touched by both
|
|
96
|
+
// the hook runner and the uninstaller simultaneously.
|
|
97
|
+
//
|
|
98
|
+
// `process.pid` plus a `Math.random()` token gives uniqueness across
|
|
99
|
+
// processes AND across rapid same-process re-entrant writes. The
|
|
100
|
+
// token is base-36 chars from a 64-bit float — collision probability
|
|
101
|
+
// between siblings within the rename window is astronomically low.
|
|
102
|
+
// No cryptographic strength required: this is a temp-file suffix,
|
|
103
|
+
// not a security boundary.
|
|
104
|
+
const uniqueSuffix = `${process.pid}.${Math.random().toString(36).slice(2, 10)}`;
|
|
105
|
+
const tmpPath = `${filePath}.${uniqueSuffix}.tmp`;
|
|
91
106
|
try {
|
|
92
107
|
writeFileSync(tmpPath, content, "utf-8");
|
|
93
108
|
} catch (err) {
|
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)
|
|
372
|
-
"
|
|
373
|
-
"
|
|
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
|
|
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
|
-
*
|
|
657
|
-
*
|
|
658
|
-
*
|
|
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: "
|
|
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
|
/**
|