skillrepo 2.0.0 → 3.0.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 +215 -150
- package/bin/skillrepo.mjs +210 -36
- package/package.json +6 -3
- package/src/commands/add.mjs +176 -0
- package/src/commands/get.mjs +116 -0
- package/src/commands/init.mjs +471 -143
- package/src/commands/list.mjs +176 -0
- package/src/commands/remove.mjs +167 -0
- package/src/commands/search.mjs +188 -0
- package/src/commands/update.mjs +67 -0
- package/src/lib/cli-config.mjs +230 -0
- package/src/lib/config.mjs +238 -0
- package/src/lib/detect-ides.mjs +0 -19
- package/src/lib/errors.mjs +264 -0
- package/src/lib/file-write.mjs +705 -0
- package/src/lib/http.mjs +817 -37
- package/src/lib/identifier.mjs +153 -0
- package/src/lib/mcp-merge.mjs +275 -0
- package/src/lib/mergers/gitignore.mjs +73 -18
- package/src/lib/paths.mjs +46 -17
- package/src/lib/prompt.mjs +11 -44
- package/src/lib/sync.mjs +305 -0
- package/src/test/commands/add.test.mjs +285 -0
- package/src/test/commands/get.test.mjs +176 -0
- package/src/test/commands/init.test.mjs +486 -0
- package/src/test/commands/list.test.mjs +172 -0
- package/src/test/commands/remove.test.mjs +234 -0
- package/src/test/commands/search.test.mjs +204 -0
- package/src/test/commands/update.test.mjs +164 -0
- package/src/test/detect-ides.test.mjs +9 -14
- package/src/test/dispatcher.test.mjs +224 -0
- package/src/test/e2e/cli-commands.test.mjs +576 -0
- package/src/test/e2e/mock-server.mjs +364 -22
- package/src/test/helpers/capture-stream.mjs +48 -0
- package/src/test/integration/file-write.integration.test.mjs +279 -0
- package/src/test/lib/cli-config.test.mjs +407 -0
- package/src/test/lib/config.test.mjs +257 -0
- package/src/test/lib/errors.test.mjs +359 -0
- package/src/test/lib/file-write.test.mjs +784 -0
- package/src/test/lib/http.test.mjs +1198 -0
- package/src/test/lib/identifier.test.mjs +157 -0
- package/src/test/lib/mcp-merge.test.mjs +345 -0
- package/src/test/lib/paths.test.mjs +83 -0
- package/src/test/lib/sync.test.mjs +514 -0
- package/src/test/mergers/gitignore.test.mjs +145 -20
- package/src/lib/write-configs.mjs +0 -202
- package/src/test/e2e/HANDOFF.md +0 -223
- package/src/test/e2e/cli-init.test.mjs +0 -213
- 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
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
22
|
-
|
|
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 ===
|
|
28
|
-
const body = await res
|
|
29
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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
|
-
// ──
|
|
706
|
+
// ── /api/v1/library/[owner]/[name] (DELETE — remove from library) ──────
|
|
45
707
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
}
|