dsh-skill-hub 0.0.1 → 0.2.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.
Files changed (88) hide show
  1. package/CONTRIBUTING.md +56 -0
  2. package/README.md +355 -20
  3. package/cordis.patch.yml +12 -0
  4. package/lib/client.js +4514 -0
  5. package/lib/client.js.map +1 -0
  6. package/lib/index.js +3119 -0
  7. package/lib/invariant.js +6 -0
  8. package/lib/types/client/SkillHubSettingsCard.d.ts +53 -0
  9. package/lib/types/client/api-config-scope.d.ts +48 -0
  10. package/lib/types/client/api.d.ts +64 -0
  11. package/lib/types/client/grouping.d.ts +64 -0
  12. package/lib/types/client/helpers.d.ts +11 -0
  13. package/lib/types/client/index.d.ts +42 -0
  14. package/lib/types/client/locales.d.ts +216 -0
  15. package/lib/types/client/market-catalog.d.ts +14 -0
  16. package/lib/types/client/panel/DisabledRow.d.ts +12 -0
  17. package/lib/types/client/panel/GroupSummary.d.ts +10 -0
  18. package/lib/types/client/panel/MarketView.d.ts +11 -0
  19. package/lib/types/client/panel/ScenesView.d.ts +11 -0
  20. package/lib/types/client/panel/SkillDetailView.d.ts +31 -0
  21. package/lib/types/client/panel/SkillHubPanel.d.ts +18 -0
  22. package/lib/types/client/panel/SkillRow.d.ts +11 -0
  23. package/lib/types/client/panel/SourceStatusBadge.d.ts +18 -0
  24. package/lib/types/client/panel/SourcesView.d.ts +10 -0
  25. package/lib/types/client/panel/TagEditorView.d.ts +29 -0
  26. package/lib/types/client/panel/dialogs.d.ts +73 -0
  27. package/lib/types/client/panel/format.d.ts +14 -0
  28. package/lib/types/client/panel/useSkillHub.d.ts +183 -0
  29. package/lib/types/client/settings-card.d.ts +81 -0
  30. package/lib/types/client/settings-form.d.ts +95 -0
  31. package/lib/types/client/settings-nav-icon.d.ts +8 -0
  32. package/lib/types/index.d.ts +36 -0
  33. package/lib/types/invariant.d.ts +3 -0
  34. package/lib/types/protocol.d.ts +571 -0
  35. package/lib/types/provider.d.ts +47 -0
  36. package/lib/types/repo.d.ts +133 -0
  37. package/lib/types/routes.d.ts +51 -0
  38. package/lib/types/skillfs.d.ts +95 -0
  39. package/lib/types/stats.d.ts +49 -0
  40. package/lib/types/store.d.ts +142 -0
  41. package/lib/types/update.d.ts +20 -0
  42. package/package.json +115 -11
  43. package/src/client/SkillHubSettingsCard.tsx +161 -0
  44. package/src/client/api-config-scope.test.ts +79 -0
  45. package/src/client/api-config-scope.ts +119 -0
  46. package/src/client/api.ts +327 -0
  47. package/src/client/css-modules.d.ts +5 -0
  48. package/src/client/grouping.test.ts +109 -0
  49. package/src/client/grouping.ts +120 -0
  50. package/src/client/helpers.ts +23 -0
  51. package/src/client/index.tsx +104 -0
  52. package/src/client/locales.ts +432 -0
  53. package/src/client/market-catalog.ts +22 -0
  54. package/src/client/panel/DisabledRow.tsx +32 -0
  55. package/src/client/panel/GroupSummary.tsx +27 -0
  56. package/src/client/panel/MarketView.tsx +173 -0
  57. package/src/client/panel/ScenesView.tsx +75 -0
  58. package/src/client/panel/SkillDetailView.tsx +95 -0
  59. package/src/client/panel/SkillHubPanel.tsx +347 -0
  60. package/src/client/panel/SkillRow.tsx +54 -0
  61. package/src/client/panel/SourceStatusBadge.tsx +61 -0
  62. package/src/client/panel/SourcesView.tsx +104 -0
  63. package/src/client/panel/TagEditorView.tsx +89 -0
  64. package/src/client/panel/dialogs.tsx +185 -0
  65. package/src/client/panel/format.ts +31 -0
  66. package/src/client/panel/panel.module.css +219 -0
  67. package/src/client/panel/useSkillHub.ts +797 -0
  68. package/src/client/settings-card.module.css +52 -0
  69. package/src/client/settings-card.tsx +237 -0
  70. package/src/client/settings-form.ts +227 -0
  71. package/src/client/settings-nav-icon.ts +42 -0
  72. package/src/index.ts +181 -0
  73. package/src/invariant.ts +4 -0
  74. package/src/protocol.ts +622 -0
  75. package/src/provider.test.ts +112 -0
  76. package/src/provider.ts +168 -0
  77. package/src/repo.test.ts +212 -0
  78. package/src/repo.ts +528 -0
  79. package/src/routes.test.ts +786 -0
  80. package/src/routes.ts +1252 -0
  81. package/src/skillfs.test.ts +290 -0
  82. package/src/skillfs.ts +306 -0
  83. package/src/stats.test.ts +168 -0
  84. package/src/stats.ts +124 -0
  85. package/src/store.test.ts +253 -0
  86. package/src/store.ts +628 -0
  87. package/src/update.test.ts +73 -0
  88. package/src/update.ts +136 -0
