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