dsh-skill-hub 0.3.7 → 0.3.8

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 (109) hide show
  1. package/README.md +40 -50
  2. package/README.zh.md +38 -48
  3. package/lib/client.js +2209 -1820
  4. package/lib/client.js.map +1 -1
  5. package/lib/index.js +653 -419
  6. package/lib/types/client/SkillHubSettingsCard.d.ts +1 -0
  7. package/lib/types/client/api.d.ts +12 -6
  8. package/lib/types/client/helpers.d.ts +0 -1
  9. package/lib/types/client/locales/common.d.ts +52 -0
  10. package/lib/types/client/locales/detail.d.ts +16 -0
  11. package/lib/types/client/locales/market.d.ts +73 -0
  12. package/lib/types/client/locales/settings.d.ts +41 -0
  13. package/lib/types/client/locales/skills.d.ts +65 -0
  14. package/lib/types/client/locales/sources.d.ts +35 -0
  15. package/lib/types/client/locales.d.ts +124 -118
  16. package/lib/types/client/panel/MarketSourceRow.d.ts +12 -0
  17. package/lib/types/client/panel/MarketView.d.ts +1 -0
  18. package/lib/types/client/panel/RepoScanCard.d.ts +10 -0
  19. package/lib/types/client/panel/hooks/shared.d.ts +72 -0
  20. package/lib/types/client/panel/hooks/useCatalogFlow.d.ts +62 -0
  21. package/lib/types/client/panel/hooks/useGroupFlow.d.ts +41 -0
  22. package/lib/types/client/panel/hooks/useMarketFlow.d.ts +73 -0
  23. package/lib/types/client/panel/hooks/useMetaFlow.d.ts +18 -0
  24. package/lib/types/client/panel/hooks/useSourceFlow.d.ts +44 -0
  25. package/lib/types/client/panel/market-format.d.ts +6 -0
  26. package/lib/types/client/panel/useSkillHub.d.ts +45 -73
  27. package/lib/types/client/settings-card.d.ts +20 -0
  28. package/lib/types/client/settings-form.d.ts +6 -0
  29. package/lib/types/protocol/api.d.ts +38 -0
  30. package/lib/types/protocol/catalog.d.ts +155 -0
  31. package/lib/types/protocol/config.d.ts +113 -0
  32. package/lib/types/protocol/groups.d.ts +95 -0
  33. package/lib/types/protocol/market.d.ts +94 -0
  34. package/lib/types/protocol/repo.d.ts +107 -0
  35. package/lib/types/protocol/sources.d.ts +139 -0
  36. package/lib/types/protocol/stats.d.ts +46 -0
  37. package/lib/types/protocol.d.ts +13 -743
  38. package/lib/types/repo/github-client.d.ts +42 -0
  39. package/lib/types/repo.d.ts +6 -17
  40. package/lib/types/routes/catalog.d.ts +7 -0
  41. package/lib/types/routes/config.d.ts +7 -0
  42. package/lib/types/routes/groups.d.ts +7 -0
  43. package/lib/types/routes/helpers.d.ts +145 -0
  44. package/lib/types/routes/market.d.ts +8 -0
  45. package/lib/types/routes/repo-import.d.ts +7 -0
  46. package/lib/types/routes/route-state.d.ts +55 -0
  47. package/lib/types/routes/sources.d.ts +8 -0
  48. package/lib/types/routes.d.ts +5 -34
  49. package/lib/types/stats.d.ts +3 -2
  50. package/lib/types/store/errors.d.ts +10 -0
  51. package/lib/types/store/migrate.d.ts +44 -0
  52. package/lib/types/store/paths.d.ts +32 -0
  53. package/lib/types/store/store.d.ts +133 -0
  54. package/lib/types/store.d.ts +7 -146
  55. package/package.json +28 -27
  56. package/src/client/SkillHubSettingsCard.tsx +17 -3
  57. package/src/client/api.ts +101 -195
  58. package/src/client/helpers.ts +10 -1
  59. package/src/client/locales/common.ts +102 -0
  60. package/src/client/locales/detail.ts +30 -0
  61. package/src/client/locales/market.ts +144 -0
  62. package/src/client/locales/settings.ts +80 -0
  63. package/src/client/locales/skills.ts +128 -0
  64. package/src/client/locales/sources.ts +68 -0
  65. package/src/client/locales.ts +26 -512
  66. package/src/client/panel/MarketSourceRow.tsx +108 -0
  67. package/src/client/panel/MarketView.tsx +12 -253
  68. package/src/client/panel/RepoScanCard.tsx +171 -0
  69. package/src/client/panel/hooks/shared.ts +81 -0
  70. package/src/client/panel/hooks/useCatalogFlow.ts +211 -0
  71. package/src/client/panel/hooks/useGroupFlow.ts +240 -0
  72. package/src/client/panel/hooks/useMarketFlow.ts +421 -0
  73. package/src/client/panel/hooks/useMetaFlow.ts +58 -0
  74. package/src/client/panel/hooks/useSourceFlow.ts +193 -0
  75. package/src/client/panel/market-format.ts +22 -0
  76. package/src/client/panel/useSkillHub.ts +141 -1032
  77. package/src/client/settings-card.tsx +51 -0
  78. package/src/client/settings-form.ts +18 -0
  79. package/src/index.ts +14 -4
  80. package/src/protocol/api.ts +38 -0
  81. package/src/protocol/catalog.ts +168 -0
  82. package/src/protocol/config.ts +145 -0
  83. package/src/protocol/groups.ts +106 -0
  84. package/src/protocol/market.ts +100 -0
  85. package/src/protocol/repo.ts +104 -0
  86. package/src/protocol/sources.ts +146 -0
  87. package/src/protocol/stats.ts +42 -0
  88. package/src/protocol.ts +83 -809
  89. package/src/repo/github-client.ts +131 -0
  90. package/src/repo.test.ts +24 -1
  91. package/src/repo.ts +50 -170
  92. package/src/routes/catalog.ts +309 -0
  93. package/src/routes/config.ts +119 -0
  94. package/src/routes/groups.ts +133 -0
  95. package/src/routes/helpers.ts +489 -0
  96. package/src/routes/market.ts +255 -0
  97. package/src/routes/repo-import.ts +267 -0
  98. package/src/routes/route-state.ts +89 -0
  99. package/src/routes/sources.ts +318 -0
  100. package/src/routes.test.ts +20 -0
  101. package/src/routes.ts +21 -1699
  102. package/src/stats.test.ts +58 -3
  103. package/src/stats.ts +45 -16
  104. package/src/store/errors.ts +14 -0
  105. package/src/store/migrate.ts +272 -0
  106. package/src/store/paths.ts +43 -0
  107. package/src/store/store.ts +532 -0
  108. package/src/store.test.ts +18 -0
  109. package/src/store.ts +7 -737
package/src/routes.ts CHANGED
@@ -7,593 +7,21 @@
7
7
  * loopback-only trust fence — these endpoints rename files under the user's
8
8
  * skill roots, so LAN-exposed dsh web deployments must not serve them.
9
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.
10
+ * 按域拆分后的聚合入口:各域 handler 在 ./routes/<domain>.ts,共享围栏在
11
+ * ./routes/helpers.ts,节流/任务状态在 ./routes/route-state.ts。
12
+ * 老 `from './routes.ts'`(makeRoutes + SkillHubRouteDeps)保持可用。
13
13
  */
14
14
 
