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
@@ -0,0 +1,309 @@
1
+ /**
2
+ * 目录域路由:catalog / skill 详情 / skill 删除 / toggle / toggle-batch /
3
+ * create / stats。从 routes.ts 原样搬出,handler 逻辑不变。
4
+ */
5
+
6
+ import { mkdir, readFile, rename, stat } from 'node:fs/promises'
7
+ import { basename, dirname, join } from 'node:path'
8
+ import type { SkillDefinition } from '@deepseek-ai/dsh-skill'
9
+ import { isSkillName } from '@deepseek-ai/dsh-skill'
10
+ import {
11
+ SKILL_HUB_API,
12
+ type CreateRequest,
13
+ type CreateResponse,
14
+ type SkillDeleteRequest,
15
+ type SkillDeleteResponse,
16
+ type SkillDetail,
17
+ type SkillDetailResponse,
18
+ type StatsResponse,
19
+ type ToggleBatchRequest,
20
+ type ToggleBatchResponse,
21
+ type ToggleRequest,
22
+ type ToggleResponse,
23
+ type WritableRoot,
24
+ } from '../protocol.ts'
25
+ import { createSkill, disableSkill, enableSkill, parseFrontmatter, readSkillInterface, rootPath, trashSkill } from '../skillfs.ts'
26
+ import {
27
+ buildCatalog,
28
+ homeOf,
29
+ pathExists,
30
+ queryParam,
31
+ resolveWritableSkill,
32
+ toDetail,
33
+ workspaceEntries,
34
+ writeError,
35
+ writeJson,
36
+ type RouteSpec,
37
+ type SkillHubRouteDeps,
38
+ } from './helpers.ts'
39
+
40
+ /** 目录域全部路由 spec(由 routes.ts 经 createRoute 包上统一围栏)。 */
41
+ export function catalogRoutes(deps: SkillHubRouteDeps): RouteSpec[] {
42
+ return [
43
+ // ------------------------------------------------------------ catalog
44
+ {
45
+ path: SKILL_HUB_API.catalog,
46
+ methods: ['GET'],
47
+ handler: async ({ res, url }) => {
48
+ const cwd = queryParam(url, 'cwd')
49
+ writeJson(res, 200, await buildCatalog(deps, cwd))
50
+ },
51
+ },
52
+ // -------------------------------------------------------------- detail
53
+ {
54
+ path: SKILL_HUB_API.skill,
55
+ methods: ['GET'],
56
+ handler: async ({ res, url }) => {
57
+ const name = queryParam(url, 'name')
58
+ if (name === undefined || name === '') { writeError(res, 400, 'name query parameter is required'); return }
59
+ const cwd = queryParam(url, 'cwd')
60
+ // 显式 cwd 只看该工作区;否则按已知工作区逐个查找(与目录默认视图
61
+ // 一致),最后回退用户级根,保证默认视图里可见的项目技能能打开详情。
62
+ let skill: SkillDefinition | undefined
63
+ if (cwd !== undefined && cwd !== '') {
64
+ skill = await deps.skills.get(name, { cwd })
65
+ } else {
66
+ for (const ws of await workspaceEntries(homeOf(deps))) {
67
+ skill = await deps.skills.get(name, { cwd: ws.path })
68
+ if (skill !== undefined) break
69
+ }
70
+ if (skill === undefined) skill = await deps.skills.get(name)
71
+ }
72
+ if (skill === undefined) {
73
+ // Hub-disabled skills live outside registry discovery (renamed to .disabled);
74
+ // serve their detail straight from the sidecar record + file.
75
+ const record = await deps.store.getDisabled(name)
76
+ if (record !== undefined) {
77
+ try {
78
+ const text = await readFile(record.path, 'utf8')
79
+ const parsed = parseFrontmatter(text)
80
+ if (!('error' in parsed)) {
81
+ const disabledDetail: SkillDetail = {
82
+ name: record.name,
83
+ description: parsed.value.description,
84
+ ...(parsed.value.whenToUse !== undefined ? { whenToUse: parsed.value.whenToUse } : {}),
85
+ invocation: { ...parsed.value.invocation },
86
+ provider: 'skill-hub (disabled)',
87
+ path: record.path,
88
+ content: parsed.value.content,
89
+ }
90
+ try {
91
+ const times = await stat(record.path)
92
+ disabledDetail.addedAt = times.birthtimeMs
93
+ disabledDetail.updatedAt = times.mtimeMs
94
+ } catch {
95
+ // ignore
96
+ }
97
+ writeJson(res, 200, { ok: true, skill: disabledDetail } satisfies SkillDetailResponse)
98
+ return
99
+ }
100
+ } catch {
101
+ // fall through to 404 below
102
+ }
103
+ }
104
+ writeError(res, 404, 'skill not found: ' + name)
105
+ return
106
+ }
107
+ const detail = toDetail(skill)
108
+ if (skill.path !== undefined) {
109
+ try {
110
+ const times = await stat(skill.path)
111
+ detail.addedAt = times.birthtimeMs
112
+ detail.updatedAt = times.mtimeMs
113
+ } catch {
114
+ // 文件不可读时省略时间字段,详情页不显示这两行。
115
+ }
116
+ // UI metadata from agents/openai.yaml beside the skill directory (codex).
117
+ try {
118
+ const rb = skill.resourceBase as { kind?: string; path?: string } | undefined
119
+ const dir = rb?.kind === 'directory' && typeof rb.path === 'string' ? rb.path : (skill.path.endsWith('SKILL.md') ? dirname(skill.path) : undefined)
120
+ if (dir !== undefined) {
121
+ const iface = await readSkillInterface(dir)
122
+ if (iface !== undefined) {
123
+ if (iface.displayName !== undefined) detail.displayName = iface.displayName
124
+ if (iface.shortDescription !== undefined) detail.shortDescription = iface.shortDescription
125
+ if (iface.brandColor !== undefined) detail.brandColor = iface.brandColor
126
+ if (iface.iconSmall !== undefined) detail.iconSmall = iface.iconSmall
127
+ if (iface.iconLarge !== undefined) detail.iconLarge = iface.iconLarge
128
+ if (iface.defaultPrompt !== undefined) detail.defaultPrompt = iface.defaultPrompt
129
+ }
130
+ }
131
+ } catch {
132
+ // best-effort
133
+ }
134
+ }
135
+ writeJson(res, 200, { ok: true, skill: detail } satisfies SkillDetailResponse)
136
+ },
137
+ },
138
+ // -------------------------------------------------------- skill/delete
139
+ // 把单个技能(目录或平面文件)移入回收站(可恢复),并清理禁用记录、tag 成员与来源映射。
140
+ {
141
+ path: SKILL_HUB_API.skillDelete,
142
+ methods: ['POST'],
143
+ jsonBody: true,
144
+ handler: async ({ res, body }) => {
145
+ const request = body as unknown as SkillDeleteRequest & { cwd?: string }
146
+ const name = typeof request.name === 'string' ? request.name : ''
147
+ if (name === '') { writeError(res, 400, 'name is required'); return }
148
+ const resolved = await resolveWritableSkill(deps, name, typeof request.cwd === 'string' ? request.cwd : undefined)
149
+ // 已禁用的技能不在 registry 中,resolve 会 404,这里单独处理:允许整组删除未开启的技能
150
+ let trashResult: { path: string; source: string } | null = null
151
+ if (!resolved.ok) {
152
+ const disabled = await deps.store.getDisabled(name)
153
+ if (disabled !== undefined) {
154
+ // 禁用态:SKILL.md.disabled 或 *.md.disabled,直接将其所在技能整体移入回收站
155
+ const isBundleDisabled = disabled.path.endsWith('SKILL.md.disabled')
156
+ const sourceToTrash = isBundleDisabled ? dirname(disabled.path) : disabled.path
157
+ const trashDir = join(dirname(sourceToTrash), '.trash')
158
+ await mkdir(trashDir, { recursive: true })
159
+ const target = join(trashDir, basename(sourceToTrash) + '-' + Date.now())
160
+ await rename(sourceToTrash, target)
161
+ trashResult = { path: target, source: sourceToTrash }
162
+ } else {
163
+ writeError(res, resolved.status, resolved.error); return
164
+ }
165
+ }
166
+ // 入回收站前快照来源归属与场景成员:恢复时把它们加回来,否则恢复
167
+ // 后的技能会丢失来源(变成「个人技能」)和场景分组。
168
+ const tracked = await deps.store.getSourceForSkill(name)
169
+ const tagIds = (await deps.store.listTags()).filter((tag) => tag.skillNames.includes(name)).map((tag) => tag.id)
170
+ const { path, source } = trashResult ?? await trashSkill((resolved as { ok: true; path: string }).path)
171
+ await deps.store.addTrash({
172
+ name,
173
+ path,
174
+ movedAt: Date.now(),
175
+ sourcePath: source,
176
+ ...(tracked !== undefined
177
+ ? { origin: { repo: tracked.repo, root: tracked.root, ...(tracked.ref !== undefined && tracked.ref !== '' ? { ref: tracked.ref } : {}), commitSha: tracked.commitSha } }
178
+ : {}),
179
+ ...(tagIds.length > 0 ? { tagIds } : {}),
180
+ })
181
+ await deps.store.removeDisabled(name)
182
+ await deps.store.removeSkillFromSources(name)
183
+ await deps.store.removeSkillFromTags(name)
184
+ deps.invalidate?.()
185
+ writeJson(res, 200, { ok: true, name, path } satisfies SkillDeleteResponse)
186
+ },
187
+ },
188
+ // -------------------------------------------------------------- toggle
189
+ {
190
+ path: SKILL_HUB_API.toggle,
191
+ methods: ['POST'],
192
+ jsonBody: true,
193
+ handler: async ({ res, body }) => {
194
+ const request = body as unknown as ToggleRequest & { cwd?: string }
195
+ const name = typeof request.name === 'string' ? request.name : ''
196
+ if (name === '') { writeError(res, 400, 'name is required'); return }
197
+ const lookup = typeof request.cwd === 'string' && request.cwd !== '' ? { cwd: request.cwd } : undefined
198
+ if (request.enabled === true) {
199
+ const record = await deps.store.getDisabled(name)
200
+ if (record === undefined) { writeError(res, 404, 'skill is not hub-disabled: ' + name); return }
201
+ try {
202
+ await enableSkill(record.path)
203
+ } catch (error) {
204
+ // The renamed file may have vanished (external cleanup); report
205
+ // precisely instead of a generic 500, like toggle-batch does.
206
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
207
+ writeError(res, 409, 'disabled skill file is missing on disk: ' + record.path)
208
+ return
209
+ }
210
+ throw error
211
+ }
212
+ await deps.store.removeDisabled(name)
213
+ } else {
214
+ const resolved = await resolveWritableSkill(deps, name, typeof request.cwd === 'string' ? request.cwd : undefined)
215
+ if (!resolved.ok) { writeError(res, resolved.status, resolved.error); return }
216
+ const disabledPath = await disableSkill(resolved.path)
217
+ await deps.store.addDisabled({
218
+ name,
219
+ description: resolved.skill.description,
220
+ path: disabledPath,
221
+ root: resolved.root,
222
+ disabledAt: Date.now(),
223
+ })
224
+ }
225
+ deps.invalidate?.()
226
+ writeJson(res, 200, { ok: true, catalog: await buildCatalog(deps, lookup?.cwd) } satisfies ToggleResponse)
227
+ },
228
+ },
229
+ // -------------------------------------------------------- toggle-batch
230
+ // One write for a whole group: enables every hub-disabled name, or
231
+ // disables every writable name. Skips already-target states as no-ops;
232
+ // per-name failures are reported, never fatal.
233
+ {
234
+ path: SKILL_HUB_API.toggleBatch,
235
+ methods: ['POST'],
236
+ jsonBody: true,
237
+ handler: async ({ res, body }) => {
238
+ const request = body as unknown as ToggleBatchRequest & { cwd?: string }
239
+ const names = Array.isArray(request.names) ? request.names.filter((n): n is string => typeof n === 'string' && n !== '') : []
240
+ if (names.length === 0) { writeError(res, 400, 'names must be a non-empty array'); return }
241
+ const enabled = request.enabled === true
242
+ const lookup = typeof request.cwd === 'string' && request.cwd !== '' ? { cwd: request.cwd } : undefined
243
+ const failures: Array<{ name: string; error: string }> = []
244
+ for (const name of names) {
245
+ try {
246
+ if (enabled) {
247
+ const record = await deps.store.getDisabled(name)
248
+ if (record === undefined) continue // already enabled: no-op
249
+ await enableSkill(record.path)
250
+ await deps.store.removeDisabled(name)
251
+ } else {
252
+ const resolved = await resolveWritableSkill(deps, name, typeof request.cwd === 'string' ? request.cwd : undefined)
253
+ if (!resolved.ok) { failures.push({ name, error: resolved.error }); continue }
254
+ const disabledPath = await disableSkill(resolved.path)
255
+ await deps.store.addDisabled({ name, description: resolved.skill.description, path: disabledPath, root: resolved.root, disabledAt: Date.now() })
256
+ }
257
+ } catch (error) {
258
+ failures.push({ name, error: error instanceof Error ? error.message : String(error) })
259
+ }
260
+ }
261
+ deps.invalidate?.()
262
+ writeJson(res, 200, { ok: true, catalog: await buildCatalog(deps, lookup?.cwd), failures } satisfies ToggleBatchResponse)
263
+ },
264
+ },
265
+ // -------------------------------------------------------------- create
266
+ {
267
+ path: SKILL_HUB_API.create,
268
+ methods: ['POST'],
269
+ jsonBody: true,
270
+ handler: async ({ res, body }) => {
271
+ const request = body as unknown as CreateRequest
272
+ const name = typeof request.name === 'string' ? request.name.trim() : ''
273
+ if (!isSkillName(name)) { writeError(res, 400, 'skill name must be kebab-case (lowercase letters, digits, dashes)'); return }
274
+ const root: WritableRoot = request.root ?? 'user-dsh'
275
+ if (root !== 'user-dsh' && root !== 'user-agents') { writeError(res, 400, 'root must be user-dsh or user-agents'); return }
276
+ const existing = await deps.skills.get(name)
277
+ if (existing !== undefined) { writeError(res, 409, 'skill name already exists: ' + name); return }
278
+ if (await deps.store.getDisabled(name) !== undefined) { writeError(res, 409, 'skill name is disabled: re-enable it from the disabled list first'); return }
279
+ // A directory may exist without producing a registry entry (invalid
280
+ // frontmatter — exactly what the diagnostics section reports).
281
+ // Refuse to overwrite it instead of silently truncating its SKILL.md.
282
+ const target = join(rootPath(root, homeOf(deps)), name)
283
+ if (await pathExists(target)) {
284
+ writeError(res, 409, 'skill directory already exists on disk: ' + name + ' (check the discovery diagnostics)')
285
+ return
286
+ }
287
+ const path = await createSkill(root, name, typeof request.description === 'string' ? request.description : '', homeOf(deps))
288
+ // 新技能自动归入默认场景(「通用」)。
289
+ const defaultTag = await deps.store.getDefaultTag()
290
+ if (defaultTag !== undefined) await deps.store.addSkillToTag(defaultTag.id, name)
291
+ deps.invalidate?.()
292
+ writeJson(res, 201, { ok: true, path, root } satisfies CreateResponse)
293
+ },
294
+ },
295
+ // ---------------------------------------------------------------- stats
296
+ {
297
+ path: SKILL_HUB_API.stats,
298
+ methods: ['GET'],
299
+ handler: async ({ res }) => {
300
+ if (deps.stats === undefined) {
301
+ writeJson(res, 200, { ok: true, available: false, stats: [] } satisfies StatsResponse)
302
+ return
303
+ }
304
+ const stats = await deps.stats()
305
+ writeJson(res, 200, { ok: true, available: true, stats } satisfies StatsResponse)
306
+ },
307
+ },
308
+ ]
309
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * 配置域路由:config(总开关开着也可用,设置卡自救入口)/ update 自身更新
3
+ * 检查 / diagnosticFix。从 routes.ts 原样搬出,handler 逻辑不变。
4
+ */
5
+
6
+ import {
7
+ SKILL_HUB_API,
8
+ GITHUB_TOKEN_RE,
9
+ HEX_COLOR_RE,
10
+ type ConfigResponse,
11
+ type DiagnosticFixRequest,
12
+ type DiagnosticFixResponse,
13
+ type HubConfig,
14
+ } from '../protocol.ts'
15
+ import { fixDiagnosticFile } from '../skillfs.ts'
16
+ import { checkLatestRelease, CURRENT_VERSION } from '../update.ts'
17
+ import {
18
+ configOf,
19
+ homeOf,
20
+ savedOf,
21
+ writeError,
22
+ writeJson,
23
+ writeRouteError,
24
+ type RouteSpec,
25
+ type SkillHubRouteDeps,
26
+ } from './helpers.ts'
27
+
28
+ /** 配置域全部路由 spec(由 routes.ts 经 createRoute 包上统一围栏)。 */
29
+ export function configRoutes(deps: SkillHubRouteDeps): RouteSpec[] {
30
+ return [
31
+ // --------------------------------------------------------------- config
32
+ // The config route stays mounted even with the master switch off, so the
33
+ // settings card can always read and re-enable the hub.
34
+ {
35
+ path: SKILL_HUB_API.config,
36
+ methods: ['GET', 'POST'],
37
+ jsonBody: true,
38
+ skipGate: true,
39
+ handler: async ({ req, res, body }) => {
40
+ if (req.method === 'GET') {
41
+ writeJson(res, 200, { ok: true, pluginVersion: CURRENT_VERSION, config: configOf(deps), saved: savedOf(deps) } satisfies ConfigResponse)
42
+ return
43
+ }
44
+ const raw = body
45
+ const patch: Partial<HubConfig> = {}
46
+ if (raw.enabled !== undefined) {
47
+ if (raw.enabled === null) patch.enabled = undefined
48
+ else if (typeof raw.enabled !== 'boolean') { writeError(res, 400, 'enabled must be a boolean or null'); return }
49
+ else patch.enabled = raw.enabled
50
+ }
51
+ if (raw.announceToAgent !== undefined) {
52
+ if (raw.announceToAgent === null) patch.announceToAgent = undefined
53
+ else if (typeof raw.announceToAgent !== 'boolean') { writeError(res, 400, 'announceToAgent must be a boolean or null'); return }
54
+ else patch.announceToAgent = raw.announceToAgent
55
+ }
56
+ for (const key of ['showUseCount', 'showUseTime', 'showGroupSummary'] as const) {
57
+ const value = raw[key]
58
+ if (value === undefined) continue
59
+ if (value === null) { patch[key] = undefined; continue }
60
+ if (typeof value !== 'boolean') { writeError(res, 400, key + ' must be a boolean or null'); return }
61
+ patch[key] = value
62
+ }
63
+ for (const key of ['dotModelColor', 'dotUserColor'] as const) {
64
+ const value = raw[key]
65
+ if (value === undefined) continue
66
+ if (value === null) { patch[key] = undefined; continue }
67
+ if (typeof value !== 'string' || !HEX_COLOR_RE.test(value)) { writeError(res, 400, key + ' must be a #rrggbb color or null'); return }
68
+ patch[key] = value
69
+ }
70
+ if (raw.githubToken !== undefined) {
71
+ const value = raw.githubToken
72
+ if (value === null) { patch.githubToken = undefined; /* cleared → anonymous/env */ }
73
+ else if (typeof value !== 'string' || value.trim() === '') { patch.githubToken = undefined }
74
+ else if (!GITHUB_TOKEN_RE.test(value.trim())) { writeError(res, 400, 'githubToken looks invalid (expected a personal access token)'); return }
75
+ else patch.githubToken = value.trim()
76
+ }
77
+ let config: HubConfig
78
+ if (deps.updateConfig === undefined) {
79
+ const merged: HubConfig = { ...configOf(deps) }
80
+ for (const [key, value] of Object.entries(patch) as Array<[keyof HubConfig, boolean | string | undefined]>) {
81
+ if (value === undefined) delete merged[key]
82
+ else (merged as unknown as Record<string, unknown>)[key] = value
83
+ }
84
+ config = merged
85
+ } else {
86
+ config = await deps.updateConfig(patch)
87
+ }
88
+ writeJson(res, 200, { ok: true, pluginVersion: CURRENT_VERSION, config, saved: savedOf(deps) } satisfies ConfigResponse)
89
+ },
90
+ },
91
+ // -------------------------------------------------------------- update
92
+ // 自身更新检查:查询 GitHub latest release。
93
+ {
94
+ path: SKILL_HUB_API.update,
95
+ methods: ['GET'],
96
+ handler: async ({ res }) => {
97
+ writeJson(res, 200, await checkLatestRelease())
98
+ },
99
+ },
100
+ // -------------------------------------------------------- diagnostic fix
101
+ {
102
+ path: SKILL_HUB_API.diagnosticFix,
103
+ methods: ['POST'],
104
+ jsonBody: true,
105
+ handler: async ({ res, body }) => {
106
+ const request = body as unknown as DiagnosticFixRequest
107
+ const rawPath = typeof request.path === 'string' ? request.path.trim() : ''
108
+ if (rawPath === '') { writeError(res, 400, 'path is required'); return }
109
+ try {
110
+ await fixDiagnosticFile(rawPath, homeOf(deps))
111
+ deps.invalidate?.()
112
+ writeJson(res, 200, { ok: true, path: rawPath } satisfies DiagnosticFixResponse)
113
+ } catch (error) {
114
+ writeRouteError(res, error)
115
+ }
116
+ },
117
+ },
118
+ ]
119
+ }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * 分组域路由:groups / tag 新建重命名 / tag 删除 / tag 成员 / tag 重排 /
3
+ * collection 重排 / source-group 重排。从 routes.ts 原样搬出,handler 逻辑不变。
4
+ */
5
+
6
+ import {
7
+ SKILL_HUB_API,
8
+ type CollectionReorderRequest,
9
+ type CollectionReorderResponse,
10
+ type SourceGroupReorderRequest,
11
+ type SourceGroupReorderResponse,
12
+ type TagDeleteRequest,
13
+ type TagDeleteResponse,
14
+ type TagMembersRequest,
15
+ type TagMembersResponse,
16
+ type TagReorderRequest,
17
+ type TagReorderResponse,
18
+ type TagSaveResponse,
19
+ } from '../protocol.ts'
20
+ import { StoreError } from '../store.ts'
21
+ import {
22
+ buildGroups,
23
+ knownSkillNames,
24
+ writeError,
25
+ writeJson,
26
+ type RouteSpec,
27
+ type SkillHubRouteDeps,
28
+ } from './helpers.ts'
29
+
30
+ /** 分组域全部路由 spec(由 routes.ts 经 createRoute 包上统一围栏)。 */
31
+ export function groupRoutes(deps: SkillHubRouteDeps): RouteSpec[] {
32
+ return [
33
+ // -------------------------------------------------------------- groups
34
+ // 用户 tag 分组 + 系统集合组 + origin 映射(前端分组栏的数据源)。
35
+ {
36
+ path: SKILL_HUB_API.groups,
37
+ methods: ['GET'],
38
+ handler: async ({ res }) => {
39
+ writeJson(res, 200, await buildGroups(deps))
40
+ },
41
+ },
42
+ // ----------------------------------------------------------------- tag
43
+ // 新建(缺省 id)或重命名(带 id)一个用户 tag 分组。
44
+ {
45
+ path: SKILL_HUB_API.tag,
46
+ methods: ['POST'],
47
+ jsonBody: true,
48
+ handler: async ({ res, body }) => {
49
+ const name = typeof body.name === 'string' ? body.name.trim() : ''
50
+ if (name === '') { writeError(res, 400, 'tag name is required'); return }
51
+ const id = typeof body.id === 'string' && body.id !== '' ? body.id : undefined
52
+ await deps.store.saveTag({ id, name })
53
+ writeJson(res, 200, { ok: true, tags: await deps.store.listTags() } satisfies TagSaveResponse)
54
+ },
55
+ },
56
+ // ----------------------------------------------------------- tag/delete
57
+ {
58
+ path: SKILL_HUB_API.tagDelete,
59
+ methods: ['POST'],
60
+ jsonBody: true,
61
+ handler: async ({ res, body }) => {
62
+ const id = typeof (body as unknown as TagDeleteRequest).id === 'string' ? (body as unknown as TagDeleteRequest).id : ''
63
+ if (id === '') { writeError(res, 400, 'tag id is required'); return }
64
+ const tag = await deps.store.getTag(id)
65
+ if (tag?.default === true) { writeError(res, 409, 'the default scene cannot be deleted'); return }
66
+ await deps.store.deleteTag(id)
67
+ writeJson(res, 200, { ok: true, tags: await deps.store.listTags() } satisfies TagDeleteResponse)
68
+ },
69
+ },
70
+ // ---------------------------------------------------------- tag/members
71
+ // 直接设置某 tag 的完整成员列表;后端只保留目录中实际存在的技能名。
72
+ {
73
+ path: SKILL_HUB_API.tagMembers,
74
+ methods: ['POST'],
75
+ jsonBody: true,
76
+ handler: async ({ res, body }) => {
77
+ const request = body as unknown as TagMembersRequest
78
+ const id = typeof request.id === 'string' ? request.id : ''
79
+ if (id === '') { writeError(res, 400, 'tag id is required'); return }
80
+ const names = Array.isArray(request.skillNames) ? request.skillNames.filter((n): n is string => typeof n === 'string') : []
81
+ const known = await knownSkillNames(deps)
82
+ const saved = await deps.store.setTagMembers(id, names.filter((n) => known.has(n)))
83
+ if (saved === undefined) { writeError(res, 404, 'tag not found: ' + id); return }
84
+ writeJson(res, 200, { ok: true, tags: await deps.store.listTags() } satisfies TagMembersResponse)
85
+ },
86
+ },
87
+ // ---------------------------------------------------------- tag/reorder
88
+ // 拖拽重排场景分组顺序
89
+ {
90
+ path: SKILL_HUB_API.tagReorder,
91
+ methods: ['POST'],
92
+ jsonBody: true,
93
+ handler: async ({ res, body }) => {
94
+ const request = body as unknown as TagReorderRequest
95
+ const orderedIds = Array.isArray(request.orderedIds) ? request.orderedIds.filter((id): id is string => typeof id === 'string' && id !== '') : []
96
+ try {
97
+ const tags = await deps.store.reorderTags(orderedIds)
98
+ writeJson(res, 200, { ok: true, tags } satisfies TagReorderResponse)
99
+ } catch (error) {
100
+ if (error instanceof StoreError) { writeError(res, error.kind === 'validation' ? 400 : error.kind === 'not-found' ? 404 : 409, error.message); return }
101
+ throw error
102
+ }
103
+ },
104
+ },
105
+ // ------------------------------------------------- collection/reorder
106
+ // 拖拽重排来源集合顺序
107
+ {
108
+ path: SKILL_HUB_API.collectionReorder,
109
+ methods: ['POST'],
110
+ jsonBody: true,
111
+ handler: async ({ res, body }) => {
112
+ const request = body as unknown as CollectionReorderRequest
113
+ const orderedNames = Array.isArray(request.orderedNames) ? request.orderedNames.filter((n): n is string => typeof n === 'string' && n !== '') : []
114
+ const order = await deps.store.reorderCollections(orderedNames)
115
+ const groups = await buildGroups(deps)
116
+ writeJson(res, 200, { ok: true, collections: groups.collections, order } satisfies CollectionReorderResponse)
117
+ },
118
+ },
119
+ // ------------------------------------------------ source-group/reorder
120
+ // 拖拽重排来源顶层分组(project / collections / personal 统一顺序)
121
+ {
122
+ path: SKILL_HUB_API.sourceGroupReorder,
123
+ methods: ['POST'],
124
+ jsonBody: true,
125
+ handler: async ({ res, body }) => {
126
+ const request = body as unknown as SourceGroupReorderRequest
127
+ const orderedKeys = Array.isArray(request.orderedKeys) ? request.orderedKeys.filter((k): k is string => typeof k === 'string' && k !== '') : []
128
+ const order = await deps.store.reorderSourceGroups(orderedKeys)
129
+ writeJson(res, 200, { ok: true, order } satisfies SourceGroupReorderResponse)
130
+ },
131
+ },
132
+ ]
133
+ }