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