15
- import { mkdir, readFile, 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
15
  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 { randomUUID } from 'node:crypto'
22
- import {
23
- cleanupLeftoverImportDirs,
24
- collectRepoSkillFiles,
25
- diffRemoteSkills,
26
- discoverRepoEntries,
27
- downloadRepoSkill,
28
- getLatestCommit,
29
- getLatestReleaseTag,
30
- isAbortError,
31
- listRepoBranches,
32
- listRepoReleases,
33
- loadRepoTree,
34
- loadRepoTreeAt,
35
- normalizeRepoInput,
36
- repoSkillEntry,
37
- repoSlug,
38
- RepoFetchError,
39
- skillDirOf,
40
- skillManifest,
41
- } from './repo.ts'
42
- import {
43
- SKILL_HUB_API,
44
- type CatalogResponse,
45
- type RepoImportCancelRequest,
46
- type RepoImportCancelResponse,
47
- type RepoImportProgressResponse,
48
- type RepoImportRequest,
49
- type RepoImportResponse,
50
- type CatalogSkill,
51
- type CollectionGroup,
52
- type ConfigResponse,
53
- type CreateResponse,
54
- type RepoDiscoverResponse,
55
- type CreateRequest,
56
- type ErrorResponse,
57
- type GroupsResponse,
58
- type HubConfig,
59
- type MarketCheckResponse,
60
- type MarketSourceRefRequest,
61
- type MarketSourceRequest,
62
- type MarketSourceResponse,
63
- type MarketSourcesResponse,
64
- type MarketSourceVersionsResponse,
65
- type MarketSyncResponse,
66
- type SourceCheckRequest,
67
- type SourceCheckResponse,
68
- type SourceCheckResult,
69
- type SourceDeleteRequest,
70
- type SourceDeleteResponse,
71
- type SourceRestoreRequest,
72
- type SourceRestoreResponse,
73
- type SourceTrashClearResponse,
74
- type SourcesResponse,
75
- type SourceSyncRequest,
76
- type SourceSyncResponse,
77
- type SkillDetail,
78
- type SkillDetailResponse,
79
- type SkillDeleteRequest,
80
- type SkillDeleteResponse,
81
- type TagDeleteRequest,
82
- type TagDeleteResponse,
83
- type TagMembersRequest,
84
- type TagMembersResponse,
85
- type TagReorderRequest,
86
- type TagReorderResponse,
87
- type CollectionReorderRequest,
88
- type CollectionReorderResponse,
89
- type SourceGroupReorderRequest,
90
- type SourceGroupReorderResponse,
91
- type TagSaveRequest,
92
- type TagSaveResponse,
93
- type DiagnosticFixRequest,
94
- type DiagnosticFixResponse,
95
- type ToggleBatchRequest,
96
- type ToggleBatchResponse,
97
- type ToggleRequest,
98
- type ToggleResponse,
99
- type WritableRoot,
100
- HEX_COLOR_RE,
101
- isProjectSource,
102
- resolveHubConfig,
103
- } from './protocol.ts'
104
- import { clearTrash, createSkill, disableSkill, enableSkill, fixDiagnosticFile, parseFrontmatter, readSkillInterface, restoreSkill, rootOfPath, rootPath, scanDiagnostics, trashSkill } from './skillfs.ts'
105
- import { checkLatestRelease, CURRENT_VERSION } from './update.ts'
106
- import { dshHome, StoreError, type SkillHubStore } from './store.ts'
107
- import type { SkillStatsReader } from './stats.ts'
16
+ import { createRoute, type SkillHubRouteDeps, type SkillLookupLike } from './routes/helpers.ts'
17
+ import { catalogRoutes } from './routes/catalog.ts'
18
+ import { configRoutes } from './routes/config.ts'
19
+ import { marketRoutes } from './routes/market.ts'
20
+ import { repoImportRoutes } from './routes/repo-import.ts'
21
+ import { groupRoutes } from './routes/groups.ts'
22
+ import { sourceRoutes } from './routes/sources.ts'
108
23
 
109
- /** Cap on JSON request bodies (toggle/create payloads are tiny). */
110
- const MAX_JSON_BODY_BYTES = 64 * 1024
111
-
112
- /** Minimum interval between network update checks per source (GitHub rate limits). */
113
- const MIN_CHECK_INTERVAL_MS = 5 * 60_000
114
-
115
- /** Loopback literal check plus browser same-origin markers. */
116
- function isLoopbackRequest(request: IncomingMessage): boolean {
117
- const address = request.socket.remoteAddress
118
- if (address !== '127.0.0.1' && address !== '::1' && address !== '::ffff:127.0.0.1') return false
119
- const host = request.headers.host
120
- if (typeof host !== 'string') return false
121
- let hostUrl: URL
122
- try {
123
- hostUrl = new URL('http://' + host)
124
- } catch {
125
- return false
126
- }
127
- if (hostUrl.hostname !== '127.0.0.1' && hostUrl.hostname !== 'localhost' && hostUrl.hostname !== '[::1]') return false
128
- if (request.headers['sec-fetch-site'] === 'cross-site') return false
129
- const origin = request.headers.origin
130
- if (origin === undefined) return true
131
- try {
132
- return new URL(origin).host === hostUrl.host
133
- } catch {
134
- return false
135
- }
136
- }
137
-
138
- /** One JSON response. */
139
- function writeJson(res: ServerResponse, status: number, body: unknown): void {
140
- const payload = JSON.stringify(body)
141
- res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'referrer-policy': 'no-referrer' })
142
- res.end(payload)
143
- }
144
-
145
- /** One JSON error response. */
146
- function writeError(res: ServerResponse, status: number, error: unknown): void {
147
- const body: ErrorResponse = { error: error instanceof Error ? error.message : String(error) }
148
- writeJson(res, status, body)
149
- }
150
-
151
- /** HTTP status per store business-rule kind (user error, not server fault). */
152
- const STORE_ERROR_STATUS = { validation: 400, 'not-found': 404, conflict: 409 } as const
153
-
154
- /** Map known error types onto HTTP statuses: repo fetch + store business rules. */
155
- function writeRouteError(res: ServerResponse, error: unknown): void {
156
- if (error instanceof RepoFetchError) {
157
- writeError(res, error.status, error)
158
- return
159
- }
160
- if (error instanceof StoreError) {
161
- writeError(res, STORE_ERROR_STATUS[error.kind], error)
162
- return
163
- }
164
- writeError(res, 500, error)
165
- }
166
-
167
- /** Read a JSON request body (undefined when too large or unparseable). */
168
- async function readJsonBody(req: IncomingMessage): Promise<Record<string, unknown> | undefined> {
169
- const chunks: Buffer[] = []
170
- let size = 0
171
- for await (const chunk of req) {
172
- const buffer = chunk as Buffer
173
- size += buffer.length
174
- if (size > MAX_JSON_BODY_BYTES) return undefined
175
- chunks.push(buffer)
176
- }
177
- try {
178
- const parsed: unknown = JSON.parse(Buffer.concat(chunks).toString('utf8'))
179
- return typeof parsed === 'object' && parsed !== null ? parsed as Record<string, unknown> : undefined
180
- } catch {
181
- return undefined
182
- }
183
- }
184
-
185
- /** URL query helper (first value, decoded). */
186
- function queryParam(url: URL, name: string): string | undefined {
187
- const value = url.searchParams.get(name)
188
- return value === null ? undefined : value
189
- }
190
-
191
- /** Async existence check (import/sync use it on the user root). */
192
- async function pathExists(path: string): Promise<boolean> {
193
- try {
194
- await stat(path)
195
- return true
196
- } catch {
197
- return false
198
- }
199
- }
200
-
201
- /** Sources the hub may toggle (the user-level filesystem roots). */
202
- function isWritableSource(source: string): source is WritableRoot {
203
- return source === 'user-dsh' || source === 'user-agents'
204
- }
205
-
206
- /** Lookup options the hub forwards to the registry (cwd selects project roots). */
207
- export interface SkillLookupLike {
208
- cwd?: string
209
- }
210
-
211
- /** Route family dependencies (narrow structural view of ctx.skills for tests). */
212
- export interface SkillHubRouteDeps {
213
- skills: {
214
- snapshot(options?: SkillLookupLike): Promise<{ skills: SkillSummary[]; complete: boolean }>
215
- get(name: string, options?: SkillLookupLike): Promise<SkillDefinition | undefined>
216
- }
217
- store: SkillHubStore
218
- /** DSH home override (tests isolate the writable roots; defaults to ~/.dsh). */
219
- home?: string
220
- /** Invalidate the registry catalog cache after hub-driven mutations. */
221
- invalidate?: () => void
222
- /** Optional invocation-count reader; absent means the stats route reports unavailable. */
223
- stats?: SkillStatsReader
224
- /** Resolves the current plugin config; business routes honour the master switch. */
225
- config?: () => HubConfig
226
- /** Resolves the raw saved config layer (fields the user explicitly overrode). */
227
- saved?: () => Partial<HubConfig>
228
- /** Persist a config patch and re-sync plugin surfaces; resolves with the fresh config. */
229
- updateConfig?: (patch: Partial<HubConfig>) => Promise<HubConfig>
230
- }
231
-
232
- /** The resolved hub config a route sees (the shared resolver fills defaults). */
233
- function configOf(deps: SkillHubRouteDeps): HubConfig {
234
- return resolveHubConfig({}, deps.config?.() ?? {})
235
- }
236
-
237
- /** The raw saved config layer a route reports (empty when the owner omits it). */
238
- function savedOf(deps: SkillHubRouteDeps): Partial<HubConfig> {
239
- return deps.saved?.() ?? {}
240
- }
241
-
242
- /** Refuse business routes while the master switch is off (the config route stays up). */
243
- function disabledGate(deps: SkillHubRouteDeps, res: ServerResponse): boolean {
244
- if (configOf(deps).enabled) return false
245
- writeError(res, 503, 'plugin disabled: enable it from the settings card')
246
- return true
247
- }
248
-
249
- /** Resolve the home used for writable-root operations. */
250
- function homeOf(deps: SkillHubRouteDeps): string {
251
- return deps.home ?? dshHome()
252
- }
253
-
254
- /** A skill resolved as a hub-writable user-level file. */
255
- interface WritableSkill {
256
- skill: SkillDefinition
257
- /** Absolute discovery-file path (guaranteed present by the resolver). */
258
- path: string
259
- /** The writable root containing the file. */
260
- root: WritableRoot
261
- }
262
-
263
- /** A refusal with the exact HTTP error the caller should answer with. */
264
- interface WritableSkillRefusal {
265
- status: number
266
- error: string
267
- }
268
-
269
- type WritableSkillResult = { ok: true } & WritableSkill | { ok: false } & WritableSkillRefusal
270
-
271
- /**
272
- * Resolve a skill by name as something the hub may write, or the exact
273
- * refusal the caller should answer with. Owns the guard sequence shared by
274
- * toggle, toggle-batch, and skill/delete: registry lookup, writable source,
275
- * writable file, and path containment inside a user root.
276
- */
277
- async function resolveWritableSkill(deps: SkillHubRouteDeps, name: string, cwd?: string): Promise<WritableSkillResult> {
278
- const lookup = cwd !== undefined && cwd !== '' ? { cwd } : undefined
279
- const skill = await deps.skills.get(name, lookup)
280
- if (skill === undefined) return { ok: false, status: 404, error: 'skill not found: ' + name }
281
- if (!isWritableSource(skill.source)) {
282
- return { ok: false, status: 409, error: 'source "' + skill.source + '" is managed outside the hub (read-only)' }
283
- }
284
- if (skill.path === undefined) return { ok: false, status: 409, error: 'provider-managed skill has no writable file' }
285
- const root = rootOfPath(skill.path, homeOf(deps))
286
- if (root === undefined) return { ok: false, status: 409, error: 'skill path is outside the hub writable roots' }
287
- return { ok: true, skill, path: skill.path, root }
288
- }
289
-
290
- /** 系统集合组 + 用户 tag + origin 映射(groups 路由的数据源)。 */
291
- async function buildGroups(deps: SkillHubRouteDeps): Promise<GroupsResponse> {
292
- const [tags, origins, collectionOrder, sourceGroupOrder] = await Promise.all([deps.store.listTags(), deps.store.listOrigins(), deps.store.getCollectionOrder(), deps.store.getSourceGroupOrder()])
293
- const byCollection = new Map<string, string[]>()
294
- for (const [skillName, origin] of Object.entries(origins)) {
295
- const list = byCollection.get(origin)
296
- if (list === undefined) byCollection.set(origin, [skillName])
297
- else list.push(skillName)
298
- }
299
- const orderIndex = new Map(collectionOrder.map((name, i) => [name, i] as const))
300
- const collections: CollectionGroup[] = [...byCollection.entries()]
301
- .map(([name, skillNames]) => ({ name, skillNames: [...skillNames].sort((a, b) => a.localeCompare(b)) }))
302
- .sort((a, b) => {
303
- const ai = orderIndex.has(a.name) ? orderIndex.get(a.name)! : Infinity
304
- const bi = orderIndex.has(b.name) ? orderIndex.get(b.name)! : Infinity
305
- if (ai !== bi) return ai - bi
306
- return a.name.localeCompare(b.name)
307
- })
308
- return { ok: true, tags, collections, origins, ...(sourceGroupOrder.length > 0 ? { sourceGroupOrder } : {}), ...(collectionOrder.length > 0 ? { collectionOrder } : {}) }
309
- }
310
-
311
- /** 目录中存在的技能名集合(tag 成员校验用):启用目录 ∪ 已禁用名单,避免成员因禁用而丢失。 */
312
- async function knownSkillNames(deps: SkillHubRouteDeps): Promise<Set<string>> {
313
- const snapshot = await deps.skills.snapshot()
314
- const names = new Set(snapshot.skills.map((skill) => skill.name))
315
- for (const disabled of await deps.store.listDisabled()) names.add(disabled.name)
316
- return names
317
- }
318
-
319
- /** 已知工作区条目(dsh 的 workspace.json 表)。 */
320
- interface WorkspaceEntry {
321
- path: string
322
- title: string
323
- }
324
-
325
- /**
326
- * 读取 dsh 的已知工作区清单(~/.dsh/storages/workspace.json 的
327
- * tables.workspaces 表)。面板默认视图据此合并所有工作区的项目技能;
328
- * 文件缺失/损坏时返回空清单(回退为仅用户级视图)。
329
- */
330
- async function workspaceEntries(home: string): Promise<WorkspaceEntry[]> {
331
- try {
332
- const raw = await readFile(join(home, 'storages', 'workspace.json'), 'utf8')
333
- const parsed: unknown = JSON.parse(raw)
334
- const tables = typeof parsed === 'object' && parsed !== null
335
- ? (parsed as Record<string, unknown>).tables as Record<string, unknown> | undefined
336
- : undefined
337
- const workspaces = tables !== undefined && typeof tables === 'object'
338
- ? (tables as Record<string, unknown>).workspaces as Record<string, unknown> | undefined
339
- : undefined
340
- const entries: WorkspaceEntry[] = []
341
- if (workspaces !== undefined && typeof workspaces === 'object') {
342
- for (const record of Object.values(workspaces)) {
343
- const entry = record as { path?: unknown; title?: unknown } | null
344
- if (entry !== null && typeof entry === 'object' && typeof entry.path === 'string' && entry.path !== '') {
345
- entries.push({
346
- path: entry.path,
347
- title: typeof entry.title === 'string' && entry.title !== '' ? entry.title : entry.path,
348
- })
349
- }
350
- }
351
- }
352
- return entries
353
- } catch {
354
- return []
355
- }
356
- }
357
-
358
- /**
359
- * Build the full catalog response (shared by catalog/toggle/create handlers).
360
- * 显式 cwd 只看该工作区;否则合并所有已知工作区(workspace.json)的项目技能
361
- * + 用户级技能,同名技能先到先得;没有任何工作区时回退为仅用户级视图。
362
- */
363
- async function buildCatalog(deps: SkillHubRouteDeps, cwd?: string): Promise<CatalogResponse> {
364
- const home = homeOf(deps)
365
- let workspaces: WorkspaceEntry[]
366
- if (cwd !== undefined && cwd !== '') {
367
- workspaces = [{ path: cwd, title: cwd }]
368
- } else {
369
- workspaces = await workspaceEntries(home)
370
- if (workspaces.length === 0) workspaces = [{ path: '', title: '' }]
371
- }
372
- // Project skills with same name but different workspace are distinct entries (grouped by workspace in SourcesView), so key includes workspace for project sources.
373
- const byKey = new Map<string, { skill: SkillSummary; workspace?: string; workspaceTitle?: string }>()
374
- // Distinct identities per logical key: the same skill reappearing across
375
- // workspace snapshots (same source+provider) is one skill, not a duplicate.
376
- // Only different source/provider identities sharing a name are ambiguous.
377
- const identitiesByKey = new Map<string, Set<string>>()
378
- let complete = true
379
- for (const ws of workspaces) {
380
- const lookup = ws.path !== '' ? { cwd: ws.path } : undefined
381
- const snapshot = await deps.skills.snapshot(lookup)
382
- if (!snapshot.complete) complete = false
383
- for (const skill of snapshot.skills) {
384
- const logicalKey = isProjectSource(skill.source) && ws.path !== '' ? `${skill.name}\0${ws.path}\0${skill.source}` : skill.name
385
- let identities = identitiesByKey.get(logicalKey)
386
- if (identities === undefined) {
387
- identities = new Set()
388
- identitiesByKey.set(logicalKey, identities)
389
- }
390
- identities.add(skill.source + '\0' + skill.provider)
391
- if (byKey.has(logicalKey)) continue
392
- byKey.set(logicalKey, {
393
- skill,
394
- ...(isProjectSource(skill.source) && ws.path !== '' ? { workspace: ws.path, workspaceTitle: ws.title } : {}),
395
- })
396
- }
397
- }
398
- const disabled = await deps.store.listDisabled()
399
- const byName = byKey
400
- // A hub-disabled record whose name is also enabled elsewhere hides one
401
- // identity behind the toggle; flag it as well.
402
- for (const d of disabled) {
403
- let identities = identitiesByKey.get(d.name)
404
- if (identities === undefined) {
405
- identities = new Set()
406
- identitiesByKey.set(d.name, identities)
407
- }
408
- identities.add('hub-disabled\0' + d.root)
409
- }
410
- const duplicateNames = [...identitiesByKey.entries()]
411
- .filter(([, identities]) => identities.size > 1)
412
- .map(([key]) => key.split('\0')[0])
413
- .filter((name, idx, arr) => arr.indexOf(name) === idx)
414
- .sort((a, b) => a.localeCompare(b))
415
- // 添加/更新时间 = 用户级技能文件的创建/修改时间(排序与详情展示用)。
416
- // snapshot 只给 SkillSummary(无 path),所以按可写根推断路径;非用户级
417
- // 来源没有稳定路径,省略字段,客户端排序会把它放到末尾。
418
- // 全部技能并发收集(面板每 5 秒轮询一次,逐个串行 stat 会让响应随技能
419
- // 数量线性变慢)。
420
- const timesByName = new Map<string, { addedAt: number; updatedAt: number }>()
421
- await Promise.all([...byName.values()].map(async ({ skill }) => {
422
- if (!isWritableSource(skill.source)) return
423
- const base = rootPath(skill.source as WritableRoot, home)
424
- for (const candidate of [join(base, skill.name, 'SKILL.md'), join(base, skill.name), join(base, skill.name + '.md')]) {
425
- try {
426
- const times = await stat(candidate)
427
- timesByName.set(skill.name, { addedAt: times.birthtimeMs, updatedAt: times.mtimeMs })
428
- return
429
- } catch {
430
- // 目录/文件不存在则尝试下一个候选路径。
431
- }
432
- }
433
- }))
434
- // UI metadata from agents/openai.yaml (codex SkillInterface) — best-effort, no error if missing.
435
- const interfaceByName = new Map<string, { displayName?: string; shortDescription?: string; brandColor?: string; iconSmall?: string; iconLarge?: string; defaultPrompt?: string }>()
436
- await Promise.all([...byName.values()].map(async ({ skill, workspace }) => {
437
- const candidates: string[] = []
438
- if (isWritableSource(skill.source as WritableRoot)) {
439
- candidates.push(join(rootPath(skill.source as WritableRoot, home), skill.name))
440
- } else if (isProjectSource(skill.source) && workspace !== undefined) {
441
- candidates.push(join(workspace, skill.source === 'project-dsh' ? '.dsh/skills' : '.agents/skills', skill.name))
442
- } else if (isProjectSource(skill.source)) {
443
- for (const ws of workspaces) if (ws.path !== '') candidates.push(join(ws.path, skill.source === 'project-dsh' ? '.dsh/skills' : '.agents/skills', skill.name))
444
- }
445
- for (const dir of candidates) {
446
- try {
447
- const iface = await readSkillInterface(dir)
448
- if (iface !== undefined) {
449
- interfaceByName.set(skill.name, iface)
450
- return
451
- }
452
- } catch {
453
- // ignore
454
- }
455
- }
456
- }))
457
- const skills: CatalogSkill[] = [...byName.values()].map(({ skill, workspace, workspaceTitle }) => {
458
- const row: CatalogSkill = {
459
- name: skill.name,
460
- description: skill.description,
461
- ...(skill.whenToUse !== undefined ? { whenToUse: skill.whenToUse } : {}),
462
- invocation: {
463
- modelInvocable: skill.invocation.modelInvocable,
464
- userInvocable: skill.invocation.userInvocable,
465
- },
466
- provider: skill.provider,
467
- writable: isWritableSource(skill.source),
468
- source: skill.source,
469
- ...(workspace !== undefined ? { workspace, workspaceTitle: workspaceTitle ?? workspace } : {}),
470
- }
471
- const times = timesByName.get(skill.name)
472
- if (times !== undefined) {
473
- row.addedAt = times.addedAt
474
- row.updatedAt = times.updatedAt
475
- }
476
- const iface = interfaceByName.get(skill.name)
477
- if (iface !== undefined) {
478
- if (iface.displayName !== undefined) row.displayName = iface.displayName
479
- if (iface.shortDescription !== undefined) row.shortDescription = iface.shortDescription
480
- if (iface.brandColor !== undefined) row.brandColor = iface.brandColor
481
- if (iface.iconSmall !== undefined) row.iconSmall = iface.iconSmall
482
- if (iface.iconLarge !== undefined) row.iconLarge = iface.iconLarge
483
- if (iface.defaultPrompt !== undefined) row.defaultPrompt = iface.defaultPrompt
484
- }
485
- return row
486
- })
487
- const diagnostics = [
488
- ...(await scanDiagnostics('user-dsh', home)),
489
- ...(await scanDiagnostics('user-agents', home)),
490
- ]
491
- return {
492
- ok: true,
493
- pluginVersion: CURRENT_VERSION,
494
- complete,
495
- skills,
496
- disabled,
497
- diagnostics,
498
- ...(duplicateNames.length > 0 ? { duplicateNames } : {}),
499
- }
500
- }
501
-
502
- /** Map a loaded definition onto the wire shape. */
503
- function toDetail(skill: SkillDefinition): SkillDetail {
504
- return {
505
- name: skill.name,
506
- description: skill.description,
507
- ...(skill.whenToUse !== undefined ? { whenToUse: skill.whenToUse } : {}),
508
- invocation: {
509
- modelInvocable: skill.invocation.modelInvocable,
510
- userInvocable: skill.invocation.userInvocable,
511
- },
512
- provider: skill.provider,
513
- ...(skill.path !== undefined ? { path: skill.path } : {}),
514
- content: skill.content,
515
- }
516
- }
517
-
518
- /** Last successful check time per repo (in-memory throttle for GitHub rate limits). */
519
- const lastSourceCheck = new Map<string, number>()
520
- /** Last successful market update check per repo (same throttle). */
521
- const lastMarketCheck = new Map<string, number>()
522
-
523
- /** Async import job (B方案:jobId + 轮询,选项2后台继续) */
524
- interface ImportJob {
525
- jobId: string
526
- repo: string
527
- ref: string
528
- total: number
529
- done: number
530
- current?: string
531
- currentFile?: string
532
- totalBytes: number
533
- downloadedBytes: number
534
- startTime: number
535
- imported: Array<{ name: string; origin: string; path: string }>
536
- skipped: Array<{ name: string; reason: 'exists' }>
537
- failed: Array<{ name: string; error: string }>
538
- status: 'running' | 'done' | 'cancelled' | 'error'
539
- error?: string
540
- controller: AbortController
541
- createdAt: number
542
- }
543
- const importJobs = new Map<string, ImportJob>()
544
- const IMPORT_JOB_TTL_MS = 5 * 60_000
545
- const IMPORT_JOB_MAX = 100
546
- function gcImportJobs(): void {
547
- if (importJobs.size > 500) importJobs.clear()
548
- const now = Date.now()
549
- for (const [id, job] of importJobs) {
550
- if (job.status !== 'running' && now - job.createdAt > IMPORT_JOB_TTL_MS) importJobs.delete(id)
551
- }
552
- if (importJobs.size > IMPORT_JOB_MAX) {
553
- const sorted = [...importJobs.entries()].sort((a, b) => a[1].createdAt - b[1].createdAt)
554
- for (let i = 0; i < sorted.length - IMPORT_JOB_MAX; i++) importJobs.delete(sorted[i][0])
555
- }
556
- }
557
-
558
- /** Replace one skill directory with a fresh download; restores the old dir on failure. */
559
- async function replaceSkillDir(targetDir: string, download: () => Promise<void>): Promise<void> {
560
- // 点前缀:发现扫描跳过点开头的目录(与导入临时目录 .<name>.import- 同一
561
- // 约定),备份即使删除失败残留下来,也不会混进目录/发现诊断,更不会
562
- // 造成同名技能重复注册。
563
- const backup = join(dirname(targetDir), '.' + basename(targetDir) + '.sync-bak-' + Date.now())
564
- await rename(targetDir, backup)
565
- try {
566
- await download()
567
- } catch (error) {
568
- await rename(backup, targetDir).catch(() => {})
569
- throw error
570
- }
571
- await rm(backup, { recursive: true, force: true })
572
- }
573
-
574
- /** What a route handler receives: fences already passed, URL/body prepared. */
575
- interface RouteContext {
576
- req: IncomingMessage
577
- res: ServerResponse
578
- /** Request URL parsed against a localhost base (query reading for GET). */
579
- url: URL
580
- /** Parsed JSON body; empty object for requests without one. */
581
- body: Record<string, unknown>
582
- }
583
-
584
- type RouteHandler = (context: RouteContext) => Promise<void>
585
-
586
- /** One declarative route: path + accepted methods + the business handler. */
587
- interface RouteSpec {
588
- path: string
589
- /** Accepted HTTP methods; anything else answers 405. */
590
- methods: readonly ('GET' | 'POST')[]
591
- /** POST requests must carry a JSON body (400 when missing/unparseable). */
592
- jsonBody?: boolean
593
- /** Skip the master-switch gate (the config route stays up while disabled). */
594
- skipGate?: boolean
595
- handler: RouteHandler
596
- }
24
+ export type { SkillHubRouteDeps, SkillLookupLike } from './routes/helpers.ts'
597
25
 
