@nanmicoder/dsh-skills-hub 0.0.1
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/AGENTS.md +9 -0
- package/CONTRIBUTING.md +11 -0
- package/LICENSE +21 -0
- package/README.md +120 -0
- package/README_EN.md +120 -0
- package/README_ZH.md +5 -0
- package/assets/readme/hero-en.png +0 -0
- package/assets/readme/hero-zh.png +0 -0
- package/assets/readme/marketplace.png +0 -0
- package/compatibility.json +10 -0
- package/cordis.patch.yml +5 -0
- package/docs/COMPATIBILITY.md +31 -0
- package/docs/CONTRACT.md +125 -0
- package/docs/RELEASE.md +24 -0
- package/lib/client.js +25115 -0
- package/lib/client.js.map +1 -0
- package/lib/config.js +51 -0
- package/lib/index.js +176 -0
- package/lib/market/cache.js +113 -0
- package/lib/market/clawhub-provider.js +598 -0
- package/lib/market/install-service.js +394 -0
- package/lib/market/market-service.js +312 -0
- package/lib/market/provider-fetch.js +216 -0
- package/lib/market/skillhub-provider.js +277 -0
- package/lib/market/types.js +82 -0
- package/lib/skills/installed.js +426 -0
- package/lib/skills/management.js +36 -0
- package/lib/skills/read-text.js +23 -0
- package/lib/skills/root.js +112 -0
- package/lib/types/client/api.d.ts +106 -0
- package/lib/types/client/components/FilterBar.d.ts +20 -0
- package/lib/types/client/components/FrontmatterPanel.d.ts +15 -0
- package/lib/types/client/components/InstallConfirmDialog.d.ts +31 -0
- package/lib/types/client/components/InstallStateBadge.d.ts +11 -0
- package/lib/types/client/components/InstalledSkills.d.ts +3 -0
- package/lib/types/client/components/MarkdownView.d.ts +4 -0
- package/lib/types/client/components/MarketDisclaimer.d.ts +10 -0
- package/lib/types/client/components/MarketHome.d.ts +12 -0
- package/lib/types/client/components/SecurityBadge.d.ts +16 -0
- package/lib/types/client/components/SkillAvatar.d.ts +14 -0
- package/lib/types/client/components/SkillCard.d.ts +17 -0
- package/lib/types/client/components/SkillDetailView.d.ts +21 -0
- package/lib/types/client/components/SourceStatusBar.d.ts +13 -0
- package/lib/types/client/icons.d.ts +71 -0
- package/lib/types/client/index.d.ts +56 -0
- package/lib/types/client/locale-context.d.ts +31 -0
- package/lib/types/client/locales.d.ts +117 -0
- package/lib/types/client/page/MarketPage.d.ts +21 -0
- package/lib/types/client/skill-markdown.d.ts +2 -0
- package/lib/types/client/state.d.ts +119 -0
- package/lib/types/config.d.ts +51 -0
- package/lib/types/index.d.ts +42 -0
- package/lib/types/market/cache.d.ts +49 -0
- package/lib/types/market/clawhub-provider.d.ts +28 -0
- package/lib/types/market/install-service.d.ts +75 -0
- package/lib/types/market/market-service.d.ts +74 -0
- package/lib/types/market/provider-fetch.d.ts +61 -0
- package/lib/types/market/skillhub-provider.d.ts +13 -0
- package/lib/types/market/types.d.ts +163 -0
- package/lib/types/skills/installed.d.ts +117 -0
- package/lib/types/skills/management.d.ts +7 -0
- package/lib/types/skills/read-text.d.ts +5 -0
- package/lib/types/skills/root.d.ts +48 -0
- package/lib/types/web-routes.d.ts +74 -0
- package/lib/web-routes.js +427 -0
- package/package.json +160 -0
- package/release-notes/v0.0.1.md +11 -0
|
@@ -0,0 +1,598 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ClawHub provider (https://clawhub.ai)
|
|
3
|
+
*
|
|
4
|
+
* Endpoints (verified against the live API):
|
|
5
|
+
* - GET /api/v1/skills?limit=&cursor=&sort=downloads → {items, nextCursor}
|
|
6
|
+
* - GET /api/v1/search?q= → {results} (no pagination)
|
|
7
|
+
* - GET /api/v1/skills/{slug} → {skill, latestVersion, owner, metadata, moderation}
|
|
8
|
+
* - GET /api/v1/skills/{slug}/versions/{v} → {version:{license, files[], security}}
|
|
9
|
+
* - GET /api/v1/skills/{slug}/file?path= → raw file text
|
|
10
|
+
*/
|
|
11
|
+
import { getProviderBase, providerFetch, providerFetchJson, readResponseTextWithLimit, MarketHttpError, } from "./provider-fetch.js";
|
|
12
|
+
import { markSourceHealth } from "./cache.js";
|
|
13
|
+
import { detectMarketLanguage, MARKET_ERROR_CODES, MARKET_LIMITS, MarketUpstreamError, skillId, } from "./types.js";
|
|
14
|
+
// ─── Frontmatter ─────────────────────────────────────────────────────────────
|
|
15
|
+
/**
|
|
16
|
+
* Frontmatter delimiters must start at column zero: an indented `---` may be
|
|
17
|
+
* YAML block-scalar content, and treating it as the close would drop fields.
|
|
18
|
+
*/
|
|
19
|
+
const FRONTMATTER_REGEX = /^---[\t ]*\r?\n([\s\S]*?)^---[\t ]*(?:\r?\n[\t \r\n]*|$)/m;
|
|
20
|
+
const BLOCK_SCALAR = /^[|>][+-]?\d*$/;
|
|
21
|
+
const SKIPPABLE_LINE = /^\s*(?:#.*)?$/;
|
|
22
|
+
/**
|
|
23
|
+
* Split `---` frontmatter from a markdown body.
|
|
24
|
+
*
|
|
25
|
+
* The reference server delegated YAML to Bun.YAML / the `yaml` package; this
|
|
26
|
+
* plugin must not add a runtime dependency, so `parseYamlSubset` below covers
|
|
27
|
+
* the subset SKILL.md frontmatter actually uses (scalars, quoted strings, flow
|
|
28
|
+
* collections, nested mappings, block sequences and block scalars) and any
|
|
29
|
+
* value it cannot understand is dropped rather than throwing.
|
|
30
|
+
*/
|
|
31
|
+
export function parseFrontmatter(markdown) {
|
|
32
|
+
const match = FRONTMATTER_REGEX.exec(markdown);
|
|
33
|
+
if (!match)
|
|
34
|
+
return { frontmatter: {}, content: markdown };
|
|
35
|
+
const content = markdown.slice(match[0].length);
|
|
36
|
+
let frontmatter = {};
|
|
37
|
+
try {
|
|
38
|
+
frontmatter = parseYamlSubset(match[1] ?? '');
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
// Broken frontmatter must not break the preview — keep the body only.
|
|
42
|
+
}
|
|
43
|
+
return { frontmatter, content };
|
|
44
|
+
}
|
|
45
|
+
function parseYamlSubset(text) {
|
|
46
|
+
const [mapping] = parseMapping(text.split(/\r?\n/), 0, 0);
|
|
47
|
+
return mapping;
|
|
48
|
+
}
|
|
49
|
+
function indentWidth(line) {
|
|
50
|
+
return /^[ \t]*/.exec(line)?.[0].length ?? 0;
|
|
51
|
+
}
|
|
52
|
+
function isSkippable(line) {
|
|
53
|
+
return SKIPPABLE_LINE.test(line);
|
|
54
|
+
}
|
|
55
|
+
/** Index of the `:` that separates a mapping key from its value, or -1. */
|
|
56
|
+
function findKeySeparator(body) {
|
|
57
|
+
let quote = null;
|
|
58
|
+
for (let i = 0; i < body.length; i++) {
|
|
59
|
+
const char = body[i];
|
|
60
|
+
if (quote) {
|
|
61
|
+
if (char === '\\' && quote === '"')
|
|
62
|
+
i++;
|
|
63
|
+
else if (char === quote)
|
|
64
|
+
quote = null;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (char === '"' || char === "'") {
|
|
68
|
+
quote = char;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (char === ':' && (i + 1 === body.length || body[i + 1] === ' ' || body[i + 1] === '\t'))
|
|
72
|
+
return i;
|
|
73
|
+
}
|
|
74
|
+
return -1;
|
|
75
|
+
}
|
|
76
|
+
function unquoteKey(key) {
|
|
77
|
+
if (key.length >= 2 && key.startsWith('"') && key.endsWith('"'))
|
|
78
|
+
return decodeDoubleQuoted(key);
|
|
79
|
+
if (key.length >= 2 && key.startsWith("'") && key.endsWith("'"))
|
|
80
|
+
return key.slice(1, -1);
|
|
81
|
+
return key;
|
|
82
|
+
}
|
|
83
|
+
const UNESCAPE = {
|
|
84
|
+
'"': '"', '\\': '\\', '/': '/', b: '\b', f: '\f', n: '\n', r: '\r', t: '\t',
|
|
85
|
+
};
|
|
86
|
+
function decodeDoubleQuoted(value) {
|
|
87
|
+
try {
|
|
88
|
+
const parsed = JSON.parse(value);
|
|
89
|
+
if (typeof parsed === 'string')
|
|
90
|
+
return parsed;
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
// Not JSON-escaped — decode the common escapes by hand below.
|
|
94
|
+
}
|
|
95
|
+
return value
|
|
96
|
+
.slice(1, -1)
|
|
97
|
+
.replace(/\\(["\\/bfnrt])/g, (_match, char) => UNESCAPE[char] ?? char);
|
|
98
|
+
}
|
|
99
|
+
function stripInlineComment(value) {
|
|
100
|
+
const index = value.search(/\s#/);
|
|
101
|
+
return (index === -1 ? value : value.slice(0, index)).trim();
|
|
102
|
+
}
|
|
103
|
+
function parseScalar(raw) {
|
|
104
|
+
const value = raw.trim();
|
|
105
|
+
if (value === '')
|
|
106
|
+
return null;
|
|
107
|
+
if (value.length >= 2 && value.startsWith('"') && value.endsWith('"'))
|
|
108
|
+
return decodeDoubleQuoted(value);
|
|
109
|
+
if (value.length >= 2 && value.startsWith("'") && value.endsWith("'")) {
|
|
110
|
+
return value.slice(1, -1).replace(/''/g, "'");
|
|
111
|
+
}
|
|
112
|
+
if (value.startsWith('[') || value.startsWith('{')) {
|
|
113
|
+
// Real ClawHub skills embed JSON-ish flow collections (e.g. `metadata: {...}`).
|
|
114
|
+
try {
|
|
115
|
+
return JSON.parse(value);
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
return parseFlowCollection(value);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (value === 'null' || value === '~')
|
|
122
|
+
return null;
|
|
123
|
+
if (value === 'true')
|
|
124
|
+
return true;
|
|
125
|
+
if (value === 'false')
|
|
126
|
+
return false;
|
|
127
|
+
if (/^[+-]?\d+$/.test(value))
|
|
128
|
+
return Number.parseInt(value, 10);
|
|
129
|
+
if (/^[+-]?(?:\d+\.\d*|\.\d+)(?:[eE][+-]?\d+)?$/.test(value))
|
|
130
|
+
return Number.parseFloat(value);
|
|
131
|
+
return stripInlineComment(value);
|
|
132
|
+
}
|
|
133
|
+
/** Split on commas that are not nested inside quotes, brackets or braces. */
|
|
134
|
+
function splitTopLevel(value) {
|
|
135
|
+
const parts = [];
|
|
136
|
+
let current = '';
|
|
137
|
+
let depth = 0;
|
|
138
|
+
let quote = null;
|
|
139
|
+
for (let i = 0; i < value.length; i++) {
|
|
140
|
+
const char = value[i];
|
|
141
|
+
if (quote) {
|
|
142
|
+
current += char;
|
|
143
|
+
if (char === '\\' && quote === '"') {
|
|
144
|
+
current += value[i + 1] ?? '';
|
|
145
|
+
i++;
|
|
146
|
+
}
|
|
147
|
+
else if (char === quote) {
|
|
148
|
+
quote = null;
|
|
149
|
+
}
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
if (char === '"' || char === "'") {
|
|
153
|
+
quote = char;
|
|
154
|
+
current += char;
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
if (char === '[' || char === '{')
|
|
158
|
+
depth++;
|
|
159
|
+
if (char === ']' || char === '}')
|
|
160
|
+
depth--;
|
|
161
|
+
if (char === ',' && depth === 0) {
|
|
162
|
+
parts.push(current);
|
|
163
|
+
current = '';
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
current += char;
|
|
167
|
+
}
|
|
168
|
+
parts.push(current);
|
|
169
|
+
return parts.map((part) => part.trim()).filter((part) => part !== '');
|
|
170
|
+
}
|
|
171
|
+
function parseFlowCollection(value) {
|
|
172
|
+
const inner = value.slice(1, -1).trim();
|
|
173
|
+
if (inner === '')
|
|
174
|
+
return value.startsWith('[') ? [] : {};
|
|
175
|
+
const parts = splitTopLevel(inner);
|
|
176
|
+
if (value.startsWith('['))
|
|
177
|
+
return parts.map((part) => parseScalar(part));
|
|
178
|
+
const out = {};
|
|
179
|
+
for (const part of parts) {
|
|
180
|
+
const separator = findKeySeparator(part);
|
|
181
|
+
if (separator <= 0)
|
|
182
|
+
continue;
|
|
183
|
+
out[unquoteKey(part.slice(0, separator).trim())] = parseScalar(part.slice(separator + 1));
|
|
184
|
+
}
|
|
185
|
+
return out;
|
|
186
|
+
}
|
|
187
|
+
/** Indentation-aware `key: value` block; stops at the first line that dedents. */
|
|
188
|
+
function parseMapping(lines, start, indent) {
|
|
189
|
+
const out = {};
|
|
190
|
+
let i = start;
|
|
191
|
+
while (i < lines.length) {
|
|
192
|
+
const line = lines[i] ?? '';
|
|
193
|
+
if (isSkippable(line)) {
|
|
194
|
+
i++;
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
const width = indentWidth(line);
|
|
198
|
+
const body = line.slice(width);
|
|
199
|
+
if (width < indent)
|
|
200
|
+
break;
|
|
201
|
+
if (width > indent) {
|
|
202
|
+
// A deeper line belongs to the value above; a stray one is ignored rather
|
|
203
|
+
// than truncating every key that follows it.
|
|
204
|
+
i++;
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
if (body.startsWith('-'))
|
|
208
|
+
break;
|
|
209
|
+
const separator = findKeySeparator(body);
|
|
210
|
+
if (separator < 0) {
|
|
211
|
+
i++;
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
const key = unquoteKey(body.slice(0, separator).trim());
|
|
215
|
+
const rest = body.slice(separator + 1).trim();
|
|
216
|
+
if (rest === '' || rest.startsWith('#')) {
|
|
217
|
+
const [value, next] = parseNested(lines, i + 1, width);
|
|
218
|
+
out[key] = value;
|
|
219
|
+
i = next;
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
if (BLOCK_SCALAR.test(rest)) {
|
|
223
|
+
const [value, next] = parseBlockScalar(lines, i + 1, width, rest.startsWith('>'));
|
|
224
|
+
out[key] = value;
|
|
225
|
+
i = next;
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
out[key] = parseScalar(rest);
|
|
229
|
+
i++;
|
|
230
|
+
}
|
|
231
|
+
return [out, i];
|
|
232
|
+
}
|
|
233
|
+
/** Value of a `key:` with nothing after the colon: nested block, sequence, or null. */
|
|
234
|
+
function parseNested(lines, start, parentIndent) {
|
|
235
|
+
let i = start;
|
|
236
|
+
while (i < lines.length && isSkippable(lines[i] ?? ''))
|
|
237
|
+
i++;
|
|
238
|
+
const line = lines[i];
|
|
239
|
+
if (line === undefined)
|
|
240
|
+
return [null, i];
|
|
241
|
+
const width = indentWidth(line);
|
|
242
|
+
if (width <= parentIndent)
|
|
243
|
+
return [null, start];
|
|
244
|
+
const body = line.slice(width);
|
|
245
|
+
if (body.startsWith('-'))
|
|
246
|
+
return parseSequence(lines, i, width);
|
|
247
|
+
return parseMapping(lines, i, width);
|
|
248
|
+
}
|
|
249
|
+
function parseSequence(lines, start, indent) {
|
|
250
|
+
const items = [];
|
|
251
|
+
let i = start;
|
|
252
|
+
while (i < lines.length) {
|
|
253
|
+
const line = lines[i] ?? '';
|
|
254
|
+
if (isSkippable(line)) {
|
|
255
|
+
i++;
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
const width = indentWidth(line);
|
|
259
|
+
const body = line.slice(width);
|
|
260
|
+
if (width !== indent || !body.startsWith('-'))
|
|
261
|
+
break;
|
|
262
|
+
const rest = body.slice(1).trim();
|
|
263
|
+
if (rest === '' || rest.startsWith('#')) {
|
|
264
|
+
const [value, next] = parseNested(lines, i + 1, width);
|
|
265
|
+
items.push(value);
|
|
266
|
+
i = next;
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
if (findKeySeparator(rest) > 0) {
|
|
270
|
+
// `- key: value` opens an inline mapping item; re-indent it and parse as a block.
|
|
271
|
+
const patched = [...lines];
|
|
272
|
+
patched[i] = `${' '.repeat(width + 2)}${rest}`;
|
|
273
|
+
const [mapping, next] = parseMapping(patched, i, width + 2);
|
|
274
|
+
items.push(mapping);
|
|
275
|
+
i = next;
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
items.push(parseScalar(rest));
|
|
279
|
+
i++;
|
|
280
|
+
}
|
|
281
|
+
return [items, i];
|
|
282
|
+
}
|
|
283
|
+
function parseBlockScalar(lines, start, parentIndent, folded) {
|
|
284
|
+
const collected = [];
|
|
285
|
+
let blockIndent = -1;
|
|
286
|
+
let i = start;
|
|
287
|
+
while (i < lines.length) {
|
|
288
|
+
const line = lines[i] ?? '';
|
|
289
|
+
if (line.trim() === '') {
|
|
290
|
+
collected.push('');
|
|
291
|
+
i++;
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
const width = indentWidth(line);
|
|
295
|
+
if (width <= parentIndent)
|
|
296
|
+
break;
|
|
297
|
+
if (blockIndent === -1)
|
|
298
|
+
blockIndent = width;
|
|
299
|
+
collected.push(line.slice(Math.min(blockIndent, width)));
|
|
300
|
+
i++;
|
|
301
|
+
}
|
|
302
|
+
// Trailing blank lines belong to the next key, not to this scalar.
|
|
303
|
+
while (collected.length > 0 && collected[collected.length - 1] === '')
|
|
304
|
+
collected.pop();
|
|
305
|
+
return [collected.join(folded ? ' ' : '\n'), i];
|
|
306
|
+
}
|
|
307
|
+
// ─── Untrusted-payload helpers ───────────────────────────────────────────────
|
|
308
|
+
/**
|
|
309
|
+
* Upstream JSON is untrusted input: a field declared `string` in the ported
|
|
310
|
+
* types can arrive as an object, an array or a number. These helpers keep every
|
|
311
|
+
* `NormalizedSkill` field at its declared runtime type, so a hostile or broken
|
|
312
|
+
* upstream cannot crash the aggregation layer (or React, downstream).
|
|
313
|
+
*/
|
|
314
|
+
function asString(value) {
|
|
315
|
+
return typeof value === 'string' ? value : undefined;
|
|
316
|
+
}
|
|
317
|
+
function asNumber(value) {
|
|
318
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
|
319
|
+
}
|
|
320
|
+
/** A usable slug is a non-empty string — anything else is not a skill entry. */
|
|
321
|
+
function hasUsableSlug(item) {
|
|
322
|
+
if (typeof item !== 'object' || item === null)
|
|
323
|
+
return false;
|
|
324
|
+
const slug = item.slug;
|
|
325
|
+
return typeof slug === 'string' && slug.trim() !== '';
|
|
326
|
+
}
|
|
327
|
+
/** File entries are only useful with a string path; sizes default to 0. */
|
|
328
|
+
function usableFileEntries(files) {
|
|
329
|
+
if (!Array.isArray(files))
|
|
330
|
+
return [];
|
|
331
|
+
const out = [];
|
|
332
|
+
for (const file of files) {
|
|
333
|
+
if (typeof file !== 'object' || file === null)
|
|
334
|
+
continue;
|
|
335
|
+
const record = file;
|
|
336
|
+
if (typeof record.path !== 'string' || record.path === '')
|
|
337
|
+
continue;
|
|
338
|
+
out.push({
|
|
339
|
+
path: record.path,
|
|
340
|
+
size: asNumber(record.size) ?? 0,
|
|
341
|
+
sha256: asString(record.sha256),
|
|
342
|
+
contentType: asString(record.contentType),
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
return out;
|
|
346
|
+
}
|
|
347
|
+
// ClawHub slugs are not unique across owners. Ambiguous slugs return
|
|
348
|
+
// 409 AMBIGUOUS_SKILL_SLUG with candidate owners; disambiguate via ?owner=
|
|
349
|
+
// (first match = primary listing) and remember the resolution.
|
|
350
|
+
const ownerCache = new Map();
|
|
351
|
+
async function clawhubFetch(url, slug) {
|
|
352
|
+
const cachedOwner = ownerCache.get(slug);
|
|
353
|
+
if (cachedOwner && !url.searchParams.has('owner')) {
|
|
354
|
+
url.searchParams.set('owner', cachedOwner);
|
|
355
|
+
}
|
|
356
|
+
const res = await providerFetch('clawhub', url.toString());
|
|
357
|
+
if (res.status !== 409)
|
|
358
|
+
return res;
|
|
359
|
+
let body = null;
|
|
360
|
+
try {
|
|
361
|
+
const { content } = await readResponseTextWithLimit('clawhub', res, MARKET_LIMITS.maxTotalSize, 'ambiguous-slug response body');
|
|
362
|
+
try {
|
|
363
|
+
body = JSON.parse(content);
|
|
364
|
+
}
|
|
365
|
+
catch {
|
|
366
|
+
body = null;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
catch (error) {
|
|
370
|
+
if (error instanceof MarketUpstreamError)
|
|
371
|
+
throw error;
|
|
372
|
+
body = null;
|
|
373
|
+
}
|
|
374
|
+
const resolvedOwner = body?.code === 'AMBIGUOUS_SKILL_SLUG' ? asString(body.matches?.[0]?.ownerHandle) : undefined;
|
|
375
|
+
if (!resolvedOwner) {
|
|
376
|
+
throw new MarketHttpError('clawhub', 409, MARKET_ERROR_CODES.upstreamError, `clawhub responded 409 for ${url.pathname}`);
|
|
377
|
+
}
|
|
378
|
+
ownerCache.set(slug, resolvedOwner);
|
|
379
|
+
url.searchParams.set('owner', resolvedOwner);
|
|
380
|
+
return providerFetch('clawhub', url.toString());
|
|
381
|
+
}
|
|
382
|
+
async function clawhubFetchJson(url, slug) {
|
|
383
|
+
const res = await clawhubFetch(url, slug);
|
|
384
|
+
if (!res.ok) {
|
|
385
|
+
throw new MarketHttpError('clawhub', res.status, res.status === 404 ? MARKET_ERROR_CODES.upstreamBadResponse : MARKET_ERROR_CODES.upstreamError, `clawhub responded ${res.status} for ${url.pathname}`);
|
|
386
|
+
}
|
|
387
|
+
const { content } = await readResponseTextWithLimit('clawhub', res, MARKET_LIMITS.maxTotalSize, 'skill response body');
|
|
388
|
+
try {
|
|
389
|
+
return JSON.parse(content);
|
|
390
|
+
}
|
|
391
|
+
catch {
|
|
392
|
+
throw new MarketUpstreamError('clawhub', MARKET_ERROR_CODES.upstreamBadResponse, 'clawhub returned invalid JSON');
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
function mapSecurity(security) {
|
|
396
|
+
const vendorStatus = asString(security?.status);
|
|
397
|
+
if (!vendorStatus)
|
|
398
|
+
return { status: 'unknown', reports: [] };
|
|
399
|
+
const clean = vendorStatus === 'clean';
|
|
400
|
+
return {
|
|
401
|
+
status: clean ? 'benign' : 'flagged',
|
|
402
|
+
reports: [
|
|
403
|
+
{
|
|
404
|
+
vendor: 'clawhub-scan',
|
|
405
|
+
status: vendorStatus,
|
|
406
|
+
statusText: clean
|
|
407
|
+
? security?.hasWarnings === true ? 'Clean (with warnings)' : 'Clean'
|
|
408
|
+
: `Scan status: ${vendorStatus}`,
|
|
409
|
+
reportUrl: asString(security?.virustotalUrl),
|
|
410
|
+
},
|
|
411
|
+
],
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
function normalizeListItem(item) {
|
|
415
|
+
return {
|
|
416
|
+
id: skillId('clawhub', item.slug),
|
|
417
|
+
source: 'clawhub',
|
|
418
|
+
slug: item.slug,
|
|
419
|
+
name: asString(item.displayName) || item.slug,
|
|
420
|
+
summary: asString(item.summary) ?? '',
|
|
421
|
+
author: { handle: '' },
|
|
422
|
+
stats: {
|
|
423
|
+
downloads: asNumber(item.stats?.downloads) ?? 0,
|
|
424
|
+
installs: asNumber(item.stats?.installs),
|
|
425
|
+
stars: asNumber(item.stats?.stars),
|
|
426
|
+
},
|
|
427
|
+
tags: Array.isArray(item.topics) ? item.topics.filter((tag) => typeof tag === 'string') : [],
|
|
428
|
+
version: asString(item.latestVersion?.version),
|
|
429
|
+
updatedAt: asNumber(item.updatedAt),
|
|
430
|
+
securityStatus: 'unknown',
|
|
431
|
+
installState: 'installable',
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
function normalizeSearchResult(result) {
|
|
435
|
+
return {
|
|
436
|
+
id: skillId('clawhub', result.slug),
|
|
437
|
+
source: 'clawhub',
|
|
438
|
+
slug: result.slug,
|
|
439
|
+
name: asString(result.displayName) || result.slug,
|
|
440
|
+
summary: asString(result.summary) ?? '',
|
|
441
|
+
author: {
|
|
442
|
+
handle: asString(result.owner?.handle) || asString(result.ownerHandle) || '',
|
|
443
|
+
displayName: asString(result.owner?.displayName),
|
|
444
|
+
avatarUrl: asString(result.owner?.image),
|
|
445
|
+
},
|
|
446
|
+
stats: { downloads: asNumber(result.downloads) ?? 0 },
|
|
447
|
+
tags: [],
|
|
448
|
+
updatedAt: asNumber(result.updatedAt),
|
|
449
|
+
securityStatus: 'unknown',
|
|
450
|
+
installState: 'installable',
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
/** Test hook: forget which owner disambiguated an ambiguous slug. */
|
|
454
|
+
export function resetClawhubOwnerCache() {
|
|
455
|
+
ownerCache.clear();
|
|
456
|
+
}
|
|
457
|
+
export const clawhubProvider = {
|
|
458
|
+
source: 'clawhub',
|
|
459
|
+
async list({ cursor, limit }) {
|
|
460
|
+
const base = getProviderBase('clawhub');
|
|
461
|
+
const url = new URL('/api/v1/skills', base);
|
|
462
|
+
url.searchParams.set('limit', String(limit));
|
|
463
|
+
url.searchParams.set('sort', 'downloads');
|
|
464
|
+
if (cursor)
|
|
465
|
+
url.searchParams.set('cursor', cursor);
|
|
466
|
+
const data = await providerFetchJson('clawhub', url.toString());
|
|
467
|
+
if (!Array.isArray(data.items)) {
|
|
468
|
+
throw new MarketUpstreamError('clawhub', MARKET_ERROR_CODES.upstreamBadResponse, 'clawhub list missing items');
|
|
469
|
+
}
|
|
470
|
+
const usable = data.items.filter(hasUsableSlug);
|
|
471
|
+
if (usable.length === 0 && data.items.length > 0) {
|
|
472
|
+
// Every entry was malformed. Dropping them silently would leave the
|
|
473
|
+
// aggregation layer serving an empty page while `/status` still claims the
|
|
474
|
+
// source is healthy — report the payload instead.
|
|
475
|
+
const error = new MarketUpstreamError('clawhub', MARKET_ERROR_CODES.upstreamBadResponse, 'clawhub list contained no usable items');
|
|
476
|
+
markSourceHealth('clawhub', 'degraded', error.message);
|
|
477
|
+
throw error;
|
|
478
|
+
}
|
|
479
|
+
if (usable.length !== data.items.length) {
|
|
480
|
+
markSourceHealth('clawhub', 'degraded', 'clawhub list contained malformed items');
|
|
481
|
+
}
|
|
482
|
+
return {
|
|
483
|
+
items: usable.map(normalizeListItem),
|
|
484
|
+
// Cursor pagination is upstream-native: pass the opaque token straight back.
|
|
485
|
+
nextCursor: asString(data.nextCursor) || undefined,
|
|
486
|
+
};
|
|
487
|
+
},
|
|
488
|
+
async search({ q, limit }) {
|
|
489
|
+
const base = getProviderBase('clawhub');
|
|
490
|
+
const url = new URL('/api/v1/search', base);
|
|
491
|
+
url.searchParams.set('q', q);
|
|
492
|
+
const data = await providerFetchJson('clawhub', url.toString());
|
|
493
|
+
if (!Array.isArray(data.results)) {
|
|
494
|
+
throw new MarketUpstreamError('clawhub', MARKET_ERROR_CODES.upstreamBadResponse, 'clawhub search missing results');
|
|
495
|
+
}
|
|
496
|
+
// Search also aggregates external registries, which cannot use ClawHub's
|
|
497
|
+
// detail/file endpoints. Older native results omit source/install metadata.
|
|
498
|
+
// Filter before capping so external results do not consume native slots —
|
|
499
|
+
// and note the search endpoint has no pagination, so there is no nextCursor.
|
|
500
|
+
const usable = data.results.filter(hasUsableSlug);
|
|
501
|
+
if (usable.length === 0 && data.results.length > 0) {
|
|
502
|
+
const error = new MarketUpstreamError('clawhub', MARKET_ERROR_CODES.upstreamBadResponse, 'clawhub search contained no usable results');
|
|
503
|
+
markSourceHealth('clawhub', 'degraded', error.message);
|
|
504
|
+
throw error;
|
|
505
|
+
}
|
|
506
|
+
const nativeResults = usable.filter((result) => (result.source === undefined || result.source === 'clawhub')
|
|
507
|
+
&& (result.install?.kind === undefined || result.install.kind === 'clawhub'));
|
|
508
|
+
return { items: nativeResults.slice(0, limit).map(normalizeSearchResult) };
|
|
509
|
+
},
|
|
510
|
+
async detail(slug) {
|
|
511
|
+
const base = getProviderBase('clawhub');
|
|
512
|
+
const data = await clawhubFetchJson(new URL(`/api/v1/skills/${encodeURIComponent(slug)}`, base), slug);
|
|
513
|
+
if (typeof data.skill?.slug !== 'string' || data.skill.slug.trim() === '') {
|
|
514
|
+
throw new MarketUpstreamError('clawhub', MARKET_ERROR_CODES.upstreamBadResponse, 'clawhub detail missing skill');
|
|
515
|
+
}
|
|
516
|
+
const version = asString(data.latestVersion?.version) || asString(data.skill.latestVersion?.version);
|
|
517
|
+
let rawFiles;
|
|
518
|
+
let license = asString(data.latestVersion?.license);
|
|
519
|
+
let security = { status: 'unknown', reports: [] };
|
|
520
|
+
if (version) {
|
|
521
|
+
try {
|
|
522
|
+
const versionDetail = await clawhubFetchJson(new URL(`/api/v1/skills/${encodeURIComponent(slug)}/versions/${encodeURIComponent(version)}`, base), slug);
|
|
523
|
+
rawFiles = versionDetail.version?.files;
|
|
524
|
+
license = asString(versionDetail.version?.license) || license;
|
|
525
|
+
security = mapSecurity(versionDetail.version?.security);
|
|
526
|
+
}
|
|
527
|
+
catch {
|
|
528
|
+
// Version detail is best-effort; the skill detail is still useful without files.
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
const files = usableFileEntries(rawFiles);
|
|
532
|
+
// Some catalogue responses omit the document; retrieve SKILL.md rather
|
|
533
|
+
// than showing an empty overview for an otherwise installable skill.
|
|
534
|
+
let rawDescription = asString(data.skill.description) ?? '';
|
|
535
|
+
if (rawDescription.trim() === '') {
|
|
536
|
+
try {
|
|
537
|
+
rawDescription = (await clawhubProvider.fetchFile(slug, 'SKILL.md')).content;
|
|
538
|
+
}
|
|
539
|
+
catch {
|
|
540
|
+
// File delivery can fail independently of catalogue metadata.
|
|
541
|
+
rawDescription = asString(data.skill.summary) ?? '';
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
let body = rawDescription;
|
|
545
|
+
let frontmatter;
|
|
546
|
+
if (rawDescription.startsWith('---')) {
|
|
547
|
+
const parsed = parseFrontmatter(rawDescription);
|
|
548
|
+
body = parsed.content;
|
|
549
|
+
frontmatter = parsed.frontmatter;
|
|
550
|
+
}
|
|
551
|
+
const item = normalizeListItem(data.skill);
|
|
552
|
+
return {
|
|
553
|
+
...item,
|
|
554
|
+
version,
|
|
555
|
+
author: {
|
|
556
|
+
handle: asString(data.owner?.handle) || '',
|
|
557
|
+
displayName: asString(data.owner?.displayName),
|
|
558
|
+
avatarUrl: asString(data.owner?.image),
|
|
559
|
+
},
|
|
560
|
+
securityStatus: security.status,
|
|
561
|
+
securityReports: security.reports.length ? security.reports : undefined,
|
|
562
|
+
description: body,
|
|
563
|
+
descriptionFrontmatter: frontmatter,
|
|
564
|
+
license,
|
|
565
|
+
files: files.map((file) => ({
|
|
566
|
+
path: file.path,
|
|
567
|
+
size: file.size,
|
|
568
|
+
sha256: file.sha256,
|
|
569
|
+
contentType: file.contentType,
|
|
570
|
+
language: detectMarketLanguage(file.path),
|
|
571
|
+
tooBig: false,
|
|
572
|
+
})),
|
|
573
|
+
totalSize: files.reduce((sum, file) => sum + file.size, 0),
|
|
574
|
+
};
|
|
575
|
+
},
|
|
576
|
+
async listFiles(slug, version) {
|
|
577
|
+
const base = getProviderBase('clawhub');
|
|
578
|
+
let resolvedVersion = version;
|
|
579
|
+
if (!resolvedVersion) {
|
|
580
|
+
const data = await clawhubFetchJson(new URL(`/api/v1/skills/${encodeURIComponent(slug)}`, base), slug);
|
|
581
|
+
resolvedVersion = asString(data.latestVersion?.version) || asString(data.skill?.latestVersion?.version);
|
|
582
|
+
}
|
|
583
|
+
if (!resolvedVersion)
|
|
584
|
+
return [];
|
|
585
|
+
const versionDetail = await clawhubFetchJson(new URL(`/api/v1/skills/${encodeURIComponent(slug)}/versions/${encodeURIComponent(resolvedVersion)}`, base), slug);
|
|
586
|
+
return usableFileEntries(versionDetail.version?.files);
|
|
587
|
+
},
|
|
588
|
+
async fetchFile(slug, filePath) {
|
|
589
|
+
const base = getProviderBase('clawhub');
|
|
590
|
+
const url = new URL(`/api/v1/skills/${encodeURIComponent(slug)}/file`, base);
|
|
591
|
+
url.searchParams.set('path', filePath);
|
|
592
|
+
const res = await clawhubFetch(url, slug);
|
|
593
|
+
if (!res.ok) {
|
|
594
|
+
throw new MarketHttpError('clawhub', res.status, res.status === 404 ? MARKET_ERROR_CODES.upstreamBadResponse : MARKET_ERROR_CODES.upstreamError, `clawhub file fetch failed (${res.status})`);
|
|
595
|
+
}
|
|
596
|
+
return await readResponseTextWithLimit('clawhub', res, MARKET_LIMITS.maxFileSize, `file ${filePath}`);
|
|
597
|
+
},
|
|
598
|
+
};
|