dsh-skill-market 1.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/src/index.js ADDED
@@ -0,0 +1,995 @@
1
+ /**
2
+ * dsh-skill-market — Host half
3
+ *
4
+ * Intercepts /api skill-hub/* RPC endpoints for the browser half.
5
+ * All RPC endpoints use the Connection RPC intercept mechanism (POST /api/<endpoint>).
6
+ *
7
+ * Endpoints:
8
+ * skill-hub/search — search skills across SkillHub + ClawHub
9
+ * skill-hub/detail — get skill detail (README, versions, stats)
10
+ * skill-hub/installed — list locally installed skills
11
+ * skill-hub/install — download + install a skill to $DSH_HOME/skills/
12
+ * skill-hub/uninstall — remove a locally installed skill
13
+ * skill-hub/readme — read README.md / SKILL.md of an installed skill
14
+ * skill-hub/categories — list skill categories from SkillHub
15
+ *
16
+ * Data sources:
17
+ * Primary: SkillHub (api.skillhub.tencent.com) — Tencent CN mirror, fast
18
+ * Fallback: ClawHub (clawhub.com) — official source
19
+ */
20
+
21
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'
22
+ import { homedir } from 'node:os'
23
+ import { join, resolve } from 'node:path'
24
+ import { Readable } from 'node:stream'
25
+ import { pipeline } from 'node:stream/promises'
26
+ import { createWriteStream } from 'node:fs'
27
+ import { tmpdir } from 'node:os'
28
+ import { randomUUID } from 'node:crypto'
29
+ import { unzipSync } from 'node:zlib'
30
+ import { execSync } from 'node:child_process'
31
+
32
+ // ── Config ──────────────────────────────────────────────────────────────
33
+
34
+ const DEFAULT_SKILLHUB_API = 'https://api.skillhub.tencent.com'
35
+ // clawhub.com 已 307 跳转到 clawhub.ai,直接用新域名少一跳
36
+ const DEFAULT_CLAWHUB_API = 'https://clawhub.ai'
37
+
38
+ // ── Helpers ─────────────────────────────────────────────────────────────
39
+
40
+ /**
41
+ * Resolve the local skills directory.
42
+ * Priority: config.localSkillsDir > $DSH_HOME/skills > ~/.dsh/skills
43
+ */
44
+ function resolveSkillsDir(config) {
45
+ if (config.localSkillsDir) return resolve(config.localSkillsDir)
46
+ const dshHome = process.env.DSH_HOME || join(homedir(), '.dsh')
47
+ return join(dshHome, 'skills')
48
+ }
49
+
50
+ /**
51
+ * HTTP GET with JSON parse, timeout, and error containment.
52
+ */
53
+ async function httpGetJson(url, { timeoutMs = 15000, headers = {} } = {}) {
54
+ const controller = new AbortController()
55
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
56
+ try {
57
+ const res = await fetch(url, {
58
+ signal: controller.signal,
59
+ headers: { 'Accept': 'application/json', ...headers },
60
+ })
61
+ if (!res.ok) {
62
+ const text = await res.text().catch(() => '')
63
+ throw new Error(`HTTP ${res.status}: ${text.slice(0, 200)}`)
64
+ }
65
+ return await res.json()
66
+ } finally {
67
+ clearTimeout(timer)
68
+ }
69
+ }
70
+
71
+ /**
72
+ * HTTP GET binary (for ZIP download).
73
+ */
74
+ async function httpGetBuffer(url, { timeoutMs = 60000, headers = {} } = {}) {
75
+ const controller = new AbortController()
76
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
77
+ try {
78
+ const res = await fetch(url, {
79
+ signal: controller.signal,
80
+ headers: { 'Accept': 'application/zip,*/*', ...headers },
81
+ })
82
+ if (!res.ok) {
83
+ const text = await res.text().catch(() => '')
84
+ throw new Error(`HTTP ${res.status}: ${text.slice(0, 200)}`)
85
+ }
86
+ const buf = Buffer.from(await res.arrayBuffer())
87
+ return buf
88
+ } finally {
89
+ clearTimeout(timer)
90
+ }
91
+ }
92
+
93
+ /**
94
+ * RPC result helpers.
95
+ */
96
+ function ok(value) {
97
+ return { ok: true, value }
98
+ }
99
+ /**
100
+ * RPC error helper.
101
+ * NOTE: The 'code' must be one of the values in the dsh RPC Zod schema enum:
102
+ * 'bad-request' | 'cancelled' | 'internal' | 'command-error' | 'unknown-command' | etc.
103
+ * We only use 'bad-request' (user error) and 'internal' (server error) for skill-hub.
104
+ */
105
+ function err(code, message, details = {}) {
106
+ // Map unknown codes to allowed ones
107
+ const allowedCodes = new Set([
108
+ 'bad-request', 'cancelled', 'internal', 'command-error', 'unknown-command',
109
+ 'settings-rejected', 'credential-rejected',
110
+ ])
111
+ const finalCode = allowedCodes.has(code) ? code : 'internal'
112
+ return { ok: false, error: { code: finalCode, message, details } }
113
+ }
114
+
115
+ // ── SkillHub API ────────────────────────────────────────────────────────
116
+
117
+ /**
118
+ * Search SkillHub (api.skillhub.tencent.com).
119
+ * GET /api/skills?page=1&pageSize=20&keyword=xxx&sortBy=downloads&order=desc
120
+ */
121
+ async function searchSkillHub(apiBase, keyword, page = 1, pageSize = 20, category = '') {
122
+ const params = new URLSearchParams()
123
+ if (keyword) params.set('keyword', keyword)
124
+ params.set('page', String(page))
125
+ params.set('pageSize', String(pageSize))
126
+ params.set('sortBy', 'downloads')
127
+ params.set('order', 'desc')
128
+ if (category) params.set('category', category)
129
+ const url = `${apiBase}/api/skills?${params.toString()}`
130
+ const data = await httpGetJson(url)
131
+ // SkillHub returns { code: 0, data: { skills: [...], total: N } }
132
+ const skills = data?.data?.skills ?? []
133
+ const total = data?.data?.total ?? skills.length
134
+ return {
135
+ source: 'skillhub',
136
+ items: skills.map(s => ({
137
+ slug: s.slug ?? s.name ?? '',
138
+ name: s.name ?? s.slug ?? '',
139
+ displayName: s.displayName ?? s.name ?? '',
140
+ summary: s.summary ?? s.description ?? '',
141
+ summaryZh: s.summary_zh ?? s.description_zh ?? s.summaryZh ?? '',
142
+ author: s.ownerName ?? s.author ?? s.namespace?.displayName ?? '',
143
+ downloads: s.downloads ?? s.stats?.downloads ?? 0,
144
+ installs: s.installs ?? s.stats?.installs ?? 0,
145
+ stars: s.stars ?? s.stats?.stars ?? 0,
146
+ iconUrl: s.iconUrl ?? '',
147
+ verified: !!(s.verified ?? s.isAuthorVerified),
148
+ homepage: s.homepage ?? s.upstream_url ?? '',
149
+ version: s.version ?? '',
150
+ tags: s.tags ?? s.subCategories ?? [],
151
+ category: s.category ?? '',
152
+ source: 'skillhub',
153
+ installRef: s.slug ?? s.name ?? '',
154
+ })),
155
+ total,
156
+ page,
157
+ pageSize,
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Get SkillHub skill detail (batch endpoint, no auth needed).
163
+ * POST /api/v1/skills/batch with { slugs: [slug] }
164
+ */
165
+ async function detailSkillHub(apiBase, slug) {
166
+ // Try batch endpoint first (no auth needed)
167
+ const url = `${apiBase}/api/v1/skills/batch`
168
+ const controller = new AbortController()
169
+ const timer = setTimeout(() => controller.abort(), 15000)
170
+ try {
171
+ const res = await fetch(url, {
172
+ method: 'POST',
173
+ signal: controller.signal,
174
+ headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
175
+ body: JSON.stringify({ slugs: [slug] }),
176
+ })
177
+ clearTimeout(timer)
178
+ if (!res.ok) {
179
+ throw new Error(`HTTP ${res.status}`)
180
+ }
181
+ const data = await res.json()
182
+ // Batch API returns {count, items: [{skill: {...}, owner: {...}, latestVersion: {...}, securityReports: {...}}]}
183
+ const items = data?.items ?? data?.data?.skills ?? data?.data ?? []
184
+ const item = Array.isArray(items) ? items[0] : items?.[slug]
185
+ if (!item) return null
186
+ // The actual skill data may be nested under item.skill or directly on item
187
+ const skill = item.skill ?? item
188
+ const owner = item.owner ?? {}
189
+ const latestVersion = item.latestVersion ?? {}
190
+ return {
191
+ slug: skill.slug ?? slug,
192
+ name: skill.name ?? skill.slug ?? slug,
193
+ displayName: skill.displayName ?? skill.name ?? slug,
194
+ summary: skill.summary ?? skill.description ?? '',
195
+ summaryZh: skill.summary_zh ?? '',
196
+ author: owner.handle ?? owner.displayName ?? skill.author ?? '',
197
+ downloads: skill.downloads ?? skill.stats?.downloads ?? 0,
198
+ installs: skill.installs ?? skill.stats?.installs ?? 0,
199
+ // 详情接口的统计值嵌在 skill.stats 里(搜索接口是平铺字段)
200
+ stars: skill.stars ?? skill.stats?.stars ?? 0,
201
+ iconUrl: skill.iconUrl ?? '',
202
+ verified: !!(skill.verified ?? skill.isAuthorVerified),
203
+ version: latestVersion.version ?? skill.version ?? '',
204
+ tags: skill.tags ?? skill.subCategories ?? [],
205
+ category: skill.category ?? '',
206
+ readme: skill.readme ?? '',
207
+ securityReports: item.securityReports ?? skill.securityReports ?? [],
208
+ source: 'skillhub',
209
+ }
210
+ } catch (e) {
211
+ clearTimeout(timer)
212
+ throw e
213
+ }
214
+ }
215
+
216
+ /**
217
+ * Read a SkillHub skill file content.
218
+ * GET /api/v1/skills/{slug}/file?path=SKILL.md
219
+ */
220
+ async function readFileSkillHub(apiBase, slug, filePath) {
221
+ const params = new URLSearchParams({ path: filePath })
222
+ const url = `${apiBase}/api/v1/skills/${encodeURIComponent(slug)}/file?${params.toString()}`
223
+ // File API returns binary (application/octet-stream), not JSON
224
+ const controller = new AbortController()
225
+ const timer = setTimeout(() => controller.abort(), 15000)
226
+ try {
227
+ const res = await fetch(url, {
228
+ signal: controller.signal,
229
+ headers: { 'Accept': '*/*' },
230
+ })
231
+ if (!res.ok) {
232
+ throw new Error(`HTTP ${res.status}`)
233
+ }
234
+ return await res.text()
235
+ } finally {
236
+ clearTimeout(timer)
237
+ }
238
+ }
239
+
240
+ /**
241
+ * Download a SkillHub skill ZIP.
242
+ * GET /api/v1/download?slug={slug}
243
+ */
244
+ async function downloadSkillHub(apiBase, slug) {
245
+ const url = `${apiBase}/api/v1/download?slug=${encodeURIComponent(slug)}`
246
+ return httpGetBuffer(url)
247
+ }
248
+
249
+ // ── ClawHub API ──────────────────────────────────────────────────────────
250
+
251
+ /**
252
+ * Search ClawHub (clawhub.com).
253
+ * GET /api/v1/search?q={keyword}&limit={limit}
254
+ */
255
+ async function searchClawHub(apiBase, keyword, limit = 20) {
256
+ const params = new URLSearchParams({ q: keyword, limit: String(limit) })
257
+ const url = `${apiBase}/api/v1/search?${params.toString()}`
258
+ const data = await httpGetJson(url)
259
+ const results = data?.results ?? []
260
+ return {
261
+ source: 'clawhub',
262
+ items: results.map(r => {
263
+ const ref = r.install?.reference ?? ''
264
+ const [owner, slugPart] = ref.split('/')
265
+ return {
266
+ slug: r.slug ?? slugPart ?? r.name ?? '',
267
+ name: r.name ?? r.slug ?? '',
268
+ displayName: r.displayName ?? r.name ?? '',
269
+ summary: r.summary ?? r.description ?? '',
270
+ summaryZh: '',
271
+ author: r.owner?.displayName ?? owner ?? r.author ?? '',
272
+ downloads: r.downloads ?? 0,
273
+ // ClawHub 没有 star,用收藏数(bookmarks)作为等价热度指标
274
+ stars: r.metrics?.bookmarks ?? r.stars ?? 0,
275
+ installs: r.metrics?.rolling60DayInstalls ?? r.installs ?? 0,
276
+ iconUrl: r.icon ?? r.owner?.image ?? '',
277
+ verified: !!(r.official ?? r.trust?.verified ?? r.publisher?.official),
278
+ version: r.version ?? '',
279
+ tags: r.tags ?? r.categories ?? [],
280
+ category: r.category ?? '',
281
+ source: 'clawhub',
282
+ installRef: ref,
283
+ ownerHandle: owner,
284
+ }
285
+ }),
286
+ total: results.length,
287
+ page: 1,
288
+ pageSize: limit,
289
+ }
290
+ }
291
+
292
+ /**
293
+ * Get ClawHub skill detail.
294
+ * GET /api/v1/skills/{slug}?ownerHandle={owner}
295
+ */
296
+ async function detailClawHub(apiBase, slug, ownerHandle) {
297
+ const params = new URLSearchParams()
298
+ if (ownerHandle) params.set('ownerHandle', ownerHandle)
299
+ const url = `${apiBase}/api/v1/skills/${encodeURIComponent(slug)}?${params.toString()}`
300
+ const data = await httpGetJson(url)
301
+ if (!data) return null
302
+ return {
303
+ slug: data.slug ?? slug,
304
+ name: data.name ?? data.slug ?? slug,
305
+ displayName: data.displayName ?? data.name ?? slug,
306
+ summary: data.summary ?? data.description ?? '',
307
+ summaryZh: '',
308
+ author: data.owner?.displayName ?? data.author ?? ownerHandle ?? '',
309
+ downloads: data.downloads ?? 0,
310
+ stars: data.metrics?.bookmarks ?? data.stars ?? 0,
311
+ installs: data.metrics?.rolling60DayInstalls ?? data.installs ?? 0,
312
+ iconUrl: data.icon ?? data.owner?.image ?? '',
313
+ verified: !!(data.official ?? data.verified),
314
+ version: data.version ?? '',
315
+ tags: data.tags ?? data.categories ?? [],
316
+ category: data.category ?? '',
317
+ readme: data.readme ?? '',
318
+ securityReports: [],
319
+ source: 'clawhub',
320
+ }
321
+ }
322
+
323
+ /**
324
+ * Download a ClawHub skill ZIP.
325
+ * GET /api/v1/download?slug={slug}&ownerHandle={owner}
326
+ */
327
+ async function downloadClawHub(apiBase, slug, ownerHandle) {
328
+ const params = new URLSearchParams({ slug })
329
+ if (ownerHandle) params.set('ownerHandle', ownerHandle)
330
+ const url = `${apiBase}/api/v1/download?${params.toString()}`
331
+ return httpGetBuffer(url)
332
+ }
333
+
334
+ // ── ZIP extraction (using system unzip or node:zlib) ────────────────────
335
+
336
+ /**
337
+ * Extract a ZIP buffer to a target directory.
338
+ * Uses system `tar` (available on Windows 10+ and all Unix) as the primary method.
339
+ * Falls back to a manual ZIP parser for simple flat archives.
340
+ */
341
+ function extractZip(zipBuffer, targetDir) {
342
+ // Ensure target dir exists
343
+ mkdirSync(targetDir, { recursive: true })
344
+
345
+ // Write the zip to a temp file
346
+ const tempZip = join(tmpdir(), `dsh-skill-${randomUUID()}.zip`)
347
+ writeFileSync(tempZip, zipBuffer)
348
+
349
+ try {
350
+ // Try system tar (Windows 10+ has tar.exe, Unix always has tar)
351
+ try {
352
+ execSync(`tar -xf "${tempZip}" -C "${targetDir}"`, {
353
+ timeout: 30000,
354
+ stdio: 'pipe',
355
+ windowsHide: true,
356
+ })
357
+ return targetDir
358
+ } catch (tarErr) {
359
+ // tar not available or failed, try PowerShell Expand-Archive (Windows)
360
+ if (process.platform === 'win32') {
361
+ try {
362
+ execSync(
363
+ `powershell -NoProfile -Command "Expand-Archive -Path '${tempZip}' -DestinationPath '${targetDir}' -Force"`,
364
+ { timeout: 30000, stdio: 'pipe', windowsHide: true }
365
+ )
366
+ return targetDir
367
+ } catch (psErr) {
368
+ // Fall through to manual extraction
369
+ }
370
+ }
371
+ }
372
+
373
+ // Last resort: manual ZIP (store method only, no compression)
374
+ // This handles the simple flat SKILL.md + _meta.json structure
375
+ extractZipManual(zipBuffer, targetDir)
376
+ return targetDir
377
+ } finally {
378
+ try { rmSync(tempZip, { force: true }) } catch {}
379
+ }
380
+ }
381
+
382
+ /**
383
+ * Manual ZIP extraction for simple archives (STORE method only).
384
+ * Handles the common case of ClawHub/SkillHub flat skill packages.
385
+ */
386
+ function extractZipManual(zipBuffer, targetDir) {
387
+ // ZIP format: each file has a local file header
388
+ let offset = 0
389
+ while (offset < zipBuffer.length - 4) {
390
+ // Look for local file header signature: PK\x03\x04 (0x04034b50)
391
+ const sig = zipBuffer.readUInt32LE(offset)
392
+ if (sig !== 0x04034b50) break
393
+
394
+ const compressionMethod = zipBuffer.readUInt16LE(offset + 8)
395
+ const compressedSize = zipBuffer.readUInt32LE(offset + 18)
396
+ const uncompressedSize = zipBuffer.readUInt32LE(offset + 22)
397
+ const filenameLen = zipBuffer.readUInt16LE(offset + 26)
398
+ const extraFieldLen = zipBuffer.readUInt16LE(offset + 28)
399
+
400
+ const filenameStart = offset + 30
401
+ const filename = zipBuffer.slice(filenameStart, filenameStart + filenameLen).toString('utf8')
402
+
403
+ const dataStart = filenameStart + filenameLen + extraFieldLen
404
+ const dataSize = compressionMethod === 0 ? uncompressedSize : compressedSize
405
+ const fileData = zipBuffer.slice(dataStart, dataStart + dataSize)
406
+
407
+ // Skip directory entries
408
+ if (filename.endsWith('/')) {
409
+ offset = dataStart + dataSize
410
+ continue
411
+ }
412
+
413
+ // Only handle STORE method (0); skip compressed entries
414
+ if (compressionMethod === 0) {
415
+ const safeName = filename.replace(/\.\.[\/\\]/g, '').replace(/[\\]/g, '/')
416
+ const outPath = join(targetDir, safeName)
417
+ const outDir = join(outPath, '..')
418
+ mkdirSync(outDir, { recursive: true })
419
+ writeFileSync(outPath, fileData)
420
+ }
421
+
422
+ // Move to next entry
423
+ offset = dataStart + dataSize
424
+ }
425
+ }
426
+
427
+ // ── Local skill management ──────────────────────────────────────────────
428
+
429
+ /**
430
+ * List locally installed skills by scanning the skills directory.
431
+ * Each subdirectory with a SKILL.md is considered an installed skill.
432
+ */
433
+ function listLocalSkills(skillsDir) {
434
+ if (!existsSync(skillsDir)) return []
435
+ const entries = readdirSync(skillsDir, { withFileTypes: true })
436
+ const skills = []
437
+ for (const entry of entries) {
438
+ if (!entry.isDirectory()) continue
439
+ const skillDir = join(skillsDir, entry.name)
440
+ const skillMdPath = join(skillDir, 'SKILL.md')
441
+ if (!existsSync(skillMdPath)) continue
442
+ // Parse frontmatter from SKILL.md
443
+ const content = readFileSync(skillMdPath, 'utf8')
444
+ const meta = parseFrontmatter(content)
445
+ skills.push({
446
+ name: meta.name ?? entry.name,
447
+ dirName: entry.name,
448
+ description: meta.description ?? '',
449
+ whenToUse: meta.whenToUse ?? '',
450
+ version: meta.metadata?.version ?? '',
451
+ author: meta.metadata?.author ?? '',
452
+ installed: true,
453
+ dir: skillDir,
454
+ })
455
+ }
456
+ return skills
457
+ }
458
+
459
+ /**
460
+ * Parse YAML frontmatter from a Markdown file.
461
+ */
462
+ function parseFrontmatter(text) {
463
+ const result = {}
464
+ if (!text.startsWith('---')) return result
465
+ const end = text.indexOf('\n---', 3)
466
+ if (end < 0) return result
467
+ const fm = text.slice(3, end).trim()
468
+ // Simple line-based parser
469
+ let currentObj = result
470
+ let currentIndent = 0
471
+ for (const line of fm.split('\n')) {
472
+ const indent = line.match(/^(\s*)/)[0].length
473
+ const trimmed = line.trim()
474
+ if (!trimmed) continue
475
+ const colonIdx = trimmed.indexOf(':')
476
+ if (colonIdx < 0) continue
477
+ const key = trimmed.slice(0, colonIdx).trim()
478
+ const val = trimmed.slice(colonIdx + 1).trim()
479
+ if (val) {
480
+ // Simple value (strip quotes)
481
+ result[key] = val.replace(/^["']|["']$/g, '')
482
+ } else {
483
+ // Nested object — only support one level
484
+ if (key === 'metadata') {
485
+ result.metadata = {}
486
+ currentObj = result.metadata
487
+ }
488
+ }
489
+ }
490
+ return result
491
+ }
492
+
493
+ /**
494
+ * Read a skill's README.md or SKILL.md content.
495
+ */
496
+ function readSkillFile(skillsDir, skillName, fileName) {
497
+ // Try exact directory name first, then case-insensitive
498
+ let skillDir = join(skillsDir, skillName)
499
+ if (!existsSync(skillDir)) {
500
+ // Search for case-insensitive match
501
+ if (existsSync(skillsDir)) {
502
+ const entries = readdirSync(skillsDir, { withFileTypes: true })
503
+ const match = entries.find(e => e.isDirectory() && e.name.toLowerCase() === skillName.toLowerCase())
504
+ if (match) {
505
+ skillDir = join(skillsDir, match.name)
506
+ } else {
507
+ return null
508
+ }
509
+ } else {
510
+ return null
511
+ }
512
+ }
513
+
514
+ // Try the requested file, then README.md, then SKILL.md
515
+ const candidates = [fileName, 'README.md', 'SKILL.md'].filter(Boolean)
516
+ for (const candidate of candidates) {
517
+ const filePath = join(skillDir, candidate)
518
+ if (existsSync(filePath)) {
519
+ const content = readFileSync(filePath, 'utf8')
520
+ return { fileName: candidate, content, dir: skillDir }
521
+ }
522
+ }
523
+ return null
524
+ }
525
+
526
+ // ── RPC handler ─────────────────────────────────────────────────────────
527
+
528
+ /**
529
+ * Main RPC dispatch: routes skill-hub/* endpoints to handlers.
530
+ */
531
+ async function dispatch(endpoint, payload, signal, config, ctx) {
532
+ // dsh RPC framework wraps the caller's payload as { args: actualPayload }
533
+ const args = (payload && payload.args) ? payload.args : (payload || {})
534
+ const skillhubApi = config.skillhubApiBase || DEFAULT_SKILLHUB_API
535
+ const clawhubApi = config.clawhubApiBase || DEFAULT_CLAWHUB_API
536
+ const preferSkillHub = config.preferSkillHub !== false
537
+ const skillsDir = resolveSkillsDir(config)
538
+
539
+ switch (endpoint) {
540
+ case 'skill-hub/search': {
541
+ const { keyword = '', page = 1, pageSize = 60, source, category = '' } = args
542
+ const sources = source ? [source] : (preferSkillHub ? ['skillhub', 'clawhub'] : ['clawhub', 'skillhub'])
543
+ const results = []
544
+ const errors = []
545
+
546
+ for (const src of sources) {
547
+ try {
548
+ if (src === 'skillhub') {
549
+ const r = await searchSkillHub(skillhubApi, keyword, page, pageSize, category)
550
+ results.push(r)
551
+ } else if (src === 'clawhub') {
552
+ const limit = Math.min(pageSize, 30)
553
+ const r = await searchClawHub(clawhubApi, keyword, limit)
554
+ results.push(r)
555
+ }
556
+ } catch (e) {
557
+ errors.push({ source: src, error: e.message })
558
+ }
559
+ }
560
+
561
+ // Merge and deduplicate by slug (case-insensitive)
562
+ const seen = new Set()
563
+ const merged = []
564
+ for (const r of results) {
565
+ for (const item of r.items) {
566
+ const key = (item.slug || item.name).toLowerCase()
567
+ if (key && !seen.has(key)) {
568
+ seen.add(key)
569
+ merged.push(item)
570
+ }
571
+ }
572
+ }
573
+
574
+ // Sort: installed first, then by downloads
575
+ const localSkills = listLocalSkills(skillsDir)
576
+ const localNames = new Set(localSkills.map(s => s.name.toLowerCase()))
577
+ merged.forEach(item => {
578
+ item.installed = localNames.has((item.slug || item.name).toLowerCase())
579
+ })
580
+ merged.sort((a, b) => {
581
+ if (a.installed !== b.installed) return a.installed ? -1 : 1
582
+ return (b.downloads ?? 0) - (a.downloads ?? 0)
583
+ })
584
+
585
+ return ok({
586
+ items: merged,
587
+ total: merged.length,
588
+ sources: results.map(r => r.source),
589
+ errors,
590
+ })
591
+ }
592
+
593
+ case 'skill-hub/detail': {
594
+ const { slug, source, ownerHandle } = args
595
+ if (!slug) return err('bad-request', 'slug is required')
596
+
597
+ let detail = null
598
+ const errors = []
599
+
600
+ // Try the specified source first, then fallback
601
+ const trySources = source
602
+ ? [source, ...(source === 'skillhub' ? ['clawhub'] : ['skillhub'])]
603
+ : (preferSkillHub ? ['skillhub', 'clawhub'] : ['clawhub', 'skillhub'])
604
+
605
+ for (const src of trySources) {
606
+ try {
607
+ if (src === 'skillhub') {
608
+ detail = await detailSkillHub(skillhubApi, slug)
609
+ } else if (src === 'clawhub') {
610
+ detail = await detailClawHub(clawhubApi, slug, ownerHandle)
611
+ }
612
+ if (detail) break
613
+ } catch (e) {
614
+ errors.push({ source: src, error: e.message })
615
+ }
616
+ }
617
+
618
+ if (!detail) {
619
+ // Last resort: try reading from local file system (installed skill)
620
+ const fileData = readSkillFile(skillsDir, slug, 'SKILL.md')
621
+ if (fileData) {
622
+ detail = {
623
+ slug,
624
+ name: slug,
625
+ displayName: slug,
626
+ summary: '',
627
+ readme: fileData.content,
628
+ installed: true,
629
+ source: 'local',
630
+ }
631
+ } else {
632
+ return err('bad-request', `Skill "${slug}" not found`, { errors })
633
+ }
634
+ }
635
+
636
+ // If no README from API, try reading from file system (installed skill)
637
+ if (!detail.readme) {
638
+ const localSkills = listLocalSkills(skillsDir)
639
+ const local = localSkills.find(s => s.name.toLowerCase() === slug.toLowerCase())
640
+ if (local) {
641
+ const fileData = readSkillFile(skillsDir, slug, 'README.md')
642
+ if (fileData) {
643
+ detail.readme = fileData.content
644
+ detail.installed = true
645
+ } else {
646
+ const skillMd = readSkillFile(skillsDir, slug, 'SKILL.md')
647
+ if (skillMd) {
648
+ detail.readme = skillMd.content
649
+ detail.installed = true
650
+ }
651
+ }
652
+ } else {
653
+ // Try ctx.skills registry (for plugin-bundled skills)
654
+ if (ctx && ctx.skills) {
655
+ try {
656
+ const regSkill = await ctx.skills.get(slug, { signal })
657
+ if (regSkill && regSkill.content) {
658
+ detail.readme = regSkill.content
659
+ detail.installed = true
660
+ }
661
+ } catch (e) { /* Ignore */ }
662
+ }
663
+ // Try fetching SKILL.md from SkillHub file API
664
+ if (!detail.readme && detail.source !== 'clawhub') {
665
+ try {
666
+ const fileContent = await readFileSkillHub(skillhubApi, slug, 'SKILL.md')
667
+ if (fileContent) {
668
+ detail.readme = fileContent
669
+ }
670
+ } catch (e) {
671
+ // Ignore file fetch errors
672
+ }
673
+ }
674
+ }
675
+ }
676
+
677
+ // Check if installed locally (don't override if already determined via ctx.skills)
678
+ if (!detail.installed) {
679
+ const localSkills = listLocalSkills(skillsDir)
680
+ detail.installed = localSkills.some(s => s.name.toLowerCase() === slug.toLowerCase())
681
+ }
682
+
683
+ return ok(detail)
684
+ }
685
+
686
+ case 'skill-hub/installed': {
687
+ const sourceLabels = {
688
+ 'user-dsh': '本地安装',
689
+ 'user-agents': '用户Agent',
690
+ 'project-dsh': '项目',
691
+ 'project-agents': '项目Agent',
692
+ 'bundled': '插件自带',
693
+ 'custom': '自定义',
694
+ 'runtime': '运行时',
695
+ }
696
+ // Try ctx.skills registry first (covers plugin-bundled + user-installed)
697
+ let registrySkills = []
698
+ if (ctx && ctx.skills) {
699
+ try {
700
+ const summaries = await ctx.skills.list({ signal })
701
+ registrySkills = summaries.map(s => ({
702
+ slug: s.name,
703
+ name: s.name,
704
+ displayName: s.name,
705
+ summary: s.description || '',
706
+ description: s.description || '',
707
+ whenToUse: s.whenToUse || '',
708
+ source: s.source || '',
709
+ sourceLabel: sourceLabels[s.source] || s.source || '',
710
+ provider: s.provider || '',
711
+ author: s.provider || '',
712
+ modelInvocable: s.invocation ? s.invocation.modelInvocable : false,
713
+ userInvocable: s.invocation ? s.invocation.userInvocable : false,
714
+ installed: true,
715
+ dir: (s.resourceBase && s.resourceBase.path) || '',
716
+ fromRegistry: true,
717
+ }))
718
+ } catch (e) {
719
+ // Fall through to file scanning
720
+ }
721
+ }
722
+ // Also scan local skills dir for metadata (version, author from frontmatter)
723
+ const localSkills = listLocalSkills(skillsDir)
724
+ if (registrySkills.length > 0) {
725
+ const localMap = {}
726
+ for (const ls of localSkills) {
727
+ localMap[ls.name.toLowerCase()] = ls
728
+ }
729
+ // Merge: enrich registry skills with local metadata, AND add local-only
730
+ // skills that the registry didn't return (e.g. user-installed skills
731
+ // under ~/.dsh/skills/ that ctx.skills.list missed because no cwd was
732
+ // passed to resolve project/user roots).
733
+ const registryNames = new Set(registrySkills.map(rs => rs.name.toLowerCase()))
734
+ // Build merged list: registry skills enriched with local metadata
735
+ const merged = registrySkills.map(rs => {
736
+ const local = localMap[rs.name.toLowerCase()]
737
+ if (local) {
738
+ return Object.assign({}, rs, {
739
+ version: local.version || '',
740
+ author: local.author || rs.author,
741
+ dir: local.dir || rs.dir,
742
+ })
743
+ }
744
+ return rs
745
+ })
746
+ // Append local-only skills not in registry
747
+ for (const ls of localSkills) {
748
+ if (!registryNames.has(ls.name.toLowerCase())) {
749
+ merged.push(Object.assign({}, ls, {
750
+ slug: ls.slug || ls.name,
751
+ displayName: ls.displayName || ls.name,
752
+ summary: ls.summary || ls.description || '',
753
+ source: ls.source || 'user-dsh',
754
+ sourceLabel: sourceLabels['user-dsh'] || '本地安装',
755
+ provider: '',
756
+ author: ls.author || '',
757
+ modelInvocable: true,
758
+ userInvocable: true,
759
+ installed: true,
760
+ dir: ls.dir || '',
761
+ fromRegistry: false,
762
+ }))
763
+ }
764
+ }
765
+ return ok({ skills: merged, dir: skillsDir })
766
+ }
767
+ // Fallback: file scan only
768
+ const localWithLabels = localSkills.map(s => Object.assign({}, s, {
769
+ slug: s.slug || s.name,
770
+ displayName: s.displayName || s.name,
771
+ summary: s.summary || s.description || '',
772
+ sourceLabel: '本地安装',
773
+ installed: true,
774
+ }))
775
+ return ok({ skills: localWithLabels, dir: skillsDir })
776
+ }
777
+
778
+ case 'skill-hub/install': {
779
+ const { slug, source, ownerHandle } = args
780
+ if (!slug) return err('bad-request', 'slug is required')
781
+
782
+ // Determine which source to use
783
+ const src = source || (preferSkillHub ? 'skillhub' : 'clawhub')
784
+ let zipBuffer
785
+
786
+ try {
787
+ if (src === 'skillhub') {
788
+ zipBuffer = await downloadSkillHub(skillhubApi, slug)
789
+ } else {
790
+ zipBuffer = await downloadClawHub(clawhubApi, slug, ownerHandle)
791
+ }
792
+ } catch (e) {
793
+ // Fallback to the other source
794
+ const fallbackSrc = src === 'skillhub' ? 'clawhub' : 'skillhub'
795
+ try {
796
+ if (fallbackSrc === 'skillhub') {
797
+ zipBuffer = await downloadSkillHub(skillhubApi, slug)
798
+ } else {
799
+ zipBuffer = await downloadClawHub(clawhubApi, slug, ownerHandle)
800
+ }
801
+ } catch (e2) {
802
+ return err('bad-request', `Failed to download skill: ${e.message}; fallback also failed: ${e2.message}`)
803
+ }
804
+ }
805
+
806
+ // Extract to skills directory
807
+ const targetDir = join(skillsDir, slug)
808
+ // Clean existing directory if any
809
+ if (existsSync(targetDir)) {
810
+ rmSync(targetDir, { recursive: true, force: true })
811
+ }
812
+ mkdirSync(targetDir, { recursive: true })
813
+
814
+ try {
815
+ extractZip(zipBuffer, targetDir)
816
+ } catch (e) {
817
+ // Cleanup on failure
818
+ try { rmSync(targetDir, { recursive: true, force: true }) } catch {}
819
+ return err('internal', `Failed to extract skill: ${e.message}`)
820
+ }
821
+
822
+ // Verify SKILL.md exists
823
+ const skillMdPath = join(targetDir, 'SKILL.md')
824
+ if (!existsSync(skillMdPath)) {
825
+ // Check if files are in a subdirectory
826
+ const subEntries = readdirSync(targetDir, { withFileTypes: true })
827
+ const subDir = subEntries.find(e => e.isDirectory())
828
+ if (subDir) {
829
+ const subSkillMd = join(targetDir, subDir.name, 'SKILL.md')
830
+ if (existsSync(subSkillMd)) {
831
+ // Move files up
832
+ const subDirPath = join(targetDir, subDir.name)
833
+ const subEntries2 = readdirSync(subDirPath)
834
+ for (const f of subEntries2) {
835
+ const src = join(subDirPath, f.name)
836
+ const dst = join(targetDir, f.name)
837
+ try {
838
+ execSync(`move "${src}" "${dst}"`, { windowsHide: true, stdio: 'pipe' })
839
+ } catch {}
840
+ }
841
+ try { rmSync(subDirPath, { recursive: true, force: true }) } catch {}
842
+ }
843
+ }
844
+ }
845
+
846
+ // Final verification
847
+ if (!existsSync(skillMdPath)) {
848
+ return err('bad-request', `Downloaded skill does not contain SKILL.md. Files: ${readdirSync(targetDir).join(', ')}`)
849
+ }
850
+
851
+ // Parse the installed skill's metadata
852
+ const content = readFileSync(skillMdPath, 'utf8')
853
+ const meta = parseFrontmatter(content)
854
+
855
+ return ok({
856
+ slug,
857
+ name: meta.name ?? slug,
858
+ description: meta.description ?? '',
859
+ installed: true,
860
+ dir: targetDir,
861
+ message: `Skill "${slug}" installed successfully`,
862
+ })
863
+ }
864
+
865
+ case 'skill-hub/uninstall': {
866
+ const { slug } = args
867
+ if (!slug) return err('bad-request', 'slug is required')
868
+
869
+ const targetDir = join(skillsDir, slug)
870
+ if (!existsSync(targetDir)) {
871
+ // Try case-insensitive search
872
+ if (existsSync(skillsDir)) {
873
+ const entries = readdirSync(skillsDir, { withFileTypes: true })
874
+ const match = entries.find(e => e.isDirectory() && e.name.toLowerCase() === slug.toLowerCase())
875
+ if (match) {
876
+ rmSync(join(skillsDir, match.name), { recursive: true, force: true })
877
+ return ok({ slug, uninstalled: true, message: `Skill "${match.name}" removed` })
878
+ }
879
+ }
880
+ return err('bad-request', `Skill "${slug}" is not installed`)
881
+ }
882
+
883
+ rmSync(targetDir, { recursive: true, force: true })
884
+ return ok({ slug, uninstalled: true, message: `Skill "${slug}" removed` })
885
+ }
886
+
887
+ case 'skill-hub/readme': {
888
+ const { slug, fileName } = args
889
+ if (!slug) return err('bad-request', 'slug is required')
890
+
891
+ // Try local file system first
892
+ const fileData = readSkillFile(skillsDir, slug, fileName)
893
+ if (fileData) {
894
+ const allFiles = existsSync(fileData.dir)
895
+ ? readdirSync(fileData.dir).filter(f => !statSync(join(fileData.dir, f)).isDirectory())
896
+ : []
897
+ return ok({
898
+ slug,
899
+ fileName: fileData.fileName,
900
+ content: fileData.content,
901
+ files: allFiles,
902
+ dir: fileData.dir,
903
+ })
904
+ }
905
+
906
+ // Try ctx.skills registry (for plugin-bundled skills not in ~/.dsh/skills)
907
+ if (ctx && ctx.skills) {
908
+ try {
909
+ const skill = await ctx.skills.get(slug, { signal })
910
+ if (skill && skill.content) {
911
+ return ok({
912
+ slug,
913
+ fileName: 'SKILL.md',
914
+ content: skill.content,
915
+ files: [],
916
+ dir: skill.path || '',
917
+ })
918
+ }
919
+ } catch (e) { /* Ignore */ }
920
+ }
921
+
922
+ return err('bad-request', `No README or SKILL.md found for "${slug}"`)
923
+ }
924
+
925
+ case 'skill-hub/categories': {
926
+ try {
927
+ const url = `${skillhubApi}/api/v1/categories`
928
+ const data = await httpGetJson(url)
929
+ const items = data?.items ?? data?.data ?? []
930
+ return ok({ categories: items })
931
+ } catch (e) {
932
+ return err('internal', `Failed to fetch categories: ${e.message}`)
933
+ }
934
+ }
935
+
936
+ default:
937
+ return err('bad-request', `Unknown endpoint: ${endpoint}`)
938
+ }
939
+ }
940
+
941
+ // ── Plugin entry ────────────────────────────────────────────────────────
942
+
943
+ export const name = 'dsh-skill-market'
944
+ export const inject = ['connection', 'skills']
945
+
946
+ export function apply(ctx, config = {}) {
947
+ // Merge config from cordis.patch.yml
948
+ const mergedConfig = {
949
+ skillhubApiBase: config.skillhubApiBase || DEFAULT_SKILLHUB_API,
950
+ clawhubApiBase: config.clawhubApiBase || DEFAULT_CLAWHUB_API,
951
+ preferSkillHub: config.preferSkillHub !== false,
952
+ localSkillsDir: config.localSkillsDir || '',
953
+ }
954
+
955
+ // 2026-09-02 修复:dsh 0.1.2-alpha 起 /api 共享通道只允许一个 interceptor
956
+ // (官方 dsh-api-gateway 独占),插件再 intercept 会把 gateway 挤掉,导致
957
+ // workspace/session/directoryPicker 等所有官方 /api 端点 404。
958
+ // 改用 exact fetch route(优先级高于 interceptor),与 gateway 共存。
959
+ const ENDPOINTS = [
960
+ 'skill-hub/search',
961
+ 'skill-hub/detail',
962
+ 'skill-hub/installed',
963
+ 'skill-hub/install',
964
+ 'skill-hub/uninstall',
965
+ 'skill-hub/readme',
966
+ 'skill-hub/categories',
967
+ ]
968
+
969
+ for (const endpoint of ENDPOINTS) {
970
+ ctx.connection.fetch.register({
971
+ path: `/api/${endpoint}`,
972
+ methods: ['POST'],
973
+ fetch: async (request) => {
974
+ if (request.method !== 'POST') return new Response('not found', { status: 404 })
975
+ let body
976
+ try {
977
+ body = await request.json()
978
+ } catch {
979
+ return new Response('body is not JSON', { status: 400 })
980
+ }
981
+ const rpcId = typeof body?.rpcId === 'string' ? body.rpcId : 'invalid-request'
982
+ try {
983
+ const result = await dispatch(endpoint, body?.payload ?? {}, request.signal, mergedConfig, ctx)
984
+ return Response.json({ type: 'server-response', rpcId, result })
985
+ } catch (error) {
986
+ return Response.json({
987
+ type: 'server-response',
988
+ rpcId,
989
+ result: err('internal', error?.message || String(error)),
990
+ })
991
+ }
992
+ },
993
+ })
994
+ }
995
+ }