598
26
  /**
599
27
  * Build every /api/skill-hub route.
@@ -601,1121 +29,15 @@ interface RouteSpec {
601
29
  * @returns the exact-path routes.
602
30
  */
603
31
  export function makeRoutes(deps: SkillHubRouteDeps): WebRoute[] {
604
- /**
605
- * Wrap one handler in the shared request fences. Order matches the
606
- * original per-handler prologue: loopback trust → HTTP method → master
607
- * switch → JSON body. The outer catch maps errors onto the JSON error
608
- * body (preserving RepoFetchError status codes).
609
- */
610
- const route = (spec: RouteSpec): WebRoute => ({
611
- kind: 'exact',
612
- path: spec.path,
613
- handler: async (req, res) => {
614
- if (!isLoopbackRequest(req)) { writeError(res, 403, 'forbidden: loopback-only'); return }
615
- if (!spec.methods.includes(req.method as 'GET' | 'POST')) {
616
- writeError(res, 405, 'method not allowed: ' + (req.method ?? ''))
617
- return
618
- }
619
- if (spec.skipGate !== true && disabledGate(deps, res)) return
620
- const url = new URL(req.url ?? '/', 'http://localhost')
621
- let body: Record<string, unknown> = {}
622
- if (spec.jsonBody === true && req.method === 'POST') {
623
- const parsed = await readJsonBody(req)
624
- if (parsed === undefined) { writeError(res, 400, 'invalid JSON body'); return }
625
- body = parsed
626
- }
627
- try {
628
- await spec.handler({ req, res, url, body })
629
- } catch (error) {
630
- writeRouteError(res, error)
631
- }
632
- },
633
- })
634
-
635
- return [
636
- // ------------------------------------------------------------ catalog
637
- route({
638
- path: SKILL_HUB_API.catalog,
639
- methods: ['GET'],
640
- handler: async ({ res, url }) => {
641
- const cwd = queryParam(url, 'cwd')
642
- writeJson(res, 200, await buildCatalog(deps, cwd))
643
- },
644
- }),
645
- // -------------------------------------------------------------- detail
646
- route({
647
- path: SKILL_HUB_API.skill,
648
- methods: ['GET'],
649
- handler: async ({ res, url }) => {
650
- const name = queryParam(url, 'name')
651
- if (name === undefined || name === '') { writeError(res, 400, 'name query parameter is required'); return }
652
- const cwd = queryParam(url, 'cwd')
653
- // 显式 cwd 只看该工作区;否则按已知工作区逐个查找(与目录默认视图
654
- // 一致),最后回退用户级根,保证默认视图里可见的项目技能能打开详情。
655
- let skill: SkillDefinition | undefined
656
- if (cwd !== undefined && cwd !== '') {
657
- skill = await deps.skills.get(name, { cwd })
658
- } else {
659
- for (const ws of await workspaceEntries(homeOf(deps))) {
660
- skill = await deps.skills.get(name, { cwd: ws.path })
661
- if (skill !== undefined) break
662
- }
663
- if (skill === undefined) skill = await deps.skills.get(name)
664
- }
665
- if (skill === undefined) {
666
- // Hub-disabled skills live outside registry discovery (renamed to .disabled);
667
- // serve their detail straight from the sidecar record + file.
668
- const record = await deps.store.getDisabled(name)
669
- if (record !== undefined) {
670
- try {
671
- const text = await readFile(record.path, 'utf8')
672
- const parsed = parseFrontmatter(text)
673
- if (!('error' in parsed)) {
674
- const disabledDetail: SkillDetail = {
675
- name: record.name,
676
- description: parsed.value.description,
677
- ...(parsed.value.whenToUse !== undefined ? { whenToUse: parsed.value.whenToUse } : {}),
678
- invocation: { ...parsed.value.invocation },
679
- provider: 'skill-hub (disabled)',
680
- path: record.path,
681
- content: parsed.value.content,
682
- }
683
- try {
684
- const times = await stat(record.path)
685
- disabledDetail.addedAt = times.birthtimeMs
686
- disabledDetail.updatedAt = times.mtimeMs
687
- } catch {
688
- // ignore
689
- }
690
- writeJson(res, 200, { ok: true, skill: disabledDetail } satisfies SkillDetailResponse)
691
- return
692
- }
693
- } catch {
694
- // fall through to 404 below
695
- }
696
- }
697
- writeError(res, 404, 'skill not found: ' + name)
698
- return
699
- }
700
- const detail = toDetail(skill)
701
- if (skill.path !== undefined) {
702
- try {
703
- const times = await stat(skill.path)
704
- detail.addedAt = times.birthtimeMs
705
- detail.updatedAt = times.mtimeMs
706
- } catch {
707
- // 文件不可读时省略时间字段,详情页不显示这两行。
708
- }
709
- // UI metadata from agents/openai.yaml beside the skill directory (codex).
710
- try {
711
- const rb = skill.resourceBase as { kind?: string; path?: string } | undefined
712
- const dir = rb?.kind === 'directory' && typeof rb.path === 'string' ? rb.path : (skill.path.endsWith('SKILL.md') ? dirname(skill.path) : undefined)
713
- if (dir !== undefined) {
714
- const iface = await readSkillInterface(dir)
715
- if (iface !== undefined) {
716
- if (iface.displayName !== undefined) detail.displayName = iface.displayName
717
- if (iface.shortDescription !== undefined) detail.shortDescription = iface.shortDescription
718
- if (iface.brandColor !== undefined) detail.brandColor = iface.brandColor
719
- if (iface.iconSmall !== undefined) detail.iconSmall = iface.iconSmall
720
- if (iface.iconLarge !== undefined) detail.iconLarge = iface.iconLarge
721
- if (iface.defaultPrompt !== undefined) detail.defaultPrompt = iface.defaultPrompt
722
- }
723
- }
724
- } catch {
725
- // best-effort
726
- }
727
- }
728
- writeJson(res, 200, { ok: true, skill: detail } satisfies SkillDetailResponse)
729
- },
730
- }),
731
- // -------------------------------------------------------- skill/delete
732
- // 把单个技能(目录或平面文件)移入回收站(可恢复),并清理禁用记录、tag 成员与来源映射。
733
- route({
734
- path: SKILL_HUB_API.skillDelete,
735
- methods: ['POST'],
736
- jsonBody: true,
737
- handler: async ({ res, body }) => {
738
- const request = body as unknown as SkillDeleteRequest & { cwd?: string }
739
- const name = typeof request.name === 'string' ? request.name : ''
740
- if (name === '') { writeError(res, 400, 'name is required'); return }
741
- const resolved = await resolveWritableSkill(deps, name, typeof request.cwd === 'string' ? request.cwd : undefined)
742
- // 已禁用的技能不在 registry 中,resolve 会 404,这里单独处理:允许整组删除未开启的技能
743
- let trashResult: { path: string; source: string } | null = null
744
- if (!resolved.ok) {
745
- const disabled = await deps.store.getDisabled(name)
746
- if (disabled !== undefined) {
747
- // 禁用态:SKILL.md.disabled 或 *.md.disabled,直接将其所在技能整体移入回收站
748
- const isBundleDisabled = disabled.path.endsWith('SKILL.md.disabled')
749
- const sourceToTrash = isBundleDisabled ? dirname(disabled.path) : disabled.path
750
- const trashDir = join(dirname(sourceToTrash), '.trash')
751
- await mkdir(trashDir, { recursive: true })
752
- const target = join(trashDir, basename(sourceToTrash) + '-' + Date.now())
753
- await rename(sourceToTrash, target)
754
- trashResult = { path: target, source: sourceToTrash }
755
- } else {
756
- writeError(res, resolved.status, resolved.error); return
757
- }
758
- }
759
- // 入回收站前快照来源归属与场景成员:恢复时把它们加回来,否则恢复
760
- // 后的技能会丢失来源(变成「个人技能」)和场景分组。
761
- const tracked = await deps.store.getSourceForSkill(name)
762
- const tagIds = (await deps.store.listTags()).filter((tag) => tag.skillNames.includes(name)).map((tag) => tag.id)
763
- const { path, source } = trashResult ?? await trashSkill((resolved as { ok: true; path: string }).path)
764
- await deps.store.addTrash({
765
- name,
766
- path,
767
- movedAt: Date.now(),
768
- sourcePath: source,
769
- ...(tracked !== undefined
770
- ? { origin: { repo: tracked.repo, root: tracked.root, ...(tracked.ref !== undefined && tracked.ref !== '' ? { ref: tracked.ref } : {}), commitSha: tracked.commitSha } }
771
- : {}),
772
- ...(tagIds.length > 0 ? { tagIds } : {}),
773
- })
774
- await deps.store.removeDisabled(name)
775
- await deps.store.removeSkillFromSources(name)
776
- await deps.store.removeSkillFromTags(name)
777
- deps.invalidate?.()
778
- writeJson(res, 200, { ok: true, name, path } satisfies SkillDeleteResponse)
779
- },
780
- }),
781
- // -------------------------------------------------------------- toggle
782
- route({
783
- path: SKILL_HUB_API.toggle,
784
- methods: ['POST'],
785
- jsonBody: true,
786
- handler: async ({ res, body }) => {
787
- const request = body as unknown as ToggleRequest & { cwd?: string }
788
- const name = typeof request.name === 'string' ? request.name : ''
789
- if (name === '') { writeError(res, 400, 'name is required'); return }
790
- const lookup = typeof request.cwd === 'string' && request.cwd !== '' ? { cwd: request.cwd } : undefined
791
- if (request.enabled === true) {
792
- const record = await deps.store.getDisabled(name)
793
- if (record === undefined) { writeError(res, 404, 'skill is not hub-disabled: ' + name); return }
794
- try {
795
- await enableSkill(record.path)
796
- } catch (error) {
797
- // The renamed file may have vanished (external cleanup); report
798
- // precisely instead of a generic 500, like toggle-batch does.
799
- if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
800
- writeError(res, 409, 'disabled skill file is missing on disk: ' + record.path)
801
- return
802
- }
803
- throw error
804
- }
805
- await deps.store.removeDisabled(name)
806
- } else {
807
- const resolved = await resolveWritableSkill(deps, name, typeof request.cwd === 'string' ? request.cwd : undefined)
808
- if (!resolved.ok) { writeError(res, resolved.status, resolved.error); return }
809
- const disabledPath = await disableSkill(resolved.path)
810
- await deps.store.addDisabled({
811
- name,
812
- description: resolved.skill.description,
813
- path: disabledPath,
814
- root: resolved.root,
815
- disabledAt: Date.now(),
816
- })
817
- }
818
- deps.invalidate?.()
819
- writeJson(res, 200, { ok: true, catalog: await buildCatalog(deps, lookup?.cwd) } satisfies ToggleResponse)
820
- },
821
- }),
822
- // -------------------------------------------------------- toggle-batch
823
- // One write for a whole group: enables every hub-disabled name, or
824
- // disables every writable name. Skips already-target states as no-ops;
825
- // per-name failures are reported, never fatal.
826
- route({
827
- path: SKILL_HUB_API.toggleBatch,
828
- methods: ['POST'],
829
- jsonBody: true,
830
- handler: async ({ res, body }) => {
831
- const request = body as unknown as ToggleBatchRequest & { cwd?: string }
832
- const names = Array.isArray(request.names) ? request.names.filter((n): n is string => typeof n === 'string' && n !== '') : []
833
- if (names.length === 0) { writeError(res, 400, 'names must be a non-empty array'); return }
834
- const enabled = request.enabled === true
835
- const lookup = typeof request.cwd === 'string' && request.cwd !== '' ? { cwd: request.cwd } : undefined
836
- const failures: Array<{ name: string; error: string }> = []
837
- for (const name of names) {
838
- try {
839
- if (enabled) {
840
- const record = await deps.store.getDisabled(name)
841
- if (record === undefined) continue // already enabled: no-op
842
- await enableSkill(record.path)
843
- await deps.store.removeDisabled(name)
844
- } else {
845
- const resolved = await resolveWritableSkill(deps, name, typeof request.cwd === 'string' ? request.cwd : undefined)
846
- if (!resolved.ok) { failures.push({ name, error: resolved.error }); continue }
847
- const disabledPath = await disableSkill(resolved.path)
848
- await deps.store.addDisabled({ name, description: resolved.skill.description, path: disabledPath, root: resolved.root, disabledAt: Date.now() })
849
- }
850
- } catch (error) {
851
- failures.push({ name, error: error instanceof Error ? error.message : String(error) })
852
- }
853
- }
854
- deps.invalidate?.()
855
- writeJson(res, 200, { ok: true, catalog: await buildCatalog(deps, lookup?.cwd), failures } satisfies ToggleBatchResponse)
856
- },
857
- }),
858
- // -------------------------------------------------------------- create
859
- route({
860
- path: SKILL_HUB_API.create,
861
- methods: ['POST'],
862
- jsonBody: true,
863
- handler: async ({ res, body }) => {
864
- const request = body as unknown as CreateRequest
865
- const name = typeof request.name === 'string' ? request.name.trim() : ''
866
- if (!isSkillName(name)) { writeError(res, 400, 'skill name must be kebab-case (lowercase letters, digits, dashes)'); return }
867
- const root: WritableRoot = request.root ?? 'user-dsh'
868
- if (root !== 'user-dsh' && root !== 'user-agents') { writeError(res, 400, 'root must be user-dsh or user-agents'); return }
869
- const existing = await deps.skills.get(name)
870
- if (existing !== undefined) { writeError(res, 409, 'skill name already exists: ' + name); return }
871
- if (await deps.store.getDisabled(name) !== undefined) { writeError(res, 409, 'skill name is disabled: re-enable it from the disabled list first'); return }
872
- // A directory may exist without producing a registry entry (invalid
873
- // frontmatter — exactly what the diagnostics section reports).
874
- // Refuse to overwrite it instead of silently truncating its SKILL.md.
875
- const target = join(rootPath(root, homeOf(deps)), name)
876
- if (await pathExists(target)) {
877
- writeError(res, 409, 'skill directory already exists on disk: ' + name + ' (check the discovery diagnostics)')
878
- return
879
- }
880
- const path = await createSkill(root, name, typeof request.description === 'string' ? request.description : '', homeOf(deps))
881
- // 新技能自动归入默认场景(「通用」)。
882
- const defaultTag = await deps.store.getDefaultTag()
883
- if (defaultTag !== undefined) await deps.store.addSkillToTag(defaultTag.id, name)
884
- deps.invalidate?.()
885
- writeJson(res, 201, { ok: true, path, root } satisfies CreateResponse)
886
- },
887
- }),
888
- // ---------------------------------------------------------------- stats
889
- route({
890
- path: SKILL_HUB_API.stats,
891
- methods: ['GET'],
892
- handler: async ({ res }) => {
893
- if (deps.stats === undefined) {
894
- writeJson(res, 200, { ok: true, available: false, stats: [] } satisfies import('./protocol.ts').StatsResponse)
895
- return
896
- }
897
- const stats = await deps.stats()
898
- writeJson(res, 200, { ok: true, available: true, stats } satisfies import('./protocol.ts').StatsResponse)
899
- },
900
- }),
901
- // --------------------------------------------------------------- config
902
- // The config route stays mounted even with the master switch off, so the
903
- // settings card can always read and re-enable the hub.
904
- route({
905
- path: SKILL_HUB_API.config,
906
- methods: ['GET', 'POST'],
907
- jsonBody: true,
908
- skipGate: true,
909
- handler: async ({ req, res, body }) => {
910
- if (req.method === 'GET') {
911
- writeJson(res, 200, { ok: true, pluginVersion: CURRENT_VERSION, config: configOf(deps), saved: savedOf(deps) } satisfies ConfigResponse)
912
- return
913
- }
914
- const raw = body
915
- const patch: Partial<HubConfig> = {}
916
- if (raw.enabled !== undefined) {
917
- if (raw.enabled === null) patch.enabled = undefined
918
- else if (typeof raw.enabled !== 'boolean') { writeError(res, 400, 'enabled must be a boolean or null'); return }
919
- else patch.enabled = raw.enabled
920
- }
921
- if (raw.announceToAgent !== undefined) {
922
- if (raw.announceToAgent === null) patch.announceToAgent = undefined
923
- else if (typeof raw.announceToAgent !== 'boolean') { writeError(res, 400, 'announceToAgent must be a boolean or null'); return }
924
- else patch.announceToAgent = raw.announceToAgent
925
- }
926
- for (const key of ['showUseCount', 'showUseTime', 'showGroupSummary'] as const) {
927
- const value = raw[key]
928
- if (value === undefined) continue
929
- if (value === null) { patch[key] = undefined; continue }
930
- if (typeof value !== 'boolean') { writeError(res, 400, key + ' must be a boolean or null'); return }
931
- patch[key] = value
932
- }
933
- for (const key of ['dotModelColor', 'dotUserColor'] as const) {
934
- const value = raw[key]
935
- if (value === undefined) continue
936
- if (value === null) { patch[key] = undefined; continue }
937
- if (typeof value !== 'string' || !HEX_COLOR_RE.test(value)) { writeError(res, 400, key + ' must be a #rrggbb color or null'); return }
938
- patch[key] = value
939
- }
940
- let config: HubConfig
941
- if (deps.updateConfig === undefined) {
942
- const merged: HubConfig = { ...configOf(deps) }
943
- for (const [key, value] of Object.entries(patch) as Array<[keyof HubConfig, boolean | string | undefined]>) {
944
- if (value === undefined) delete merged[key]
945
- else (merged as unknown as Record<string, unknown>)[key] = value
946
- }
947
- config = merged
948
- } else {
949
- config = await deps.updateConfig(patch)
950
- }
951
- writeJson(res, 200, { ok: true, pluginVersion: CURRENT_VERSION, config, saved: savedOf(deps) } satisfies ConfigResponse)
952
- },
953
- }),
954
- // --------------------------------------------------------------- market
955
- // Market sources: the user adds repo slugs; each source can
956
- // be scanned through /repo and imported through /repo/import.
957
- route({
958
- path: SKILL_HUB_API.market,
959
- methods: ['GET'],
960
- handler: async ({ res }) => {
961
- writeJson(res, 200, { ok: true, repos: await deps.store.listMarketSources() } satisfies MarketSourcesResponse)
962
- },
963
- }),
964
- route({
965
- path: SKILL_HUB_API.marketSource,
966
- methods: ['POST'],
967
- jsonBody: true,
968
- handler: async ({ res, body }) => {
969
- const request = body as unknown as MarketSourceRequest
970
- const input = typeof request.repo === 'string' ? request.repo.trim() : ''
971
- const parsed = normalizeRepoInput(input)
972
- if (parsed === null) { writeError(res, 400, 'repo must be owner/repo or a github.com URL'); return }
973
- // An explicit @ref pins the source immediately (e.g. repo@v1.2.3);
974
- // otherwise the first scan resolves latest-release / branch choice.
975
- const ref = parsed.ref !== undefined ? parsed.ref : undefined
976
- writeJson(res, 200, { ok: true, repos: await deps.store.addMarketSource(repoSlug(parsed), ref) } satisfies MarketSourceResponse)
977
- },
978
- }),
979
- route({
980
- path: SKILL_HUB_API.marketSourceDelete,
981
- methods: ['POST'],
982
- jsonBody: true,
983
- handler: async ({ res, body }) => {
984
- const request = body as unknown as MarketSourceRequest
985
- const input = typeof request.repo === 'string' ? request.repo.trim() : ''
986
- const parsed = normalizeRepoInput(input)
987
- if (parsed === null) { writeError(res, 400, 'repo must be owner/repo or a github.com URL'); return }
988
- writeJson(res, 200, { ok: true, repos: await deps.store.removeMarketSource(repoSlug(parsed)) } satisfies MarketSourceResponse)
989
- },
990
- }),
991
- // ------------------------------------------------------- market/source/ref
992
- // Pin a market source to an explicit ref (branch chosen after a scan
993
- // found no release, or a user-specified tag).
994
- route({
995
- path: SKILL_HUB_API.marketSourceRef,
996
- methods: ['POST'],
997
- jsonBody: true,
998
- handler: async ({ res, body }) => {
999
- const request = body as unknown as MarketSourceRefRequest
1000
- const parsed = normalizeRepoInput(typeof request.repo === 'string' ? request.repo : '')
1001
- const ref = typeof request.ref === 'string' ? request.ref.trim() : ''
1002
- if (parsed === null) { writeError(res, 400, 'repo must be owner/repo or a github.com URL'); return }
1003
- if (ref === '') { writeError(res, 400, 'ref is required'); return }
1004
- const record = await deps.store.setMarketSourceRef(repoSlug(parsed), ref)
1005
- if (record === undefined) { writeError(res, 404, 'market source not found: ' + repoSlug(parsed)); return }
1006
- writeJson(res, 200, { ok: true, repos: await deps.store.listMarketSources() } satisfies MarketSourceResponse)
1007
- },
1008
- }),
1009
- // -------------------------------------------- market/source/versions
1010
- // Version picker data: release tags + branch names for one market source.
1011
- route({
1012
- path: SKILL_HUB_API.marketSourceVersions,
1013
- methods: ['GET'],
1014
- handler: async ({ res, url }) => {
1015
- const input = queryParam(url, 'repo') ?? ''
1016
- const parsed = normalizeRepoInput(input)
1017
- if (parsed === null) { writeError(res, 400, 'repo must be owner/repo or a github.com URL'); return }
1018
- const repo = repoSlug(parsed)
1019
- const source = await deps.store.getMarketSource(repo)
1020
- if (source === undefined) { writeError(res, 404, 'market source not found: ' + repo); return }
1021
- const [releases, branches] = await Promise.all([listRepoReleases(repo), listRepoBranches(repo)])
1022
- writeJson(res, 200, {
1023
- ok: true,
1024
- repo,
1025
- ...(source.ref !== undefined && source.ref !== '' ? { current: source.ref } : {}),
1026
- releases,
1027
- branches,
1028
- } satisfies MarketSourceVersionsResponse)
1029
- },
1030
- }),
1031
- // ------------------------------------------------------- market/check
1032
- // Update check over every market source: compares the pinned ref's
1033
- // commit against the recorded baseline and surfaces newer releases.
1034
- // 5-minute throttle mirrors the source check.
1035
- route({
1036
- path: SKILL_HUB_API.marketCheck,
1037
- methods: ['GET'],
1038
- handler: async ({ res }) => {
1039
- const sources = await deps.store.listMarketSources()
1040
- if (lastMarketCheck.size > 500) lastMarketCheck.clear()
1041
- const results: MarketCheckResponse['results'] = []
1042
- for (const source of sources) {
1043
- const base = { repo: source.repo, ...(source.ref !== undefined ? { ref: source.ref } : {}) }
1044
- const now = Date.now()
1045
- const last = lastMarketCheck.get(source.repo) ?? 0
1046
- if (now - last < MIN_CHECK_INTERVAL_MS) {
1047
- results.push({ ...base, updateAvailable: false, commitSha: source.commitSha ?? '', throttled: true })
1048
- continue
1049
- }
1050
- try {
1051
- const latestTag = await getLatestReleaseTag(source.repo)
1052
- if (source.ref === undefined) {
1053
- // Not pinned yet: report the latest release so the UI can
1054
- // suggest pinning, but never claim an update.
1055
- results.push({ ...base, updateAvailable: false, commitSha: source.commitSha ?? '', ...(latestTag !== undefined ? { latestTag } : {}) })
1056
- continue
1057
- }
1058
- const latest = await getLatestCommit(source.repo, source.ref)
1059
- lastMarketCheck.set(source.repo, now)
1060
- const commitMoved = source.commitSha !== undefined && latest.commitSha !== source.commitSha
1061
- const newRelease = latestTag !== undefined && latestTag !== source.ref
1062
- results.push({ ...base, updateAvailable: commitMoved || newRelease, commitSha: latest.commitSha, ...(newRelease ? { latestTag } : {}) })
1063
- } catch (error) {
1064
- results.push({ ...base, updateAvailable: false, commitSha: source.commitSha ?? '', error: error instanceof Error ? error.message : String(error) })
1065
- }
1066
- }
1067
- writeJson(res, 200, { ok: true, results } satisfies MarketCheckResponse)
1068
- },
1069
- }),
1070
- // ------------------------------------------------------- market/source/sync
1071
- // Align a market source to its version: resolve the pinned ref (or pick
1072
- // the latest release when unpinned), record the commit, and propagate the
1073
- // ref to the matching tracked source record so source checks/syncs follow
1074
- // it. Returns the local skills tracked from this repo so the UI can offer
1075
- // a batch update.
1076
- route({
1077
- path: SKILL_HUB_API.marketSync,
1078
- methods: ['POST'],
1079
- jsonBody: true,
1080
- handler: async ({ res, body }) => {
1081
- const request = body as unknown as MarketSourceRefRequest
1082
- const parsed = normalizeRepoInput(typeof request.repo === 'string' ? request.repo : '')
1083
- if (parsed === null) { writeError(res, 400, 'repo must be owner/repo or a github.com URL'); return }
1084
- const repo = repoSlug(parsed)
1085
- const source = await deps.store.getMarketSource(repo)
1086
- if (source === undefined) { writeError(res, 404, 'market source not found: ' + repo); return }
1087
- // Resolve the ref: pinned ref wins; otherwise auto-adopt the latest
1088
- // release; a repo without releases must be pinned via the branch picker first.
1089
- let ref = source.ref
1090
- if (ref === undefined || ref === '') {
1091
- const latestTag = await getLatestReleaseTag(repo)
1092
- if (latestTag === undefined) { writeError(res, 409, 'repo has no release — pick a branch first'); return }
1093
- ref = latestTag
1094
- await deps.store.setMarketSourceRef(repo, ref)
1095
- }
1096
- const latest = await getLatestCommit(repo, ref)
1097
- await deps.store.setMarketSourceCommit(repo, latest.commitSha)
1098
- // Propagate the ref to the tracked source record so checks follow it.
1099
- await deps.store.setSourceRef(repo, ref)
1100
- const tracked = await deps.store.getSource(repo)
1101
- writeJson(res, 200, {
1102
- ok: true,
1103
- repo,
1104
- ref,
1105
- commitSha: latest.commitSha,
1106
- skills: tracked !== undefined ? [...tracked.skills] : [],
1107
- } satisfies MarketSyncResponse)
1108
- },
1109
- }),
1110
- // ---------------------------------------------------------------- repo
1111
- // Discover importable skills in a public GitHub repo (any top-level root containing SKILL.md).
1112
- route({
1113
- path: SKILL_HUB_API.repo,
1114
- methods: ['GET'],
1115
- handler: async ({ res, url }) => {
1116
- const input = queryParam(url, 'repo') ?? ''
1117
- const parsed = normalizeRepoInput(input)
1118
- if (parsed === null) { writeError(res, 400, 'repo must be owner/repo or a github.com URL'); return }
1119
- const repo = repoSlug(parsed)
1120
- // Version resolution: explicit @ref wins; then the market source's
1121
- // pinned ref (branch picked earlier); then the latest release;
1122
- // a repo without releases must be pinned to a branch by the user
1123
- // (branches returned, ref null).
1124
- let ref = parsed.ref
1125
- if (ref === undefined || ref === '') {
1126
- const pinned = await deps.store.getMarketSource(repo)
1127
- if (pinned?.ref !== undefined && pinned.ref !== '') {
1128
- ref = pinned.ref
1129
- }
1130
- }
1131
- if (ref === undefined || ref === '') {
1132
- const releaseTag = await getLatestReleaseTag(repo)
1133
- if (releaseTag !== undefined) {
1134
- ref = releaseTag
1135
- // Auto-pin an existing but unpinned market source to the resolved
1136
- // release, so the row shows the tracked version from the first scan.
1137
- const existing = await deps.store.getMarketSource(repo)
1138
- if (existing !== undefined && (existing.ref === undefined || existing.ref === '')) {
1139
- await deps.store.setMarketSourceRef(repo, releaseTag)
1140
- }
1141
- } else {
1142
- const branches = await listRepoBranches(repo)
1143
- if (branches.length === 0) { writeError(res, 404, 'repo has no branches to scan'); return }
1144
- writeJson(res, 200, { ok: true, repo, ref: null, branches, entries: [] } satisfies RepoDiscoverResponse)
1145
- return
1146
- }
1147
- }
1148
- const { ref: resolvedRef, tree, truncated } = await loadRepoTree(repo, ref)
1149
- const existing = await knownSkillNames(deps)
1150
- const entries = discoverRepoEntries(tree, repo, existing)
1151
- writeJson(res, 200, { ok: true, repo, ref: resolvedRef, entries, ...(truncated ? { truncated: true } : {}) } satisfies RepoDiscoverResponse)
1152
- },
1153
- }),
1154
- // ----------------------------------------------------------- repo/import B方案 Job
1155
- // Install selected repo skills — now async job (B方案:轮询 + 选项2后台继续)
1156
- // POST returns jobId instantly (<500ms), GET /progress polls for done.
1157
- route({
1158
- path: SKILL_HUB_API.repoImport,
1159
- methods: ['POST'],
1160
- jsonBody: true,
1161
- handler: async ({ res, body }) => {
1162
- const request = body as unknown as RepoImportRequest
1163
- const input = typeof request.repo === 'string' ? request.repo.trim() : ''
1164
- const paths = Array.isArray(request.paths) ? request.paths.filter((path): path is string => typeof path === 'string' && path !== '') : []
1165
- if (paths.length === 0) { writeError(res, 400, 'paths must be a non-empty array'); return }
1166
- const parsed = normalizeRepoInput(input)
1167
- if (parsed === null) { writeError(res, 400, 'repo must be owner/repo or a github.com URL'); return }
1168
- const repo = repoSlug(parsed)
1169
- const ref = typeof request.ref === 'string' && request.ref !== '' ? request.ref : parsed.ref
1170
- const { ref: resolvedRef, tree } = await loadRepoTree(repo, ref)
1171
- const existing = await knownSkillNames(deps)
1172
- const entries = discoverRepoEntries(tree, repo, existing)
1173
- // truncated tree is still usable for the selected paths the user already picked from the prior discover response
1174
- const byPath = new Map(entries.map((entry) => [entry.path, entry]))
1175
- const selected = paths.map((path) => byPath.get(path)).filter((entry): entry is NonNullable<typeof entry> => entry !== undefined)
1176
- if (selected.length === 0) { writeError(res, 400, 'no matching skills for selected paths'); return }
1177
- if (selected.length > 500) { writeError(res, 400, 'select at most 500 skills per import'); return }
1178
- const totalBytes = selected.reduce((sum, entry) => sum + entry.totalBytes, 0)
1179
- if (totalBytes > 200 * 1024 * 1024) { writeError(res, 400, 'selected skills exceed the 200MB import limit'); return }
1180
-
1181
- let commitSha = ''
1182
- try {
1183
- const latest = await getLatestCommit(repo, resolvedRef)
1184
- commitSha = latest.commitSha
1185
- } catch {
1186
- // empty snapshot ok
1187
- }
1188
-
1189
- const jobId = 'imp_' + randomUUID().slice(0, 8)
1190
- const controller = new AbortController()
1191
- const now = Date.now()
1192
- const job: ImportJob = {
1193
- jobId,
1194
- repo,
1195
- ref: resolvedRef,
1196
- total: selected.length,
1197
- done: 0,
1198
- totalBytes,
1199
- downloadedBytes: 0,
1200
- startTime: now,
1201
- imported: [],
1202
- skipped: [],
1203
- failed: [],
1204
- status: 'running',
1205
- controller,
1206
- createdAt: now,
1207
- }
1208
- gcImportJobs()
1209
- importJobs.set(jobId, job)
1210
-
1211
- // 启动时顺手清理一次残留临时目录(best-effort,不阻塞响应)
1212
- const targetRootEarly = rootPath('user-dsh', homeOf(deps))
1213
- void cleanupLeftoverImportDirs(targetRootEarly).catch(() => {})
1214
-
1215
- // 后台执行,不阻塞响应(选项2:关面板也继续跑,唯有 /cancel 才 abort)
1216
- void (async () => {
1217
- const targetRoot = rootPath('user-dsh', homeOf(deps))
1218
- await mkdir(targetRoot, { recursive: true })
1219
- const defaultTag = await deps.store.getDefaultTag()
1220
- let needInvalidate = false
1221
- for (const entry of selected) {
1222
- if (controller.signal.aborted) break
1223
- job.current = entry.name
1224
- job.currentFile = entry.dir + '/SKILL.md'
1225
- if (entry.existing) {
1226
- job.skipped.push({ name: entry.name, reason: 'exists' })
1227
- job.downloadedBytes += entry.totalBytes
1228
- job.done += 1
1229
- continue
1230
- }
1231
- if (await pathExists(join(targetRoot, entry.name))) {
1232
- job.skipped.push({ name: entry.name, reason: 'exists' })
1233
- job.downloadedBytes += entry.totalBytes
1234
- job.done += 1
1235
- continue
1236
- }
1237
- if (controller.signal.aborted) break
1238
- const files = collectRepoSkillFiles(tree, entry.dir)
1239
- try {
1240
- const result = await downloadRepoSkill(repo, resolvedRef, entry, files, targetRoot, fetch, controller.signal, (bytes, file) => {
1241
- job.downloadedBytes += bytes
1242
- job.currentFile = entry.dir + '/' + file
1243
- })
1244
- await deps.store.addSourceSkill(repo, entry.root, commitSha, resolvedRef, entry.name)
1245
- await deps.store.mergeSourceManifest(repo, skillManifest(tree, entry.dir), entry.dir)
1246
- if (defaultTag !== undefined) await deps.store.addSkillToTag(defaultTag.id, entry.name)
1247
- job.imported.push({ name: entry.name, origin: entry.origin, path: result.skillPath })
1248
- needInvalidate = true
1249
- } catch (error) {
1250
- if (isAbortError(error) || controller.signal.aborted) {
1251
- // 取消时不记为 failed,保留已完成的 imported/skipped,直接跳出
1252
- break
1253
- }
1254
- job.failed.push({ name: entry.name, error: error instanceof Error ? error.message : String(error) })
1255
- } finally {
1256
- job.done += 1
1257
- }
1258
- }
1259
- if (controller.signal.aborted) {
1260
- job.status = 'cancelled'
1261
- job.current = undefined
1262
- job.currentFile = undefined
1263
- } else {
1264
- job.status = 'done'
1265
- job.current = undefined
1266
- job.currentFile = undefined
1267
- // 确保字节进度最终对齐(避免浮点/并发尾差)
1268
- job.downloadedBytes = job.totalBytes
1269
- }
1270
- if (needInvalidate) deps.invalidate?.()
1271
- })().catch((error) => {
1272
- job.status = 'error'
1273
- job.error = error instanceof Error ? error.message : String(error)
1274
- job.current = undefined
1275
- job.currentFile = undefined
1276
- })
1277
-
1278
- writeJson(res, 200, { ok: true, jobId, total: selected.length, totalBytes } satisfies RepoImportResponse)
1279
- },
1280
- }),
1281
- // 进度轮询(含字节级进度和速度)
1282
- route({
1283
- path: SKILL_HUB_API.repoImportProgress,
1284
- methods: ['GET'],
1285
- handler: async ({ res, url }) => {
1286
- const jobId = queryParam(url, 'jobId') ?? ''
1287
- if (jobId === '') { writeError(res, 400, 'jobId is required'); return }
1288
- const job = importJobs.get(jobId)
1289
- if (job === undefined) { writeError(res, 404, 'import job not found: ' + jobId); return }
1290
- const elapsedSec = Math.max(0.5, (Date.now() - job.startTime) / 1000)
1291
- const bytesPerSecond = job.status === 'running' ? Math.round(job.downloadedBytes / elapsedSec) : undefined
1292
- writeJson(res, 200, {
1293
- ok: true,
1294
- jobId: job.jobId,
1295
- status: job.status,
1296
- total: job.total,
1297
- done: job.done,
1298
- ...(job.current !== undefined ? { current: job.current } : {}),
1299
- ...(job.currentFile !== undefined ? { currentFile: job.currentFile } : {}),
1300
- totalBytes: job.totalBytes,
1301
- downloadedBytes: job.downloadedBytes,
1302
- ...(bytesPerSecond !== undefined ? { bytesPerSecond } : {}),
1303
- imported: [...job.imported],
1304
- skipped: [...job.skipped],
1305
- failed: [...job.failed],
1306
- ...(job.error !== undefined ? { error: job.error } : {}),
1307
- } satisfies RepoImportProgressResponse)
1308
- },
1309
- }),
1310
- // 取消任务(选项2:唯有点取消才停)
1311
- route({
1312
- path: SKILL_HUB_API.repoImportCancel,
1313
- methods: ['POST'],
1314
- jsonBody: true,
1315
- handler: async ({ res, body }) => {
1316
- const request = body as unknown as RepoImportCancelRequest
1317
- const jobId = typeof request.jobId === 'string' ? request.jobId : ''
1318
- if (jobId === '') { writeError(res, 400, 'jobId is required'); return }
1319
- const job = importJobs.get(jobId)
1320
- if (job === undefined) { writeError(res, 404, 'import job not found: ' + jobId); return }
1321
- if (job.status === 'running') {
1322
- job.controller.abort()
1323
- job.status = 'cancelled'
1324
- }
1325
- writeJson(res, 200, { ok: true, jobId: job.jobId, status: job.status as 'cancelled' | 'done' } satisfies RepoImportCancelResponse)
1326
- },
1327
- }),
1328
- // -------------------------------------------------------------- update
1329
- // 自身更新检查:查询 GitHub latest release。
1330
- route({
1331
- path: SKILL_HUB_API.update,
1332
- methods: ['GET'],
1333
- handler: async ({ res }) => {
1334
- writeJson(res, 200, await checkLatestRelease())
1335
- },
1336
- }),
1337
- // -------------------------------------------------------------- groups
1338
- // 用户 tag 分组 + 系统集合组 + origin 映射(前端分组栏的数据源)。
1339
- route({
1340
- path: SKILL_HUB_API.groups,
1341
- methods: ['GET'],
1342
- handler: async ({ res }) => {
1343
- writeJson(res, 200, await buildGroups(deps))
1344
- },
1345
- }),
1346
- // ----------------------------------------------------------------- tag
1347
- // 新建(缺省 id)或重命名(带 id)一个用户 tag 分组。
1348
- route({
1349
- path: SKILL_HUB_API.tag,
1350
- methods: ['POST'],
1351
- jsonBody: true,
1352
- handler: async ({ res, body }) => {
1353
- const name = typeof body.name === 'string' ? body.name.trim() : ''
1354
- if (name === '') { writeError(res, 400, 'tag name is required'); return }
1355
- const id = typeof body.id === 'string' && body.id !== '' ? body.id : undefined
1356
- await deps.store.saveTag({ id, name })
1357
- writeJson(res, 200, { ok: true, tags: await deps.store.listTags() } satisfies TagSaveResponse)
1358
- },
1359
- }),
1360
- // ----------------------------------------------------------- tag/delete
1361
- route({
1362
- path: SKILL_HUB_API.tagDelete,
1363
- methods: ['POST'],
1364
- jsonBody: true,
1365
- handler: async ({ res, body }) => {
1366
- const id = typeof (body as unknown as TagDeleteRequest).id === 'string' ? (body as unknown as TagDeleteRequest).id : ''
1367
- if (id === '') { writeError(res, 400, 'tag id is required'); return }
1368
- const tag = await deps.store.getTag(id)
1369
- if (tag?.default === true) { writeError(res, 409, 'the default scene cannot be deleted'); return }
1370
- await deps.store.deleteTag(id)
1371
- writeJson(res, 200, { ok: true, tags: await deps.store.listTags() } satisfies TagDeleteResponse)
1372
- },
1373
- }),
1374
- // ---------------------------------------------------------- tag/members
1375
- // 直接设置某 tag 的完整成员列表;后端只保留目录中实际存在的技能名。
1376
- route({
1377
- path: SKILL_HUB_API.tagMembers,
1378
- methods: ['POST'],
1379
- jsonBody: true,
1380
- handler: async ({ res, body }) => {
1381
- const request = body as unknown as TagMembersRequest
1382
- const id = typeof request.id === 'string' ? request.id : ''
1383
- if (id === '') { writeError(res, 400, 'tag id is required'); return }
1384
- const names = Array.isArray(request.skillNames) ? request.skillNames.filter((n): n is string => typeof n === 'string') : []
1385
- const known = await knownSkillNames(deps)
1386
- const saved = await deps.store.setTagMembers(id, names.filter((n) => known.has(n)))
1387
- if (saved === undefined) { writeError(res, 404, 'tag not found: ' + id); return }
1388
- writeJson(res, 200, { ok: true, tags: await deps.store.listTags() } satisfies TagMembersResponse)
1389
- },
1390
- }),
1391
- // ---------------------------------------------------------- tag/reorder
1392
- // 拖拽重排场景分组顺序
1393
- route({
1394
- path: SKILL_HUB_API.tagReorder,
1395
- methods: ['POST'],
1396
- jsonBody: true,
1397
- handler: async ({ res, body }) => {
1398
- const request = body as unknown as TagReorderRequest
1399
- const orderedIds = Array.isArray(request.orderedIds) ? request.orderedIds.filter((id): id is string => typeof id === 'string' && id !== '') : []
1400
- try {
1401
- const tags = await deps.store.reorderTags(orderedIds)
1402
- writeJson(res, 200, { ok: true, tags } satisfies TagReorderResponse)
1403
- } catch (error) {
1404
- if (error instanceof StoreError) { writeError(res, error.kind === 'validation' ? 400 : error.kind === 'not-found' ? 404 : 409, error.message); return }
1405
- throw error
1406
- }
1407
- },
1408
- }),
1409
- // ------------------------------------------------- collection/reorder
1410
- // 拖拽重排来源集合顺序
1411
- route({
1412
- path: SKILL_HUB_API.collectionReorder,
1413
- methods: ['POST'],
1414
- jsonBody: true,
1415
- handler: async ({ res, body }) => {
1416
- const request = body as unknown as CollectionReorderRequest
1417
- const orderedNames = Array.isArray(request.orderedNames) ? request.orderedNames.filter((n): n is string => typeof n === 'string' && n !== '') : []
1418
- const order = await deps.store.reorderCollections(orderedNames)
1419
- const groups = await buildGroups(deps)
1420
- writeJson(res, 200, { ok: true, collections: groups.collections, order } satisfies CollectionReorderResponse)
1421
- },
1422
- }),
1423
- // ------------------------------------------------ source-group/reorder
1424
- // 拖拽重排来源顶层分组(project / collections / personal 统一顺序)
1425
- route({
1426
- path: SKILL_HUB_API.sourceGroupReorder,
1427
- methods: ['POST'],
1428
- jsonBody: true,
1429
- handler: async ({ res, body }) => {
1430
- const request = body as unknown as SourceGroupReorderRequest
1431
- const orderedKeys = Array.isArray(request.orderedKeys) ? request.orderedKeys.filter((k): k is string => typeof k === 'string' && k !== '') : []
1432
- const order = await deps.store.reorderSourceGroups(orderedKeys)
1433
- writeJson(res, 200, { ok: true, order } satisfies SourceGroupReorderResponse)
1434
- },
1435
- }),
1436
- // ------------------------------------------------------------- sources
1437
- // 来源列表 + 派生 origin 映射 + 集合组 + 回收站。
1438
- route({
1439
- path: SKILL_HUB_API.sources,
1440
- methods: ['GET'],
1441
- handler: async ({ res }) => {
1442
- const [sources, origins, trash, collectionOrder] = await Promise.all([deps.store.listSources(), deps.store.listOrigins(), deps.store.listTrash(), deps.store.getCollectionOrder()])
1443
- const byCollection = new Map<string, string[]>()
1444
- for (const [skillName, origin] of Object.entries(origins)) {
1445
- const list = byCollection.get(origin)
1446
- if (list === undefined) byCollection.set(origin, [skillName])
1447
- else list.push(skillName)
1448
- }
1449
- const orderIndex = new Map(collectionOrder.map((name, i) => [name, i] as const))
1450
- const collections: CollectionGroup[] = [...byCollection.entries()]
1451
- .map(([name, skillNames]) => ({ name, skillNames: [...skillNames].sort((a, b) => a.localeCompare(b)) }))
1452
- .sort((a, b) => {
1453
- const ai = orderIndex.has(a.name) ? orderIndex.get(a.name)! : Infinity
1454
- const bi = orderIndex.has(b.name) ? orderIndex.get(b.name)! : Infinity
1455
- if (ai !== bi) return ai - bi
1456
- return a.name.localeCompare(b.name)
1457
- })
1458
- writeJson(res, 200, { ok: true, sources, origins, collections, trash } satisfies SourcesResponse)
1459
- },
1460
- }),
1461
- // -------------------------------------------------------- sources/check
1462
- // 检查指定(或全部)来源的上游更新。每个来源最多 1 次 commit 请求;
1463
- // 仅当 commit 变化时再拉一次 tree 做逐技能差异。5 分钟节流。
1464
- route({
1465
- path: SKILL_HUB_API.sourceCheck,
1466
- methods: ['POST'],
1467
- jsonBody: true,
1468
- handler: async ({ res, body }) => {
1469
- const request = body as unknown as SourceCheckRequest
1470
- let only: string | undefined
1471
- if (typeof request.repo === 'string' && request.repo !== '') {
1472
- // Normalize URLs/slugs the same way every other route does, so a
1473
- // check for "https://github.com/a/b" finds the "a/b" record.
1474
- const parsedOnly = normalizeRepoInput(request.repo)
1475
- only = parsedOnly !== null ? repoSlug(parsedOnly) : undefined
1476
- }
1477
- const source = only !== undefined ? await deps.store.getSource(only) : undefined
1478
- if (only !== undefined && source === undefined) { writeError(res, 404, 'source not found: ' + only); return }
1479
- const sources = source !== undefined ? [source] : await deps.store.listSources()
1480
- if (lastSourceCheck.size > 500) lastSourceCheck.clear()
1481
- const results: SourceCheckResult[] = []
1482
- for (const item of sources) {
1483
- const base = { repo: item.repo, ...(item.ref !== undefined ? { ref: item.ref } : {}) }
1484
- const now = Date.now()
1485
- const last = lastSourceCheck.get(item.repo) ?? 0
1486
- if (now - last < MIN_CHECK_INTERVAL_MS) {
1487
- results.push({ ...base, changed: false, updated: [], deleted: [], throttled: true })
1488
- continue
1489
- }
1490
- try {
1491
- const latest = await getLatestCommit(item.repo, item.ref)
1492
- lastSourceCheck.set(item.repo, now)
1493
- if (item.commitSha === '') {
1494
- // Migrated/legacy record without a snapshot: backfill the
1495
- // commit now and report "unverified" instead of claiming every
1496
- // skill is updated (there is no baseline to diff against yet).
1497
- await deps.store.setSourceCommit(item.repo, latest.commitSha)
1498
- results.push({ ...base, changed: false, updated: [], deleted: [], unverified: true, commitSha: latest.commitSha })
1499
- continue
1500
- }
1501
- if (latest.commitSha === item.commitSha) {
1502
- results.push({ ...base, changed: false, updated: [], deleted: [] })
1503
- continue
1504
- }
1505
- const tree = await loadRepoTreeAt(item.repo, latest.treeSha)
1506
- const diff = diffRemoteSkills(tree, item)
1507
- results.push({ ...base, changed: true, commitSha: latest.commitSha, updated: diff.updated, deleted: diff.deleted })
1508
- } catch (error) {
1509
- results.push({ ...base, changed: false, updated: [], deleted: [], error: error instanceof Error ? error.message : String(error) })
1510
- }
1511
- }
1512
- writeJson(res, 200, { ok: true, results } satisfies SourceCheckResponse)
1513
- },
1514
- }),
1515
- // --------------------------------------------------------- sources/sync
1516
- // 按上游重新下载所选(或全部)技能并更新 commit 快照与 manifest。
1517
- route({
1518
- path: SKILL_HUB_API.sourceSync,
1519
- methods: ['POST'],
1520
- jsonBody: true,
1521
- handler: async ({ res, body }) => {
1522
- const request = body as unknown as SourceSyncRequest
1523
- const repo = typeof request.repo === 'string' ? request.repo.trim() : ''
1524
- if (repo === '') { writeError(res, 400, 'repo is required'); return }
1525
- const selected = Array.isArray(request.skills) ? request.skills.filter((n): n is string => typeof n === 'string' && n !== '') : undefined
1526
- const source = await deps.store.getSource(repo)
1527
- if (source === undefined) { writeError(res, 404, 'source not found: ' + repo); return }
1528
- const targets = selected !== undefined ? selected : source.skills
1529
- const latest = await getLatestCommit(repo, source.ref)
1530
- const tree = await loadRepoTreeAt(repo, latest.treeSha)
1531
- const targetRoot = rootPath('user-dsh', homeOf(deps))
1532
- await mkdir(targetRoot, { recursive: true })
1533
- const synced: string[] = []
1534
- const failed: Array<{ name: string; error: string }> = []
1535
- for (const name of targets) {
1536
- // Sync writes under the user root: only accept real skill names
1537
- // this source actually tracks (a bare join would otherwise fold
1538
- // `..` segments out of the writable root).
1539
- if (!isSkillName(name) || !source.skills.includes(name)) {
1540
- failed.push({ name, error: 'skill is not tracked by this source' })
1541
- continue
1542
- }
1543
- try {
1544
- const entry = repoSkillEntry(name, source.root, repo)
1545
- // 上游可能用分类子目录(skills/engineering/<name>/)且目录会移动:
1546
- // 先在上游 tree 里搜真实位置,manifest 兜底,否则嵌套技能会 404。
1547
- entry.dir = skillDirOf(source, name, tree.map((item) => item.path))
1548
- entry.path = entry.dir + '/SKILL.md'
1549
- const files = collectRepoSkillFiles(tree, entry.dir)
1550
- if (files.length === 0) { failed.push({ name, error: 'skill missing upstream' }); continue }
1551
- const targetDir = join(targetRoot, name)
1552
- const wasDisabled = (await deps.store.getDisabled(name)) !== undefined
1553
- if (await pathExists(targetDir)) {
1554
- await replaceSkillDir(targetDir, async () => {
1555
- await downloadRepoSkill(repo, latest.commitSha, entry, files, targetRoot)
1556
- })
1557
- } else {
1558
- await downloadRepoSkill(repo, latest.commitSha, entry, files, targetRoot)
1559
- }
1560
- if (wasDisabled) {
1561
- // Preserve the disabled state: the fresh SKILL.md must not
1562
- // re-enter discovery.
1563
- await rename(join(targetDir, 'SKILL.md'), join(targetDir, 'SKILL.md.disabled'))
1564
- } else {
1565
- await deps.store.removeDisabled(name)
1566
- }
1567
- await deps.store.mergeSourceManifest(repo, skillManifest(tree, entry.dir), entry.dir)
1568
- synced.push(name)
1569
- } catch (error) {
1570
- failed.push({ name, error: error instanceof Error ? error.message : String(error) })
1571
- }
1572
- }
1573
- // Only advance the commit snapshot when every skill landed: a failed
1574
- // sync keeps the old commit, so the next check still diffs the tree
1575
- // and re-reports the missing skills instead of silently hiding them.
1576
- if (failed.length === 0) {
1577
- await deps.store.setSourceCommit(repo, latest.commitSha)
1578
- }
1579
- deps.invalidate?.()
1580
- writeJson(res, 200, { ok: true, repo, commitSha: latest.commitSha, synced, failed } satisfies SourceSyncResponse)
1581
- },
1582
- }),
1583
- // ------------------------------------------------------- sources/delete
1584
- // 跟进上游删除:把所选技能的本地目录移入回收站(可恢复)。
1585
- route({
1586
- path: SKILL_HUB_API.sourceDelete,
1587
- methods: ['POST'],
1588
- jsonBody: true,
1589
- handler: async ({ res, body }) => {
1590
- const request = body as unknown as SourceDeleteRequest
1591
- const repo = typeof request.repo === 'string' ? request.repo.trim() : ''
1592
- const skills = Array.isArray(request.skills) ? request.skills.filter((n): n is string => typeof n === 'string' && n !== '') : []
1593
- if (repo === '') { writeError(res, 400, 'repo is required'); return }
1594
- if (skills.length === 0) { writeError(res, 400, 'skills must be a non-empty array'); return }
1595
- const source = await deps.store.getSource(repo)
1596
- if (source === undefined) { writeError(res, 404, 'source not found: ' + repo); return }
1597
- const home = homeOf(deps)
1598
- const trashed: string[] = []
1599
- const failed: Array<{ name: string; error: string }> = []
1600
- const sourceNames = new Set(source.skills)
1601
- for (const name of skills) {
1602
- // Every name must be a real kebab-case skill that this source
1603
- // actually tracks; anything else must never touch the filesystem
1604
- // (a bare `join` would otherwise fold `..` segments out of the
1605
- // writable root — see the path-containment assert below).
1606
- if (!isSkillName(name) || !sourceNames.has(name)) {
1607
- failed.push({ name, error: 'skill is not tracked by this source' })
1608
- continue
1609
- }
1610
- const sourcePath = join(rootPath('user-dsh', home), name)
1611
- if (rootOfPath(sourcePath, home) === undefined) {
1612
- failed.push({ name, error: 'skill path is outside the hub writable roots' })
1613
- continue
1614
- }
1615
- if (!await pathExists(sourcePath)) {
1616
- failed.push({ name, error: 'skill directory not found' })
1617
- continue
1618
- }
1619
- try {
1620
- // 入回收站前快照来源与场景归属,恢复时挂回(见 sourceRestore)。
1621
- const tagIds = (await deps.store.listTags()).filter((tag) => tag.skillNames.includes(name)).map((tag) => tag.id)
1622
- const { path } = await trashSkill(sourcePath)
1623
- await deps.store.addTrash({
1624
- name,
1625
- path,
1626
- movedAt: Date.now(),
1627
- sourcePath,
1628
- origin: { repo: source.repo, root: source.root, ...(source.ref !== undefined && source.ref !== '' ? { ref: source.ref } : {}), commitSha: source.commitSha },
1629
- ...(tagIds.length > 0 ? { tagIds } : {}),
1630
- })
1631
- await deps.store.removeDisabled(name)
1632
- await deps.store.removeSkillFromTags(name)
1633
- trashed.push(name)
1634
- } catch (error) {
1635
- failed.push({ name, error: error instanceof Error ? error.message : String(error) })
1636
- }
1637
- }
1638
- if (trashed.length > 0) {
1639
- await deps.store.setSourceSkills(repo, source.skills.filter((n) => !trashed.includes(n)))
1640
- deps.invalidate?.()
1641
- }
1642
- writeJson(res, 200, { ok: true, trashed, failed } satisfies SourceDeleteResponse)
1643
- },
1644
- }),
1645
- // ------------------------------------------------------ sources/restore
1646
- // 从回收站恢复一个技能目录。
1647
- route({
1648
- path: SKILL_HUB_API.sourceRestore,
1649
- methods: ['POST'],
1650
- jsonBody: true,
1651
- handler: async ({ res, body }) => {
1652
- const request = body as unknown as SourceRestoreRequest
1653
- const name = typeof request.name === 'string' ? request.name : ''
1654
- if (name === '') { writeError(res, 400, 'name is required'); return }
1655
- const entry = await deps.store.getTrash(name)
1656
- if (entry === undefined) { writeError(res, 404, 'trash entry not found: ' + name); return }
1657
- const home = homeOf(deps)
1658
- const target = entry.sourcePath ?? join(rootPath('user-dsh', home), name)
1659
- if (await pathExists(target)) {
1660
- writeError(res, 409, 'skill already exists: ' + name)
1661
- return
1662
- }
1663
- const path = await restoreSkill(entry, home)
1664
- // 恢复来源归属(入回收站前快照的来源记录)与场景成员,否则恢复后
1665
- // 的技能会变成「个人技能」并脱离原场景。
1666
- if (entry.origin !== undefined) {
1667
- await deps.store.addSourceSkill(entry.origin.repo, entry.origin.root, entry.origin.commitSha, entry.origin.ref, name)
1668
- }
1669
- for (const tagId of entry.tagIds ?? []) {
1670
- await deps.store.addSkillToTag(tagId, name)
1671
- }
1672
- await deps.store.removeTrash(name)
1673
- deps.invalidate?.()
1674
- writeJson(res, 200, { ok: true, name, path } satisfies SourceRestoreResponse)
1675
- },
1676
- }),
1677
- // ------------------------------------------------- sources/trash/clear
1678
- // 清空回收站:永久删除 .trash 里的技能,失败项保留可重试。
1679
- route({
1680
- path: SKILL_HUB_API.sourceTrashClear,
1681
- methods: ['POST'],
1682
- handler: async ({ res }) => {
1683
- const home = homeOf(deps)
1684
- const entries = await deps.store.listTrash()
1685
- const deleted: string[] = []
1686
- const failed: Array<{ name: string; error: string }> = []
1687
- for (const entry of entries) {
1688
- try {
1689
- await clearTrash(entry, home)
1690
- // A permanently deleted skill must not linger in user groups.
1691
- await deps.store.removeSkillFromTags(entry.name)
1692
- deleted.push(entry.name)
1693
- } catch (error) {
1694
- failed.push({ name: entry.name, error: error instanceof Error ? error.message : String(error) })
1695
- }
1696
- }
1697
- for (const name of deleted) await deps.store.removeTrash(name)
1698
- if (deleted.length > 0) deps.invalidate?.()
1699
- writeJson(res, 200, { ok: true, deleted, failed } satisfies SourceTrashClearResponse)
1700
- },
1701
- }),
1702
- // -------------------------------------------------------- diagnostic fix
1703
- route({
1704
- path: SKILL_HUB_API.diagnosticFix,
1705
- methods: ['POST'],
1706
- jsonBody: true,
1707
- handler: async ({ res, body }) => {
1708
- const request = body as unknown as DiagnosticFixRequest
1709
- const rawPath = typeof request.path === 'string' ? request.path.trim() : ''
1710
- if (rawPath === '') { writeError(res, 400, 'path is required'); return }
1711
- try {
1712
- await fixDiagnosticFile(rawPath, homeOf(deps))
1713
- deps.invalidate?.()
1714
- writeJson(res, 200, { ok: true, path: rawPath } satisfies DiagnosticFixResponse)
1715
- } catch (error) {
1716
- writeRouteError(res, error)
1717
- }
1718
- },
1719
- }),
32
+ // 各域返回裸 spec,统一在这里包上请求围栏(回环信任 → 方法 → 总开关
33
+ // → JSON 体 → 统一错误映射),新路由不会漏掉围栏。
34
+ const specs = [
35
+ ...catalogRoutes(deps),
36
+ ...configRoutes(deps),
37
+ ...marketRoutes(deps),
38
+ ...repoImportRoutes(deps),
39
+ ...groupRoutes(deps),
40
+ ...sourceRoutes(deps),
1720
41
  ]
42
+ return specs.map((spec) => createRoute(deps, spec))
1721
43
  }