dsh-skill-store 0.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.
package/lib/index.js ADDED
@@ -0,0 +1,951 @@
1
+ // dsh-skill-store host plugin
2
+ // Community skill marketplace. Two sources:
3
+ // 1. github — the awesome-skills-cn mirror (broad coverage, no popularity data)
4
+ // 2. clawhub — ClawHub registry (stars / downloads / installs / topics)
5
+ // 3. skillhub — SkillHub.cn, 131k+ skills with a bilingual 13-category taxonomy
6
+ import { readFile, writeFile, mkdir, access, readdir, rm } from "node:fs/promises";
7
+ import { join, resolve, dirname } from "node:path";
8
+ import { homedir } from "node:os";
9
+ import { fileURLToPath } from "node:url";
10
+ import * as clawhub from "./clawhub.js";
11
+ import * as skillhub from "./skillhub.js";
12
+
13
+ const __dirname = dirname(fileURLToPath(import.meta.url));
14
+ const name = "dsh-skill-store";
15
+
16
+ // No inject export — all service dependencies are acquired inside apply()
17
+ // via ctx.inject(["webServer"], callback), same as dshmarket does.
18
+
19
+ const Config = null;
20
+
21
+ // ── API client (in-process, not network) ───────────────────────────
22
+
23
+ const DSH_HOME = process.env.DSH_HOME
24
+ ? resolve(process.env.DSH_HOME)
25
+ : join(homedir(), ".dsh");
26
+ const SKILLS_DIR = join(DSH_HOME, "skills");
27
+ const CACHE_DIR = join(DSH_HOME, "skill-store-cache");
28
+ const CACHE_FILE = join(CACHE_DIR, "registry.json");
29
+ const CACHE_TTL = 24 * 60 * 60 * 1000;
30
+
31
+ async function ensureDir(dir) {
32
+ try { await mkdir(dir, { recursive: true }); } catch {}
33
+ }
34
+
35
+ async function fileExists(p) {
36
+ try { await access(p); return true; } catch { return false; }
37
+ }
38
+
39
+ // ── Registry (awesome-skills-cn via GitHub API) ────────────────────
40
+
41
+ const REGISTRY_REPO = "lingxling/awesome-skills-cn";
42
+ const API_BASE = `https://api.github.com/repos/${REGISTRY_REPO}`;
43
+
44
+ // ── Collection metadata ────────────────────────────────────────────────────
45
+ // The upstream repo ships no per-skill taxonomy (measured: `tags` present in
46
+ // only 2% of SKILL.md, `category` in 6%), so "everything is uncategorized" is
47
+ // a property of the data source, not a bug. We therefore derive the primary
48
+ // category from the collection directory, and surface the upstream project's
49
+ // GitHub stars as a collection-level popularity signal.
50
+ const COLLECTION_META = {
51
+ "anthropics-skills": { label: "Anthropic 官方", upstream: "anthropics/skills" },
52
+ "antigravity-awesome-skills": { label: "Antigravity 社区", upstream: "sickn33/antigravity-awesome-skills" },
53
+ "awesome-openclaw-skills": { label: "OpenClaw 社区", upstream: "VoltAgent/awesome-clawdbot-skills" },
54
+ "claude-scientific-skills": { label: "科学研究", upstream: "K-Dense-AI/claude-scientific-skills" },
55
+ "composiohq-awesome-claude-skills": { label: "Composio 工具集", upstream: "ComposioHQ/awesome-claude-skills" },
56
+ "huggingface-skills": { label: "HuggingFace", upstream: "huggingface/skills" },
57
+ "obsidian-skills": { label: "Obsidian", upstream: "kepano/obsidian-skills" },
58
+ "openai-skills": { label: "OpenAI 官方", upstream: "openai/skills" },
59
+ "ui-ux-pro-max-skill": { label: "UI/UX 设计", upstream: "nextlevelbuilder/ui-ux-pro-max-skill" },
60
+ "vercel-labs-agent-skills": { label: "Vercel Agent", upstream: "vercel-labs/agent-skills" },
61
+ "vercel-labs-skills": { label: "Vercel", upstream: "vercel-labs/skills" },
62
+ };
63
+
64
+ /** collection key → upstream star count (filled lazily, cached in memory). */
65
+ const _upstreamStars = new Map();
66
+
67
+ /**
68
+ * Fetch upstream repo stars for every known collection. Best effort and
69
+ * cached; failures leave the value undefined so the UI can simply omit it.
70
+ */
71
+ async function fetchUpstreamStars() {
72
+ const jobs = Object.entries(COLLECTION_META).map(async ([key, meta]) => {
73
+ if (_upstreamStars.has(key) || !meta.upstream) return;
74
+ try {
75
+ const res = await fetch(`https://api.github.com/repos/${meta.upstream}`, {
76
+ headers: {
77
+ Accept: "application/vnd.github.v3+json",
78
+ "User-Agent": "dsh-skill-store/0.1.0",
79
+ ...(GITHUB_TOKEN ? { Authorization: `token ${GITHUB_TOKEN}` } : {}),
80
+ },
81
+ signal: AbortSignal.timeout(12000),
82
+ });
83
+ if (res.ok) {
84
+ const d = await res.json();
85
+ _upstreamStars.set(key, d.stargazers_count ?? 0);
86
+ }
87
+ } catch { /* leave undefined */ }
88
+ });
89
+ await Promise.allSettled(jobs);
90
+ }
91
+
92
+ // ── Raw content mirrors ────────────────────────────────────────────────────
93
+ // raw.githubusercontent.com is unreachable on some networks (DNS blocked).
94
+ // jsDelivr mirrors the same repo content and is reachable from CN, so we try
95
+ // each mirror in order and fall through on failure.
96
+ const RAW_MIRRORS = [
97
+ `https://cdn.jsdelivr.net/gh/${REGISTRY_REPO}@main`,
98
+ `https://gcore.jsdelivr.net/gh/${REGISTRY_REPO}@main`,
99
+ `https://fastly.jsdelivr.net/gh/${REGISTRY_REPO}@main`,
100
+ `https://raw.githubusercontent.com/${REGISTRY_REPO}/main`,
101
+ ];
102
+ // Kept for backwards compatibility with any code path referencing RAW_BASE.
103
+ const RAW_BASE = RAW_MIRRORS[0];
104
+
105
+ /** Index of the mirror that most recently succeeded; tried first next time. */
106
+ let _activeMirror = 0;
107
+
108
+ // Optional GitHub token for higher rate limit (60 → 5000/hour)
109
+ const GITHUB_TOKEN = process.env.GITHUB_TOKEN || process.env.GH_TOKEN || "";
110
+
111
+ let _lastError = null;
112
+
113
+ async function githubApi(path) {
114
+ const headers = {
115
+ Accept: "application/vnd.github.v3+json",
116
+ "User-Agent": "dsh-skill-store/0.1.0",
117
+ };
118
+ if (GITHUB_TOKEN) headers.Authorization = `token ${GITHUB_TOKEN}`;
119
+
120
+ const res = await fetch(`${API_BASE}${path}`, { headers });
121
+ if (!res.ok) {
122
+ const msg = res.status === 403
123
+ ? `GitHub API rate limit exceeded (403). Set GITHUB_TOKEN env var for 5000 req/hr, or wait ~1 hour.`
124
+ : res.status === 429
125
+ ? `GitHub API rate limit exceeded (429). Retry after ${res.headers.get("retry-after") || "a while"}.`
126
+ : `GitHub API ${res.status}`;
127
+ throw new Error(msg);
128
+ }
129
+ return res.json();
130
+ }
131
+
132
+ async function rawFetch(path) {
133
+ // Start from the mirror that worked last time, then walk the rest.
134
+ const order = [];
135
+ for (let i = 0; i < RAW_MIRRORS.length; i++) {
136
+ order.push((_activeMirror + i) % RAW_MIRRORS.length);
137
+ }
138
+
139
+ let lastErr = null;
140
+ for (const idx of order) {
141
+ const base = RAW_MIRRORS[idx];
142
+ try {
143
+ const res = await fetch(`${base}/${path}`, {
144
+ headers: { "User-Agent": "dsh-skill-store/0.1.0" },
145
+ signal: AbortSignal.timeout(15000),
146
+ });
147
+ if (res.ok) {
148
+ _activeMirror = idx;
149
+ return await res.text();
150
+ }
151
+ // 404 means the file genuinely does not exist — no point trying others.
152
+ if (res.status === 404) return null;
153
+ lastErr = new Error(`${base} -> HTTP ${res.status}`);
154
+ } catch (e) {
155
+ lastErr = e;
156
+ }
157
+ }
158
+ if (lastErr) _lastError = `rawFetch failed for ${path}: ${lastErr.message}`;
159
+ return null;
160
+ }
161
+
162
+ function parseFrontmatter(content) {
163
+ const lines = content.split(/\r?\n/);
164
+ if (lines[0]?.trim() !== "---") return null;
165
+ let end = -1;
166
+ for (let i = 1; i < lines.length; i++) {
167
+ if (lines[i]?.trim() === "---") { end = i; break; }
168
+ }
169
+ if (end < 0) return null;
170
+ const data = {};
171
+ let ck = "", ca = [], inA = false;
172
+ for (const line of lines.slice(1, end)) {
173
+ const t = line.trim();
174
+ if (!t) continue;
175
+ const am = t.match(/^\s*-\s+(.+)$/);
176
+ if (am) { if (inA) ca.push(am[1].replace(/^["']|["']$/g, "")); continue; }
177
+ if (inA && ck) { data[ck] = ca; ca = []; ck = ""; inA = false; }
178
+ const km = t.match(/^(\w[\w-]*)\s*:\s*(.+)$/);
179
+ if (km) {
180
+ let v = km[2].replace(/^["']|["']$/g, "");
181
+ if (v.startsWith("[") && v.endsWith("]")) {
182
+ v = v.slice(1,-1).split(",").map(s => s.trim().replace(/^["']|["']$/g, ""));
183
+ } else if (v === "" || v === "[]") { ck = km[1]; ca = []; inA = true; continue; }
184
+ data[km[1]] = v; ck = ""; inA = false;
185
+ }
186
+ }
187
+ if (inA && ck) data[ck] = ca;
188
+ return { data, body: lines.slice(end + 1).join("\n") };
189
+ }
190
+
191
+ let registryCache = null;
192
+
193
+ /**
194
+ * Build the category index. Primary facet is the collection label (always
195
+ * present for this source); per-skill tags are added as secondary facets when
196
+ * the SKILL.md actually provides them.
197
+ */
198
+ function buildCategories(skills) {
199
+ const categories = {};
200
+ for (const [name, s] of Object.entries(skills)) {
201
+ const cats = [];
202
+ if (s.collectionLabel) cats.push(s.collectionLabel);
203
+ for (const tag of s.tags || []) if (!cats.includes(tag)) cats.push(tag);
204
+ if (cats.length === 0) cats.push("uncategorized");
205
+ for (const c of cats) {
206
+ (categories[c] = categories[c] || []).push(name);
207
+ }
208
+ }
209
+ return categories;
210
+ }
211
+
212
+ async function getRegistry(force = false) {
213
+ if (!force && registryCache) return registryCache;
214
+ // Try disk cache first
215
+ try {
216
+ await access(CACHE_FILE);
217
+ const raw = await readFile(CACHE_FILE, "utf-8");
218
+ const cached = JSON.parse(raw);
219
+ if (Date.now() - new Date(cached.builtAt).getTime() < CACHE_TTL) {
220
+ registryCache = cached;
221
+ return cached;
222
+ }
223
+ } catch {}
224
+ _lastError = null;
225
+ try {
226
+ // Build from GitHub — fetch child trees so we don't time out on one
227
+ // giant recursive tree. awesome-skills-cn is ~60 MB; its recursive
228
+ // tree JSON is too large for a single request to complete reliably.
229
+ //
230
+ // Strategy: each collection tree costs 1 API call; each `skills/`
231
+ // sub-tree costs 1 more. We avoid per-skill tree checks (they blow
232
+ // through the unauthenticated rate limit of 60/hour for 7000+ skills).
233
+ // Instead we assume every directory inside `skills/` is a valid skill
234
+ // and only download SKILL.md content (raw.githubusercontent.com, no
235
+ // rate limit).
236
+ const rootTree = await githubApi("/git/trees/main");
237
+ const collectionTrees = rootTree.tree
238
+ .filter((item) => item.type === "tree");
239
+
240
+ // skillName → { rawPath: "...", collection: "..." }
241
+ const skillDirs = new Map();
242
+
243
+ for (const coll of collectionTrees) {
244
+ try {
245
+ const collTree = await githubApi(`/git/trees/main:${coll.path}`);
246
+
247
+ // Pattern A: coll/skills/skillName/ (e.g. anthropics-skills/skills/xxx/)
248
+ const skillsDir = collTree.tree.find(
249
+ (item) => item.type === "tree" && item.path.toLowerCase() === "skills"
250
+ );
251
+ if (skillsDir) {
252
+ const skillsTree = await githubApi(`/git/trees/main:${coll.path}/skills`);
253
+ for (const item of skillsTree.tree) {
254
+ if (item.type !== "tree") continue;
255
+ if (skillDirs.has(item.path)) continue;
256
+ skillDirs.set(item.path, {
257
+ rawPath: `${coll.path}/skills/${item.path}`,
258
+ collection: coll.path,
259
+ });
260
+ }
261
+ }
262
+ } catch {
263
+ // Collection tree fetch failed — skip this collection
264
+ }
265
+ }
266
+
267
+ // ── Build the skeleton only ───────────────────────────────────────────────
268
+ // Fetching 2000+ SKILL.md files up front takes many minutes and hammers the
269
+ // CDN. Instead we publish the directory listing immediately (name, path,
270
+ // collection) and enrich entries with description/tags in the background.
271
+ // The UI shows the full list right away; detail arrives progressively.
272
+ // Populate upstream stars before building so entries can carry the signal.
273
+ await fetchUpstreamStars();
274
+
275
+ const skills = {};
276
+ for (const [skillName, info] of skillDirs) {
277
+ const meta = COLLECTION_META[info.collection];
278
+ skills[skillName] = {
279
+ name: skillName,
280
+ description: skillName, // placeholder until enriched
281
+ collection: info.collection,
282
+ collectionLabel: meta?.label ?? info.collection, // primary category
283
+ upstream: meta?.upstream ?? null,
284
+ upstreamStars: _upstreamStars.get(info.collection), // collection-level popularity
285
+ source: "github",
286
+ stars: null, // no per-skill stars exist for this source
287
+ tags: [], // filled by enrichment if SKILL.md has them
288
+ rawPath: info.rawPath, // stable path; URL built at fetch time
289
+ browseUrl: `https://github.com/${REGISTRY_REPO}/tree/main/${info.rawPath}`,
290
+ enriched: false,
291
+ };
292
+ }
293
+
294
+ // Restore any previously enriched metadata so we don't lose work on rebuild.
295
+ try {
296
+ const prevRaw = await readFile(CACHE_FILE, "utf-8");
297
+ const prev = JSON.parse(prevRaw).skills || {};
298
+ for (const [n, s] of Object.entries(prev)) {
299
+ if (skills[n] && s.enriched) {
300
+ skills[n] = { ...s, rawPath: skills[n].rawPath, browseUrl: skills[n].browseUrl };
301
+ }
302
+ }
303
+ } catch {}
304
+
305
+ // Primary categories come from the collection; per-skill tags (rare on this
306
+ // source) are kept as secondary facets.
307
+ const categories = {};
308
+ for (const [name, s] of Object.entries(skills)) {
309
+ const cats = s.collectionLabel ? [s.collectionLabel] : [];
310
+ for (const tag of s.tags || []) if (!cats.includes(tag)) cats.push(tag);
311
+ if (cats.length === 0) cats.push("uncategorized");
312
+ for (const c of cats) {
313
+ if (!categories[c]) categories[c] = [];
314
+ categories[c].push(name);
315
+ }
316
+ }
317
+
318
+ const collections = {};
319
+ for (const [key, meta] of Object.entries(COLLECTION_META)) {
320
+ const n = Object.values(skills).filter((s) => s.collection === key).length;
321
+ if (n > 0) {
322
+ collections[key] = {
323
+ label: meta.label,
324
+ upstream: meta.upstream,
325
+ stars: _upstreamStars.get(key) ?? null,
326
+ count: n,
327
+ };
328
+ }
329
+ }
330
+
331
+ const index = {
332
+ builtAt: new Date().toISOString(),
333
+ count: Object.keys(skills).length,
334
+ skills,
335
+ categories,
336
+ collections,
337
+ _debug: _lastError,
338
+ };
339
+ await ensureDir(CACHE_DIR);
340
+ await writeFile(CACHE_FILE, JSON.stringify(index), "utf-8");
341
+ registryCache = index;
342
+ return index;
343
+ } catch (e) {
344
+ _lastError = e.message;
345
+ // Return empty but cached result so the UI doesn't hang
346
+ const fallback = { builtAt: new Date().toISOString(), count: 0, skills: {}, categories: {}, _error: e.message };
347
+ registryCache = fallback;
348
+ return fallback;
349
+ }
350
+ }
351
+
352
+ async function getSkillEntry(name) {
353
+ const reg = await getRegistry();
354
+ return reg.skills[name] ?? null;
355
+ }
356
+
357
+ // ── Background enrichment ──────────────────────────────────────────
358
+ // Walks the skeleton and fills in description/tags by downloading each
359
+ // SKILL.md. Runs unattended; the cache is rewritten periodically so a
360
+ // restart resumes where it left off.
361
+
362
+ let _enriching = false;
363
+ let _enrichedCount = 0;
364
+
365
+ async function enrichRegistry() {
366
+ if (_enriching) return;
367
+ const reg = await getRegistry();
368
+ const pending = Object.values(reg.skills).filter((s) => !s.enriched);
369
+ if (pending.length === 0) return;
370
+
371
+ _enriching = true;
372
+ _enrichedCount = 0;
373
+ try {
374
+ const BATCH = 6;
375
+ for (let i = 0; i < pending.length; i += BATCH) {
376
+ const batch = pending.slice(i, i + BATCH);
377
+ const results = await Promise.allSettled(batch.map(async (entry) => {
378
+ const content = await rawFetch(`${entry.rawPath}/SKILL.md`);
379
+ if (!content) return null;
380
+ const parsed = parseFrontmatter(content);
381
+ if (!parsed) return null;
382
+ return { name: entry.name, parsed: parsed.data };
383
+ }));
384
+ let changed = false;
385
+ for (const r of results) {
386
+ if (r.status !== "fulfilled" || !r.value) continue;
387
+ const { name, parsed } = r.value;
388
+ const cur = reg.skills[name];
389
+ if (!cur) continue;
390
+ const tags = Array.isArray(parsed.tags) ? parsed.tags
391
+ : typeof parsed.tags === "string" ? [parsed.tags] : [];
392
+ reg.skills[name] = {
393
+ ...cur,
394
+ description: typeof parsed.description === "string" ? parsed.description : cur.description,
395
+ whenToUse: typeof parsed.whenToUse === "string" ? parsed.whenToUse : undefined,
396
+ tags,
397
+ author: typeof parsed.author === "string" ? parsed.author : undefined,
398
+ enriched: true,
399
+ };
400
+ _enrichedCount++;
401
+ changed = true;
402
+ }
403
+ // Persist every 10 batches (~60 skills) so progress survives a restart.
404
+ if (changed && (i / BATCH) % 10 === 0) {
405
+ reg.categories = buildCategories(reg.skills);
406
+ await ensureDir(CACHE_DIR);
407
+ await writeFile(CACHE_FILE, JSON.stringify(reg), "utf-8");
408
+ }
409
+ }
410
+ // Final flush
411
+ const categories = {};
412
+ reg.categories = buildCategories(reg.skills);
413
+ await ensureDir(CACHE_DIR);
414
+ await writeFile(CACHE_FILE, JSON.stringify(reg), "utf-8");
415
+ } catch {
416
+ // Enrichment is best-effort; never break the host.
417
+ } finally {
418
+ _enriching = false;
419
+ }
420
+ }
421
+
422
+ async function queryRegistry({ search, category, limit = 50, offset = 0 } = {}) {
423
+ const reg = await getRegistry();
424
+ let names = Object.keys(reg.skills);
425
+ if (category && reg.categories[category]) {
426
+ names = names.filter(n => reg.categories[category].includes(n));
427
+ }
428
+ if (search) {
429
+ const q = search.toLowerCase();
430
+ names = names.filter(n => n.toLowerCase().includes(q) || (reg.skills[n]?.description?.toLowerCase().includes(q)));
431
+ }
432
+ return {
433
+ entries: names.slice(offset, offset + limit).map(n => reg.skills[n]).filter(Boolean),
434
+ total: names.length,
435
+ };
436
+ }
437
+
438
+ // ── Install / Uninstall ────────────────────────────────────────────
439
+
440
+ /**
441
+ * Resolve a registry entry to a repo-relative path usable by rawFetch().
442
+ * New entries carry `rawPath`; legacy cached entries may only have `rawUrl`,
443
+ * in which case we strip whichever mirror prefix it was built with.
444
+ */
445
+ function resolveSkillPath(entry) {
446
+ if (typeof entry.rawPath === "string" && entry.rawPath) {
447
+ return `${entry.rawPath}/SKILL.md`;
448
+ }
449
+ if (typeof entry.rawUrl === "string") {
450
+ let p = entry.rawUrl;
451
+ for (const m of RAW_MIRRORS) {
452
+ if (p.startsWith(m + "/")) return p.slice(m.length + 1);
453
+ }
454
+ return p;
455
+ }
456
+ return null;
457
+ }
458
+
459
+ async function installSkill(skillName) {
460
+ const entry = await getSkillEntry(skillName);
461
+ if (!entry) throw new Error(`Skill "${skillName}" not found in registry`);
462
+ const skillDir = join(SKILLS_DIR, skillName);
463
+ const skillFile = join(skillDir, "SKILL.md");
464
+ const relPath = resolveSkillPath(entry);
465
+ if (!relPath) throw new Error(`Skill "${skillName}" has no retrievable path`);
466
+ const content = await rawFetch(relPath);
467
+ if (!content) throw new Error("Failed to download skill content");
468
+ await ensureDir(skillDir);
469
+ await writeFile(skillFile, content, "utf-8");
470
+ return { installed: true, path: skillFile };
471
+ }
472
+
473
+ async function uninstallSkill(skillName) {
474
+ const skillDir = join(SKILLS_DIR, skillName);
475
+ const skillFile = join(skillDir, "SKILL.md");
476
+ if (!(await fileExists(skillFile))) throw new Error(`Skill "${skillName}" is not installed`);
477
+ const trashDir = join(DSH_HOME, ".trash");
478
+ await ensureDir(trashDir);
479
+ const trashPath = join(trashDir, `skills-${skillName}-${Date.now()}`);
480
+ try {
481
+ const { rename } = await import("node:fs/promises");
482
+ await rename(skillDir, trashPath);
483
+ } catch {
484
+ await rm(skillDir, { recursive: true, force: true });
485
+ }
486
+ return { uninstalled: true };
487
+ }
488
+
489
+ async function listInstalled() {
490
+ const results = [];
491
+ try {
492
+ const entries = await readdir(SKILLS_DIR, { withFileTypes: true });
493
+ for (const e of entries) {
494
+ if (!e.isDirectory()) continue;
495
+ if (await fileExists(join(SKILLS_DIR, e.name, "SKILL.md"))) {
496
+ results.push({ name: e.name, path: join(SKILLS_DIR, e.name, "SKILL.md"), source: "user" });
497
+ }
498
+ }
499
+ } catch {}
500
+ return results;
501
+ }
502
+
503
+ // ── HTTP helpers ───────────────────────────────────────────────────
504
+
505
+ function sendJson(response, status, body) {
506
+ response.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
507
+ response.end(JSON.stringify(body));
508
+ }
509
+
510
+ async function readJsonBody(request, maxBytes = 65536) {
511
+ return new Promise((resolve, reject) => {
512
+ const chunks = [];
513
+ let size = 0;
514
+ request.on("data", (chunk) => {
515
+ size += chunk.length;
516
+ if (size > maxBytes) { reject(new Error("body too large")); request.destroy(); return; }
517
+ chunks.push(chunk);
518
+ });
519
+ request.on("end", () => {
520
+ try { resolve(chunks.length > 0 ? JSON.parse(Buffer.concat(chunks).toString("utf-8")) : {}); }
521
+ catch (e) { reject(e); }
522
+ });
523
+ request.on("error", reject);
524
+ });
525
+ }
526
+
527
+ // ── Route handlers ─────────────────────────────────────────────────
528
+
529
+ async function handleList(query) {
530
+ const search = typeof query.search === "string" ? query.search : "";
531
+ const category = typeof query.category === "string" ? query.category : "";
532
+ const limit = Math.min(Number(query.limit) || 100, 200);
533
+ const offset = Math.max(Number(query.offset) || 0, 0);
534
+ const source = typeof query.source === "string" ? query.source : "github";
535
+ const sort = typeof query.sort === "string" ? query.sort : "";
536
+
537
+ if (source === "clawhub") {
538
+ return clawhub.query({ search, category, limit, offset, sort });
539
+ }
540
+ if (source === "skillhub") {
541
+ return skillhub.query({ search, category, limit, offset, sort });
542
+ }
543
+ return queryRegistry({ search, category, limit, offset });
544
+ }
545
+
546
+ /** Merge category facets from both sources. */
547
+ async function handleCategories(source = "github") {
548
+ if (source === "clawhub") {
549
+ return (await clawhub.getRegistry()).categories;
550
+ }
551
+ if (source === "skillhub") {
552
+ return (await skillhub.getRegistry()).categories;
553
+ }
554
+ if (source === "all") {
555
+ const merged = {};
556
+ const gh = (await getRegistry()).categories || {};
557
+ const ch = (await clawhub.getRegistry()).categories || {};
558
+ for (const [c, names] of Object.entries(gh)) {
559
+ merged[`github:${c}`] = names;
560
+ }
561
+ for (const [c, names] of Object.entries(ch)) {
562
+ merged[`clawhub:${c}`] = names;
563
+ }
564
+ return merged;
565
+ }
566
+ return (await getRegistry()).categories;
567
+ }
568
+
569
+ /** Per-source health, useful for the UI header. */
570
+ async function handleSources() {
571
+ const gh = await getRegistry();
572
+ const ch = await clawhub.getRegistry();
573
+ const sh = await skillhub.getRegistry();
574
+ return {
575
+ sources: [
576
+ {
577
+ id: "github",
578
+ label: "GitHub 镜像",
579
+ note: "awesome-skills-cn 中文翻译仓库",
580
+ count: gh.count,
581
+ hasPopularity: false,
582
+ collections: gh.collections || {},
583
+ error: gh._error || null,
584
+ },
585
+ {
586
+ id: "clawhub",
587
+ label: "ClawHub",
588
+ note: "OpenClaw 官方技能注册表(含星标/下载量)",
589
+ count: ch.count,
590
+ hasPopularity: true,
591
+ status: clawhub.status(),
592
+ },
593
+ {
594
+ id: "skillhub",
595
+ label: "SkillHub",
596
+ note: "腾讯云 SkillHub 中文社区(13 万+技能,中英双语分类)",
597
+ count: sh.count,
598
+ hasPopularity: true,
599
+ status: skillhub.status(),
600
+ },
601
+ ],
602
+ };
603
+ }
604
+
605
+ async function handleDetail(skillName, source = "github") {
606
+ // ── ClawHub: metadata is already complete, only the body needs fetching ──
607
+ if (source === "clawhub") {
608
+ const reg = await clawhub.getRegistry();
609
+ const entry = reg.skills?.[skillName];
610
+ if (!entry) {
611
+ const err = new Error(`Skill "${skillName}" not found in ClawHub`);
612
+ err.statusCode = 404;
613
+ throw err;
614
+ }
615
+ const file = await clawhub.fetchFile(skillName, "SKILL.md");
616
+ return {
617
+ ...entry,
618
+ content: file.content,
619
+ owner: file.owner,
620
+ fetchError: file.content ? null : file.error,
621
+ };
622
+ }
623
+
624
+ // ── SkillHub: same shape as ClawHub ──
625
+ if (source === "skillhub") {
626
+ const reg = await skillhub.getRegistry();
627
+ const entry = reg.skills?.[skillName];
628
+ if (!entry) {
629
+ const err = new Error(`Skill "${skillName}" not found in SkillHub`);
630
+ err.statusCode = 404;
631
+ throw err;
632
+ }
633
+ const file = await skillhub.fetchFile(skillName, entry.namespace);
634
+ return {
635
+ ...entry,
636
+ content: file.content,
637
+ owner: file.namespace,
638
+ fetchError: file.content ? null : file.error,
639
+ };
640
+ }
641
+
642
+ const entry = await getSkillEntry(skillName);
643
+ if (!entry) {
644
+ const err = new Error(`Skill "${skillName}" not found`);
645
+ err.statusCode = 404;
646
+ throw err;
647
+ }
648
+
649
+ const relPath = resolveSkillPath(entry);
650
+ if (!relPath) {
651
+ const err = new Error(`Skill "${skillName}" has no retrievable path`);
652
+ err.statusCode = 404;
653
+ throw err;
654
+ }
655
+
656
+ const content = await rawFetch(relPath);
657
+
658
+ // Opportunistically promote this entry's metadata while we have the file.
659
+ if (content && !entry.enriched && registryCache) {
660
+ const parsed = parseFrontmatter(content);
661
+ if (parsed) {
662
+ const tags = Array.isArray(parsed.data.tags) ? parsed.data.tags
663
+ : typeof parsed.data.tags === "string" ? [parsed.data.tags] : ["uncategorized"];
664
+ registryCache.skills[skillName] = {
665
+ ...entry,
666
+ description: typeof parsed.data.description === "string" ? parsed.data.description : entry.description,
667
+ whenToUse: typeof parsed.data.whenToUse === "string" ? parsed.data.whenToUse : undefined,
668
+ tags,
669
+ author: typeof parsed.data.author === "string" ? parsed.data.author : undefined,
670
+ enriched: true,
671
+ };
672
+ }
673
+ }
674
+
675
+ return { ...entry, content };
676
+ }
677
+
678
+ /**
679
+ * Install a skill coming from ClawHub. Downloads the skill body and writes it
680
+ * as SKILL.md, then records provenance so uninstall/list stay accurate.
681
+ */
682
+ async function installClawhubSkill(skillName) {
683
+ const reg = await clawhub.getRegistry();
684
+ const entry = reg.skills?.[skillName];
685
+ if (!entry) throw new Error(`Skill "${skillName}" not found in ClawHub`);
686
+
687
+ const file = await clawhub.fetchFile(skillName, "SKILL.md");
688
+ if (!file.content) throw new Error(`Failed to download skill content (${file.error || "unknown"})`);
689
+
690
+ // Namespace by source to avoid collisions with GitHub-sourced skills.
691
+ const safeName = skillName.replace(/[^\w.-]/g, "_");
692
+ const skillDir = join(SKILLS_DIR, safeName);
693
+ const skillFile = join(skillDir, "SKILL.md");
694
+ await ensureDir(skillDir);
695
+ await writeFile(skillFile, file.content, "utf-8");
696
+
697
+ const metaFile = join(skillDir, ".dsh-skill-store.json");
698
+ await writeFile(metaFile, JSON.stringify({
699
+ source: "clawhub",
700
+ slug: skillName,
701
+ owner: file.owner || null,
702
+ version: file.version || null,
703
+ stars: entry.stars ?? 0,
704
+ downloads: entry.downloads ?? 0,
705
+ topics: entry.topics || [],
706
+ browseUrl: entry.browseUrl,
707
+ installedAt: new Date().toISOString(),
708
+ }, null, 2), "utf-8");
709
+
710
+ return { installed: true, path: skillFile, source: "clawhub", name: safeName };
711
+ }
712
+
713
+ /**
714
+ * Install a skill coming from SkillHub. Writes SKILL.md plus a provenance
715
+ * sidecar so uninstall/list stay accurate.
716
+ */
717
+ async function installSkillhubSkill(skillName) {
718
+ const reg = await skillhub.getRegistry();
719
+ const entry = reg.skills?.[skillName];
720
+ if (!entry) throw new Error(`Skill "${skillName}" not found in SkillHub`);
721
+
722
+ const file = await skillhub.fetchFile(skillName, entry.namespace);
723
+ if (!file.content) throw new Error(`Failed to download skill content (${file.error || "unknown"})`);
724
+
725
+ const safeName = skillName.replace(/[^\w.-]/g, "_");
726
+ const skillDir = join(SKILLS_DIR, safeName);
727
+ const skillFile = join(skillDir, "SKILL.md");
728
+ await ensureDir(skillDir);
729
+ await writeFile(skillFile, file.content, "utf-8");
730
+
731
+ await writeFile(join(skillDir, ".dsh-skill-store.json"), JSON.stringify({
732
+ source: "skillhub",
733
+ slug: skillName,
734
+ namespace: entry.namespace || null,
735
+ canonicalName: entry.canonicalName || null,
736
+ version: file.version || entry.version || null,
737
+ stars: entry.stars ?? 0,
738
+ downloads: entry.downloads ?? 0,
739
+ category: entry.category || null,
740
+ categoryLabel: entry.categoryLabel || null,
741
+ topics: entry.topics || [],
742
+ browseUrl: entry.browseUrl,
743
+ installedAt: new Date().toISOString(),
744
+ }, null, 2), "utf-8");
745
+
746
+ return { installed: true, path: skillFile, source: "skillhub", name: safeName };
747
+ }
748
+
749
+ async function handleInstall(skillName, source = "github") {
750
+ if (source === "clawhub") return installClawhubSkill(skillName);
751
+ if (source === "skillhub") return installSkillhubSkill(skillName);
752
+ return installSkill(skillName);
753
+ }
754
+
755
+ async function handleUninstall(skillName) {
756
+ return uninstallSkill(skillName);
757
+ }
758
+
759
+ async function handleInstalled() {
760
+ const skills = await listInstalled();
761
+ return { skills };
762
+ }
763
+
764
+ let _building = false;
765
+ let _buildPromise = null;
766
+
767
+ async function handleRefresh() {
768
+ if (_building) return { refreshed: false, reason: "already building index" };
769
+ _building = true;
770
+ registryCache = null;
771
+ try { await rm(CACHE_FILE, { force: true }); } catch {}
772
+ _lastError = null;
773
+ // Start async build — returns immediately so the HTTP request doesn't time out.
774
+ // Once the skeleton is ready, kick off background enrichment of details.
775
+ _buildPromise = getRegistry(true).then(() => { enrichRegistry(); });
776
+ _buildPromise.finally(() => { _building = false; _buildPromise = null; });
777
+ return {
778
+ refreshed: false,
779
+ status: "building",
780
+ note: "Skeleton index builds in seconds; descriptions fill in progressively in the background.",
781
+ hint: !GITHUB_TOKEN ? "No GITHUB_TOKEN set — unauthenticated limit is 60 req/hr. Set GITHUB_TOKEN env var for 5000/hr." : undefined,
782
+ };
783
+ }
784
+
785
+ // ── DSH Plugin apply ───────────────────────────────────────────────
786
+
787
+ function apply(ctx) {
788
+ ctx.inject(["webServer"], (hostCtx) => {
789
+ const ws = hostCtx.webServer;
790
+ if (!ws?.register) {
791
+ console.warn("dsh-skill-store: webServer service unavailable");
792
+ return;
793
+ }
794
+
795
+ const disposers = [];
796
+
797
+ disposers.push(ws.register({
798
+ kind: "prefix", path: "/api/dsh-skill-store/list",
799
+ handler: async (req, res) => {
800
+ try {
801
+ const url = new URL(req.url ?? "/", "http://localhost");
802
+ sendJson(res, 200, await handleList(Object.fromEntries(url.searchParams)));
803
+ } catch (e) { sendJson(res, 500, { error: e.message }); }
804
+ },
805
+ }));
806
+
807
+ disposers.push(ws.register({
808
+ kind: "prefix", path: "/api/dsh-skill-store/categories",
809
+ handler: async (req, res) => {
810
+ try {
811
+ const url = new URL(req.url ?? "/", "http://localhost");
812
+ const source = url.searchParams.get("source") || "github";
813
+ sendJson(res, 200, await handleCategories(source));
814
+ } catch (e) { sendJson(res, 500, { error: e.message }); }
815
+ },
816
+ }));
817
+
818
+ disposers.push(ws.register({
819
+ kind: "prefix", path: "/api/dsh-skill-store/sources",
820
+ handler: async (req, res) => {
821
+ try { sendJson(res, 200, await handleSources()); }
822
+ catch (e) { sendJson(res, 500, { error: e.message }); }
823
+ },
824
+ }));
825
+
826
+ disposers.push(ws.register({
827
+ kind: "prefix", path: "/api/dsh-skill-store/detail/",
828
+ handler: async (req, res) => {
829
+ try {
830
+ const url = new URL(req.url ?? "/", "http://localhost");
831
+ const source = url.searchParams.get("source") || "github";
832
+ const raw = (req.url ?? "/").split("/detail/")[1] || "";
833
+ const skillName = decodeURIComponent(raw.split("?")[0]);
834
+ sendJson(res, 200, await handleDetail(skillName, source));
835
+ } catch (e) { sendJson(res, e.statusCode || 500, { error: e.message }); }
836
+ },
837
+ }));
838
+
839
+ disposers.push(ws.register({
840
+ kind: "prefix", path: "/api/dsh-skill-store/install",
841
+ handler: async (req, res) => {
842
+ if (req.method !== "POST") { sendJson(res, 405, { error: "POST only" }); return; }
843
+ try {
844
+ const body = await readJsonBody(req);
845
+ if (!body.name || typeof body.name !== "string") { sendJson(res, 400, { error: "name is required" }); return; }
846
+ const source = typeof body.source === "string" ? body.source : "github";
847
+ sendJson(res, 200, await handleInstall(body.name, source));
848
+ } catch (e) { sendJson(res, 500, { error: e.message }); }
849
+ },
850
+ }));
851
+
852
+ disposers.push(ws.register({
853
+ kind: "prefix", path: "/api/dsh-skill-store/uninstall",
854
+ handler: async (req, res) => {
855
+ if (req.method !== "POST") { sendJson(res, 405, { error: "POST only" }); return; }
856
+ try {
857
+ const body = await readJsonBody(req);
858
+ if (!body.name || typeof body.name !== "string") { sendJson(res, 400, { error: "name is required" }); return; }
859
+ sendJson(res, 200, await handleUninstall(body.name));
860
+ } catch (e) { sendJson(res, 500, { error: e.message }); }
861
+ },
862
+ }));
863
+
864
+ disposers.push(ws.register({
865
+ kind: "prefix", path: "/api/dsh-skill-store/installed",
866
+ handler: async (req, res) => {
867
+ try { sendJson(res, 200, await handleInstalled()); }
868
+ catch (e) { sendJson(res, 500, { error: e.message }); }
869
+ },
870
+ }));
871
+
872
+ disposers.push(ws.register({
873
+ kind: "prefix", path: "/api/dsh-skill-store/refresh",
874
+ handler: async (req, res) => {
875
+ try { sendJson(res, 200, await handleRefresh()); }
876
+ catch (e) { sendJson(res, 500, { error: e.message }); }
877
+ },
878
+ }));
879
+
880
+ disposers.push(ws.register({
881
+ kind: "prefix", path: "/api/dsh-skill-store/status",
882
+ handler: async (req, res) => {
883
+ try {
884
+ const reg = await getRegistry();
885
+ const total = Object.keys(reg.skills).length;
886
+ const done = Object.values(reg.skills).filter((s) => s.enriched).length;
887
+ sendJson(res, 200, {
888
+ github: {
889
+ total,
890
+ enriched: done,
891
+ pending: total - done,
892
+ enriching: _enriching,
893
+ activeMirror: RAW_MIRRORS[_activeMirror],
894
+ building: _building,
895
+ error: reg._error || reg._debug || null,
896
+ },
897
+ clawhub: clawhub.status(),
898
+ skillhub: skillhub.status(),
899
+ });
900
+ } catch (e) { sendJson(res, 500, { error: e.message }); }
901
+ },
902
+ }));
903
+
904
+ disposers.push(ws.register({
905
+ kind: "prefix", path: "/api/dsh-skill-store/refresh-clawhub",
906
+ handler: async (req, res) => {
907
+ try {
908
+ // Kick off in the background; the full walk takes ~1-2 minutes.
909
+ clawhub.getRegistry(true);
910
+ sendJson(res, 200, {
911
+ refreshed: false,
912
+ status: "building",
913
+ note: "ClawHub registry reindex started in the background.",
914
+ });
915
+ } catch (e) { sendJson(res, 500, { error: e.message }); }
916
+ },
917
+ }));
918
+
919
+ disposers.push(ws.register({
920
+ kind: "prefix", path: "/api/dsh-skill-store/refresh-skillhub",
921
+ handler: async (req, res) => {
922
+ try {
923
+ // SkillHub has 131k+ skills, so we cap the walk and take the most
924
+ // downloaded ones; expect ~30-60s.
925
+ skillhub.getRegistry(true);
926
+ sendJson(res, 200, {
927
+ refreshed: false,
928
+ status: "building",
929
+ note: "SkillHub registry reindex started in the background.",
930
+ });
931
+ } catch (e) { sendJson(res, 500, { error: e.message }); }
932
+ },
933
+ }));
934
+
935
+ // Kick off background work shortly after startup: GitHub enrichment for
936
+ // descriptions, and a ClawHub reindex for popularity data.
937
+ const enrichTimer = setTimeout(() => { enrichRegistry(); }, 3000);
938
+ const clawhubTimer = setTimeout(() => { clawhub.getRegistry(false); }, 8000);
939
+ // SkillHub is the richest source, so start it slightly earlier than ClawHub.
940
+ const skillhubTimer = setTimeout(() => { skillhub.getRegistry(false); }, 6000);
941
+
942
+ return () => {
943
+ clearTimeout(enrichTimer);
944
+ clearTimeout(clawhubTimer);
945
+ clearTimeout(skillhubTimer);
946
+ for (const d of disposers) d();
947
+ };
948
+ }, "dsh-skill-store: http routes");
949
+ }
950
+
951
+ export { name, Config, apply };