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/LICENSE +21 -0
- package/README.en.md +103 -0
- package/README.md +103 -0
- package/cordis.patch.yml +15 -0
- package/docs/screenshots/detail.png +0 -0
- package/docs/screenshots/market.png +0 -0
- package/docs/screenshots/picker.png +0 -0
- package/package.json +59 -0
- package/screenshots.json +5 -0
- package/src/client.js +1445 -0
- package/src/index.js +995 -0
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
|
+
}
|