skillrepo 2.0.0 → 3.1.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.
Files changed (72) hide show
  1. package/README.md +276 -145
  2. package/bin/skillrepo.mjs +224 -36
  3. package/package.json +6 -3
  4. package/src/commands/add.mjs +176 -0
  5. package/src/commands/get.mjs +116 -0
  6. package/src/commands/init.mjs +589 -143
  7. package/src/commands/list.mjs +176 -0
  8. package/src/commands/remove.mjs +162 -0
  9. package/src/commands/search.mjs +188 -0
  10. package/src/commands/session-sync.mjs +152 -0
  11. package/src/commands/uninstall.mjs +484 -0
  12. package/src/commands/update.mjs +184 -0
  13. package/src/lib/artifact-registry.mjs +265 -0
  14. package/src/lib/cli-config.mjs +230 -0
  15. package/src/lib/config.mjs +238 -0
  16. package/src/lib/detect-ides.mjs +0 -19
  17. package/src/lib/errors.mjs +264 -0
  18. package/src/lib/file-write.mjs +705 -0
  19. package/src/lib/fs-utils.mjs +83 -1
  20. package/src/lib/http.mjs +817 -37
  21. package/src/lib/identifier.mjs +153 -0
  22. package/src/lib/mcp-merge.mjs +275 -0
  23. package/src/lib/mergers/gitignore.mjs +73 -18
  24. package/src/lib/mergers/session-hook.mjs +298 -0
  25. package/src/lib/paths.mjs +67 -17
  26. package/src/lib/prompt.mjs +11 -44
  27. package/src/lib/removers/claude-mcp.mjs +67 -0
  28. package/src/lib/removers/cursor-mcp.mjs +60 -0
  29. package/src/lib/removers/env-local.mjs +55 -0
  30. package/src/lib/removers/gitignore.mjs +108 -0
  31. package/src/lib/removers/settings.mjs +183 -0
  32. package/src/lib/removers/vscode-mcp.mjs +87 -0
  33. package/src/lib/removers/windsurf-mcp.mjs +65 -0
  34. package/src/lib/sync.mjs +305 -0
  35. package/src/test/commands/add.test.mjs +285 -0
  36. package/src/test/commands/get.test.mjs +176 -0
  37. package/src/test/commands/init.test.mjs +697 -0
  38. package/src/test/commands/list.test.mjs +172 -0
  39. package/src/test/commands/remove.test.mjs +234 -0
  40. package/src/test/commands/search.test.mjs +204 -0
  41. package/src/test/commands/session-sync.test.mjs +350 -0
  42. package/src/test/commands/uninstall.test.mjs +768 -0
  43. package/src/test/commands/update.test.mjs +322 -0
  44. package/src/test/detect-ides.test.mjs +9 -14
  45. package/src/test/dispatcher.test.mjs +224 -0
  46. package/src/test/e2e/cli-commands.test.mjs +576 -0
  47. package/src/test/e2e/mock-server.mjs +364 -22
  48. package/src/test/helpers/capture-stream.mjs +48 -0
  49. package/src/test/integration/file-write.integration.test.mjs +279 -0
  50. package/src/test/lib/artifact-registry.test.mjs +268 -0
  51. package/src/test/lib/cli-config.test.mjs +407 -0
  52. package/src/test/lib/config.test.mjs +257 -0
  53. package/src/test/lib/errors.test.mjs +359 -0
  54. package/src/test/lib/file-write.test.mjs +784 -0
  55. package/src/test/lib/http.test.mjs +1198 -0
  56. package/src/test/lib/identifier.test.mjs +157 -0
  57. package/src/test/lib/mcp-merge.test.mjs +345 -0
  58. package/src/test/lib/paths.test.mjs +83 -0
  59. package/src/test/lib/sync.test.mjs +514 -0
  60. package/src/test/mergers/gitignore.test.mjs +145 -20
  61. package/src/test/mergers/session-hook.test.mjs +745 -0
  62. package/src/test/mergers/uninstall-claude-mcp.test.mjs +145 -0
  63. package/src/test/mergers/uninstall-cursor-mcp.test.mjs +108 -0
  64. package/src/test/mergers/uninstall-env-local.test.mjs +144 -0
  65. package/src/test/mergers/uninstall-gitignore.test.mjs +209 -0
  66. package/src/test/mergers/uninstall-settings.test.mjs +285 -0
  67. package/src/test/mergers/uninstall-vscode-mcp.test.mjs +215 -0
  68. package/src/test/mergers/uninstall-windsurf-mcp.test.mjs +122 -0
  69. package/src/lib/write-configs.mjs +0 -202
  70. package/src/test/e2e/HANDOFF.md +0 -223
  71. package/src/test/e2e/cli-init.test.mjs +0 -213
  72. package/src/test/e2e/payload-factory.mjs +0 -22
package/src/lib/http.mjs CHANGED
@@ -1,66 +1,846 @@
1
1
  /**
2
- * HTTP client for the SkillRepo API.
3
- * Uses Node 18+ built-in fetch. Zero dependencies.
2
+ * HTTP client for the SkillRepo v1 API (#646 PR2).
3
+ *
4
+ * Replaces the v2.0.0 setup-payload client with one function per
5
+ * documented v1 endpoint. Every endpoint is implemented as a thin
6
+ * fetch + status-code dispatcher that returns a structured result
7
+ * (success or typed CliError) so commands can map outcomes to
8
+ * exit codes without duplicating HTTP bookkeeping.
9
+ *
10
+ * Design rules:
11
+ * • Zero non-Node dependencies — use built-in `fetch`
12
+ * • Every function takes `(serverUrl, apiKey, ...args)` so callers
13
+ * stay explicit about credentials and target
14
+ * • 401 → authError (exit 2)
15
+ * • 403 with `code: scope_required` (or any 403 on a scoped route) → scopeError (exit 4)
16
+ * • 403 generic → authError (suspended account)
17
+ * • 404 → null result (let caller decide if that's an error)
18
+ * • 429/5xx → networkError with the response body as hint
19
+ * • Network failures (fetch throws) → networkError
20
+ *
21
+ * The `User-Agent` header threads through every request so server-side
22
+ * telemetry can attribute traffic by CLI version.
4
23
  */