package/src/routes.ts ADDED
@@ -0,0 +1,1252 @@
1
+ /**
2
+ * The /api/skill-hub route family: full catalog (enabled skills from the
3
+ * official registry + hub-disabled skills + discovery diagnostics), skill
4
+ * detail, enable/disable toggle, new-skill scaffold, user groups (tags +
5
+ * origin collections), and upstream source tracking (check/sync/follow
6
+ * upstream deletion into a restorable trash). Every route carries a
7
+ * loopback-only trust fence — these endpoints rename files under the user's
8
+ * skill roots, so LAN-exposed dsh web deployments must not serve them.
9
+ *
10
+ * Every handler is declared through the local `route()` wrapper, which owns
11
+ * the shared request fences (loopback trust, HTTP method, master switch,
12
+ * JSON body) exactly once, so a new route cannot forget them.
13
+ */
14
+
15
+ import { mkdir, rename, rm, stat, writeFile } from 'node:fs/promises'
16
+ import { basename, dirname, join } from 'node:path'
17
+ import type { IncomingMessage, ServerResponse } from 'node:http'
18
+ import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
19
+ import type { SkillDefinition, SkillSummary } from '@deepseek-ai/dsh-skill'
20
+ import { isSkillName } from '@deepseek-ai/dsh-skill'
21
+ import {
22
+ collectRepoSkillFiles,
23
+ diffRemoteSkills,
24
+ discoverRepoEntries,
25
+ downloadRepoSkill,
26
+ getLatestCommit,
27
+ getLatestReleaseTag,
28
+ listRepoBranches,
29
+ loadRepoTree,
30
+ loadRepoTreeAt,
31
+ normalizeRepoInput,
32
+ repoSkillEntry,
33
+ repoSlug,
34
+ RepoFetchError,
35
+ skillDirOf,
36
+ skillManifest,
37
+ } from './repo.ts'
38
+ import {
39
+ SKILL_HUB_API,
40
+ type CatalogResponse,
41
+ type CatalogSkill,
42
+ type CollectionGroup,
43
+ type ConfigResponse,
44
+ type CreateResponse,
45
+ type RepoDiscoverResponse,
46
+ type RepoImportRequest,
47
+ type RepoImportResponse,
48
+ type CreateRequest,
49
+ type ErrorResponse,
50
+ type GroupsResponse,
51
+ type HubConfig,
52
+ type MarketCheckResponse,
53
+ type MarketSourceRefRequest,
54
+ type MarketSourceRequest,
55
+ type MarketSourceResponse,
56
+ type MarketSourcesResponse,
57
+ type MarketSyncResponse,
58
+ type SourceCheckRequest,
59
+ type SourceCheckResponse,
60
+ type SourceCheckResult,
61
+ type SourceDeleteRequest,
62
+ type SourceDeleteResponse,
63
+ type SourceRestoreRequest,
64
+ type SourceRestoreResponse,
65
+ type SourceTrashClearResponse,
66
+ type SourcesResponse,
67
+ type SourceSyncRequest,
68
+ type SourceSyncResponse,
69
+ type SkillDetail,
70
+ type SkillDetailResponse,
71
+ type SkillDeleteRequest,
72
+ type SkillDeleteResponse,
73
+ type TagDeleteRequest,
74
+ type TagDeleteResponse,
75
+ type TagMembersRequest,
76
+ type TagMembersResponse,
77
+ type TagSaveRequest,
78
+ type TagSaveResponse,
79
+ type ToggleBatchRequest,
80
+ type ToggleBatchResponse,
81
+ type ToggleRequest,
82
+ type ToggleResponse,
83
+ type WritableRoot,
84
+ HEX_COLOR_RE,
85
+ resolveHubConfig,
86
+ } from './protocol.ts'
87
+ import { clearTrash, createSkill, disableSkill, enableSkill, restoreSkill, rootOfPath, rootPath, scanDiagnostics, trashSkill } from './skillfs.ts'
88
+ import { checkLatestRelease } from './update.ts'
89
+ import { dshHome, StoreError, type SkillHubStore } from './store.ts'
90
+ import type { SkillStatsReader } from './stats.ts'
91
+
92
+ /** Cap on JSON request bodies (toggle/create payloads are tiny). */
93
+ const MAX_JSON_BODY_BYTES = 64 * 1024
94
+
95
+ /** Minimum interval between network update checks per source (GitHub rate limits). */
96
+ const MIN_CHECK_INTERVAL_MS = 5 * 60_000
97
+
98
+ /** Loopback literal check plus browser same-origin markers. */
99
+ function isLoopbackRequest(request: IncomingMessage): boolean {
100
+ const address = request.socket.remoteAddress
101
+ if (address !== '127.0.0.1' && address !== '::1' && address !== '::ffff:127.0.0.1') return false
102
+ const host = request.headers.host
103
+ if (typeof host !== 'string') return false
104
+ let hostUrl: URL
105
+ try {
106
+ hostUrl = new URL('http://' + host)
107
+ } catch {
108
+ return false
109
+ }
110
+ if (hostUrl.hostname !== '127.0.0.1' && hostUrl.hostname !== 'localhost' && hostUrl.hostname !== '[::1]') return false
111
+ if (request.headers['sec-fetch-site'] === 'cross-site') return false
112
+ const origin = request.headers.origin
113
+ if (origin === undefined) return true
114
+ try {
115
+ return new URL(origin).host === hostUrl.host
116
+ } catch {
117
+ return false
118
+ }
119
+ }
120
+
121
+ /** One JSON response. */
122
+ function writeJson(res: ServerResponse, status: number, body: unknown): void {
123
+ const payload = JSON.stringify(body)
124
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'referrer-policy': 'no-referrer' })
125
+ res.end(payload)
126
+ }
127
+
128
+ /** One JSON error response. */
129
+ function writeError(res: ServerResponse, status: number, error: unknown): void {
130
+ const body: ErrorResponse = { error: error instanceof Error ? error.message : String(error) }
131
+ writeJson(res, status, body)
132
+ }
133
+
134
+ /** HTTP status per store business-rule kind (user error, not server fault). */
135
+ const STORE_ERROR_STATUS = { validation: 400, 'not-found': 404, conflict: 409 } as const
136
+
137
+ /** Map known error types onto HTTP statuses: repo fetch + store business rules. */
138
+ function writeRouteError(res: ServerResponse, error: unknown): void {
139
+ if (error instanceof RepoFetchError) {
140
+ writeError(res, error.status, error)
141
+ return
142
+ }
143
+ if (error instanceof StoreError) {
144
+ writeError(res, STORE_ERROR_STATUS[error.kind], error)
145
+ return
146
+ }
147
+ writeError(res, 500, error)
148
+ }
149
+
150
+ /** Read a JSON request body (undefined when too large or unparseable). */
151
+ async function readJsonBody(req: IncomingMessage): Promise<Record<string, unknown> | undefined> {
152
+ const chunks: Buffer[] = []
153
+ let size = 0
154
+ for await (const chunk of req) {
155
+ const buffer = chunk as Buffer
156
+ size += buffer.length
157
+ if (size > MAX_JSON_BODY_BYTES) return undefined
158
+ chunks.push(buffer)
159
+ }
160
+ try {
161
+ const parsed: unknown = JSON.parse(Buffer.concat(chunks).toString('utf8'))
162
+ return typeof parsed === 'object' && parsed !== null ? parsed as Record<string, unknown> : undefined
163
+ } catch {
164
+ return undefined
165
+ }
166
+ }
167
+
168
+ /** URL query helper (first value, decoded). */
169
+ function queryParam(url: URL, name: string): string | undefined {
170
+ const value = url.searchParams.get(name)
171
+ return value === null ? undefined : value
172
+ }
173
+
174
+ /** Async existence check (import/sync use it on the user root). */
175
+ async function pathExists(path: string): Promise<boolean> {
176
+ try {
177
+ await stat(path)
178
+ return true
179
+ } catch {
180
+ return false
181
+ }
182
+ }
183
+
184
+ /** Sources the hub may toggle (the user-level filesystem roots). */
185
+ function isWritableSource(source: string): source is WritableRoot {
186
+ return source === 'user-dsh' || source === 'user-agents'
187
+ }
188
+
189
+ /** Lookup options the hub forwards to the registry (cwd selects project roots). */
190
+ export interface SkillLookupLike {
191
+ cwd?: string
192
+ }
193
+
194
+ /** Route family dependencies (narrow structural view of ctx.skills for tests). */
195
+ export interface SkillHubRouteDeps {
196
+ skills: {
197
+ snapshot(options?: SkillLookupLike): Promise<{ skills: SkillSummary[]; complete: boolean }>
198
+ get(name: string, options?: SkillLookupLike): Promise<SkillDefinition | undefined>
199
+ }
200
+ store: SkillHubStore
201
+ /** DSH home override (tests isolate the writable roots; defaults to ~/.dsh). */
202
+ home?: string
203
+ /** Invalidate the registry catalog cache after hub-driven mutations. */
204
+ invalidate?: () => void
205
+ /** Optional invocation-count reader; absent means the stats route reports unavailable. */
206
+ stats?: SkillStatsReader
207
+ /** Resolves the current plugin config; business routes honour the master switch. */
208
+ config?: () => HubConfig
209
+ /** Resolves the raw saved config layer (fields the user explicitly overrode). */
210
+ saved?: () => Partial<HubConfig>
211
+ /** Persist a config patch and re-sync plugin surfaces; resolves with the fresh config. */
212
+ updateConfig?: (patch: Partial<HubConfig>) => Promise<HubConfig>
213
+ }
214
+
215
+ /** The resolved hub config a route sees (the shared resolver fills defaults). */
216
+ function configOf(deps: SkillHubRouteDeps): HubConfig {
217
+ return resolveHubConfig({}, deps.config?.() ?? {})
218
+ }
219
+
220
+ /** The raw saved config layer a route reports (empty when the owner omits it). */
221
+ function savedOf(deps: SkillHubRouteDeps): Partial<HubConfig> {
222
+ return deps.saved?.() ?? {}
223
+ }
224
+
225
+ /** Refuse business routes while the master switch is off (the config route stays up). */
226
+ function disabledGate(deps: SkillHubRouteDeps, res: ServerResponse): boolean {
227
+ if (configOf(deps).enabled) return false
228
+ writeError(res, 503, 'plugin disabled: enable it from the settings card')
229
+ return true
230
+ }
231
+
232
+ /** Resolve the home used for writable-root operations. */
233
+ function homeOf(deps: SkillHubRouteDeps): string {
234
+ return deps.home ?? dshHome()
235
+ }
236
+
237
+ /** A skill resolved as a hub-writable user-level file. */
238
+ interface WritableSkill {
239
+ skill: SkillDefinition
240
+ /** Absolute discovery-file path (guaranteed present by the resolver). */
241
+ path: string
242
+ /** The writable root containing the file. */
243
+ root: WritableRoot
244
+ }
245
+
246
+ /** A refusal with the exact HTTP error the caller should answer with. */
247
+ interface WritableSkillRefusal {
248
+ status: number
249
+ error: string
250
+ }
251
+
252
+ type WritableSkillResult = { ok: true } & WritableSkill | { ok: false } & WritableSkillRefusal
253
+
254
+ /**
255
+ * Resolve a skill by name as something the hub may write, or the exact
256
+ * refusal the caller should answer with. Owns the guard sequence shared by
257
+ * toggle, toggle-batch, and skill/delete: registry lookup, writable source,
258
+ * writable file, and path containment inside a user root.
259
+ */
260
+ async function resolveWritableSkill(deps: SkillHubRouteDeps, name: string, cwd?: string): Promise<WritableSkillResult> {
261
+ const lookup = cwd !== undefined && cwd !== '' ? { cwd } : undefined
262
+ const skill = await deps.skills.get(name, lookup)
263
+ if (skill === undefined) return { ok: false, status: 404, error: 'skill not found: ' + name }
264
+ if (!isWritableSource(skill.source)) {
265
+ return { ok: false, status: 409, error: 'source "' + skill.source + '" is managed outside the hub (read-only)' }
266
+ }
267
+ if (skill.path === undefined) return { ok: false, status: 409, error: 'provider-managed skill has no writable file' }
268
+ const root = rootOfPath(skill.path, homeOf(deps))
269
+ if (root === undefined) return { ok: false, status: 409, error: 'skill path is outside the hub writable roots' }
270
+ return { ok: true, skill, path: skill.path, root }
271
+ }
272
+
273
+ /** 系统集合组 + 用户 tag + origin 映射(groups 路由的数据源)。 */
274
+ async function buildGroups(deps: SkillHubRouteDeps): Promise<GroupsResponse> {
275
+ const [tags, origins] = await Promise.all([deps.store.listTags(), deps.store.listOrigins()])
276
+ const byCollection = new Map<string, string[]>()
277
+ for (const [skillName, origin] of Object.entries(origins)) {
278
+ const list = byCollection.get(origin)
279
+ if (list === undefined) byCollection.set(origin, [skillName])
280
+ else list.push(skillName)
281
+ }
282
+ const collections: CollectionGroup[] = [...byCollection.entries()]
283
+ .map(([name, skillNames]) => ({ name, skillNames: [...skillNames].sort((a, b) => a.localeCompare(b)) }))
284
+ .sort((a, b) => a.name.localeCompare(b.name))
285
+ return { ok: true, tags, collections, origins }
286
+ }
287
+
288
+ /** 目录中存在的技能名集合(tag 成员校验用):启用目录 ∪ 已禁用名单,避免成员因禁用而丢失。 */
289
+ async function knownSkillNames(deps: SkillHubRouteDeps): Promise<Set<string>> {
290
+ const snapshot = await deps.skills.snapshot()
291
+ const names = new Set(snapshot.skills.map((skill) => skill.name))
292
+ for (const disabled of await deps.store.listDisabled()) names.add(disabled.name)
293
+ return names
294
+ }
295
+
296
+ /** Build the full catalog response (shared by catalog/toggle/create handlers). */
297
+ async function buildCatalog(deps: SkillHubRouteDeps, cwd?: string): Promise<CatalogResponse> {
298
+ const lookup = cwd !== undefined ? { cwd } : undefined
299
+ const snapshot = await deps.skills.snapshot(lookup)
300
+ const home = homeOf(deps)
301
+ // 添加/更新时间 = 用户级技能文件的创建/修改时间(排序与详情展示用)。
302
+ // snapshot 只给 SkillSummary(无 path),所以按可写根推断路径;非用户级
303
+ // 来源没有稳定路径,省略字段,客户端排序会把它放到末尾。
304
+ // 全部技能并发收集(面板每 5 秒轮询一次,逐个串行 stat 会让响应随技能
305
+ // 数量线性变慢)。
306
+ const timesByName = new Map<string, { addedAt: number; updatedAt: number }>()
307
+ await Promise.all(snapshot.skills.map(async (skill) => {
308
+ if (!isWritableSource(skill.source)) return
309
+ const base = rootPath(skill.source, home)
310
+ for (const candidate of [join(base, skill.name, 'SKILL.md'), join(base, skill.name), join(base, skill.name + '.md')]) {
311
+ try {
312
+ const times = await stat(candidate)
313
+ timesByName.set(skill.name, { addedAt: times.birthtimeMs, updatedAt: times.mtimeMs })
314
+ return
315
+ } catch {
316
+ // 目录/文件不存在则尝试下一个候选路径。
317
+ }
318
+ }
319
+ }))
320
+ const skills: CatalogSkill[] = snapshot.skills.map((skill) => {
321
+ const row: CatalogSkill = {
322
+ name: skill.name,
323
+ description: skill.description,
324
+ ...(skill.whenToUse !== undefined ? { whenToUse: skill.whenToUse } : {}),
325
+ invocation: {
326
+ modelInvocable: skill.invocation.modelInvocable,
327
+ userInvocable: skill.invocation.userInvocable,
328
+ },
329
+ provider: skill.provider,
330
+ writable: isWritableSource(skill.source),
331
+ }
332
+ const times = timesByName.get(skill.name)
333
+ if (times !== undefined) {
334
+ row.addedAt = times.addedAt
335
+ row.updatedAt = times.updatedAt
336
+ }
337
+ return row
338
+ })
339
+ const disabled = await deps.store.listDisabled()
340
+ const diagnostics = [
341
+ ...(await scanDiagnostics('user-dsh', home)),
342
+ ...(await scanDiagnostics('user-agents', home)),
343
+ ]
344
+ return { ok: true, complete: snapshot.complete, skills, disabled, diagnostics }
345
+ }
346
+
347
+ /** Map a loaded definition onto the wire shape. */
348
+ function toDetail(skill: SkillDefinition): SkillDetail {
349
+ return {
350
+ name: skill.name,
351
+ description: skill.description,
352
+ ...(skill.whenToUse !== undefined ? { whenToUse: skill.whenToUse } : {}),
353
+ invocation: {
354
+ modelInvocable: skill.invocation.modelInvocable,
355
+ userInvocable: skill.invocation.userInvocable,
356
+ },
357
+ provider: skill.provider,
358
+ ...(skill.path !== undefined ? { path: skill.path } : {}),
359
+ content: skill.content,
360
+ }
361
+ }
362
+
363
+ /** Last successful check time per repo (in-memory throttle for GitHub rate limits). */
364
+ const lastSourceCheck = new Map<string, number>()
365
+ /** Last successful market update check per repo (same throttle). */
366
+ const lastMarketCheck = new Map<string, number>()
367
+
368
+ /** Replace one skill directory with a fresh download; restores the old dir on failure. */
369
+ async function replaceSkillDir(targetDir: string, download: () => Promise<void>): Promise<void> {
370
+ // 点前缀:发现扫描跳过点开头的目录(与导入临时目录 .<name>.import- 同一
371
+ // 约定),备份即使删除失败残留下来,也不会混进目录/发现诊断,更不会
372
+ // 造成同名技能重复注册。
373
+ const backup = join(dirname(targetDir), '.' + basename(targetDir) + '.sync-bak-' + Date.now())
374
+ await rename(targetDir, backup)
375
+ try {
376
+ await download()
377
+ } catch (error) {
378
+ await rename(backup, targetDir).catch(() => {})
379
+ throw error
380
+ }
381
+ await rm(backup, { recursive: true, force: true })
382
+ }
383
+
384
+ /** What a route handler receives: fences already passed, URL/body prepared. */
385
+ interface RouteContext {
386
+ req: IncomingMessage
387
+ res: ServerResponse
388
+ /** Request URL parsed against a localhost base (query reading for GET). */
389
+ url: URL
390
+ /** Parsed JSON body; empty object for requests without one. */
391
+ body: Record<string, unknown>
392
+ }
393
+
394
+ type RouteHandler = (context: RouteContext) => Promise<void>
395
+
396
+ /** One declarative route: path + accepted methods + the business handler. */
397
+ interface RouteSpec {
398
+ path: string
399
+ /** Accepted HTTP methods; anything else answers 405. */
400
+ methods: readonly ('GET' | 'POST')[]
401
+ /** POST requests must carry a JSON body (400 when missing/unparseable). */
402
+ jsonBody?: boolean
403
+ /** Skip the master-switch gate (the config route stays up while disabled). */
404
+ skipGate?: boolean
405
+ handler: RouteHandler
406
+ }
407
+
408
+ /**
409
+ * Build every /api/skill-hub route.
410
+ * @param deps - skill registry view + sidecar store.
411
+ * @returns the exact-path routes.
412
+ */
413
+ export function makeRoutes(deps: SkillHubRouteDeps): WebRoute[] {
414
+ /**
415
+ * Wrap one handler in the shared request fences. Order matches the
416
+ * original per-handler prologue: loopback trust → HTTP method → master
417
+ * switch → JSON body. The outer catch maps errors onto the JSON error
418
+ * body (preserving RepoFetchError status codes).
419
+ */
420
+ const route = (spec: RouteSpec): WebRoute => ({
421
+ kind: 'exact',
422
+ path: spec.path,
423
+ handler: async (req, res) => {
424
+ if (!isLoopbackRequest(req)) { writeError(res, 403, 'forbidden: loopback-only'); return }
425
+ if (!spec.methods.includes(req.method as 'GET' | 'POST')) {
426
+ writeError(res, 405, 'method not allowed: ' + (req.method ?? ''))
427
+ return
428
+ }
429
+ if (spec.skipGate !== true && disabledGate(deps, res)) return
430
+ const url = new URL(req.url ?? '/', 'http://localhost')
431
+ let body: Record<string, unknown> = {}
432
+ if (spec.jsonBody === true && req.method === 'POST') {
433
+ const parsed = await readJsonBody(req)
434
+ if (parsed === undefined) { writeError(res, 400, 'invalid JSON body'); return }
435
+ body = parsed
436
+ }
437
+ try {
438
+ await spec.handler({ req, res, url, body })
439
+ } catch (error) {
440
+ writeRouteError(res, error)
441
+ }
442
+ },
443
+ })
444
+
445
+ return [
446
+ // ------------------------------------------------------------ catalog
447
+ route({
448
+ path: SKILL_HUB_API.catalog,
449
+ methods: ['GET'],
450
+ handler: async ({ res, url }) => {
451
+ const cwd = queryParam(url, 'cwd')
452
+ writeJson(res, 200, await buildCatalog(deps, cwd))
453
+ },
454
+ }),
455
+ // -------------------------------------------------------------- detail
456
+ route({
457
+ path: SKILL_HUB_API.skill,
458
+ methods: ['GET'],
459
+ handler: async ({ res, url }) => {
460
+ const name = queryParam(url, 'name')
461
+ if (name === undefined || name === '') { writeError(res, 400, 'name query parameter is required'); return }
462
+ const cwd = queryParam(url, 'cwd')
463
+ const skill = await deps.skills.get(name, cwd !== undefined ? { cwd } : undefined)
464
+ if (skill === undefined) { writeError(res, 404, 'skill not found: ' + name); return }
465
+ const detail = toDetail(skill)
466
+ if (skill.path !== undefined) {
467
+ try {
468
+ const times = await stat(skill.path)
469
+ detail.addedAt = times.birthtimeMs
470
+ detail.updatedAt = times.mtimeMs
471
+ } catch {
472
+ // 文件不可读时省略时间字段,详情页不显示这两行。
473
+ }
474
+ }
475
+ writeJson(res, 200, { ok: true, skill: detail } satisfies SkillDetailResponse)
476
+ },
477
+ }),
478
+ // -------------------------------------------------------- skill/delete
479
+ // 把单个技能(目录或平面文件)移入回收站(可恢复),并清理禁用记录、tag 成员与来源映射。
480
+ route({
481
+ path: SKILL_HUB_API.skillDelete,
482
+ methods: ['POST'],
483
+ jsonBody: true,
484
+ handler: async ({ res, body }) => {
485
+ const request = body as unknown as SkillDeleteRequest & { cwd?: string }
486
+ const name = typeof request.name === 'string' ? request.name : ''
487
+ if (name === '') { writeError(res, 400, 'name is required'); return }
488
+ const resolved = await resolveWritableSkill(deps, name, typeof request.cwd === 'string' ? request.cwd : undefined)
489
+ if (!resolved.ok) { writeError(res, resolved.status, resolved.error); return }
490
+ // 入回收站前快照来源归属与场景成员:恢复时把它们加回来,否则恢复
491
+ // 后的技能会丢失来源(变成「个人技能」)和场景分组。
492
+ const tracked = await deps.store.getSourceForSkill(name)
493
+ const tagIds = (await deps.store.listTags()).filter((tag) => tag.skillNames.includes(name)).map((tag) => tag.id)
494
+ const { path, source } = await trashSkill(resolved.path)
495
+ await deps.store.addTrash({
496
+ name,
497
+ path,
498
+ movedAt: Date.now(),
499
+ sourcePath: source,
500
+ ...(tracked !== undefined
501
+ ? { origin: { repo: tracked.repo, root: tracked.root, ...(tracked.ref !== undefined && tracked.ref !== '' ? { ref: tracked.ref } : {}), commitSha: tracked.commitSha } }
502
+ : {}),
503
+ ...(tagIds.length > 0 ? { tagIds } : {}),
504
+ })
505
+ await deps.store.removeDisabled(name)
506
+ await deps.store.removeSkillFromSources(name)
507
+ await deps.store.removeSkillFromTags(name)
508
+ deps.invalidate?.()
509
+ writeJson(res, 200, { ok: true, name, path } satisfies SkillDeleteResponse)
510
+ },
511
+ }),
512
+ // -------------------------------------------------------------- toggle
513
+ route({
514
+ path: SKILL_HUB_API.toggle,
515
+ methods: ['POST'],
516
+ jsonBody: true,
517
+ handler: async ({ res, body }) => {
518
+ const request = body as unknown as ToggleRequest & { cwd?: string }
519
+ const name = typeof request.name === 'string' ? request.name : ''
520
+ if (name === '') { writeError(res, 400, 'name is required'); return }
521
+ const lookup = typeof request.cwd === 'string' && request.cwd !== '' ? { cwd: request.cwd } : undefined
522
+ if (request.enabled === true) {
523
+ const record = await deps.store.getDisabled(name)
524
+ if (record === undefined) { writeError(res, 404, 'skill is not hub-disabled: ' + name); return }
525
+ try {
526
+ await enableSkill(record.path)
527
+ } catch (error) {
528
+ // The renamed file may have vanished (external cleanup); report
529
+ // precisely instead of a generic 500, like toggle-batch does.
530
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
531
+ writeError(res, 409, 'disabled skill file is missing on disk: ' + record.path)
532
+ return
533
+ }
534
+ throw error
535
+ }
536
+ await deps.store.removeDisabled(name)
537
+ } else {
538
+ const resolved = await resolveWritableSkill(deps, name, typeof request.cwd === 'string' ? request.cwd : undefined)
539
+ if (!resolved.ok) { writeError(res, resolved.status, resolved.error); return }
540
+ const disabledPath = await disableSkill(resolved.path)
541
+ await deps.store.addDisabled({
542
+ name,
543
+ description: resolved.skill.description,
544
+ path: disabledPath,
545
+ root: resolved.root,
546
+ disabledAt: Date.now(),
547
+ })
548
+ }
549
+ deps.invalidate?.()
550
+ writeJson(res, 200, { ok: true, catalog: await buildCatalog(deps, lookup?.cwd) } satisfies ToggleResponse)
551
+ },
552
+ }),
553
+ // -------------------------------------------------------- toggle-batch
554
+ // One write for a whole group: enables every hub-disabled name, or
555
+ // disables every writable name. Skips already-target states as no-ops;
556
+ // per-name failures are reported, never fatal.
557
+ route({
558
+ path: SKILL_HUB_API.toggleBatch,
559
+ methods: ['POST'],
560
+ jsonBody: true,
561
+ handler: async ({ res, body }) => {
562
+ const request = body as unknown as ToggleBatchRequest & { cwd?: string }
563
+ const names = Array.isArray(request.names) ? request.names.filter((n): n is string => typeof n === 'string' && n !== '') : []
564
+ if (names.length === 0) { writeError(res, 400, 'names must be a non-empty array'); return }
565
+ const enabled = request.enabled === true
566
+ const lookup = typeof request.cwd === 'string' && request.cwd !== '' ? { cwd: request.cwd } : undefined
567
+ const failures: Array<{ name: string; error: string }> = []
568
+ for (const name of names) {
569
+ try {
570
+ if (enabled) {
571
+ const record = await deps.store.getDisabled(name)
572
+ if (record === undefined) continue // already enabled: no-op
573
+ await enableSkill(record.path)
574
+ await deps.store.removeDisabled(name)
575
+ } else {
576
+ const resolved = await resolveWritableSkill(deps, name, typeof request.cwd === 'string' ? request.cwd : undefined)
577
+ if (!resolved.ok) { failures.push({ name, error: resolved.error }); continue }
578
+ const disabledPath = await disableSkill(resolved.path)
579
+ await deps.store.addDisabled({ name, description: resolved.skill.description, path: disabledPath, root: resolved.root, disabledAt: Date.now() })
580
+ }
581
+ } catch (error) {
582
+ failures.push({ name, error: error instanceof Error ? error.message : String(error) })
583
+ }
584
+ }
585
+ deps.invalidate?.()
586
+ writeJson(res, 200, { ok: true, catalog: await buildCatalog(deps, lookup?.cwd), failures } satisfies ToggleBatchResponse)
587
+ },
588
+ }),
589
+ // -------------------------------------------------------------- create
590
+ route({
591
+ path: SKILL_HUB_API.create,
592
+ methods: ['POST'],
593
+ jsonBody: true,
594
+ handler: async ({ res, body }) => {
595
+ const request = body as unknown as CreateRequest
596
+ const name = typeof request.name === 'string' ? request.name.trim() : ''
597
+ if (!isSkillName(name)) { writeError(res, 400, 'skill name must be kebab-case (lowercase letters, digits, dashes)'); return }
598
+ const root: WritableRoot = request.root ?? 'user-dsh'
599
+ if (root !== 'user-dsh' && root !== 'user-agents') { writeError(res, 400, 'root must be user-dsh or user-agents'); return }
600
+ const existing = await deps.skills.get(name)
601
+ if (existing !== undefined) { writeError(res, 409, 'skill name already exists: ' + name); return }
602
+ if (await deps.store.getDisabled(name) !== undefined) { writeError(res, 409, 'skill name is disabled: re-enable it from the disabled list first'); return }
603
+ // A directory may exist without producing a registry entry (invalid
604
+ // frontmatter — exactly what the diagnostics section reports).
605
+ // Refuse to overwrite it instead of silently truncating its SKILL.md.
606
+ const target = join(rootPath(root, homeOf(deps)), name)
607
+ if (await pathExists(target)) {
608
+ writeError(res, 409, 'skill directory already exists on disk: ' + name + ' (check the discovery diagnostics)')
609
+ return
610
+ }
611
+ const path = await createSkill(root, name, typeof request.description === 'string' ? request.description : '', homeOf(deps))
612
+ // 新技能自动归入默认场景(「通用」)。
613
+ const defaultTag = await deps.store.getDefaultTag()
614
+ if (defaultTag !== undefined) await deps.store.addSkillToTag(defaultTag.id, name)
615
+ deps.invalidate?.()
616
+ writeJson(res, 201, { ok: true, path, root } satisfies CreateResponse)
617
+ },
618
+ }),
619
+ // ---------------------------------------------------------------- stats
620
+ route({
621
+ path: SKILL_HUB_API.stats,
622
+ methods: ['GET'],
623
+ handler: async ({ res }) => {
624
+ if (deps.stats === undefined) {
625
+ writeJson(res, 200, { ok: true, available: false, stats: [] } satisfies import('./protocol.ts').StatsResponse)
626
+ return
627
+ }
628
+ const stats = await deps.stats()
629
+ writeJson(res, 200, { ok: true, available: true, stats } satisfies import('./protocol.ts').StatsResponse)
630
+ },
631
+ }),
632
+ // --------------------------------------------------------------- config
633
+ // The config route stays mounted even with the master switch off, so the
634
+ // settings card can always read and re-enable the hub.
635
+ route({
636
+ path: SKILL_HUB_API.config,
637
+ methods: ['GET', 'POST'],
638
+ jsonBody: true,
639
+ skipGate: true,
640
+ handler: async ({ req, res, body }) => {
641
+ if (req.method === 'GET') {
642
+ writeJson(res, 200, { ok: true, config: configOf(deps), saved: savedOf(deps) } satisfies ConfigResponse)
643
+ return
644
+ }
645
+ const raw = body
646
+ const patch: Partial<HubConfig> = {}
647
+ if (raw.enabled !== undefined) {
648
+ if (raw.enabled === null) patch.enabled = undefined
649
+ else if (typeof raw.enabled !== 'boolean') { writeError(res, 400, 'enabled must be a boolean or null'); return }
650
+ else patch.enabled = raw.enabled
651
+ }
652
+ if (raw.announceToAgent !== undefined) {
653
+ if (raw.announceToAgent === null) patch.announceToAgent = undefined
654
+ else if (typeof raw.announceToAgent !== 'boolean') { writeError(res, 400, 'announceToAgent must be a boolean or null'); return }
655
+ else patch.announceToAgent = raw.announceToAgent
656
+ }
657
+ for (const key of ['showUseCount', 'showUseTime', 'showGroupSummary'] as const) {
658
+ const value = raw[key]
659
+ if (value === undefined) continue
660
+ if (value === null) { patch[key] = undefined; continue }
661
+ if (typeof value !== 'boolean') { writeError(res, 400, key + ' must be a boolean or null'); return }
662
+ patch[key] = value
663
+ }
664
+ for (const key of ['dotModelColor', 'dotUserColor'] as const) {
665
+ const value = raw[key]
666
+ if (value === undefined) continue
667
+ if (value === null) { patch[key] = undefined; continue }
668
+ if (typeof value !== 'string' || !HEX_COLOR_RE.test(value)) { writeError(res, 400, key + ' must be a #rrggbb color or null'); return }
669
+ patch[key] = value
670
+ }
671
+ let config: HubConfig
672
+ if (deps.updateConfig === undefined) {
673
+ const merged: HubConfig = { ...configOf(deps) }
674
+ for (const [key, value] of Object.entries(patch) as Array<[keyof HubConfig, boolean | string | undefined]>) {
675
+ if (value === undefined) delete merged[key]
676
+ else (merged as unknown as Record<string, unknown>)[key] = value
677
+ }
678
+ config = merged
679
+ } else {
680
+ config = await deps.updateConfig(patch)
681
+ }
682
+ writeJson(res, 200, { ok: true, config, saved: savedOf(deps) } satisfies ConfigResponse)
683
+ },
684
+ }),
685
+ // --------------------------------------------------------------- market
686
+ // Codex-style market sources: the user adds repo slugs; each source can
687
+ // be scanned through /repo and imported through /repo/import.
688
+ route({
689
+ path: SKILL_HUB_API.market,
690
+ methods: ['GET'],
691
+ handler: async ({ res }) => {
692
+ writeJson(res, 200, { ok: true, repos: await deps.store.listMarketSources() } satisfies MarketSourcesResponse)
693
+ },
694
+ }),
695
+ route({
696
+ path: SKILL_HUB_API.marketSource,
697
+ methods: ['POST'],
698
+ jsonBody: true,
699
+ handler: async ({ res, body }) => {
700
+ const request = body as unknown as MarketSourceRequest
701
+ const input = typeof request.repo === 'string' ? request.repo.trim() : ''
702
+ const parsed = normalizeRepoInput(input)
703
+ if (parsed === null) { writeError(res, 400, 'repo must be owner/repo or a github.com URL'); return }
704
+ // An explicit @ref pins the source immediately (e.g. repo@v1.2.3);
705
+ // otherwise the first scan resolves latest-release / branch choice.
706
+ const ref = parsed.ref !== undefined ? parsed.ref : undefined
707
+ writeJson(res, 200, { ok: true, repos: await deps.store.addMarketSource(repoSlug(parsed), ref) } satisfies MarketSourceResponse)
708
+ },
709
+ }),
710
+ route({
711
+ path: SKILL_HUB_API.marketSourceDelete,
712
+ methods: ['POST'],
713
+ jsonBody: true,
714
+ handler: async ({ res, body }) => {
715
+ const request = body as unknown as MarketSourceRequest
716
+ const input = typeof request.repo === 'string' ? request.repo.trim() : ''
717
+ const parsed = normalizeRepoInput(input)
718
+ if (parsed === null) { writeError(res, 400, 'repo must be owner/repo or a github.com URL'); return }
719
+ writeJson(res, 200, { ok: true, repos: await deps.store.removeMarketSource(repoSlug(parsed)) } satisfies MarketSourceResponse)
720
+ },
721
+ }),
722
+ // ------------------------------------------------------- market/source/ref
723
+ // Pin a market source to an explicit ref (branch chosen after a scan
724
+ // found no release, or a user-specified tag).
725
+ route({
726
+ path: SKILL_HUB_API.marketSourceRef,
727
+ methods: ['POST'],
728
+ jsonBody: true,
729
+ handler: async ({ res, body }) => {
730
+ const request = body as unknown as MarketSourceRefRequest
731
+ const parsed = normalizeRepoInput(typeof request.repo === 'string' ? request.repo : '')
732
+ const ref = typeof request.ref === 'string' ? request.ref.trim() : ''
733
+ if (parsed === null) { writeError(res, 400, 'repo must be owner/repo or a github.com URL'); return }
734
+ if (ref === '') { writeError(res, 400, 'ref is required'); return }
735
+ const record = await deps.store.setMarketSourceRef(repoSlug(parsed), ref)
736
+ if (record === undefined) { writeError(res, 404, 'market source not found: ' + repoSlug(parsed)); return }
737
+ writeJson(res, 200, { ok: true, repos: await deps.store.listMarketSources() } satisfies MarketSourceResponse)
738
+ },
739
+ }),
740
+ // ------------------------------------------------------- market/check
741
+ // Update check over every market source: compares the pinned ref's
742
+ // commit against the recorded baseline and surfaces newer releases.
743
+ // 5-minute throttle mirrors the source check.
744
+ route({
745
+ path: SKILL_HUB_API.marketCheck,
746
+ methods: ['GET'],
747
+ handler: async ({ res }) => {
748
+ const sources = await deps.store.listMarketSources()
749
+ if (lastMarketCheck.size > 500) lastMarketCheck.clear()
750
+ const results: MarketCheckResponse['results'] = []
751
+ for (const source of sources) {
752
+ const base = { repo: source.repo, ...(source.ref !== undefined ? { ref: source.ref } : {}) }
753
+ const now = Date.now()
754
+ const last = lastMarketCheck.get(source.repo) ?? 0
755
+ if (now - last < MIN_CHECK_INTERVAL_MS) {
756
+ results.push({ ...base, updateAvailable: false, commitSha: source.commitSha ?? '', throttled: true })
757
+ continue
758
+ }
759
+ try {
760
+ const latestTag = await getLatestReleaseTag(source.repo)
761
+ if (source.ref === undefined) {
762
+ // Not pinned yet: report the latest release so the UI can
763
+ // suggest pinning, but never claim an update.
764
+ results.push({ ...base, updateAvailable: false, commitSha: source.commitSha ?? '', ...(latestTag !== undefined ? { latestTag } : {}) })
765
+ continue
766
+ }
767
+ const latest = await getLatestCommit(source.repo, source.ref)
768
+ lastMarketCheck.set(source.repo, now)
769
+ const commitMoved = source.commitSha !== undefined && latest.commitSha !== source.commitSha
770
+ const newRelease = latestTag !== undefined && latestTag !== source.ref
771
+ results.push({ ...base, updateAvailable: commitMoved || newRelease, commitSha: latest.commitSha, ...(newRelease ? { latestTag } : {}) })
772
+ } catch (error) {
773
+ results.push({ ...base, updateAvailable: false, commitSha: source.commitSha ?? '', error: error instanceof Error ? error.message : String(error) })
774
+ }
775
+ }
776
+ writeJson(res, 200, { ok: true, results } satisfies MarketCheckResponse)
777
+ },
778
+ }),
779
+ // ------------------------------------------------------- market/source/sync
780
+ // Align a market source to its version: resolve the pinned ref (or pick
781
+ // the latest release when unpinned), record the commit, and propagate the
782
+ // ref to the matching tracked source record so source checks/syncs follow
783
+ // it. Returns the local skills tracked from this repo so the UI can offer
784
+ // a batch update.
785
+ route({
786
+ path: SKILL_HUB_API.marketSync,
787
+ methods: ['POST'],
788
+ jsonBody: true,
789
+ handler: async ({ res, body }) => {
790
+ const request = body as unknown as MarketSourceRefRequest
791
+ const parsed = normalizeRepoInput(typeof request.repo === 'string' ? request.repo : '')
792
+ if (parsed === null) { writeError(res, 400, 'repo must be owner/repo or a github.com URL'); return }
793
+ const repo = repoSlug(parsed)
794
+ const source = await deps.store.getMarketSource(repo)
795
+ if (source === undefined) { writeError(res, 404, 'market source not found: ' + repo); return }
796
+ // Resolve the ref: pinned ref wins; otherwise auto-adopt the latest
797
+ // release; a repo without releases must be pinned via the branch picker first.
798
+ let ref = source.ref
799
+ if (ref === undefined || ref === '') {
800
+ const latestTag = await getLatestReleaseTag(repo)
801
+ if (latestTag === undefined) { writeError(res, 409, 'repo has no release — pick a branch first'); return }
802
+ ref = latestTag
803
+ await deps.store.setMarketSourceRef(repo, ref)
804
+ }
805
+ const latest = await getLatestCommit(repo, ref)
806
+ await deps.store.setMarketSourceCommit(repo, latest.commitSha)
807
+ // Propagate the ref to the tracked source record so checks follow it.
808
+ await deps.store.setSourceRef(repo, ref)
809
+ const tracked = await deps.store.getSource(repo)
810
+ writeJson(res, 200, {
811
+ ok: true,
812
+ repo,
813
+ ref,
814
+ commitSha: latest.commitSha,
815
+ skills: tracked !== undefined ? [...tracked.skills] : [],
816
+ } satisfies MarketSyncResponse)
817
+ },
818
+ }),
819
+ // ---------------------------------------------------------------- repo
820
+ // Discover importable skills in a public GitHub repo (skills/ + design-templates/).
821
+ route({
822
+ path: SKILL_HUB_API.repo,
823
+ methods: ['GET'],
824
+ handler: async ({ res, url }) => {
825
+ const input = queryParam(url, 'repo') ?? ''
826
+ const parsed = normalizeRepoInput(input)
827
+ if (parsed === null) { writeError(res, 400, 'repo must be owner/repo or a github.com URL'); return }
828
+ const repo = repoSlug(parsed)
829
+ // Version resolution: explicit @ref wins; then the market source's
830
+ // pinned ref (branch picked earlier); then the latest release;
831
+ // a repo without releases must be pinned to a branch by the user
832
+ // (branches returned, ref null).
833
+ let ref = parsed.ref
834
+ if (ref === undefined || ref === '') {
835
+ const pinned = await deps.store.getMarketSource(repo)
836
+ if (pinned?.ref !== undefined && pinned.ref !== '') {
837
+ ref = pinned.ref
838
+ }
839
+ }
840
+ if (ref === undefined || ref === '') {
841
+ const releaseTag = await getLatestReleaseTag(repo)
842
+ if (releaseTag !== undefined) {
843
+ ref = releaseTag
844
+ } else {
845
+ const branches = await listRepoBranches(repo)
846
+ if (branches.length === 0) { writeError(res, 404, 'repo has no branches to scan'); return }
847
+ writeJson(res, 200, { ok: true, repo, ref: null, branches, entries: [] } satisfies RepoDiscoverResponse)
848
+ return
849
+ }
850
+ }
851
+ const { ref: resolvedRef, tree } = await loadRepoTree(repo, ref)
852
+ const existing = await knownSkillNames(deps)
853
+ const entries = discoverRepoEntries(tree, repo, existing)
854
+ writeJson(res, 200, { ok: true, repo, ref: resolvedRef, entries } satisfies RepoDiscoverResponse)
855
+ },
856
+ }),
857
+ // ----------------------------------------------------------- repo/import
858
+ // Install selected repo skills, preserving full skill directories and
859
+ // recording the upstream source (repo + commit snapshot + manifest) so
860
+ // they form one tracked collection group.
861
+ route({
862
+ path: SKILL_HUB_API.repoImport,
863
+ methods: ['POST'],
864
+ jsonBody: true,
865
+ handler: async ({ res, body }) => {
866
+ const request = body as unknown as RepoImportRequest
867
+ const input = typeof request.repo === 'string' ? request.repo.trim() : ''
868
+ const paths = Array.isArray(request.paths) ? request.paths.filter((path): path is string => typeof path === 'string' && path !== '') : []
869
+ if (paths.length === 0) { writeError(res, 400, 'paths must be a non-empty array'); return }
870
+ const parsed = normalizeRepoInput(input)
871
+ if (parsed === null) { writeError(res, 400, 'repo must be owner/repo or a github.com URL'); return }
872
+ const repo = repoSlug(parsed)
873
+ // The ref the discovery ran against must drive this import too, so
874
+ // the tree, commit snapshot, and recorded source ref all agree
875
+ // (falls back to the input's own @ref when the client omitted it).
876
+ const ref = typeof request.ref === 'string' && request.ref !== '' ? request.ref : parsed.ref
877
+ const { ref: resolvedRef, tree } = await loadRepoTree(repo, ref)
878
+ const existing = await knownSkillNames(deps)
879
+ const entries = discoverRepoEntries(tree, repo, existing)
880
+ const byPath = new Map(entries.map((entry) => [entry.path, entry]))
881
+ const selected = paths.map((path) => byPath.get(path)).filter((entry): entry is NonNullable<typeof entry> => entry !== undefined)
882
+ if (selected.length > 500) { writeError(res, 400, 'select at most 500 skills per import'); return }
883
+ const totalBytes = selected.reduce((sum, entry) => sum + entry.totalBytes, 0)
884
+ if (totalBytes > 200 * 1024 * 1024) { writeError(res, 400, 'selected skills exceed the 200MB import limit'); return }
885
+
886
+ // Snapshot the upstream commit for the source record (best-effort:
887
+ // a failure still imports, just with an unverified snapshot).
888
+ let commitSha = ''
889
+ try {
890
+ const latest = await getLatestCommit(repo, resolvedRef)
891
+ commitSha = latest.commitSha
892
+ } catch {
893
+ // source record keeps an empty snapshot; first check backfills it
894
+ }
895
+
896
+ const imported: RepoImportResponse['imported'] = []
897
+ const skipped: RepoImportResponse['skipped'] = []
898
+ const failed: RepoImportResponse['failed'] = []
899
+ const targetRoot = rootPath('user-dsh', homeOf(deps))
900
+ await mkdir(targetRoot, { recursive: true })
901
+ // 导入的技能与新建技能一样自动归入默认场景「通用」,否则它们在
902
+ // 场景 tab 里不可见(来源集合只出现在来源 tab)。
903
+ const defaultTag = await deps.store.getDefaultTag()
904
+ for (const entry of selected) {
905
+ if (entry.existing) {
906
+ skipped.push({ name: entry.name, reason: 'exists' })
907
+ continue
908
+ }
909
+ if (await pathExists(join(targetRoot, entry.name))) {
910
+ skipped.push({ name: entry.name, reason: 'exists' })
911
+ continue
912
+ }
913
+ const files = collectRepoSkillFiles(tree, entry.dir)
914
+ try {
915
+ const result = await downloadRepoSkill(repo, resolvedRef, entry, files, targetRoot)
916
+ await deps.store.addSourceSkill(repo, entry.root, commitSha, resolvedRef, entry.name)
917
+ await deps.store.mergeSourceManifest(repo, skillManifest(tree, entry.dir), entry.dir)
918
+ if (defaultTag !== undefined) await deps.store.addSkillToTag(defaultTag.id, entry.name)
919
+ imported.push({ name: entry.name, origin: entry.origin, path: result.skillPath })
920
+ } catch (error) {
921
+ failed.push({ name: entry.name, error: error instanceof Error ? error.message : String(error) })
922
+ }
923
+ }
924
+ deps.invalidate?.()
925
+ writeJson(res, 200, { ok: true, imported, skipped, failed } satisfies RepoImportResponse)
926
+ },
927
+ }),
928
+ // -------------------------------------------------------------- update
929
+ // 自身更新检查:查询 GitHub latest release(语义同 cc-switch 的检查更新)。
930
+ route({
931
+ path: SKILL_HUB_API.update,
932
+ methods: ['GET'],
933
+ handler: async ({ res }) => {
934
+ writeJson(res, 200, await checkLatestRelease())
935
+ },
936
+ }),
937
+ // -------------------------------------------------------------- groups
938
+ // 用户 tag 分组 + 系统集合组 + origin 映射(前端分组栏的数据源)。
939
+ route({
940
+ path: SKILL_HUB_API.groups,
941
+ methods: ['GET'],
942
+ handler: async ({ res }) => {
943
+ writeJson(res, 200, await buildGroups(deps))
944
+ },
945
+ }),
946
+ // ----------------------------------------------------------------- tag
947
+ // 新建(缺省 id)或重命名(带 id)一个用户 tag 分组。
948
+ route({
949
+ path: SKILL_HUB_API.tag,
950
+ methods: ['POST'],
951
+ jsonBody: true,
952
+ handler: async ({ res, body }) => {
953
+ const name = typeof body.name === 'string' ? body.name.trim() : ''
954
+ if (name === '') { writeError(res, 400, 'tag name is required'); return }
955
+ const id = typeof body.id === 'string' && body.id !== '' ? body.id : undefined
956
+ await deps.store.saveTag({ id, name })
957
+ writeJson(res, 200, { ok: true, tags: await deps.store.listTags() } satisfies TagSaveResponse)
958
+ },
959
+ }),
960
+ // ----------------------------------------------------------- tag/delete
961
+ route({
962
+ path: SKILL_HUB_API.tagDelete,
963
+ methods: ['POST'],
964
+ jsonBody: true,
965
+ handler: async ({ res, body }) => {
966
+ const id = typeof (body as unknown as TagDeleteRequest).id === 'string' ? (body as unknown as TagDeleteRequest).id : ''
967
+ if (id === '') { writeError(res, 400, 'tag id is required'); return }
968
+ const tag = await deps.store.getTag(id)
969
+ if (tag?.default === true) { writeError(res, 409, 'the default scene cannot be deleted'); return }
970
+ await deps.store.deleteTag(id)
971
+ writeJson(res, 200, { ok: true, tags: await deps.store.listTags() } satisfies TagDeleteResponse)
972
+ },
973
+ }),
974
+ // ---------------------------------------------------------- tag/members
975
+ // 直接设置某 tag 的完整成员列表;后端只保留目录中实际存在的技能名。
976
+ route({
977
+ path: SKILL_HUB_API.tagMembers,
978
+ methods: ['POST'],
979
+ jsonBody: true,
980
+ handler: async ({ res, body }) => {
981
+ const request = body as unknown as TagMembersRequest
982
+ const id = typeof request.id === 'string' ? request.id : ''
983
+ if (id === '') { writeError(res, 400, 'tag id is required'); return }
984
+ const names = Array.isArray(request.skillNames) ? request.skillNames.filter((n): n is string => typeof n === 'string') : []
985
+ const known = await knownSkillNames(deps)
986
+ const saved = await deps.store.setTagMembers(id, names.filter((n) => known.has(n)))
987
+ if (saved === undefined) { writeError(res, 404, 'tag not found: ' + id); return }
988
+ writeJson(res, 200, { ok: true, tags: await deps.store.listTags() } satisfies TagMembersResponse)
989
+ },
990
+ }),
991
+ // ------------------------------------------------------------- sources
992
+ // 来源列表 + 派生 origin 映射 + 集合组 + 回收站。
993
+ route({
994
+ path: SKILL_HUB_API.sources,
995
+ methods: ['GET'],
996
+ handler: async ({ res }) => {
997
+ const [sources, origins, trash] = await Promise.all([deps.store.listSources(), deps.store.listOrigins(), deps.store.listTrash()])
998
+ const byCollection = new Map<string, string[]>()
999
+ for (const [skillName, origin] of Object.entries(origins)) {
1000
+ const list = byCollection.get(origin)
1001
+ if (list === undefined) byCollection.set(origin, [skillName])
1002
+ else list.push(skillName)
1003
+ }
1004
+ const collections: CollectionGroup[] = [...byCollection.entries()]
1005
+ .map(([name, skillNames]) => ({ name, skillNames: [...skillNames].sort((a, b) => a.localeCompare(b)) }))
1006
+ .sort((a, b) => a.name.localeCompare(b.name))
1007
+ writeJson(res, 200, { ok: true, sources, origins, collections, trash } satisfies SourcesResponse)
1008
+ },
1009
+ }),
1010
+ // -------------------------------------------------------- sources/check
1011
+ // 检查指定(或全部)来源的上游更新。每个来源最多 1 次 commit 请求;
1012
+ // 仅当 commit 变化时再拉一次 tree 做逐技能差异。5 分钟节流。
1013
+ route({
1014
+ path: SKILL_HUB_API.sourceCheck,
1015
+ methods: ['POST'],
1016
+ jsonBody: true,
1017
+ handler: async ({ res, body }) => {
1018
+ const request = body as unknown as SourceCheckRequest
1019
+ let only: string | undefined
1020
+ if (typeof request.repo === 'string' && request.repo !== '') {
1021
+ // Normalize URLs/slugs the same way every other route does, so a
1022
+ // check for "https://github.com/a/b" finds the "a/b" record.
1023
+ const parsedOnly = normalizeRepoInput(request.repo)
1024
+ only = parsedOnly !== null ? repoSlug(parsedOnly) : undefined
1025
+ }
1026
+ const source = only !== undefined ? await deps.store.getSource(only) : undefined
1027
+ if (only !== undefined && source === undefined) { writeError(res, 404, 'source not found: ' + only); return }
1028
+ const sources = source !== undefined ? [source] : await deps.store.listSources()
1029
+ if (lastSourceCheck.size > 500) lastSourceCheck.clear()
1030
+ const results: SourceCheckResult[] = []
1031
+ for (const item of sources) {
1032
+ const base = { repo: item.repo, ...(item.ref !== undefined ? { ref: item.ref } : {}) }
1033
+ const now = Date.now()
1034
+ const last = lastSourceCheck.get(item.repo) ?? 0
1035
+ if (now - last < MIN_CHECK_INTERVAL_MS) {
1036
+ results.push({ ...base, changed: false, updated: [], deleted: [], throttled: true })
1037
+ continue
1038
+ }
1039
+ try {
1040
+ const latest = await getLatestCommit(item.repo, item.ref)
1041
+ lastSourceCheck.set(item.repo, now)
1042
+ if (item.commitSha === '') {
1043
+ // Migrated/legacy record without a snapshot: backfill the
1044
+ // commit now and report "unverified" instead of claiming every
1045
+ // skill is updated (there is no baseline to diff against yet).
1046
+ await deps.store.setSourceCommit(item.repo, latest.commitSha)
1047
+ results.push({ ...base, changed: false, updated: [], deleted: [], unverified: true, commitSha: latest.commitSha })
1048
+ continue
1049
+ }
1050
+ if (latest.commitSha === item.commitSha) {
1051
+ results.push({ ...base, changed: false, updated: [], deleted: [] })
1052
+ continue
1053
+ }
1054
+ const tree = await loadRepoTreeAt(item.repo, latest.treeSha)
1055
+ const diff = diffRemoteSkills(tree, item)
1056
+ results.push({ ...base, changed: true, commitSha: latest.commitSha, updated: diff.updated, deleted: diff.deleted })
1057
+ } catch (error) {
1058
+ results.push({ ...base, changed: false, updated: [], deleted: [], error: error instanceof Error ? error.message : String(error) })
1059
+ }
1060
+ }
1061
+ writeJson(res, 200, { ok: true, results } satisfies SourceCheckResponse)
1062
+ },
1063
+ }),
1064
+ // --------------------------------------------------------- sources/sync
1065
+ // 按上游重新下载所选(或全部)技能并更新 commit 快照与 manifest。
1066
+ route({
1067
+ path: SKILL_HUB_API.sourceSync,
1068
+ methods: ['POST'],
1069
+ jsonBody: true,
1070
+ handler: async ({ res, body }) => {
1071
+ const request = body as unknown as SourceSyncRequest
1072
+ const repo = typeof request.repo === 'string' ? request.repo.trim() : ''
1073
+ if (repo === '') { writeError(res, 400, 'repo is required'); return }
1074
+ const selected = Array.isArray(request.skills) ? request.skills.filter((n): n is string => typeof n === 'string' && n !== '') : undefined
1075
+ const source = await deps.store.getSource(repo)
1076
+ if (source === undefined) { writeError(res, 404, 'source not found: ' + repo); return }
1077
+ const targets = selected !== undefined ? selected : source.skills
1078
+ const latest = await getLatestCommit(repo, source.ref)
1079
+ const tree = await loadRepoTreeAt(repo, latest.treeSha)
1080
+ const targetRoot = rootPath('user-dsh', homeOf(deps))
1081
+ await mkdir(targetRoot, { recursive: true })
1082
+ const synced: string[] = []
1083
+ const failed: Array<{ name: string; error: string }> = []
1084
+ for (const name of targets) {
1085
+ // Sync writes under the user root: only accept real skill names
1086
+ // this source actually tracks (a bare join would otherwise fold
1087
+ // `..` segments out of the writable root).
1088
+ if (!isSkillName(name) || !source.skills.includes(name)) {
1089
+ failed.push({ name, error: 'skill is not tracked by this source' })
1090
+ continue
1091
+ }
1092
+ try {
1093
+ const entry = repoSkillEntry(name, source.root, repo)
1094
+ // 上游可能用分类子目录(skills/engineering/<name>/)且目录会移动:
1095
+ // 先在上游 tree 里搜真实位置,manifest 兜底,否则嵌套技能会 404。
1096
+ entry.dir = skillDirOf(source, name, tree.map((item) => item.path))
1097
+ entry.path = entry.dir + '/SKILL.md'
1098
+ const files = collectRepoSkillFiles(tree, entry.dir)
1099
+ if (files.length === 0) { failed.push({ name, error: 'skill missing upstream' }); continue }
1100
+ const targetDir = join(targetRoot, name)
1101
+ const wasDisabled = (await deps.store.getDisabled(name)) !== undefined
1102
+ if (await pathExists(targetDir)) {
1103
+ await replaceSkillDir(targetDir, async () => {
1104
+ await downloadRepoSkill(repo, latest.commitSha, entry, files, targetRoot)
1105
+ })
1106
+ } else {
1107
+ await downloadRepoSkill(repo, latest.commitSha, entry, files, targetRoot)
1108
+ }
1109
+ if (wasDisabled) {
1110
+ // Preserve the disabled state: the fresh SKILL.md must not
1111
+ // re-enter discovery.
1112
+ await rename(join(targetDir, 'SKILL.md'), join(targetDir, 'SKILL.md.disabled'))
1113
+ } else {
1114
+ await deps.store.removeDisabled(name)
1115
+ }
1116
+ await deps.store.mergeSourceManifest(repo, skillManifest(tree, entry.dir), entry.dir)
1117
+ synced.push(name)
1118
+ } catch (error) {
1119
+ failed.push({ name, error: error instanceof Error ? error.message : String(error) })
1120
+ }
1121
+ }
1122
+ // Only advance the commit snapshot when every skill landed: a failed
1123
+ // sync keeps the old commit, so the next check still diffs the tree
1124
+ // and re-reports the missing skills instead of silently hiding them.
1125
+ if (failed.length === 0) {
1126
+ await deps.store.setSourceCommit(repo, latest.commitSha)
1127
+ }
1128
+ deps.invalidate?.()
1129
+ writeJson(res, 200, { ok: true, repo, commitSha: latest.commitSha, synced, failed } satisfies SourceSyncResponse)
1130
+ },
1131
+ }),
1132
+ // ------------------------------------------------------- sources/delete
1133
+ // 跟进上游删除:把所选技能的本地目录移入回收站(可恢复)。
1134
+ route({
1135
+ path: SKILL_HUB_API.sourceDelete,
1136
+ methods: ['POST'],
1137
+ jsonBody: true,
1138
+ handler: async ({ res, body }) => {
1139
+ const request = body as unknown as SourceDeleteRequest
1140
+ const repo = typeof request.repo === 'string' ? request.repo.trim() : ''
1141
+ const skills = Array.isArray(request.skills) ? request.skills.filter((n): n is string => typeof n === 'string' && n !== '') : []
1142
+ if (repo === '') { writeError(res, 400, 'repo is required'); return }
1143
+ if (skills.length === 0) { writeError(res, 400, 'skills must be a non-empty array'); return }
1144
+ const source = await deps.store.getSource(repo)
1145
+ if (source === undefined) { writeError(res, 404, 'source not found: ' + repo); return }
1146
+ const home = homeOf(deps)
1147
+ const trashed: string[] = []
1148
+ const failed: Array<{ name: string; error: string }> = []
1149
+ const sourceNames = new Set(source.skills)
1150
+ for (const name of skills) {
1151
+ // Every name must be a real kebab-case skill that this source
1152
+ // actually tracks; anything else must never touch the filesystem
1153
+ // (a bare `join` would otherwise fold `..` segments out of the
1154
+ // writable root — see the path-containment assert below).
1155
+ if (!isSkillName(name) || !sourceNames.has(name)) {
1156
+ failed.push({ name, error: 'skill is not tracked by this source' })
1157
+ continue
1158
+ }
1159
+ const sourcePath = join(rootPath('user-dsh', home), name)
1160
+ if (rootOfPath(sourcePath, home) === undefined) {
1161
+ failed.push({ name, error: 'skill path is outside the hub writable roots' })
1162
+ continue
1163
+ }
1164
+ if (!await pathExists(sourcePath)) {
1165
+ failed.push({ name, error: 'skill directory not found' })
1166
+ continue
1167
+ }
1168
+ try {
1169
+ // 入回收站前快照来源与场景归属,恢复时挂回(见 sourceRestore)。
1170
+ const tagIds = (await deps.store.listTags()).filter((tag) => tag.skillNames.includes(name)).map((tag) => tag.id)
1171
+ const { path } = await trashSkill(sourcePath)
1172
+ await deps.store.addTrash({
1173
+ name,
1174
+ path,
1175
+ movedAt: Date.now(),
1176
+ sourcePath,
1177
+ origin: { repo: source.repo, root: source.root, ...(source.ref !== undefined && source.ref !== '' ? { ref: source.ref } : {}), commitSha: source.commitSha },
1178
+ ...(tagIds.length > 0 ? { tagIds } : {}),
1179
+ })
1180
+ await deps.store.removeDisabled(name)
1181
+ await deps.store.removeSkillFromTags(name)
1182
+ trashed.push(name)
1183
+ } catch (error) {
1184
+ failed.push({ name, error: error instanceof Error ? error.message : String(error) })
1185
+ }
1186
+ }
1187
+ if (trashed.length > 0) {
1188
+ await deps.store.setSourceSkills(repo, source.skills.filter((n) => !trashed.includes(n)))
1189
+ deps.invalidate?.()
1190
+ }
1191
+ writeJson(res, 200, { ok: true, trashed, failed } satisfies SourceDeleteResponse)
1192
+ },
1193
+ }),
1194
+ // ------------------------------------------------------ sources/restore
1195
+ // 从回收站恢复一个技能目录。
1196
+ route({
1197
+ path: SKILL_HUB_API.sourceRestore,
1198
+ methods: ['POST'],
1199
+ jsonBody: true,
1200
+ handler: async ({ res, body }) => {
1201
+ const request = body as unknown as SourceRestoreRequest
1202
+ const name = typeof request.name === 'string' ? request.name : ''
1203
+ if (name === '') { writeError(res, 400, 'name is required'); return }
1204
+ const entry = await deps.store.getTrash(name)
1205
+ if (entry === undefined) { writeError(res, 404, 'trash entry not found: ' + name); return }
1206
+ const home = homeOf(deps)
1207
+ const target = entry.sourcePath ?? join(rootPath('user-dsh', home), name)
1208
+ if (await pathExists(target)) {
1209
+ writeError(res, 409, 'skill already exists: ' + name)
1210
+ return
1211
+ }
1212
+ const path = await restoreSkill(entry, home)
1213
+ // 恢复来源归属(入回收站前快照的来源记录)与场景成员,否则恢复后
1214
+ // 的技能会变成「个人技能」并脱离原场景。
1215
+ if (entry.origin !== undefined) {
1216
+ await deps.store.addSourceSkill(entry.origin.repo, entry.origin.root, entry.origin.commitSha, entry.origin.ref, name)
1217
+ }
1218
+ for (const tagId of entry.tagIds ?? []) {
1219
+ await deps.store.addSkillToTag(tagId, name)
1220
+ }
1221
+ await deps.store.removeTrash(name)
1222
+ deps.invalidate?.()
1223
+ writeJson(res, 200, { ok: true, name, path } satisfies SourceRestoreResponse)
1224
+ },
1225
+ }),
1226
+ // ------------------------------------------------- sources/trash/clear
1227
+ // 清空回收站:永久删除 .trash 里的技能,失败项保留可重试。
1228
+ route({
1229
+ path: SKILL_HUB_API.sourceTrashClear,
1230
+ methods: ['POST'],
1231
+ handler: async ({ res }) => {
1232
+ const home = homeOf(deps)
1233
+ const entries = await deps.store.listTrash()
1234
+ const deleted: string[] = []
1235
+ const failed: Array<{ name: string; error: string }> = []
1236
+ for (const entry of entries) {
1237
+ try {
1238
+ await clearTrash(entry, home)
1239
+ // A permanently deleted skill must not linger in user groups.
1240
+ await deps.store.removeSkillFromTags(entry.name)
1241
+ deleted.push(entry.name)
1242
+ } catch (error) {
1243
+ failed.push({ name: entry.name, error: error instanceof Error ? error.message : String(error) })
1244
+ }
1245
+ }
1246
+ for (const name of deleted) await deps.store.removeTrash(name)
1247
+ if (deleted.length > 0) deps.invalidate?.()
1248
+ writeJson(res, 200, { ok: true, deleted, failed } satisfies SourceTrashClearResponse)
1249
+ },
1250
+ }),
1251
+ ]
1252
+ }