dsh-skill-hub 0.2.6 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/routes.ts CHANGED
@@ -18,13 +18,16 @@ import type { IncomingMessage, ServerResponse } from 'node:http'
18
18
  import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
19
19
  import type { SkillDefinition, SkillSummary } from '@deepseek-ai/dsh-skill'
20
20
  import { isSkillName } from '@deepseek-ai/dsh-skill'
21
+ import { randomUUID } from 'node:crypto'
21
22
  import {
23
+ cleanupLeftoverImportDirs,
22
24
  collectRepoSkillFiles,
23
25
  diffRemoteSkills,
24
26
  discoverRepoEntries,
25
27
  downloadRepoSkill,
26
28
  getLatestCommit,
27
29
  getLatestReleaseTag,
30
+ isAbortError,
28
31
  listRepoBranches,
29
32
  loadRepoTree,
30
33
  loadRepoTreeAt,
@@ -38,13 +41,16 @@ import {
38
41
  import {
39
42
  SKILL_HUB_API,
40
43
  type CatalogResponse,
44
+ type RepoImportCancelRequest,
45
+ type RepoImportCancelResponse,
46
+ type RepoImportProgressResponse,
47
+ type RepoImportRequest,
48
+ type RepoImportResponse,
41
49
  type CatalogSkill,
42
50
  type CollectionGroup,
43
51
  type ConfigResponse,
44
52
  type CreateResponse,
45
53
  type RepoDiscoverResponse,
46
- type RepoImportRequest,
47
- type RepoImportResponse,
48
54
  type CreateRequest,
49
55
  type ErrorResponse,
50
56
  type GroupsResponse,
@@ -74,6 +80,12 @@ import {
74
80
  type TagDeleteResponse,
75
81
  type TagMembersRequest,
76
82
  type TagMembersResponse,
83
+ type TagReorderRequest,
84
+ type TagReorderResponse,
85
+ type CollectionReorderRequest,
86
+ type CollectionReorderResponse,
87
+ type SourceGroupReorderRequest,
88
+ type SourceGroupReorderResponse,
77
89
  type TagSaveRequest,
78
90
  type TagSaveResponse,
79
91
  type ToggleBatchRequest,
@@ -272,17 +284,23 @@ async function resolveWritableSkill(deps: SkillHubRouteDeps, name: string, cwd?:
272
284
 
273
285
  /** 系统集合组 + 用户 tag + origin 映射(groups 路由的数据源)。 */
274
286
  async function buildGroups(deps: SkillHubRouteDeps): Promise<GroupsResponse> {
275
- const [tags, origins] = await Promise.all([deps.store.listTags(), deps.store.listOrigins()])
287
+ const [tags, origins, collectionOrder, sourceGroupOrder] = await Promise.all([deps.store.listTags(), deps.store.listOrigins(), deps.store.getCollectionOrder(), deps.store.getSourceGroupOrder()])
276
288
  const byCollection = new Map<string, string[]>()
277
289
  for (const [skillName, origin] of Object.entries(origins)) {
278
290
  const list = byCollection.get(origin)
279
291
  if (list === undefined) byCollection.set(origin, [skillName])
280
292
  else list.push(skillName)
281
293
  }
294
+ const orderIndex = new Map(collectionOrder.map((name, i) => [name, i] as const))
282
295
  const collections: CollectionGroup[] = [...byCollection.entries()]
283
296
  .map(([name, skillNames]) => ({ name, skillNames: [...skillNames].sort((a, b) => a.localeCompare(b)) }))
284
- .sort((a, b) => a.name.localeCompare(b.name))
285
- return { ok: true, tags, collections, origins }
297
+ .sort((a, b) => {
298
+ const ai = orderIndex.has(a.name) ? orderIndex.get(a.name)! : Infinity
299
+ const bi = orderIndex.has(b.name) ? orderIndex.get(b.name)! : Infinity
300
+ if (ai !== bi) return ai - bi
301
+ return a.name.localeCompare(b.name)
302
+ })
303
+ return { ok: true, tags, collections, origins, ...(sourceGroupOrder.length > 0 ? { sourceGroupOrder } : {}), ...(collectionOrder.length > 0 ? { collectionOrder } : {}) }
286
304
  }
287
305
 
288
306
  /** 目录中存在的技能名集合(tag 成员校验用):启用目录 ∪ 已禁用名单,避免成员因禁用而丢失。 */
@@ -434,6 +452,41 @@ const lastSourceCheck = new Map<string, number>()
434
452
  /** Last successful market update check per repo (same throttle). */
435
453
  const lastMarketCheck = new Map<string, number>()
436
454
 
455
+ /** Async import job (B方案:jobId + 轮询,选项2后台继续) */
456
+ interface ImportJob {
457
+ jobId: string
458
+ repo: string
459
+ ref: string
460
+ total: number
461
+ done: number
462
+ current?: string
463
+ currentFile?: string
464
+ totalBytes: number
465
+ downloadedBytes: number
466
+ startTime: number
467
+ imported: Array<{ name: string; origin: string; path: string }>
468
+ skipped: Array<{ name: string; reason: 'exists' }>
469
+ failed: Array<{ name: string; error: string }>
470
+ status: 'running' | 'done' | 'cancelled' | 'error'
471
+ error?: string
472
+ controller: AbortController
473
+ createdAt: number
474
+ }
475
+ const importJobs = new Map<string, ImportJob>()
476
+ const IMPORT_JOB_TTL_MS = 5 * 60_000
477
+ const IMPORT_JOB_MAX = 100
478
+ function gcImportJobs(): void {
479
+ if (importJobs.size > 500) importJobs.clear()
480
+ const now = Date.now()
481
+ for (const [id, job] of importJobs) {
482
+ if (job.status !== 'running' && now - job.createdAt > IMPORT_JOB_TTL_MS) importJobs.delete(id)
483
+ }
484
+ if (importJobs.size > IMPORT_JOB_MAX) {
485
+ const sorted = [...importJobs.entries()].sort((a, b) => a[1].createdAt - b[1].createdAt)
486
+ for (let i = 0; i < sorted.length - IMPORT_JOB_MAX; i++) importJobs.delete(sorted[i][0])
487
+ }
488
+ }
489
+
437
490
  /** Replace one skill directory with a fresh download; restores the old dir on failure. */
438
491
  async function replaceSkillDir(targetDir: string, download: () => Promise<void>): Promise<void> {
439
492
  // 点前缀:发现扫描跳过点开头的目录(与导入临时目录 .<name>.import- 同一
@@ -566,12 +619,28 @@ export function makeRoutes(deps: SkillHubRouteDeps): WebRoute[] {
566
619
  const name = typeof request.name === 'string' ? request.name : ''
567
620
  if (name === '') { writeError(res, 400, 'name is required'); return }
568
621
  const resolved = await resolveWritableSkill(deps, name, typeof request.cwd === 'string' ? request.cwd : undefined)
569
- if (!resolved.ok) { writeError(res, resolved.status, resolved.error); return }
622
+ // 已禁用的技能不在 registry 中,resolve 会 404,这里单独处理:允许整组删除未开启的技能
623
+ let trashResult: { path: string; source: string } | null = null
624
+ if (!resolved.ok) {
625
+ const disabled = await deps.store.getDisabled(name)
626
+ if (disabled !== undefined) {
627
+ // 禁用态:SKILL.md.disabled 或 *.md.disabled,直接将其所在技能整体移入回收站
628
+ const isBundleDisabled = disabled.path.endsWith('SKILL.md.disabled')
629
+ const sourceToTrash = isBundleDisabled ? dirname(disabled.path) : disabled.path
630
+ const trashDir = join(dirname(sourceToTrash), '.trash')
631
+ await mkdir(trashDir, { recursive: true })
632
+ const target = join(trashDir, basename(sourceToTrash) + '-' + Date.now())
633
+ await rename(sourceToTrash, target)
634
+ trashResult = { path: target, source: sourceToTrash }
635
+ } else {
636
+ writeError(res, resolved.status, resolved.error); return
637
+ }
638
+ }
570
639
  // 入回收站前快照来源归属与场景成员:恢复时把它们加回来,否则恢复
571
640
  // 后的技能会丢失来源(变成「个人技能」)和场景分组。
572
641
  const tracked = await deps.store.getSourceForSkill(name)
573
642
  const tagIds = (await deps.store.listTags()).filter((tag) => tag.skillNames.includes(name)).map((tag) => tag.id)
574
- const { path, source } = await trashSkill(resolved.path)
643
+ const { path, source } = trashResult ?? await trashSkill((resolved as { ok: true; path: string }).path)
575
644
  await deps.store.addTrash({
576
645
  name,
577
646
  path,
@@ -897,7 +966,7 @@ export function makeRoutes(deps: SkillHubRouteDeps): WebRoute[] {
897
966
  },
898
967
  }),
899
968
  // ---------------------------------------------------------------- repo
900
- // Discover importable skills in a public GitHub repo (skills/ + design-templates/).
969
+ // Discover importable skills in a public GitHub repo (any top-level root containing SKILL.md).
901
970
  route({
902
971
  path: SKILL_HUB_API.repo,
903
972
  methods: ['GET'],
@@ -934,10 +1003,9 @@ export function makeRoutes(deps: SkillHubRouteDeps): WebRoute[] {
934
1003
  writeJson(res, 200, { ok: true, repo, ref: resolvedRef, entries } satisfies RepoDiscoverResponse)
935
1004
  },
936
1005
  }),
937
- // ----------------------------------------------------------- repo/import
938
- // Install selected repo skills, preserving full skill directories and
939
- // recording the upstream source (repo + commit snapshot + manifest) so
940
- // they form one tracked collection group.
1006
+ // ----------------------------------------------------------- repo/import B方案 Job
1007
+ // Install selected repo skills — now async job (B方案:轮询 + 选项2后台继续)
1008
+ // POST returns jobId instantly (<500ms), GET /progress polls for done.
941
1009
  route({
942
1010
  path: SKILL_HUB_API.repoImport,
943
1011
  methods: ['POST'],
@@ -950,59 +1018,162 @@ export function makeRoutes(deps: SkillHubRouteDeps): WebRoute[] {
950
1018
  const parsed = normalizeRepoInput(input)
951
1019
  if (parsed === null) { writeError(res, 400, 'repo must be owner/repo or a github.com URL'); return }
952
1020
  const repo = repoSlug(parsed)
953
- // The ref the discovery ran against must drive this import too, so
954
- // the tree, commit snapshot, and recorded source ref all agree
955
- // (falls back to the input's own @ref when the client omitted it).
956
1021
  const ref = typeof request.ref === 'string' && request.ref !== '' ? request.ref : parsed.ref
957
1022
  const { ref: resolvedRef, tree } = await loadRepoTree(repo, ref)
958
1023
  const existing = await knownSkillNames(deps)
959
1024
  const entries = discoverRepoEntries(tree, repo, existing)
960
1025
  const byPath = new Map(entries.map((entry) => [entry.path, entry]))
961
1026
  const selected = paths.map((path) => byPath.get(path)).filter((entry): entry is NonNullable<typeof entry> => entry !== undefined)
1027
+ if (selected.length === 0) { writeError(res, 400, 'no matching skills for selected paths'); return }
962
1028
  if (selected.length > 500) { writeError(res, 400, 'select at most 500 skills per import'); return }
963
1029
  const totalBytes = selected.reduce((sum, entry) => sum + entry.totalBytes, 0)
964
1030
  if (totalBytes > 200 * 1024 * 1024) { writeError(res, 400, 'selected skills exceed the 200MB import limit'); return }
965
1031
 
966
- // Snapshot the upstream commit for the source record (best-effort:
967
- // a failure still imports, just with an unverified snapshot).
968
1032
  let commitSha = ''
969
1033
  try {
970
1034
  const latest = await getLatestCommit(repo, resolvedRef)
971
1035
  commitSha = latest.commitSha
972
1036
  } catch {
973
- // source record keeps an empty snapshot; first check backfills it
1037
+ // empty snapshot ok
974
1038
  }
975
1039
 
976
- const imported: RepoImportResponse['imported'] = []
977
- const skipped: RepoImportResponse['skipped'] = []
978
- const failed: RepoImportResponse['failed'] = []
979
- const targetRoot = rootPath('user-dsh', homeOf(deps))
980
- await mkdir(targetRoot, { recursive: true })
981
- // 导入的技能与新建技能一样自动归入默认场景「通用」,否则它们在
982
- // 场景 tab 里不可见(来源集合只出现在来源 tab)。
983
- const defaultTag = await deps.store.getDefaultTag()
984
- for (const entry of selected) {
985
- if (entry.existing) {
986
- skipped.push({ name: entry.name, reason: 'exists' })
987
- continue
988
- }
989
- if (await pathExists(join(targetRoot, entry.name))) {
990
- skipped.push({ name: entry.name, reason: 'exists' })
991
- continue
1040
+ const jobId = 'imp_' + randomUUID().slice(0, 8)
1041
+ const controller = new AbortController()
1042
+ const now = Date.now()
1043
+ const job: ImportJob = {
1044
+ jobId,
1045
+ repo,
1046
+ ref: resolvedRef,
1047
+ total: selected.length,
1048
+ done: 0,
1049
+ totalBytes,
1050
+ downloadedBytes: 0,
1051
+ startTime: now,
1052
+ imported: [],
1053
+ skipped: [],
1054
+ failed: [],
1055
+ status: 'running',
1056
+ controller,
1057
+ createdAt: now,
1058
+ }
1059
+ gcImportJobs()
1060
+ importJobs.set(jobId, job)
1061
+
1062
+ // 启动时顺手清理一次残留临时目录(best-effort,不阻塞响应)
1063
+ const targetRootEarly = rootPath('user-dsh', homeOf(deps))
1064
+ void cleanupLeftoverImportDirs(targetRootEarly).catch(() => {})
1065
+
1066
+ // 后台执行,不阻塞响应(选项2:关面板也继续跑,唯有 /cancel 才 abort)
1067
+ void (async () => {
1068
+ const targetRoot = rootPath('user-dsh', homeOf(deps))
1069
+ await mkdir(targetRoot, { recursive: true })
1070
+ const defaultTag = await deps.store.getDefaultTag()
1071
+ let needInvalidate = false
1072
+ for (const entry of selected) {
1073
+ if (controller.signal.aborted) break
1074
+ job.current = entry.name
1075
+ job.currentFile = entry.dir + '/SKILL.md'
1076
+ if (entry.existing) {
1077
+ job.skipped.push({ name: entry.name, reason: 'exists' })
1078
+ job.downloadedBytes += entry.totalBytes
1079
+ job.done += 1
1080
+ continue
1081
+ }
1082
+ if (await pathExists(join(targetRoot, entry.name))) {
1083
+ job.skipped.push({ name: entry.name, reason: 'exists' })
1084
+ job.downloadedBytes += entry.totalBytes
1085
+ job.done += 1
1086
+ continue
1087
+ }
1088
+ if (controller.signal.aborted) break
1089
+ const files = collectRepoSkillFiles(tree, entry.dir)
1090
+ try {
1091
+ const result = await downloadRepoSkill(repo, resolvedRef, entry, files, targetRoot, fetch, controller.signal, (bytes, file) => {
1092
+ job.downloadedBytes += bytes
1093
+ job.currentFile = entry.dir + '/' + file
1094
+ })
1095
+ await deps.store.addSourceSkill(repo, entry.root, commitSha, resolvedRef, entry.name)
1096
+ await deps.store.mergeSourceManifest(repo, skillManifest(tree, entry.dir), entry.dir)
1097
+ if (defaultTag !== undefined) await deps.store.addSkillToTag(defaultTag.id, entry.name)
1098
+ job.imported.push({ name: entry.name, origin: entry.origin, path: result.skillPath })
1099
+ needInvalidate = true
1100
+ } catch (error) {
1101
+ if (isAbortError(error) || controller.signal.aborted) {
1102
+ // 取消时不记为 failed,保留已完成的 imported/skipped,直接跳出
1103
+ break
1104
+ }
1105
+ job.failed.push({ name: entry.name, error: error instanceof Error ? error.message : String(error) })
1106
+ } finally {
1107
+ job.done += 1
1108
+ }
992
1109
  }
993
- const files = collectRepoSkillFiles(tree, entry.dir)
994
- try {
995
- const result = await downloadRepoSkill(repo, resolvedRef, entry, files, targetRoot)
996
- await deps.store.addSourceSkill(repo, entry.root, commitSha, resolvedRef, entry.name)
997
- await deps.store.mergeSourceManifest(repo, skillManifest(tree, entry.dir), entry.dir)
998
- if (defaultTag !== undefined) await deps.store.addSkillToTag(defaultTag.id, entry.name)
999
- imported.push({ name: entry.name, origin: entry.origin, path: result.skillPath })
1000
- } catch (error) {
1001
- failed.push({ name: entry.name, error: error instanceof Error ? error.message : String(error) })
1110
+ if (controller.signal.aborted) {
1111
+ job.status = 'cancelled'
1112
+ job.current = undefined
1113
+ job.currentFile = undefined
1114
+ } else {
1115
+ job.status = 'done'
1116
+ job.current = undefined
1117
+ job.currentFile = undefined
1118
+ // 确保字节进度最终对齐(避免浮点/并发尾差)
1119
+ job.downloadedBytes = job.totalBytes
1002
1120
  }
1121
+ if (needInvalidate) deps.invalidate?.()
1122
+ })().catch((error) => {
1123
+ job.status = 'error'
1124
+ job.error = error instanceof Error ? error.message : String(error)
1125
+ job.current = undefined
1126
+ job.currentFile = undefined
1127
+ })
1128
+
1129
+ writeJson(res, 200, { ok: true, jobId, total: selected.length, totalBytes } satisfies RepoImportResponse)
1130
+ },
1131
+ }),
1132
+ // 进度轮询(含字节级进度和速度)
1133
+ route({
1134
+ path: SKILL_HUB_API.repoImportProgress,
1135
+ methods: ['GET'],
1136
+ handler: async ({ res, url }) => {
1137
+ const jobId = queryParam(url, 'jobId') ?? ''
1138
+ if (jobId === '') { writeError(res, 400, 'jobId is required'); return }
1139
+ const job = importJobs.get(jobId)
1140
+ if (job === undefined) { writeError(res, 404, 'import job not found: ' + jobId); return }
1141
+ const elapsedSec = Math.max(0.5, (Date.now() - job.startTime) / 1000)
1142
+ const bytesPerSecond = job.status === 'running' ? Math.round(job.downloadedBytes / elapsedSec) : undefined
1143
+ writeJson(res, 200, {
1144
+ ok: true,
1145
+ jobId: job.jobId,
1146
+ status: job.status,
1147
+ total: job.total,
1148
+ done: job.done,
1149
+ ...(job.current !== undefined ? { current: job.current } : {}),
1150
+ ...(job.currentFile !== undefined ? { currentFile: job.currentFile } : {}),
1151
+ totalBytes: job.totalBytes,
1152
+ downloadedBytes: job.downloadedBytes,
1153
+ ...(bytesPerSecond !== undefined ? { bytesPerSecond } : {}),
1154
+ imported: [...job.imported],
1155
+ skipped: [...job.skipped],
1156
+ failed: [...job.failed],
1157
+ ...(job.error !== undefined ? { error: job.error } : {}),
1158
+ } satisfies RepoImportProgressResponse)
1159
+ },
1160
+ }),
1161
+ // 取消任务(选项2:唯有点取消才停)
1162
+ route({
1163
+ path: SKILL_HUB_API.repoImportCancel,
1164
+ methods: ['POST'],
1165
+ jsonBody: true,
1166
+ handler: async ({ res, body }) => {
1167
+ const request = body as unknown as RepoImportCancelRequest
1168
+ const jobId = typeof request.jobId === 'string' ? request.jobId : ''
1169
+ if (jobId === '') { writeError(res, 400, 'jobId is required'); return }
1170
+ const job = importJobs.get(jobId)
1171
+ if (job === undefined) { writeError(res, 404, 'import job not found: ' + jobId); return }
1172
+ if (job.status === 'running') {
1173
+ job.controller.abort()
1174
+ job.status = 'cancelled'
1003
1175
  }
1004
- deps.invalidate?.()
1005
- writeJson(res, 200, { ok: true, imported, skipped, failed } satisfies RepoImportResponse)
1176
+ writeJson(res, 200, { ok: true, jobId: job.jobId, status: job.status as 'cancelled' | 'done' } satisfies RepoImportCancelResponse)
1006
1177
  },
1007
1178
  }),
1008
1179
  // -------------------------------------------------------------- update
@@ -1068,22 +1239,73 @@ export function makeRoutes(deps: SkillHubRouteDeps): WebRoute[] {
1068
1239
  writeJson(res, 200, { ok: true, tags: await deps.store.listTags() } satisfies TagMembersResponse)
1069
1240
  },
1070
1241
  }),
1242
+ // ---------------------------------------------------------- tag/reorder
1243
+ // 拖拽重排场景分组顺序
1244
+ route({
1245
+ path: SKILL_HUB_API.tagReorder,
1246
+ methods: ['POST'],
1247
+ jsonBody: true,
1248
+ handler: async ({ res, body }) => {
1249
+ const request = body as unknown as TagReorderRequest
1250
+ const orderedIds = Array.isArray(request.orderedIds) ? request.orderedIds.filter((id): id is string => typeof id === 'string' && id !== '') : []
1251
+ try {
1252
+ const tags = await deps.store.reorderTags(orderedIds)
1253
+ writeJson(res, 200, { ok: true, tags } satisfies TagReorderResponse)
1254
+ } catch (error) {
1255
+ if (error instanceof StoreError) { writeError(res, error.kind === 'validation' ? 400 : error.kind === 'not-found' ? 404 : 409, error.message); return }
1256
+ throw error
1257
+ }
1258
+ },
1259
+ }),
1260
+ // ------------------------------------------------- collection/reorder
1261
+ // 拖拽重排来源集合顺序
1262
+ route({
1263
+ path: SKILL_HUB_API.collectionReorder,
1264
+ methods: ['POST'],
1265
+ jsonBody: true,
1266
+ handler: async ({ res, body }) => {
1267
+ const request = body as unknown as CollectionReorderRequest
1268
+ const orderedNames = Array.isArray(request.orderedNames) ? request.orderedNames.filter((n): n is string => typeof n === 'string' && n !== '') : []
1269
+ const order = await deps.store.reorderCollections(orderedNames)
1270
+ const groups = await buildGroups(deps)
1271
+ writeJson(res, 200, { ok: true, collections: groups.collections, order } satisfies CollectionReorderResponse)
1272
+ },
1273
+ }),
1274
+ // ------------------------------------------------ source-group/reorder
1275
+ // 拖拽重排来源顶层分组(project / collections / personal 统一顺序)
1276
+ route({
1277
+ path: SKILL_HUB_API.sourceGroupReorder,
1278
+ methods: ['POST'],
1279
+ jsonBody: true,
1280
+ handler: async ({ res, body }) => {
1281
+ const request = body as unknown as SourceGroupReorderRequest
1282
+ const orderedKeys = Array.isArray(request.orderedKeys) ? request.orderedKeys.filter((k): k is string => typeof k === 'string' && k !== '') : []
1283
+ const order = await deps.store.reorderSourceGroups(orderedKeys)
1284
+ writeJson(res, 200, { ok: true, order } satisfies SourceGroupReorderResponse)
1285
+ },
1286
+ }),
1071
1287
  // ------------------------------------------------------------- sources
1072
1288
  // 来源列表 + 派生 origin 映射 + 集合组 + 回收站。
1073
1289
  route({
1074
1290
  path: SKILL_HUB_API.sources,
1075
1291
  methods: ['GET'],
1076
1292
  handler: async ({ res }) => {
1077
- const [sources, origins, trash] = await Promise.all([deps.store.listSources(), deps.store.listOrigins(), deps.store.listTrash()])
1293
+ const [sources, origins, trash, collectionOrder] = await Promise.all([deps.store.listSources(), deps.store.listOrigins(), deps.store.listTrash(), deps.store.getCollectionOrder()])
1078
1294
  const byCollection = new Map<string, string[]>()
1079
1295
  for (const [skillName, origin] of Object.entries(origins)) {
1080
1296
  const list = byCollection.get(origin)
1081
1297
  if (list === undefined) byCollection.set(origin, [skillName])
1082
1298
  else list.push(skillName)
1083
1299
  }
1300
+ const orderIndex = new Map(collectionOrder.map((name, i) => [name, i] as const))
1084
1301
  const collections: CollectionGroup[] = [...byCollection.entries()]
1085
1302
  .map(([name, skillNames]) => ({ name, skillNames: [...skillNames].sort((a, b) => a.localeCompare(b)) }))
1086
- .sort((a, b) => a.name.localeCompare(b.name))
1303
+ .sort((a, b) => {
1304
+ const ai = orderIndex.has(a.name) ? orderIndex.get(a.name)! : Infinity
1305
+ const bi = orderIndex.has(b.name) ? orderIndex.get(b.name)! : Infinity
1306
+ if (ai !== bi) return ai - bi
1307
+ return a.name.localeCompare(b.name)
1308
+ })
1087
1309
  writeJson(res, 200, { ok: true, sources, origins, collections, trash } satisfies SourcesResponse)
1088
1310
  },
1089
1311
  }),
package/src/skillfs.ts CHANGED
@@ -14,7 +14,7 @@
14
14
 
15
15
  import { mkdir, readdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'
16
16
  import { homedir } from 'node:os'
17
- import { basename, dirname, join, resolve } from 'node:path'
17
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
18
18
  import { dump, load } from 'js-yaml'
19
19
  import { isSkillName } from '@deepseek-ai/dsh-skill'
20
20
  import { dshHome } from './store.ts'
@@ -35,10 +35,23 @@ export function rootPath(root: WritableRoot, home = dshHome()): string {
35
35
 
36
36
  /** Root of an absolute skill file path, or undefined when not user-owned. */
37
37
  export function rootOfPath(path: string, home = dshHome()): WritableRoot | undefined {
38
- const normalized = join(path)
38
+ const normalized = resolve(path)
39
39
  for (const root of WRITABLE_ROOTS) {
40
- const base = rootPath(root, home)
41
- if (normalized.startsWith(base + '/') || normalized === base) return root
40
+ const base = resolve(rootPath(root, home))
41
+ if (process.platform === 'win32') {
42
+ const n = normalized.toLowerCase()
43
+ const b = base.toLowerCase()
44
+ if (n === b) return root
45
+ if (n.startsWith(b + '/') || n.startsWith(b + '\\') || n.startsWith(b + sep)) return root
46
+ // 兜底:relative 判断(处理盘符、大小写、.. 段,兼容 / 与 \)
47
+ const rel = relative(base, normalized)
48
+ if (rel !== '' && !rel.startsWith('..' + '/') && !rel.startsWith('..' + '\\') && !rel.startsWith('..' + sep) && rel !== '..' && !isAbsolute(rel)) return root
49
+ } else {
50
+ if (normalized === base) return root
51
+ if (normalized.startsWith(base + sep) || normalized.startsWith(base + '/') || normalized.startsWith(base + '\\')) return root
52
+ const rel = relative(base, normalized)
53
+ if (rel !== '' && !rel.startsWith('..' + sep) && !rel.startsWith('..' + '/') && !rel.startsWith('..' + '\\') && rel !== '..' && !isAbsolute(rel)) return root
54
+ }
42
55
  }
43
56
  return undefined
44
57
  }
package/src/store.ts CHANGED
@@ -36,6 +36,10 @@ interface StoreFile {
36
36
  trash?: TrashEntry[]
37
37
  /** Usage-statistics incremental-scan checkpoint (frozen watermark + totals). */
38
38
  skillStats?: SkillStatsCheckpoint
39
+ /** Drag-reorder: collection name order for 来源分组 */
40
+ collectionOrder?: string[]
41
+ /** Drag-reorder: 来源顶层分组整体顺序(project / col:xxx / uncategorized-source) */
42
+ sourceGroupOrder?: string[]
39
43
  }
40
44
 
41
45
  /** Resolve the DSH home directory (the filesystem provider's user-dsh root base). */
@@ -76,7 +80,7 @@ export class StoreError extends Error {
76
80
  * Returns null when the file claims a newer schema than this plugin
77
81
  * understands, so the caller starts empty instead of risking data loss.
78
82
  */
79
- function migrateStore(parsed: unknown): { version: number; disabled: unknown; config?: unknown; tags?: unknown; sources?: unknown; marketSources?: unknown; trash?: unknown; skillStats?: unknown } | null {
83
+ function migrateStore(parsed: unknown): { version: number; disabled: unknown; config?: unknown; tags?: unknown; sources?: unknown; marketSources?: unknown; trash?: unknown; skillStats?: unknown; collectionOrder?: unknown; sourceGroupOrder?: unknown } | null {
80
84
  if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return null
81
85
  const record = parsed as Record<string, unknown>
82
86
  const version = typeof record.version === 'number' ? record.version : 0
@@ -125,6 +129,8 @@ function migrateStore(parsed: unknown): { version: number; disabled: unknown; co
125
129
  // v4 (skillStats) is a pure addition — older files simply lack the field,
126
130
  // and the loader validates its shape, so pass it through untouched.
127
131
  ...(record.skillStats !== undefined ? { skillStats: record.skillStats } : {}),
132
+ ...(Array.isArray(record.collectionOrder) ? { collectionOrder: record.collectionOrder } : {}),
133
+ ...(Array.isArray(record.sourceGroupOrder) ? { sourceGroupOrder: record.sourceGroupOrder } : {}),
128
134
  }
129
135
  }
130
136
 
@@ -137,6 +143,8 @@ export class SkillHubStore {
137
143
  private marketSources: MarketSourceRecord[] = []
138
144
  private trashByName = new Map<string, TrashEntry>()
139
145
  private skillStats: SkillStatsCheckpoint | undefined = undefined
146
+ private collectionOrder: string[] = []
147
+ private sourceGroupOrder: string[] = []
140
148
  private loaded = false
141
149
  /** Serializes persist runs: concurrent mutators must not let an earlier
142
150
  * snapshot overwrite a later one (rename is atomic, ordering is not). */
@@ -189,10 +197,11 @@ export class SkillHubStore {
189
197
  const source = entry as { repo?: unknown; ref?: unknown; root?: unknown; commitSha?: unknown; skills?: unknown; manifest?: unknown } | null
190
198
  if (source !== null && typeof source === 'object' && typeof source.repo === 'string' && source.repo !== '' && Array.isArray(source.skills)) {
191
199
  const manifest = source.manifest as Record<string, unknown> | undefined
200
+ const rawRoot = typeof source.root === 'string' && source.root !== '' && /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(source.root) ? source.root : 'skills'
192
201
  this.sourcesByRepo.set(source.repo, {
193
202
  repo: source.repo,
194
203
  ...(typeof source.ref === 'string' && source.ref !== '' ? { ref: source.ref } : {}),
195
- root: (source.root === 'design-templates' ? 'design-templates' : 'skills') as RepoRoot,
204
+ root: rawRoot,
196
205
  commitSha: typeof source.commitSha === 'string' ? source.commitSha : '',
197
206
  skills: source.skills.filter((n): n is string => typeof n === 'string'),
198
207
  ...(manifest !== null && typeof manifest === 'object' && !Array.isArray(manifest)
@@ -225,7 +234,7 @@ export class SkillHubStore {
225
234
  path: item.path,
226
235
  movedAt: typeof item.movedAt === 'number' ? item.movedAt : 0,
227
236
  ...(typeof item.sourcePath === 'string' && item.sourcePath !== '' ? { sourcePath: item.sourcePath } : {}),
228
- ...(origin !== null && typeof origin === 'object' && typeof origin.repo === 'string' && origin.repo !== '' && (origin.root === 'skills' || origin.root === 'design-templates')
237
+ ...(origin !== null && typeof origin === 'object' && typeof origin.repo === 'string' && origin.repo !== '' && typeof origin.root === 'string' && origin.root !== '' && /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(origin.root)
229
238
  ? {
230
239
  origin: {
231
240
  repo: origin.repo,
@@ -268,6 +277,14 @@ export class SkillHubStore {
268
277
  lastFullReconcile: savedStats.lastFullReconcile,
269
278
  }
270
279
  }
280
+ const rawColOrder = (migrated as Record<string, unknown>).collectionOrder as unknown
281
+ if (Array.isArray(rawColOrder)) {
282
+ this.collectionOrder = rawColOrder.filter((n): n is string => typeof n === 'string' && n !== '')
283
+ }
284
+ const rawSrcOrder = (migrated as Record<string, unknown>).sourceGroupOrder as unknown
285
+ if (Array.isArray(rawSrcOrder)) {
286
+ this.sourceGroupOrder = rawSrcOrder.filter((n): n is string => typeof n === 'string' && n !== '')
287
+ }
271
288
  }
272
289
  } catch (error) {
273
290
  // Missing or unreadable state starts empty; never crash the plugin.
@@ -421,6 +438,55 @@ export class SkillHubStore {
421
438
  if (changed) await this.persist()
422
439
  }
423
440
 
441
+ /** Reorder tag groups by orderedIds (drag-and-drop). */
442
+ async reorderTags(orderedIds: string[]): Promise<SkillTag[]> {
443
+ await this.ensureLoaded()
444
+ const currentIds = [...this.tagsById.keys()]
445
+ if (orderedIds.length !== currentIds.length) throw new StoreError('validation', 'orderedIds length mismatch')
446
+ const seen = new Set<string>()
447
+ for (const id of orderedIds) {
448
+ if (typeof id !== 'string' || id === '') throw new StoreError('validation', 'invalid tag id')
449
+ if (seen.has(id)) throw new StoreError('validation', 'duplicate tag id: ' + id)
450
+ if (!this.tagsById.has(id)) throw new StoreError('not-found', 'tag not found: ' + id)
451
+ seen.add(id)
452
+ }
453
+ const newMap = new Map<string, SkillTag>()
454
+ for (const id of orderedIds) newMap.set(id, this.tagsById.get(id)!)
455
+ this.tagsById = newMap
456
+ await this.persist()
457
+ return [...this.tagsById.values()]
458
+ }
459
+
460
+ /** Collection order for 来源分组拖拽 */
461
+ async getCollectionOrder(): Promise<string[]> {
462
+ await this.ensureLoaded()
463
+ return [...this.collectionOrder]
464
+ }
465
+
466
+ /** Reorder collections by orderedNames (drag-and-drop). */
467
+ async reorderCollections(orderedNames: string[]): Promise<string[]> {
468
+ await this.ensureLoaded()
469
+ const uniq = [...new Set(orderedNames.filter((n): n is string => typeof n === 'string' && n !== ''))]
470
+ this.collectionOrder = uniq
471
+ await this.persist()
472
+ return [...this.collectionOrder]
473
+ }
474
+
475
+ /** Source top-level group order for 来源分组(project / col:xxx / personal) */
476
+ async getSourceGroupOrder(): Promise<string[]> {
477
+ await this.ensureLoaded()
478
+ return [...this.sourceGroupOrder]
479
+ }
480
+
481
+ /** Reorder source top-level groups by orderedKeys */
482
+ async reorderSourceGroups(orderedKeys: string[]): Promise<string[]> {
483
+ await this.ensureLoaded()
484
+ const uniq = [...new Set(orderedKeys.filter((k): k is string => typeof k === 'string' && k !== ''))]
485
+ this.sourceGroupOrder = uniq
486
+ await this.persist()
487
+ return [...this.sourceGroupOrder]
488
+ }
489
+
424
490
  // ------------------------------------------------------------ sources
425
491
 
426
492
  /** All source records, sorted by repo. */
@@ -455,7 +521,7 @@ export class SkillHubStore {
455
521
  * Upsert one skill into a source record. When the repo has no record yet a
456
522
  * new one is created (root + commit snapshot from the caller).
457
523
  */
458
- async addSourceSkill(repo: string, root: RepoRoot, commitSha: string, ref: string | undefined, skillName: string): Promise<void> {
524
+ async addSourceSkill(repo: string, root: string, commitSha: string, ref: string | undefined, skillName: string): Promise<void> {
459
525
  await this.ensureLoaded()
460
526
  const existing = this.sourcesByRepo.get(repo)
461
527
  if (existing === undefined) {
@@ -670,6 +736,8 @@ export class SkillHubStore {
670
736
  ...(this.marketSources.length > 0 ? { marketSources: [...this.marketSources] } : {}),
671
737
  ...(this.trashByName.size > 0 ? { trash: [...this.trashByName.values()] } : {}),
672
738
  ...(this.skillStats !== undefined ? { skillStats: this.skillStats } : {}),
739
+ ...(this.collectionOrder.length > 0 ? { collectionOrder: [...this.collectionOrder] } : {}),
740
+ ...(this.sourceGroupOrder.length > 0 ? { sourceGroupOrder: [...this.sourceGroupOrder] } : {}),
673
741
  }
674
742
  const tmp = this.file + '.tmp'
675
743
  await mkdir(dirname(this.file), { recursive: true })