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.
- package/README.md +40 -50
- package/README.zh.md +38 -48
- package/lib/client.js +2209 -1820
- package/lib/client.js.map +1 -1
- package/lib/index.js +653 -419
- package/lib/types/client/SkillHubSettingsCard.d.ts +1 -0
- package/lib/types/client/api.d.ts +12 -6
- package/lib/types/client/helpers.d.ts +0 -1
- package/lib/types/client/locales/common.d.ts +52 -0
- package/lib/types/client/locales/detail.d.ts +16 -0
- package/lib/types/client/locales/market.d.ts +73 -0
- package/lib/types/client/locales/settings.d.ts +41 -0
- package/lib/types/client/locales/skills.d.ts +65 -0
- package/lib/types/client/locales/sources.d.ts +35 -0
- package/lib/types/client/locales.d.ts +124 -118
- package/lib/types/client/panel/MarketSourceRow.d.ts +12 -0
- package/lib/types/client/panel/MarketView.d.ts +1 -0
- package/lib/types/client/panel/RepoScanCard.d.ts +10 -0
- package/lib/types/client/panel/hooks/shared.d.ts +72 -0
- package/lib/types/client/panel/hooks/useCatalogFlow.d.ts +62 -0
- package/lib/types/client/panel/hooks/useGroupFlow.d.ts +41 -0
- package/lib/types/client/panel/hooks/useMarketFlow.d.ts +73 -0
- package/lib/types/client/panel/hooks/useMetaFlow.d.ts +18 -0
- package/lib/types/client/panel/hooks/useSourceFlow.d.ts +44 -0
- package/lib/types/client/panel/market-format.d.ts +6 -0
- package/lib/types/client/panel/useSkillHub.d.ts +45 -73
- package/lib/types/client/settings-card.d.ts +20 -0
- package/lib/types/client/settings-form.d.ts +6 -0
- package/lib/types/protocol/api.d.ts +38 -0
- package/lib/types/protocol/catalog.d.ts +155 -0
- package/lib/types/protocol/config.d.ts +113 -0
- package/lib/types/protocol/groups.d.ts +95 -0
- package/lib/types/protocol/market.d.ts +94 -0
- package/lib/types/protocol/repo.d.ts +107 -0
- package/lib/types/protocol/sources.d.ts +139 -0
- package/lib/types/protocol/stats.d.ts +46 -0
- package/lib/types/protocol.d.ts +13 -743
- package/lib/types/repo/github-client.d.ts +42 -0
- package/lib/types/repo.d.ts +6 -17
- package/lib/types/routes/catalog.d.ts +7 -0
- package/lib/types/routes/config.d.ts +7 -0
- package/lib/types/routes/groups.d.ts +7 -0
- package/lib/types/routes/helpers.d.ts +145 -0
- package/lib/types/routes/market.d.ts +8 -0
- package/lib/types/routes/repo-import.d.ts +7 -0
- package/lib/types/routes/route-state.d.ts +55 -0
- package/lib/types/routes/sources.d.ts +8 -0
- package/lib/types/routes.d.ts +5 -34
- package/lib/types/stats.d.ts +3 -2
- package/lib/types/store/errors.d.ts +10 -0
- package/lib/types/store/migrate.d.ts +44 -0
- package/lib/types/store/paths.d.ts +32 -0
- package/lib/types/store/store.d.ts +133 -0
- package/lib/types/store.d.ts +7 -146
- package/package.json +28 -27
- package/src/client/SkillHubSettingsCard.tsx +17 -3
- package/src/client/api.ts +101 -195
- package/src/client/helpers.ts +10 -1
- package/src/client/locales/common.ts +102 -0
- package/src/client/locales/detail.ts +30 -0
- package/src/client/locales/market.ts +144 -0
- package/src/client/locales/settings.ts +80 -0
- package/src/client/locales/skills.ts +128 -0
- package/src/client/locales/sources.ts +68 -0
- package/src/client/locales.ts +26 -512
- package/src/client/panel/MarketSourceRow.tsx +108 -0
- package/src/client/panel/MarketView.tsx +12 -253
- package/src/client/panel/RepoScanCard.tsx +171 -0
- package/src/client/panel/hooks/shared.ts +81 -0
- package/src/client/panel/hooks/useCatalogFlow.ts +211 -0
- package/src/client/panel/hooks/useGroupFlow.ts +240 -0
- package/src/client/panel/hooks/useMarketFlow.ts +421 -0
- package/src/client/panel/hooks/useMetaFlow.ts +58 -0
- package/src/client/panel/hooks/useSourceFlow.ts +193 -0
- package/src/client/panel/market-format.ts +22 -0
- package/src/client/panel/useSkillHub.ts +141 -1032
- package/src/client/settings-card.tsx +51 -0
- package/src/client/settings-form.ts +18 -0
- package/src/index.ts +14 -4
- package/src/protocol/api.ts +38 -0
- package/src/protocol/catalog.ts +168 -0
- package/src/protocol/config.ts +145 -0
- package/src/protocol/groups.ts +106 -0
- package/src/protocol/market.ts +100 -0
- package/src/protocol/repo.ts +104 -0
- package/src/protocol/sources.ts +146 -0
- package/src/protocol/stats.ts +42 -0
- package/src/protocol.ts +83 -809
- package/src/repo/github-client.ts +131 -0
- package/src/repo.test.ts +24 -1
- package/src/repo.ts +50 -170
- package/src/routes/catalog.ts +309 -0
- package/src/routes/config.ts +119 -0
- package/src/routes/groups.ts +133 -0
- package/src/routes/helpers.ts +489 -0
- package/src/routes/market.ts +255 -0
- package/src/routes/repo-import.ts +267 -0
- package/src/routes/route-state.ts +89 -0
- package/src/routes/sources.ts +318 -0
- package/src/routes.test.ts +20 -0
- package/src/routes.ts +21 -1699
- package/src/stats.test.ts +58 -3
- package/src/stats.ts +45 -16
- package/src/store/errors.ts +14 -0
- package/src/store/migrate.ts +272 -0
- package/src/store/paths.ts +43 -0
- package/src/store/store.ts +532 -0
- package/src/store.test.ts +18 -0
- 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
|
+
}
|