5
24
 
6
- const VERSION = "1.9.0";
25
+ import {
26
+ authError,
27
+ networkError,
28
+ scopeError,
29
+ validationError,
30
+ withRetry,
31
+ } from "./errors.mjs";
32
+
7
33
  const DEFAULT_URL = "https://skillrepo.dev";
8
34
 
9
35
  /**
10
- * Fetch the setup payload from the SkillRepo API.
11
- * @param {string} apiKey - Access key (sk_live_...)
12
- * @param {string} [baseUrl] - SkillRepo base URL (defaults to https://skillrepo.dev)
13
- * @returns {Promise<object>} The setup payload
36
+ * Default per-request timeout in milliseconds. The CLI must NOT hang
37
+ * indefinitely on a slow or unreachable server — every fetch wraps
38
+ * itself in an AbortController that fires this timeout. PR4's retry
39
+ * logic layers on top by wrapping individual calls.
40
+ *
41
+ * 30 seconds is generous for the slowest expected operation
42
+ * (the bulk library sync with 100+ inlined skill files), and short
43
+ * enough that a hung connection surfaces as a clean error within
44
+ * the user's patience window. Override via SKILLREPO_TIMEOUT_MS for
45
+ * smoke tests against a known-slow staging environment.
46
+ *
47
+ * Special values:
48
+ * • SKILLREPO_TIMEOUT_MS=0 → disable the timeout entirely (Infinity).
49
+ * Use only when you know the server can hang forever (debugging).
50
+ * • SKILLREPO_TIMEOUT_MS=<positive> → use that value
51
+ * • SKILLREPO_TIMEOUT_MS=<negative or non-numeric> → fall back to 30s
52
+ */
53
+ const DEFAULT_TIMEOUT_MS = (() => {
54
+ const raw = process.env.SKILLREPO_TIMEOUT_MS;
55
+ if (raw === "0") return Infinity;
56
+ const parsed = Number(raw);
57
+ if (Number.isFinite(parsed) && parsed > 0) return parsed;
58
+ return 30_000;
59
+ })();
60
+
61
+ // PR3b deleted the legacy AuthError/SuspendedError/NetworkError
62
+ // classes and `fetchSetupPayload` function — the v2.0.0 init flow
63
+ // was rewritten to use `validateAccessKey` via the new
64
+ // /api/v1/auth/validate route, and nothing else in the codebase
65
+ // imported those names. The /api/v1/setup route deletion is
66
+ // tracked as a follow-up PR (scheduled after the v3.0.0 npm publish).
67
+
68
+ /**
69
+ * Read the package version once at import time. Separate from the
70
+ * dispatcher so commands that import http.mjs without going through
71
+ * the binary still get a meaningful UA string.
72
+ */
73
+ let _cachedVersion = null;
74
+ async function userAgent() {
75
+ if (_cachedVersion) return `skillrepo-cli/${_cachedVersion}`;
76
+ try {
77
+ const pkg = await import("../../package.json", { with: { type: "json" } });
78
+ _cachedVersion = pkg.default.version;
79
+ } catch {
80
+ _cachedVersion = "unknown";
81
+ }
82
+ return `skillrepo-cli/${_cachedVersion}`;
83
+ }
84
+
85
+ /**
86
+ * Build the canonical headers for every authenticated request.
87
+ */
88
+ async function authHeaders(apiKey) {
89
+ // Upfront guard so a misconfigured caller fails with a clear,
90
+ // typed error instead of sending `Bearer undefined` over the wire.
91
+ // Without this, the round-trip succeeds (server returns 401), the
92
+ // user sees an opaque auth error, and server logs get a spammed
93
+ // literal "undefined" Bearer token. The PR3 commands all flow
94
+ // through here, so a single guard catches the whole class.
95
+ //
96
+ // Round-1 review (PR4) caught that init.mjs trims the key before
97
+ // persisting, but `--key` flags and env-var fallbacks do NOT —
98
+ // so a pasted key with a trailing newline could reach this
99
+ // helper with whitespace and get sent verbatim as
100
+ // `Bearer sk_live_xyz\n`. We trim unconditionally here so every
101
+ // path through authHeaders produces a clean Bearer header.
102
+ if (typeof apiKey !== "string") {
103
+ throw authError("No access key configured.", {
104
+ hint: "Run `skillrepo init` or pass --key <sk_live_...>.",
105
+ });
106
+ }
107
+ const trimmed = apiKey.trim();
108
+ if (trimmed === "") {
109
+ throw authError("No access key configured.", {
110
+ hint: "Run `skillrepo init` or pass --key <sk_live_...>.",
111
+ });
112
+ }
113
+ return {
114
+ Authorization: `Bearer ${trimmed}`,
115
+ Accept: "application/json",
116
+ "User-Agent": await userAgent(),
117
+ };
118
+ }
119
+
120
+ /**
121
+ * Normalize a server URL for use in API requests.
122
+ *
123
+ * Treats `null` and `undefined` as "use the default" but rejects an
124
+ * explicit empty string so a misconfigured `--url ""` flag fails
125
+ * loudly instead of silently targeting production. This matters for
126
+ * write commands (PR3a's add/remove) where a user thinking they're
127
+ * hitting staging would mutate production data.
128
+ */
129
+ function normalizeUrl(serverUrl) {
130
+ if (serverUrl === null || serverUrl === undefined) {
131
+ return DEFAULT_URL;
132
+ }
133
+ if (typeof serverUrl !== "string" || serverUrl.trim() === "") {
134
+ throw validationError(
135
+ `Invalid server URL: ${JSON.stringify(serverUrl)}`,
136
+ { hint: "Pass --url <url> with a valid URL, or omit it to use the default." },
137
+ );
138
+ }
139
+ return serverUrl.replace(/\/+$/, "");
140
+ }
141
+
142
+ /**
143
+ * Wrap a fetch invocation with a timeout and a typed error.
144
+ *
145
+ * The AbortController fires `DEFAULT_TIMEOUT_MS` after the request
146
+ * starts. A timeout produces a `networkError` with a hint pointing the
147
+ * user at `SKILLREPO_TIMEOUT_MS` for the slow-server case. Any other
148
+ * fetch failure (DNS, refused, TLS, etc.) is also wrapped as
149
+ * `networkError` so callers can catch by exit code without checking
150
+ * the underlying cause.
151
+ *
152
+ * Signal composition: the init may include its own `signal`, but this
153
+ * function intentionally OVERWRITES it with the timeout signal — see
154
+ * the inline comment at the spread. When retry is enabled, each
155
+ * attempt creates its own `AbortController` inside the nested
156
+ * `attempt()` closure so aborting attempt 1 does NOT cascade into
157
+ * attempt 2 — this is the per-attempt timeout pattern. No caller
158
+ * passes its own signal today, so first-caller-wins on the spread
159
+ * is still acceptable for the single-attempt path.
160
+ *
161
+ * If `DEFAULT_TIMEOUT_MS` is `Infinity` (set via `SKILLREPO_TIMEOUT_MS=0`),
162
+ * we skip the `setTimeout` entirely so we don't leak a never-firing
163
+ * timer into the event loop.
164
+ */
165
+ async function safeFetch(url, init = {}) {
166
+ // `retry` and `retryOptions` are consumed here and NOT forwarded
167
+ // to fetch — pull them out before building the fetch init.
168
+ const { retry, retryOptions, ...fetchInit } = init;
169
+
170
+ const attempt = async () => {
171
+ const controller = new AbortController();
172
+ const timeoutId = Number.isFinite(DEFAULT_TIMEOUT_MS)
173
+ ? setTimeout(() => controller.abort(), DEFAULT_TIMEOUT_MS)
174
+ : null;
175
+ try {
176
+ // Intentional: our timeout signal wins over any caller-provided
177
+ // signal. The retry loop creates a fresh controller per attempt
178
+ // so aborting attempt 1 doesn't cascade into attempt 2.
179
+ return await fetch(url, { ...fetchInit, signal: controller.signal });
180
+ } catch (err) {
181
+ if (err.name === "AbortError") {
182
+ throw networkError(
183
+ `Request to ${url} timed out after ${DEFAULT_TIMEOUT_MS}ms`,
184
+ {
185
+ cause: err,
186
+ hint: "Set SKILLREPO_TIMEOUT_MS (milliseconds) to override, or 0 to disable the timeout.",
187
+ },
188
+ );
189
+ }
190
+ throw networkError(`Cannot reach ${url}: ${err.message}`, { cause: err });
191
+ } finally {
192
+ if (timeoutId !== null) clearTimeout(timeoutId);
193
+ }
194
+ };
195
+
196
+ if (!retry) {
197
+ return attempt();
198
+ }
199
+
200
+ // Retry path: wrap attempt() in withRetry so both a thrown
201
+ // networkError AND a Response with a transient status get retried.
202
+ // The retried attempt() returns a Response — withRetry checks
203
+ // `isRetryable(result)` which handles the Response case via the
204
+ // TRANSIENT_STATUS_CODES set. After retries are exhausted (either
205
+ // from success on a later attempt or from running out), we return
206
+ // whatever attempt() returned so mapErrorResponse handles the
207
+ // final status normally.
208
+ //
209
+ // --verbose wiring: the dispatcher sets SKILLREPO_VERBOSE=1 when
210
+ // the user passes --verbose, and this layer installs a default
211
+ // onRetry handler that writes to stderr. Callers can still pass
212
+ // their own retryOptions.onRetry to override (it wins). Without
213
+ // --verbose, there's no onRetry handler and retries fire silently
214
+ // — that's the pre-existing behavior.
215
+ const mergedOptions = resolveRetryOptions(url, retryOptions);
216
+ return withRetry(attempt, mergedOptions);
217
+ }
218
+
219
+ /**
220
+ * Merge caller-supplied retry options with the verbose default.
221
+ * When SKILLREPO_VERBOSE is truthy and the caller did not supply
222
+ * its own onRetry, install a default handler that reports each
223
+ * retry attempt to stderr. The handler honors NO_COLOR and is
224
+ * silenced when the caller supplies its own onRetry (explicit
225
+ * test injections still win).
226
+ */
227
+ function resolveRetryOptions(url, callerOptions) {
228
+ if (!process.env.SKILLREPO_VERBOSE) return callerOptions;
229
+ if (callerOptions?.onRetry) return callerOptions;
230
+
231
+ const dim = (s) =>
232
+ process.env.NO_COLOR || !process.stderr.isTTY ? s : `\x1b[2m${s}\x1b[0m`;
233
+ const onRetry = ({ attempt, delayMs, cause }) => {
234
+ const causeSummary =
235
+ cause instanceof Error
236
+ ? cause.message
237
+ : cause && typeof cause === "object" && typeof cause.status === "number"
238
+ ? `HTTP ${cause.status}`
239
+ : String(cause);
240
+ process.stderr.write(
241
+ dim(
242
+ ` [retry] ${url}: attempt ${attempt} failed (${causeSummary}), ` +
243
+ `sleeping ${delayMs}ms before retry\n`,
244
+ ),
245
+ );
246
+ };
247
+ return { ...(callerOptions ?? {}), onRetry };
248
+ }
249
+
250
+ /**
251
+ * Parse a JSON body in the success path, wrapping any parse failure
252
+ * as a typed `networkError`. The error path uses its own `try/catch`
253
+ * around `res.json()` because it tolerates a body-less or non-JSON
254
+ * response (the status code is the signal). The success path cannot
255
+ * tolerate a malformed body — if a proxy or CDN returned 200 with an
256
+ * HTML error page, the consumer would otherwise get an untyped
257
+ * `SyntaxError` with no exit code.
258
+ */
259
+ async function parseJsonOrThrow(res, url) {
260
+ try {
261
+ return await res.json();
262
+ } catch (err) {
263
+ throw networkError(
264
+ `Server returned a non-JSON ${res.status} response from ${url}`,
265
+ {
266
+ cause: err,
267
+ hint: "An upstream proxy or CDN may have replaced the response body. Check the URL or retry.",
268
+ },
269
+ );
270
+ }
271
+ }
272
+
273
+ /**
274
+ * Map an error response body to a CliError of the right exit code.
275
+ * Pulled out as a shared helper so every endpoint applies the same
276
+ * 401/403 semantics.
277
+ */
278
+ async function mapErrorResponse(res, url) {
279
+ let body = {};
280
+ try {
281
+ body = await res.json();
282
+ } catch {
283
+ /* not JSON — body stays {} */
284
+ }
285
+ const message = body.error || `${res.status} ${res.statusText}`;
286
+ const code = body.code;
287
+
288
+ if (res.status === 401) {
289
+ return authError(message, {
290
+ hint: "Run `skillrepo init` to refresh your access key.",
291
+ });
292
+ }
293
+ if (res.status === 403) {
294
+ // 403 has THREE distinct meanings on the CLI surface, distinguished
295
+ // by the `code` field in the response body:
296
+ //
297
+ // 1. `plan_limit` — the user is over their library quota. POST
298
+ // /api/v1/library returns this with code `plan_limit` per
299
+ // `src/app/api/v1/library/route.ts:226-235`. The route comment
300
+ // EXPLICITLY documents that the CLI must distinguish this from
301
+ // scope errors. We map it to `validationError` (exit 5) with a
302
+ // hint pointing at billing — it is not a key-permission issue
303
+ // so neither authError nor scopeError is the right fit, and
304
+ // adding a dedicated planLimitError to the exit code matrix is
305
+ // out of scope for v3.0.0.
306
+ //
307
+ // 2. Scope-related 403s carry a `code` containing "scope" (the
308
+ // exact name is documented in #875). These map to scopeError.
309
+ //
310
+ // 3. Generic 403 — suspended account, blocked user, etc. Maps to
311
+ // authError with a "contact support" hint.
312
+ if (typeof code === "string" && code === "plan_limit") {
313
+ return validationError(message, {
314
+ hint: "Upgrade your plan at /app/settings/billing or remove an existing skill from your library.",
315
+ });
316
+ }
317
+ if (typeof code === "string" && code.toLowerCase().includes("scope")) {
318
+ return scopeError(message, {
319
+ hint: "Create a write-scoped key at /app/settings/access-keys.",
320
+ });
321
+ }
322
+ return authError(message, {
323
+ hint: "If your account is suspended, contact support.",
324
+ });
325
+ }
326
+ if (res.status === 404) {
327
+ return null; // Caller decides
328
+ }
329
+ if (res.status === 429) {
330
+ // 429 hits mapErrorResponse in two cases: (1) a read-only
331
+ // endpoint's retry budget was exhausted (safeFetch with
332
+ // retry:true), or (2) a write endpoint that doesn't retry
333
+ // (add/remove) just saw its single shot return 429. The old
334
+ // hint claimed "retried automatically" which is only true for
335
+ // case 1 — case 2 users saw a false claim. The generic hint
336
+ // below is accurate for both without needing to thread the
337
+ // retry flag all the way through.
338
+ return networkError("Rate limit exceeded — server returned 429.", {
339
+ hint:
340
+ "Wait a minute and retry. Read-only commands (update, get, " +
341
+ "list, search) retry automatically; write commands (add, " +
342
+ "remove) are single-shot. Pass --verbose to see retry " +
343
+ "attempts when they happen.",
344
+ });
345
+ }
346
+ if (res.status >= 500) {
347
+ return networkError(`Server error ${res.status} from ${url}: ${message}`);
348
+ }
349
+ // Generic 4xx (validation etc.)
350
+ return validationError(message);
351
+ }
352
+
353
+ // ── /api/v1/auth/validate ──────────────────────────────────────────────
354
+
355
+ /**
356
+ * @typedef {Object} AuthValidateResult
357
+ * @property {string} userId
358
+ * @property {string} accountId
359
+ * @property {string} accountSlug
360
+ * @property {string} accountName
361
+ * @property {string[]} scopes
362
+ * @property {string} keyId
363
+ * @property {string} tier
364
+ */
365
+
366
+ /**
367
+ * POST /api/v1/auth/validate — verify an access key and fetch the
368
+ * authenticated account context. Replaces the deprecated
369
+ * `/api/v1/setup` endpoint for credential validation.
370
+ *
371
+ * @param {string} serverUrl
372
+ * @param {string} apiKey
373
+ * @returns {Promise<AuthValidateResult>}
374
+ */
375
+ export async function validateAccessKey(serverUrl, apiKey) {
376
+ const url = `${normalizeUrl(serverUrl)}/api/v1/auth/validate`;
377
+ const res = await safeFetch(url, {
378
+ method: "POST",
379
+ headers: await authHeaders(apiKey),
380
+ // POST /auth/validate is side-effect-free on the server (it
381
+ // only reads the key and returns account context), so retrying
382
+ // a transient 5xx is safe. 4xx auth errors are NOT retried
383
+ // because isRetryable() only matches networkError + TRANSIENT_STATUS_CODES.
384
+ retry: true,
385
+ });
386
+ if (!res.ok) {
387
+ const err = await mapErrorResponse(res, url);
388
+ if (err === null) {
389
+ // 404 on validate means the route doesn't exist on the server —
390
+ // surface that as a validation error so the user knows the URL
391
+ // is wrong, not the key.
392
+ throw validationError(`The server at ${normalizeUrl(serverUrl)} does not expose /api/v1/auth/validate. Is --url correct?`);
393
+ }
394
+ throw err;
395
+ }
396
+ return parseJsonOrThrow(res, url);
397
+ }
398
+
399
+ // ── /api/v1/library ─────────────────────────────────────────────────────
400
+
401
+ /**
402
+ * @typedef {Object} SyncFile
403
+ * @property {string} path
404
+ * @property {string} content
405
+ * @property {string} sha256
406
+ * @property {number} size
407
+ * @property {string} contentType
408
+ * @property {string} [encoding] - Optional encoding hint. The server
409
+ * sends `"utf-8"` on the SKILL.md entry; supporting files generally
410
+ * omit it. PR2 commands should pass through verbatim — they don't
411
+ * need to interpret it.
412
+ */
413
+
414
+ /**
415
+ * @typedef {Object} SyncSkill
416
+ * @property {string} owner
417
+ * @property {string} name
418
+ * @property {string} version
419
+ * @property {string} description
420
+ * @property {string|null} [license]
421
+ * @property {string[]} [keywords] - Trigger keywords extracted from the
422
+ * skill's frontmatter `triggers` field. Used by `list` and `search`
423
+ * for display, not by `update`/`get` for anything functional.
424
+ * @property {string} etag - Per-skill version-pinned ETag computed by
425
+ * the server as `"<owner>/<name>@<updatedAtMs>"`. Stable across
426
+ * reads of the same version. PR4's retry logic may use this for
427
+ * single-skill cache invalidation.
428
+ * @property {object} [contextSignals] - Server-computed activation
429
+ * signals (file globs, prompt patterns, etc.). Forward-compatible
430
+ * payload — PR2 commands ignore it.
431
+ * @property {SyncFile[]} files
432
+ * @property {boolean} [filesIncomplete] - True when one or more files
433
+ * failed to inline from blob storage. CLI must NOT cache the
434
+ * skill's ETag in that case (the server already stripped its own
435
+ * ETag header for incomplete responses).
436
+ * @property {string} updatedAt
437
+ */
438
+
439
+ /**
440
+ * @typedef {Object} Removal
441
+ * @property {string} owner
442
+ * @property {string} name
443
+ * @property {string} removedAt
14
444
  */
