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