15
- export async function fetchSetupPayload(apiKey, baseUrl) {
16
- const url = `${(baseUrl || DEFAULT_URL).replace(/\/+$/, "")}/api/v1/setup`;
17
445
 
18
- const res = await fetch(url, {
446
+ /**
447
+ * @typedef {Object} LibrarySyncResult
448
+ * @property {SyncSkill[]} skills
449
+ * @property {Removal[]} removals
450
+ * @property {string} syncedAt
451
+ * @property {string|null} etag
452
+ * @property {boolean} notModified - True if the server returned 304
453
+ */
454
+
455
+ /**
456
+ * GET /api/v1/library — bulk library sync with ETag + delta support.
457
+ *
458
+ * Returns either a fresh payload OR a `notModified: true` sentinel.
459
+ *
460
+ * Contract for the 304 path: `notModified: true` is reachable ONLY
461
+ * when `opts.ifNoneMatch` was provided, because the server only
462
+ * returns 304 in response to an `If-None-Match` header. In that case
463
+ * the returned `etag` is whatever the caller sent (the same value is
464
+ * still authoritative — the server confirms it's current). If a
465
+ * misconfigured server returns 304 without an `If-None-Match` (rare;
466
+ * misbehaving CDN/proxy), `etag` falls back to null and the caller's
467
+ * next sync will be unconditional. That fallback is a safety net, not
468
+ * a contract — callers should treat `etag === null && notModified`
469
+ * as "something is wrong, refetch fresh".
470
+ *
471
+ * @param {string} serverUrl
472
+ * @param {string} apiKey
473
+ * @param {object} [opts]
474
+ * @param {string} [opts.ifNoneMatch] - ETag to send as If-None-Match
475
+ * @param {string} [opts.since] - ISO timestamp for delta filter
476
+ * @returns {Promise<LibrarySyncResult>}
477
+ */
478
+ export async function getLibrary(serverUrl, apiKey, opts = {}) {
479
+ const base = normalizeUrl(serverUrl);
480
+ const params = new URLSearchParams();
481
+ if (opts.since) params.set("since", opts.since);
482
+ const url = params.toString()
483
+ ? `${base}/api/v1/library?${params.toString()}`
484
+ : `${base}/api/v1/library`;
485
+
486
+ const headers = await authHeaders(apiKey);
487
+ if (opts.ifNoneMatch) {
488
+ headers["If-None-Match"] = opts.ifNoneMatch;
489
+ }
490
+
491
+ // GET /library is read-only and idempotent — safe to retry on
492
+ // transient 5xx / network errors / 429.
493
+ const res = await safeFetch(url, { method: "GET", headers, retry: true });
494
+
495
+ if (res.status === 304) {
496
+ return {
497
+ skills: [],
498
+ removals: [],
499
+ syncedAt: new Date().toISOString(),
500
+ etag: opts.ifNoneMatch || null,
501
+ notModified: true,
502
+ };
503
+ }
504
+
505
+ if (!res.ok) {
506
+ const err = await mapErrorResponse(res, url);
507
+ if (err === null) {
508
+ throw validationError(`Library endpoint returned 404 — server URL probably wrong.`);
509
+ }
510
+ throw err;
511
+ }
512
+
513
+ const body = await parseJsonOrThrow(res, url);
514
+ return {
515
+ skills: body.skills ?? [],
516
+ removals: body.removals ?? [],
517
+ syncedAt: body.syncedAt ?? new Date().toISOString(),
518
+ etag: res.headers.get("etag"),
519
+ notModified: false,
520
+ };
521
+ }
522
+
523
+ // ── /api/v1/skills/[owner]/[name] ───────────────────────────────────────
524
+
525
+ /**
526
+ * GET /api/v1/skills/{owner}/{name} — fetch a single skill with inlined
527
+ * file content.
528
+ *
529
+ * Returns `null` on 404. This is intentionally asymmetric with
530
+ * `validateAccessKey` and `getLibrary`, which throw a `validationError`
531
+ * when `mapErrorResponse` returns null. The reason: 404 on a single
532
+ * skill means "this owner/name doesn't exist OR isn't accessible to
533
+ * you", which is a normal user-facing outcome that the `get` command
534
+ * surfaces as a clean error message ("skill not found"). 404 on the
535
+ * library or auth/validate endpoint means the route isn't on the
536
+ * server at all, which is a configuration mistake the CLI should
537
+ * loudly flag.
538
+ *
539
+ * The double-handling (`if (res.status === 404)` AND
540
+ * `if (err === null)`) exists for defense-in-depth: the first branch
541
+ * catches the documented 404 contract, the second catches any future
542
+ * status code that might be added to `mapErrorResponse`'s null-return
543
+ * path.
544
+ *
545
+ * @param {string} serverUrl
546
+ * @param {string} apiKey
547
+ * @param {string} owner
548
+ * @param {string} name
549
+ * @returns {Promise<SyncSkill|null>}
550
+ */
551
+ export async function getSkill(serverUrl, apiKey, owner, name) {
552
+ const url = `${normalizeUrl(serverUrl)}/api/v1/skills/${encodeURIComponent(owner)}/${encodeURIComponent(name)}`;
553
+ const res = await safeFetch(url, {
19
554
  method: "GET",
555
+ headers: await authHeaders(apiKey),
556
+ // Read-only and idempotent.
557
+ retry: true,
558
+ });
559
+
560
+ if (res.status === 404) return null;
561
+ if (!res.ok) {
562
+ const err = await mapErrorResponse(res, url);
563
+ if (err === null) return null;
564
+ throw err;
565
+ }
566
+
567
+ const body = await parseJsonOrThrow(res, url);
568
+ return body.skill ?? null;
569
+ }
570
+
571
+ // ── /api/v1/library (POST — add to library) ────────────────────────────
572
+
573
+ /**
574
+ * @typedef {Object} LibraryAddSuccess
575
+ * @property {"added"} status - Server confirmed the row was inserted
576
+ * @property {string} owner
577
+ * @property {string} name
578
+ * @property {string|null} version
579
+ * @property {string} addedAt - ISO timestamp
580
+ *
581
+ * @typedef {Object} LibraryAddAlreadyInLibrary
582
+ * @property {"already-in-library"} status
583
+ * @property {string} owner
584
+ * @property {string} name
585
+ *
586
+ * @typedef {Object} LibraryAddNotFound
587
+ * @property {"not-found"} status
588
+ * @property {string} owner
589
+ * @property {string} name
590
+ *
591
+ * @typedef {Object} LibraryAddSelfOwnership
592
+ * @property {"self-ownership"} status
593
+ * @property {string} owner
594
+ * @property {string} name
595
+ *
596
+ * @typedef {LibraryAddSuccess | LibraryAddAlreadyInLibrary | LibraryAddNotFound | LibraryAddSelfOwnership} LibraryAddResult
597
+ */
598
+
599
+ /**
600
+ * POST /api/v1/library — add a skill to the authenticated account's
601
+ * library.
602
+ *
603
+ * The server's POST handler distinguishes several outcomes via both
604
+ * HTTP status and a `code` field in the response body (see
605
+ * src/app/api/v1/library/route.ts:190-237):
606
+ *
607
+ * 201 → { added: {...} } → "added"
608
+ * 404 { code: "not_found" } → "not-found"
609
+ * 409 { code: "already_in_library" } → "already-in-library"
610
+ * 409 { code: "self_ownership" } → "self-ownership"
611
+ * 403 { code: "plan_limit" } → mapErrorResponse → validationError
612
+ * 403 { code: "scope_required" } (or similar) → mapErrorResponse → scopeError
613
+ * 401 → mapErrorResponse → authError
614
+ *
615
+ * The 404 / 409 cases are documented idempotent-friendly outcomes that
616
+ * the CLI's `add` command intentionally routes through its own
617
+ * dispatcher (not mapErrorResponse) so it can react differently to
618
+ * each. Everything else flows through mapErrorResponse.
619
+ *
620
+ * Returns a discriminated union instead of throwing for the four
621
+ * "documented outcome" cases. That lets the command handle them
622
+ * without needing to catch CliError subclasses. Undocumented error
623
+ * responses are still thrown.
624
+ *
625
+ * @param {string} serverUrl
626
+ * @param {string} apiKey
627
+ * @param {string} owner
628
+ * @param {string} name
629
+ * @returns {Promise<LibraryAddResult>}
630
+ */
631
+ export async function addSkillToLibrary(serverUrl, apiKey, owner, name) {
632
+ const url = `${normalizeUrl(serverUrl)}/api/v1/library`;
633
+ const res = await safeFetch(url, {
634
+ method: "POST",
20
635
  headers: {
21
- Authorization: `Bearer ${apiKey}`,
22
- Accept: "application/json",
23
- "User-Agent": `skillrepo-cli/${VERSION}`,
636
+ ...(await authHeaders(apiKey)),
637
+ "Content-Type": "application/json",
24
638
  },
639
+ body: JSON.stringify({ owner, name }),
25
640
  });
26
641
 
27
- if (res.status === 401) {
28
- const body = await res.json().catch(() => ({}));
29
- throw new AuthError(body.error || "Invalid access key");
642
+ if (res.status === 201) {
643
+ const body = await parseJsonOrThrow(res, url);
644
+ const added = body?.added ?? {};
645
+ return {
646
+ status: "added",
647
+ owner: added.owner ?? owner,
648
+ name: added.name ?? name,
649
+ version: added.version ?? null,
650
+ addedAt: added.addedAt ?? new Date().toISOString(),
651
+ };
30
652
  }
31
653
 
32
- if (res.status === 403) {
33
- const body = await res.json().catch(() => ({}));
34
- throw new SuspendedError(body.error || "Account suspended", body.reason);
654
+ // 404 skill doesn't exist or isn't accessible
655
+ if (res.status === 404) {
656
+ // Drain the body so the response isn't left open
657
+ await res.json().catch(() => undefined);
658
+ return { status: "not-found", owner, name };
35
659
  }
36
660
 
37
- if (!res.ok) {
38
- throw new NetworkError(`Server returned ${res.status}: ${res.statusText}`);
661
+ // 409 — two distinct outcomes, disambiguated by `code`:
662
+ // code: "self_ownership" "self-ownership" (validationError)
663
+ // code: "already_in_library" → "already-in-library" (idempotent)
664
+ // anything else → throw networkError, don't guess
665
+ //
666
+ // Previously the 409 handler used `res.json().catch(() => ({}))`
667
+ // which silently mapped a malformed body to `"already-in-library"`.
668
+ // The round-1 review caught that — a 409 with a corrupt body that
669
+ // semantically means self_ownership would have silently fallen
670
+ // through to the refresh-and-write path. Now we require a
671
+ // parseable body with one of the two documented codes; any other
672
+ // shape is a server contract violation and surfaces as a typed
673
+ // network error.
674
+ if (res.status === 409) {
675
+ let body;
676
+ try {
677
+ body = await res.json();
678
+ } catch (err) {
679
+ throw networkError(
680
+ `Server returned 409 with unparseable body from ${url}`,
681
+ { cause: err },
682
+ );
683
+ }
684
+ if (body?.code === "self_ownership") {
685
+ return { status: "self-ownership", owner, name };
686
+ }
687
+ if (body?.code === "already_in_library") {
688
+ return { status: "already-in-library", owner, name };
689
+ }
690
+ // Unknown 409 code — don't guess which outcome was meant
691
+ throw validationError(
692
+ `Server returned 409 with unexpected code "${body?.code}" from ${url}`,
693
+ );
39
694
  }
40
695
 
41
- return res.json();
696
+ // Everything else → typed error via mapErrorResponse
697
+ const err = await mapErrorResponse(res, url);
698
+ if (err === null) {
699
+ throw validationError(
700
+ `Library POST returned ${res.status} with no documented meaning.`,
701
+ );
702
+ }
703
+ throw err;
42
704
  }
43
705
 
44
- // ── Error types ─────────────────────────────────────────────────────────
706
+ // ── /api/v1/library/[owner]/[name] (DELETE — remove from library) ──────
45
707
 
46
- export class AuthError extends Error {
47
- constructor(message) {
48
- super(message);
49
- this.name = "AuthError";
708
+ /**
709
+ * @typedef {Object} LibraryRemoveSuccess
710
+ * @property {"removed"} status
711
+ * @property {string} owner
712
+ * @property {string} name
713
+ * @property {string} removedAt
714
+ *
715
+ * @typedef {Object} LibraryRemoveNotInLibrary
716
+ * @property {"not-in-library"} status
717
+ * @property {string} owner
718
+ * @property {string} name
719
+ *
720
+ * @typedef {LibraryRemoveSuccess | LibraryRemoveNotInLibrary} LibraryRemoveResult
721
+ */
722
+
723
+ /**
724
+ * DELETE /api/v1/library/{owner}/{name} — remove a skill from the
725
+ * authenticated account's library.
726
+ *
727
+ * The server writes a `libraryRemovals` tombstone so other machines
728
+ * syncing this account see the removal on their next sync (pending
729
+ * the ETag fix in #875). The CLI's `remove` command does NOT depend
730
+ * on the tombstone for local cleanup — it deletes the local files
731
+ * directly after a successful DELETE (see the plan's #875 workaround).
732
+ *
733
+ * Documented outcomes:
734
+ * 200 → { removed: {...} } → "removed"
735
+ * 404 { code: "not_in_library" } → "not-in-library" (idempotent)
736
+ * 403 { code: "scope_required" } → mapErrorResponse → scopeError
737
+ * 401 → mapErrorResponse → authError
738
+ *
739
+ * Like `addSkillToLibrary`, this returns a discriminated union for the
740
+ * two documented outcomes so the command's idempotency logic stays
741
+ * out of mapErrorResponse.
742
+ *
743
+ * @param {string} serverUrl
744
+ * @param {string} apiKey
745
+ * @param {string} owner
746
+ * @param {string} name
747
+ * @returns {Promise<LibraryRemoveResult>}
748
+ */
749
+ export async function removeSkillFromLibrary(serverUrl, apiKey, owner, name) {
750
+ const url = `${normalizeUrl(serverUrl)}/api/v1/library/${encodeURIComponent(owner)}/${encodeURIComponent(name)}`;
751
+ const res = await safeFetch(url, {
752
+ method: "DELETE",
753
+ headers: await authHeaders(apiKey),
754
+ });
755
+
756
+ if (res.status === 200) {
757
+ const body = await parseJsonOrThrow(res, url);
758
+ const removed = body?.removed ?? {};
759
+ return {
760
+ status: "removed",
761
+ owner: removed.owner ?? owner,
762
+ name: removed.name ?? name,
763
+ removedAt: removed.removedAt ?? new Date().toISOString(),
764
+ };
50
765
  }
51
- }
52
766
 
53
- export class SuspendedError extends Error {
54
- constructor(message, reason) {
55
- super(message);
56
- this.name = "SuspendedError";
57
- this.reason = reason;
767
+ if (res.status === 404) {
768
+ await res.json().catch(() => undefined);
769
+ return { status: "not-in-library", owner, name };
58
770
  }
771
+
772
+ const err = await mapErrorResponse(res, url);
773
+ if (err === null) {
774
+ throw validationError(
775
+ `Library DELETE returned ${res.status} with no documented meaning.`,
776
+ );
777
+ }
778
+ throw err;
59
779
  }
60
780
 
61
- export class NetworkError extends Error {
62
- constructor(message) {
63
- super(message);
64
- this.name = "NetworkError";
781
+ // ── /api/v1/skills/search ───────────────────────────────────────────────
782
+
783
+ /**
784
+ * @typedef {Object} SearchResult
785
+ * @property {string} owner
786
+ * @property {string} name
787
+ * @property {string} description
788
+ * @property {string} version
789
+ * @property {string|null} license
790
+ * @property {string|null} compatibility
791
+ * @property {number} installs
792
+ * @property {number|null} avgRating
793
+ * @property {string|null} safetyGrade
794
+ * @property {string|null} publishedAt
795
+ */
796
+
797
+ /**
798
+ * @typedef {Object} SearchResponse
799
+ * @property {SearchResult[]} skills
800
+ * @property {{ total: number, limit: number, offset: number }} pagination
801
+ */
802
+
803
+ /**
804
+ * GET /api/v1/skills/search — public skill search via account key.
805
+ *
806
+ * @param {string} serverUrl
807
+ * @param {string} apiKey
808
+ * @param {object} opts
809
+ * @param {string} [opts.q]
810
+ * @param {number} [opts.limit]
811
+ * @param {number} [opts.offset]
812
+ * @param {string} [opts.sort]
813
+ * @returns {Promise<SearchResponse>}
814
+ */
815
+ export async function searchSkills(serverUrl, apiKey, opts = {}) {
816
+ const base = normalizeUrl(serverUrl);
817
+ const params = new URLSearchParams();
818
+ if (opts.q) params.set("q", opts.q);
819
+ if (opts.limit != null) params.set("limit", String(opts.limit));
820
+ if (opts.offset != null) params.set("offset", String(opts.offset));
821
+ if (opts.sort) params.set("sort", opts.sort);
822
+ const url = params.toString()
823
+ ? `${base}/api/v1/skills/search?${params.toString()}`
824
+ : `${base}/api/v1/skills/search`;
825
+
826
+ const res = await safeFetch(url, {
827
+ method: "GET",
828
+ headers: await authHeaders(apiKey),
829
+ // Read-only and idempotent.
830
+ retry: true,
831
+ });
832
+
833
+ if (!res.ok) {
834
+ const err = await mapErrorResponse(res, url);
835
+ if (err === null) {
836
+ throw validationError(`Search endpoint returned 404 — server URL probably wrong.`);
837
+ }
838
+ throw err;
65
839
  }
840
+
841
+ const body = await parseJsonOrThrow(res, url);
842
+ return {
843
+ skills: body.skills ?? [],
844
+ pagination: body.pagination ?? { total: 0, limit: opts.limit ?? 20, offset: opts.offset ?? 0 },
845
+ };
66
846
  }