dsh-skill-hub 0.3.19 → 0.3.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/CONTRIBUTING.md +1 -1
  2. package/README.md +4 -3
  3. package/README.zh.md +4 -3
  4. package/lib/client.js +10 -6
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +428 -156
  7. package/lib/types/client/api.d.ts +3 -3
  8. package/lib/types/protocol/config.d.ts +17 -11
  9. package/lib/types/protocol/market.d.ts +11 -0
  10. package/lib/types/protocol/sources.d.ts +5 -0
  11. package/lib/types/protocol.d.ts +1 -1
  12. package/lib/types/reconcile.d.ts +24 -0
  13. package/lib/types/repo/github-client.d.ts +11 -4
  14. package/lib/types/repo.d.ts +1 -1
  15. package/lib/types/routes/deps.d.ts +6 -1
  16. package/lib/types/routes/route-state.d.ts +22 -2
  17. package/lib/types/skillfs/scan.d.ts +32 -5
  18. package/lib/types/skillfs.d.ts +34 -2
  19. package/lib/types/store/migrate.d.ts +1 -1
  20. package/lib/types/store/store.d.ts +10 -5
  21. package/package.json +21 -20
  22. package/src/client/api.ts +4 -3
  23. package/src/client/panel/hooks/useMarketFlow.ts +4 -2
  24. package/src/client/settings-card.tsx +2 -1
  25. package/src/client/settings-form.ts +6 -3
  26. package/src/index.ts +12 -2
  27. package/src/protocol/config.ts +27 -24
  28. package/src/protocol/market.ts +12 -0
  29. package/src/protocol/sources.ts +5 -0
  30. package/src/protocol.ts +1 -0
  31. package/src/reconcile.test.ts +74 -1
  32. package/src/reconcile.ts +50 -1
  33. package/src/repo/github-client.ts +26 -14
  34. package/src/repo.test.ts +20 -1
  35. package/src/repo.ts +1 -1
  36. package/src/routes/catalog-data.ts +7 -4
  37. package/src/routes/catalog.ts +51 -28
  38. package/src/routes/deps.ts +7 -2
  39. package/src/routes/market.ts +13 -2
  40. package/src/routes/repo-import.ts +17 -30
  41. package/src/routes/route-state.test.ts +4 -4
  42. package/src/routes/route-state.ts +45 -10
  43. package/src/routes/sources.ts +79 -23
  44. package/src/routes.test.ts +300 -0
  45. package/src/skillfs/scan.ts +119 -17
  46. package/src/skillfs.test.ts +64 -3
  47. package/src/skillfs.ts +51 -8
  48. package/src/store/migrate.ts +6 -4
  49. package/src/store/store.ts +21 -11
  50. package/src/store.test.ts +17 -1
  51. package/src/update.ts +6 -6
@@ -5,6 +5,7 @@ import type { IncomingMessage } from 'node:http'
5
5
  import type { SkillDefinition, SkillSummary } from '@deepseek-ai/dsh-skill'
6
6
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
7
7
  import { makeRoutes, type SkillHubRouteDeps } from './routes.ts'
8
+ import { lastMarketCheck } from './routes/route-state.ts'
8
9
  import { SkillHubStore, statePath } from './store.ts'
9
10
  import { SKILL_HUB_API, SKILL_HUB_API_ROOT, type CatalogResponse, type ConfigResponse, type ErrorResponse, type HubConfig } from './protocol.ts'
10
11
 
@@ -645,6 +646,73 @@ describe('skill-hub routes', () => {
645
646
  expect((del.json() as import('./protocol.ts').MarketSourceResponse).repos).toEqual([{ repo: 'other/repo', ref: 'v2.0.0' }])
646
647
  })
647
648
 
649
+ // 回归:重复添加一个已存在的源只改它的 ref、不重排列表,所以响应必须回传
650
+ // 服务端归一化的 slug —— 客户端不能靠 `repos[repos.length - 1]` 猜操作对象。
651
+ it('returns the resolved slug of the added market source, not a list position', async () => {
652
+ const add = async (repo: string): Promise<import('./protocol.ts').MarketSourceAddResponse> => {
653
+ const res = new FakeResponse()
654
+ await routeFor(SKILL_HUB_API.marketSource).handler(fakeReq('POST', SKILL_HUB_API.marketSource, { repo }), res as never)
655
+ expect(res.status).toBe(200)
656
+ return res.json() as import('./protocol.ts').MarketSourceAddResponse
657
+ }
658
+ await add('first/repo')
659
+ await add('second/repo')
660
+ const again = await add('first/repo@v9.9.9')
661
+ expect(again.repo).toBe('first/repo')
662
+ // 本次操作对象并不在末位:按位置猜会拿到 second/repo。
663
+ expect(again.repos[again.repos.length - 1].repo).toBe('second/repo')
664
+ expect(again.repos).toEqual([{ repo: 'first/repo', ref: 'v9.9.9' }, { repo: 'second/repo' }])
665
+ // URL 输入也归一到同一个 slug。
666
+ expect((await add('https://github.com/third/repo')).repo).toBe('third/repo')
667
+ })
668
+
669
+ // 回归:未定版的市场源也必须吃 5 分钟节流。曾经把节流时间戳写在「未定版
670
+ // 早退」之后,于是未定版的源每次检查都重新打 GitHub /releases/latest。
671
+ it('throttles the market check for an unpinned source as well', async () => {
672
+ lastMarketCheck.clear()
673
+ await store.addMarketSource('unpinned/throttle-target')
674
+ let releaseLookups = 0
675
+ vi.stubGlobal('fetch', vi.fn(async (url: string) => {
676
+ if (url.includes('/releases/latest')) {
677
+ releaseLookups += 1
678
+ return jsonResponse({ tag_name: 'v3.1.4' })
679
+ }
680
+ return new Response('unexpected url: ' + url, { status: 599 })
681
+ }))
682
+ try {
683
+ const first = new FakeResponse()
684
+ await routeFor(SKILL_HUB_API.marketCheck).handler(fakeReq('GET', SKILL_HUB_API.marketCheck), first as never)
685
+ const firstBody = first.json() as import('./protocol.ts').MarketCheckResponse
686
+ expect(firstBody.results[0]).toMatchObject({ repo: 'unpinned/throttle-target', latestTag: 'v3.1.4' })
687
+ expect(firstBody.results[0].throttled).toBeUndefined()
688
+
689
+ const second = new FakeResponse()
690
+ await routeFor(SKILL_HUB_API.marketCheck).handler(fakeReq('GET', SKILL_HUB_API.marketCheck), second as never)
691
+ const secondBody = second.json() as import('./protocol.ts').MarketCheckResponse
692
+ expect(secondBody.results[0].throttled).toBe(true)
693
+ // 第二次没有发生任何上游查询。
694
+ expect(releaseLookups).toBe(1)
695
+ } finally {
696
+ vi.unstubAllGlobals()
697
+ lastMarketCheck.clear()
698
+ }
699
+ })
700
+
701
+ // resolveHubConfig 收敛为单参数后,越界值仍必须回落到 schema 默认,
702
+ // 而不是被写进有效配置。
703
+ it('clamps the numeric stats knobs and fills the remaining defaults', async () => {
704
+ const routes = makeRoutes({
705
+ ...deps,
706
+ config: () => ({ enabled: true, announceToAgent: true, statsWindowDays: -5, statsScanMinutes: 0 }),
707
+ })
708
+ const res = new FakeResponse()
709
+ await routes.find((r) => r.path === SKILL_HUB_API.config)?.handler(fakeReq('GET', SKILL_HUB_API.config), res as never)
710
+ expect(res.status).toBe(200)
711
+ const body = res.json() as ConfigResponse
712
+ expect(body.config.statsWindowDays).toBe(14)
713
+ expect(body.config.statsScanMinutes).toBe(5)
714
+ })
715
+
648
716
  // ------------------------------------------------------- repo import (B方案 job+轮询)
649
717
  it('imports repo skills and records the upstream source', async () => {
650
718
  const skillMd = '---\nname: code-review\ndescription: Reviews code\n---\n\nbody'
@@ -1049,6 +1117,238 @@ describe('skill-hub routes', () => {
1049
1117
  expect(invalidate).toHaveBeenCalledTimes(1)
1050
1118
  })
1051
1119
 
1120
+ // --------------------------- 同名双形态 / 陈旧禁用记录(issue #16 类,实测 G5/G7/G8)
1121
+ it('self-heals a stale disabled record by pointing it at the surviving placeholder', async () => {
1122
+ // 记录指目录形态,磁盘上只剩扁平占位 —— 导入让位/L2 迁移后的典型残留。
1123
+ await writeFile(join(home, 'skills', 'code-review.md.disabled'), '---\nname: code-review\ndescription: stub\n---', 'utf8')
1124
+ await store.addDisabled({ name: 'code-review', description: 'stub', path: join(home, 'skills', 'code-review', 'SKILL.md.disabled'), root: 'user-dsh', disabledAt: 1 })
1125
+ const res = new FakeResponse()
1126
+ await routeFor(SKILL_HUB_API.toggle).handler(fakeReq('POST', SKILL_HUB_API.toggle, { name: 'code-review', enabled: true }), res as never)
1127
+ expect(res.status).toBe(200)
1128
+ await expect(access(join(home, 'skills', 'code-review.md'))).resolves.toBeUndefined()
1129
+ expect(await store.getDisabled('code-review')).toBeUndefined()
1130
+ })
1131
+
1132
+ it('drops a fully stale disabled record so the name becomes usable again', async () => {
1133
+ await store.addDisabled({ name: 'ghost', description: 'x', path: join(home, 'skills', 'ghost', 'SKILL.md.disabled'), root: 'user-dsh', disabledAt: 1 })
1134
+ // 记录还在时,新建同名技能被 409 挡住。
1135
+ const blocked = new FakeResponse()
1136
+ await routeFor(SKILL_HUB_API.create).handler(fakeReq('POST', SKILL_HUB_API.create, { name: 'ghost', description: 'x' }), blocked as never)
1137
+ expect(blocked.status).toBe(409)
1138
+ // 启用报「记录已失效」并把记录清掉,而不是留下一条谁都动不了的死记录。
1139
+ const enable = new FakeResponse()
1140
+ await routeFor(SKILL_HUB_API.toggle).handler(fakeReq('POST', SKILL_HUB_API.toggle, { name: 'ghost', enabled: true }), enable as never)
1141
+ expect(enable.status).toBe(409)
1142
+ expect(String((enable.json() as { error: string }).error)).toContain('stale')
1143
+ expect(await store.getDisabled('ghost')).toBeUndefined()
1144
+ // 名字已释放:新建成功,再次启用则报「不是禁用态」。
1145
+ const created = new FakeResponse()
1146
+ await routeFor(SKILL_HUB_API.create).handler(fakeReq('POST', SKILL_HUB_API.create, { name: 'ghost', description: 'x' }), created as never)
1147
+ expect(created.status).toBe(201)
1148
+ await expect(access(join(home, 'skills', 'ghost', 'SKILL.md'))).resolves.toBeUndefined()
1149
+ const again = new FakeResponse()
1150
+ await routeFor(SKILL_HUB_API.toggle).handler(fakeReq('POST', SKILL_HUB_API.toggle, { name: 'ghost', enabled: true }), again as never)
1151
+ expect(again.status).toBe(404)
1152
+ })
1153
+
1154
+ it('deletes a stale disabled record instead of failing on the missing file', async () => {
1155
+ const tag = await store.saveTag({ name: 'scene-x' })
1156
+ await store.setTagMembers(tag.id, ['ghost'])
1157
+ await store.addDisabled({ name: 'ghost', description: 'x', path: join(home, 'skills', 'ghost', 'SKILL.md.disabled'), root: 'user-dsh', disabledAt: 1 })
1158
+ const res = new FakeResponse()
1159
+ await routeFor(SKILL_HUB_API.skillDelete).handler(fakeReq('POST', SKILL_HUB_API.skillDelete, { name: 'ghost' }), res as never)
1160
+ expect(res.status).toBe(200)
1161
+ expect(await store.getDisabled('ghost')).toBeUndefined()
1162
+ expect((await store.getTag(tag.id))?.skillNames).toEqual([])
1163
+ })
1164
+
1165
+ it('flags two discovery forms occupying the same skill name in one root', async () => {
1166
+ // 目录包与扁平文件同名:注册表只给一个,另一个静默不可达 → 必须报诊断。
1167
+ await writeFile(join(home, 'skills', 'dup-skill.md'), '---\nname: dup-skill\ndescription: flat one\n---', 'utf8')
1168
+ await mkdir(join(home, 'skills', 'dup-skill'), { recursive: true })
1169
+ await writeFile(join(home, 'skills', 'dup-skill', 'SKILL.md'), '---\nname: dup-skill\ndescription: bundle one\n---', 'utf8')
1170
+ const res = new FakeResponse()
1171
+ await routeFor(SKILL_HUB_API.catalog).handler(fakeReq('GET', SKILL_HUB_API.catalog), res as never)
1172
+ expect(res.status).toBe(200)
1173
+ const body = res.json() as CatalogResponse
1174
+ const collisions = body.diagnostics.filter((d) => d.reason.includes('registers the same name'))
1175
+ expect(collisions.map((d) => d.path).sort()).toEqual([
1176
+ join(home, 'skills', 'dup-skill.md'),
1177
+ join(home, 'skills', 'dup-skill', 'SKILL.md'),
1178
+ ].sort())
1179
+ expect(collisions[0].reason).toContain('"dup-skill"')
1180
+ })
1181
+
1182
+ it('reclaims unreferenced trash directories when clearing the trash', async () => {
1183
+ const trash = join(home, 'skills', '.trash')
1184
+ await mkdir(join(trash, 'foo-1'), { recursive: true })
1185
+ await mkdir(join(trash, 'foo-2'), { recursive: true })
1186
+ // 名字索引只剩最新那条;foo-1 已经没有任何记录指得到它。
1187
+ await store.addTrash({ name: 'foo', path: join(trash, 'foo-2'), movedAt: 2, sourcePath: join(home, 'skills', 'foo') })
1188
+ const res = new FakeResponse()
1189
+ await routeFor(SKILL_HUB_API.sourceTrashClear).handler(fakeReq('POST', SKILL_HUB_API.sourceTrashClear), res as never)
1190
+ expect(res.status).toBe(200)
1191
+ const body = res.json() as import('./protocol.ts').SourceTrashClearResponse
1192
+ expect(body.deleted).toEqual(['foo'])
1193
+ expect(body.orphans).toEqual([join(trash, 'foo-1')])
1194
+ await expect(access(join(trash, 'foo-1'))).rejects.toThrow()
1195
+ await expect(access(join(trash, 'foo-2'))).rejects.toThrow()
1196
+ })
1197
+
1198
+ // ------------------------- 导入/同步/删除/新建 遇到扁平形态(issue #16 类,实测 G1–G4)
1199
+ /** 造一个「扁平已禁用占位」:`<name>.md.disabled` + 指向它的 sidecar 记录。 */
1200
+ async function seedFlatDisabledPlaceholder(name: string) {
1201
+ const flat = join(home, 'skills', name + '.md.disabled')
1202
+ await writeFile(flat, `---\nname: ${name}\ndescription: stub\n---\n`, 'utf8')
1203
+ await store.addDisabled({ name, description: 'stub', path: flat, root: 'user-dsh', disabledAt: Date.now() })
1204
+ return flat
1205
+ }
1206
+
1207
+ it('imports over a flat disabled placeholder instead of leaving it behind', async () => {
1208
+ const flat = await seedFlatDisabledPlaceholder('code-review')
1209
+ const skillMd = '---\nname: code-review\ndescription: Reviews code\n---\n\nbody'
1210
+ stubFetch([
1211
+ ['repos/example/repo/git/trees/main', jsonResponse({ tree: [
1212
+ { path: 'skills/code-review/SKILL.md', type: 'blob', size: 60 },
1213
+ ] })],
1214
+ ['repos/example/repo/commits', jsonResponse({ sha: 'abc123', commit: { tree: { sha: 'tree1' } } })],
1215
+ ['repos/example/repo', jsonResponse({ default_branch: 'main' })],
1216
+ ['raw.githubusercontent.com/example/repo/main/skills/code-review/SKILL.md', new Response(skillMd, { status: 200 })],
1217
+ ])
1218
+ try {
1219
+ const res = new FakeResponse()
1220
+ await routeFor(SKILL_HUB_API.repoImport).handler(fakeReq('POST', SKILL_HUB_API.repoImport, { repo: 'example/repo', paths: ['skills/code-review/SKILL.md'] }), res as never)
1221
+ const created = res.json() as import('./protocol.ts').RepoImportResponse
1222
+ let prog: import('./protocol.ts').RepoImportProgressResponse | undefined
1223
+ for (let i = 0; i < 30; i++) {
1224
+ await new Promise((r) => setTimeout(r, 40))
1225
+ const p = new FakeResponse()
1226
+ await routeFor(SKILL_HUB_API.repoImportProgress).handler(fakeReq('GET', `${SKILL_HUB_API.repoImportProgress}?jobId=${created.jobId}`), p as never)
1227
+ prog = p.json() as import('./protocol.ts').RepoImportProgressResponse
1228
+ if (prog.status !== 'running') break
1229
+ }
1230
+ expect(prog!.status).toBe('done')
1231
+ expect(prog!.skipped).toEqual([])
1232
+ expect(prog!.imported.map((r) => r.name)).toEqual(['code-review'])
1233
+ // 关键差异:扁平占位不再「看不见」——它被移入回收站、如实上报,真技能落地。
1234
+ expect(prog!.replaced).toEqual(['code-review'])
1235
+ await expect(access(flat)).rejects.toThrow()
1236
+ await expect(readFile(join(home, 'skills', 'code-review', 'SKILL.md'), 'utf8')).resolves.toContain('name: code-review')
1237
+ expect(await store.listDisabled()).toEqual([])
1238
+ expect((await store.listTrash()).map((entry) => entry.name)).toEqual(['code-review'])
1239
+ } finally {
1240
+ vi.unstubAllGlobals()
1241
+ }
1242
+ })
1243
+
1244
+ /** 同步一个已跟踪技能所需的 fetch 桩(tree + commit + raw)。 */
1245
+ function stubSyncFetch(): void {
1246
+ stubFetch([
1247
+ ['repos/example/repo/git/trees/tree1', jsonResponse({ tree: [
1248
+ { path: 'skills/code-review/SKILL.md', type: 'blob', size: 60 },
1249
+ ] })],
1250
+ ['repos/example/repo/commits', jsonResponse({ sha: 'abc123', commit: { tree: { sha: 'tree1' } } })],
1251
+ ['raw.githubusercontent.com/example/repo/abc123/skills/code-review/SKILL.md', new Response('---\nname: code-review\ndescription: upstream\n---\n', { status: 200 })],
1252
+ ])
1253
+ }
1254
+
1255
+ it('syncs a flat-stored skill into the bundle slot instead of leaving both', async () => {
1256
+ await writeFile(join(home, 'skills', 'code-review.md'), '---\nname: code-review\ndescription: flat\n---\n', 'utf8')
1257
+ await store.addSourceSkill('example/repo', 'skills', '', 'main', 'code-review')
1258
+ stubSyncFetch()
1259
+ try {
1260
+ const res = new FakeResponse()
1261
+ await routeFor(SKILL_HUB_API.sourceSync).handler(fakeReq('POST', SKILL_HUB_API.sourceSync, { repo: 'example/repo' }), res as never)
1262
+ expect(res.status).toBe(200)
1263
+ expect((res.json() as import('./protocol.ts').SourceSyncResponse).synced).toEqual(['code-review'])
1264
+ // 旧扁平文件必须先让开,否则同名两形态并存(注册表只会给一个)。
1265
+ await expect(access(join(home, 'skills', 'code-review.md'))).rejects.toThrow()
1266
+ await expect(readFile(join(home, 'skills', 'code-review', 'SKILL.md'), 'utf8')).resolves.toContain('description: upstream')
1267
+ } finally {
1268
+ vi.unstubAllGlobals()
1269
+ }
1270
+ })
1271
+
1272
+ it('syncs a flat hub-disabled skill and repoints its record at the new bundle', async () => {
1273
+ const flat = await seedFlatDisabledPlaceholder('code-review')
1274
+ await store.addSourceSkill('example/repo', 'skills', '', 'main', 'code-review')
1275
+ stubSyncFetch()
1276
+ try {
1277
+ const res = new FakeResponse()
1278
+ await routeFor(SKILL_HUB_API.sourceSync).handler(fakeReq('POST', SKILL_HUB_API.sourceSync, { repo: 'example/repo' }), res as never)
1279
+ expect(res.status).toBe(200)
1280
+ // 禁用态要保持,但记录必须改指新形态 —— 否则「启用」会去动那个已被替换掉的旧文件。
1281
+ const bundleDisabled = join(home, 'skills', 'code-review', 'SKILL.md.disabled')
1282
+ await expect(access(bundleDisabled)).resolves.toBeUndefined()
1283
+ await expect(access(flat)).rejects.toThrow()
1284
+ expect((await store.getDisabled('code-review'))?.path).toBe(bundleDisabled)
1285
+ } finally {
1286
+ vi.unstubAllGlobals()
1287
+ }
1288
+ })
1289
+
1290
+ it('keeps a bundle-disabled skill disabled after syncing it again', async () => {
1291
+ // 记录本来就指向目录包形态(`<name>/SKILL.md.disabled`):让位步骤会把记录删掉,
1292
+ // 若只在「path 变了」时写回,这里就会连禁用状态和记录一起丢掉(活体实测抓到过)。
1293
+ const dir = join(home, 'skills', 'code-review')
1294
+ await mkdir(dir, { recursive: true })
1295
+ const bundleDisabled = join(dir, 'SKILL.md.disabled')
1296
+ await writeFile(bundleDisabled, '---\nname: code-review\ndescription: old\n---\n', 'utf8')
1297
+ await store.addDisabled({ name: 'code-review', description: 'old', path: bundleDisabled, root: 'user-dsh', disabledAt: 1 })
1298
+ await store.addSourceSkill('example/repo', 'skills', '', 'main', 'code-review')
1299
+ stubSyncFetch()
1300
+ try {
1301
+ const res = new FakeResponse()
1302
+ await routeFor(SKILL_HUB_API.sourceSync).handler(fakeReq('POST', SKILL_HUB_API.sourceSync, { repo: 'example/repo' }), res as never)
1303
+ expect((res.json() as import('./protocol.ts').SourceSyncResponse).synced).toEqual(['code-review'])
1304
+ await expect(access(bundleDisabled)).resolves.toBeUndefined()
1305
+ expect((await store.getDisabled('code-review'))?.path).toBe(bundleDisabled)
1306
+ } finally {
1307
+ vi.unstubAllGlobals()
1308
+ }
1309
+ })
1310
+
1311
+ it('follows an upstream delete for a flat-stored skill', async () => {
1312
+ const flat = join(home, 'skills', 'code-review.md')
1313
+ await writeFile(flat, '---\nname: code-review\ndescription: flat\n---\n', 'utf8')
1314
+ await store.addSourceSkill('example/repo', 'skills', 'abc', undefined, 'code-review')
1315
+ const res = new FakeResponse()
1316
+ await routeFor(SKILL_HUB_API.sourceDelete).handler(fakeReq('POST', SKILL_HUB_API.sourceDelete, { repo: 'example/repo', skills: ['code-review'] }), res as never)
1317
+ expect(res.status).toBe(200)
1318
+ const body = res.json() as import('./protocol.ts').SourceDeleteResponse
1319
+ expect(body.failed).toEqual([])
1320
+ expect(body.trashed).toEqual(['code-review'])
1321
+ await expect(access(flat)).rejects.toThrow()
1322
+ expect((await store.listTrash())[0]?.sourcePath).toBe(flat)
1323
+ })
1324
+
1325
+ it('refuses to create a skill whose name is taken by a flat file on disk', async () => {
1326
+ // frontmatter 坏 → 注册表看不见它,但名字确实被占了;建目录会立刻造出同名两形态。
1327
+ await writeFile(join(home, 'skills', 'foo.md'), 'not a frontmatter file', 'utf8')
1328
+ const res = new FakeResponse()
1329
+ await routeFor(SKILL_HUB_API.create).handler(fakeReq('POST', SKILL_HUB_API.create, { name: 'foo', description: 'x' }), res as never)
1330
+ expect(res.status).toBe(409)
1331
+ expect(String((res.json() as { error: string }).error)).toContain('occupies this name on disk')
1332
+ await expect(access(join(home, 'skills', 'foo'))).rejects.toThrow()
1333
+ })
1334
+
1335
+ it('refuses to create a skill whose name is held by a disabled placeholder', async () => {
1336
+ // 有 sidecar 记录时走既有的「先重新启用」提示;没有记录(记录丢失/手改过)
1337
+ // 时以前会直接建档造出同名两形态,现在由磁盘占位检查兜住。
1338
+ await writeFile(join(home, 'skills', 'foo.md.disabled'), '---\nname: foo\ndescription: stub\n---\n', 'utf8')
1339
+ const res = new FakeResponse()
1340
+ await routeFor(SKILL_HUB_API.create).handler(fakeReq('POST', SKILL_HUB_API.create, { name: 'foo', description: 'x' }), res as never)
1341
+ expect(res.status).toBe(409)
1342
+ expect(String((res.json() as { error: string }).error)).toContain('disabled placeholder')
1343
+ await expect(access(join(home, 'skills', 'foo'))).rejects.toThrow()
1344
+ // 有记录时保持原有提示口径。
1345
+ await seedFlatDisabledPlaceholder('bar')
1346
+ const withRecord = new FakeResponse()
1347
+ await routeFor(SKILL_HUB_API.create).handler(fakeReq('POST', SKILL_HUB_API.create, { name: 'bar', description: 'x' }), withRecord as never)
1348
+ expect(withRecord.status).toBe(409)
1349
+ expect(String((withRecord.json() as { error: string }).error)).toContain('re-enable it from the disabled list first')
1350
+ })
1351
+
1052
1352
  it('patches display toggles on the config route', async () => {
1053
1353
  const res = new FakeResponse()
1054
1354
  await routeFor(SKILL_HUB_API.config).handler(fakeReq('POST', SKILL_HUB_API.config, { showUseCount: false }), res as never)
@@ -4,8 +4,9 @@ import { load } from 'js-yaml'
4
4
  import { parseFrontmatter, repairFrontmatterFileText } from './frontmatter.ts'
5
5
  import { rootPath } from './paths.ts'
6
6
  import { errorText } from '../error-text.ts'
7
+ import { mapConcurrent } from '../concurrency.ts'
7
8
  import { dshHome } from '../store.ts'
8
- import type { DiagnosticEntry, WritableRoot } from '../protocol.ts'
9
+ import { HEX_COLOR_RE, type DiagnosticEntry, type WritableRoot } from '../protocol.ts'
9
10
 
10
11
  /** One discoverable skill file in a scanned root. */
11
12
  export interface SkillEntry {
@@ -84,7 +85,9 @@ export async function scanDisabledRoot(base: string): Promise<string[]> {
84
85
  } catch {
85
86
  // 目录里没有禁用的发现文件,跳过
86
87
  }
87
- } else if (name.endsWith('.md.disabled') && name !== 'SKILL.md.disabled') {
88
+ } else if (name.endsWith('.md.disabled')) {
89
+ // 扁平形态的禁用文件。根级 `SKILL.md.disabled` 也归这里:`scanRoot` 会把
90
+ // 根级 `SKILL.md` 文件当作一个扁平技能,它的禁用对应物就该被对账到。
88
91
  paths.push(absolute)
89
92
  }
90
93
  }
@@ -130,7 +133,9 @@ export async function readSkillInterface(directory: string): Promise<SkillInterf
130
133
  const hexColor = (value: unknown): string | undefined => {
131
134
  if (typeof value !== 'string') return undefined
132
135
  const trimmed = value.trim()
133
- return /^#[0-9a-fA-F]{6}$/.test(trimmed) ? trimmed : undefined
136
+ // Shared with the settings card and the config route: one definition, so a
137
+ // change to the accepted color format cannot land in only some of them.
138
+ return HEX_COLOR_RE.test(trimmed) ? trimmed : undefined
134
139
  }
135
140
  const iconPath = (value: unknown): string | undefined => {
136
141
  if (typeof value !== 'string' || value.trim() === '') return undefined
@@ -167,47 +172,144 @@ export async function findProjectRoot(cwd: string): Promise<string> {
167
172
  }
168
173
 
169
174
  /**
170
- * Scan one writable root for files the provider ignores, so the GUI can
171
- * show why a skill never appears — a
172
- * missing frontmatter must be visible, not silent). .disabled files belong
173
- * to the hub and are skipped.
175
+ * Scan one writable root for files the provider ignores, so the GUI can show
176
+ * why a skill never appears — a missing frontmatter, a name that disagrees
177
+ * with the discovery path, and the like must be visible, not silent. Files the
178
+ * hub itself disabled (`.disabled`) are skipped: they are a toggle state, not
179
+ * a defect.
174
180
  */
175
181
  export async function scanDiagnostics(root: WritableRoot, home = dshHome()): Promise<DiagnosticEntry[]> {
176
- const diagnostics: DiagnosticEntry[] = []
177
- for (const entry of await listSkillEntries(root, home)) {
182
+ const entries = await listSkillEntries(root, home)
183
+ // 同一个名字可以由两种发现形态同时占据(目录包 `<name>/SKILL.md` 与扁平
184
+ // `<name>.md`)。注册表只会给出其中一个,另一个静默不可达 —— 必须报出来,
185
+ // 否则面板上完全看不出「技能改了没生效」的原因。分组键同时取「发现路径名」
186
+ // 与「frontmatter 名」:前者是官方 provider 的注册键,后者是本插件 provider
187
+ // 的注册键,两边不一致时至少有一侧会撞上。
188
+ const discovered: Array<{ path: string; keys: string[] }> = []
189
+ // Each entry is read and parsed independently, and the panel polls /catalog
190
+ // every 5 seconds. A serial read would make the response time grow linearly
191
+ // with the skill count — the same reason the per-skill `stat` collection in
192
+ // routes/catalog-data.ts is already parallel. Bounded, not a bare
193
+ // Promise.all: a root can hold hundreds of entries.
194
+ const perEntry = await mapConcurrent(entries, 8, async (entry): Promise<DiagnosticEntry[]> => {
195
+ const found: DiagnosticEntry[] = []
178
196
  let text: string
179
197
  try {
180
198
  text = await readFile(entry.path, 'utf8')
181
199
  } catch (error) {
182
200
  if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
183
- diagnostics.push({ path: entry.path, root, reason: 'unreadable: ' + (errorText(error)) })
201
+ found.push({ path: entry.path, root, reason: 'unreadable: ' + (errorText(error)) })
184
202
  }
185
- continue
203
+ return found
186
204
  }
187
205
  const parsed = parseFrontmatter(text)
188
206
  if ('error' in parsed) {
189
207
  const fixable = repairFrontmatterFileText(text) !== null
190
- diagnostics.push({ path: entry.path, root, reason: parsed.error, ...(fixable ? { fixable: true } : {}) })
191
- continue
208
+ found.push({ path: entry.path, root, reason: parsed.error, ...(fixable ? { fixable: true } : {}) })
209
+ return found
192
210
  }
193
211
  // File was auto-repaired in memory (e.g. unquoted `:`); surface a fixable hint so the user can persist it.
194
212
  const repaired = repairFrontmatterFileText(text)
195
213
  if (repaired !== null) {
196
- diagnostics.push({ path: entry.path, root, reason: 'frontmatter contains unquoted colon/bracket (auto-repaired in memory; click Fix to persist)', fixable: true })
214
+ found.push({ path: entry.path, root, reason: 'frontmatter contains unquoted colon/bracket (auto-repaired in memory; click Fix to persist)', fixable: true })
197
215
  }
198
216
  const { value } = parsed
199
217
  // The provider registers a skill by its discovery path (directory name or
200
218
  // flat file name), so a frontmatter name that diverges from the path makes
201
219
  // the skill show up under a different identity than its metadata claims.
202
220
  const pathName = entry.kind === 'directory' ? basename(entry.directory) : basename(entry.path, '.md')
221
+ discovered.push({ path: entry.path, keys: [...new Set([pathName, value.name])] })
203
222
  if (value.name !== pathName) {
204
- diagnostics.push({ path: entry.path, root, reason: `frontmatter name "${value.name}" does not match the discovery path "${pathName}" (the provider registers by path)` })
223
+ found.push({ path: entry.path, root, reason: `frontmatter name "${value.name}" does not match the discovery path "${pathName}" (the provider registers by path)` })
205
224
  }
206
225
  // Agents decide to auto-activate a skill from its one-line description, so
207
226
  // a too-short description leaves the skill hard to discover automatically.
208
227
  if (value.description.length < 10) {
209
- diagnostics.push({ path: entry.path, root, reason: `description is only ${value.description.length} chars; write a one-line description so agents can auto-activate this skill` })
228
+ found.push({ path: entry.path, root, reason: `description is only ${value.description.length} chars; write a one-line description so agents can auto-activate this skill` })
229
+ }
230
+ return found
231
+ })
232
+ // 同名冲突:把每个命中冲突的发现文件各报一条,让用户两边都能看到并选一个处理。
233
+ const owners = new Map<string, string[]>()
234
+ for (const item of discovered) {
235
+ for (const key of item.keys) {
236
+ const list = owners.get(key)
237
+ if (list === undefined) owners.set(key, [item.path])
238
+ else list.push(item.path)
210
239
  }
211
240
  }
212
- return diagnostics
241
+ const collisions: DiagnosticEntry[] = []
242
+ for (const item of discovered) {
243
+ const shared = item.keys.filter((key) => (owners.get(key)?.length ?? 0) > 1)
244
+ if (shared.length === 0) continue
245
+ collisions.push({
246
+ path: item.path,
247
+ root,
248
+ reason: `another skill in this root registers the same name (${shared.map((key) => '"' + key + '"').join(', ')}); the registry keeps only one of them, so this one is unreachable — delete or rename one of the two`,
249
+ })
250
+ }
251
+ // mapConcurrent preserves index order, so flattening keeps the previous
252
+ // per-entry ordering of the diagnostics.
253
+ return [...perEntry.flat(), ...collisions]
254
+ }
255
+
256
+ /**
257
+ * 某个名字在一个 root 下现存的禁用发现文件(两种形态都认),没有则 undefined。
258
+ * 目录包形态优先(`<name>/SKILL.md.disabled`),其次扁平 `<name>.md.disabled`。
259
+ * 单点定义「禁用态在磁盘上长什么样」,避免各处再拼一次路径(issue #16 类)。
260
+ */
261
+ export async function disabledOccupancyIn(base: string, name: string): Promise<string | undefined> {
262
+ return (await occupancyOf(base, name)).disabled[0]
263
+ }
264
+
265
+ /** stat 一次给出形态;不存在或不可读时 undefined。 */
266
+ async function statKind(path: string): Promise<'file' | 'directory' | undefined> {
267
+ try {
268
+ const info = await stat(path)
269
+ return info.isDirectory() ? 'directory' : 'file'
270
+ } catch {
271
+ return undefined
272
+ }
273
+ }
274
+
275
+ /**
276
+ * 一个名字在某个 root 下的全部磁盘占位。技能名是系统里唯一的身份,但同一个名字
277
+ * 可以被两种形态(目录包 `<name>/SKILL.md` / 扁平 `<name>.md`)× 两种状态
278
+ * (启用 / `.disabled` 改名)占据,而且两种形态可以**同时存在** —— 注册表只会
279
+ * 给出其中一个,另一个静默不可达。所有按名字操作文件的路径都应该从这里取值,
280
+ * 而不是各自拼一次路径(issue #16 类的根因)。
281
+ *
282
+ * 两个数组都按「目录包优先」排序:目录包是 hub 自己写入的形态(导入、创建、
283
+ * 同步都建目录包),冲突时它是更「权威」的那份。
284
+ */
285
+ export interface NameOccupancy {
286
+ /** 启用态发现文件(目录包 `<name>/SKILL.md` 在前,扁平 `<name>.md` 在后)。 */
287
+ enabled: string[]
288
+ /** 禁用态发现文件(`<name>/SKILL.md.disabled` 在前,`<name>.md.disabled` 在后)。 */
289
+ disabled: string[]
290
+ /** `<name>/` 存在,但既没有 `SKILL.md` 也没有 `SKILL.md.disabled`(空壳/残留目录)。 */
291
+ bareDir?: string
292
+ }
293
+
294
+ /** 读一个名字在某个 skills root 下的全部占位。只读,不修改磁盘。 */
295
+ export async function occupancyOf(base: string, name: string): Promise<NameOccupancy> {
296
+ const bundleDir = join(base, name)
297
+ const bundleSkill = join(bundleDir, 'SKILL.md')
298
+ const bundleDisabled = join(bundleDir, 'SKILL.md.disabled')
299
+ const flatSkill = join(base, name + '.md')
300
+ const flatDisabled = join(base, name + '.md.disabled')
301
+ const [bundleSkillKind, bundleDisabledKind, flatSkillKind, flatDisabledKind, bundleDirKind] = await Promise.all([
302
+ statKind(bundleSkill),
303
+ statKind(bundleDisabled),
304
+ statKind(flatSkill),
305
+ statKind(flatDisabled),
306
+ statKind(bundleDir),
307
+ ])
308
+ const result: NameOccupancy = { enabled: [], disabled: [] }
309
+ if (bundleSkillKind === 'file') result.enabled.push(bundleSkill)
310
+ if (flatSkillKind === 'file') result.enabled.push(flatSkill)
311
+ if (bundleDisabledKind === 'file') result.disabled.push(bundleDisabled)
312
+ if (flatDisabledKind === 'file') result.disabled.push(flatDisabled)
313
+ if (bundleDirKind === 'directory' && bundleSkillKind !== 'file' && bundleDisabledKind !== 'file') result.bareDir = bundleDir
314
+ return result
213
315
  }
@@ -1,8 +1,8 @@
1
- import { access, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
1
+ import { access, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'
2
2
  import { tmpdir } from 'node:os'
3
- import { join } from 'node:path'
3
+ import { dirname, join } from 'node:path'
4
4
  import { afterEach, beforeEach, describe, expect, it } from 'vitest'
5
- import { clearTrash, createSkill, disableSkill, enableSkill, parseFrontmatter, restoreSkill, rootOfPath, scanDiagnostics, trashSkill } from './skillfs.ts'
5
+ import { clearTrash, createSkill, disableSkill, enableSkill, fixDiagnosticFile, parseFrontmatter, restoreSkill, rootOfPath, scanDiagnostics, trashSkill } from './skillfs.ts'
6
6
 
7
7
  describe('parseFrontmatter', () => {
8
8
  it('parses a healthy frontmatter', () => {
@@ -260,6 +260,20 @@ describe('scanDiagnostics', () => {
260
260
  expect(await scanDiagnostics('user-dsh', join(dir, 'missing'))).toEqual([])
261
261
  })
262
262
 
263
+ // 并发化(读盘 + 解析从串行改为有界并发)不得让输出变得不确定:
264
+ // 诊断顺序必须仍然等于发现顺序(按路径排序)。
265
+ it('keeps diagnostics in discovery order now that the scan is concurrent', async () => {
266
+ await writeFile(join(skills, 'b-bad.md'), '# nope', 'utf8')
267
+ await writeFile(join(skills, 'a-bad.md'), '# nope', 'utf8')
268
+ await writeFile(join(skills, 'c-bad.md'), '# nope', 'utf8')
269
+ const entries = await scanDiagnostics('user-dsh', home)
270
+ expect(entries.map((entry) => entry.path)).toEqual([
271
+ join(skills, 'a-bad.md'),
272
+ join(skills, 'b-bad.md'),
273
+ join(skills, 'c-bad.md'),
274
+ ])
275
+ })
276
+
263
277
  it('flags a frontmatter name that diverges from the discovery path', async () => {
264
278
  await mkdir(join(skills, 'folder-name'))
265
279
  await writeFile(join(skills, 'folder-name', 'SKILL.md'), '---\nname: other-name\ndescription: A decent longer description\n---', 'utf8')
@@ -277,6 +291,53 @@ describe('scanDiagnostics', () => {
277
291
  })
278
292
  })
279
293
 
294
+ describe('fixDiagnosticFile', () => {
295
+ let dir: string
296
+ let home: string
297
+ let skills: string
298
+
299
+ beforeEach(async () => {
300
+ dir = await mkdtemp(join(tmpdir(), 'dsh-skill-hub-fixdiag-'))
301
+ home = join(dir, 'home')
302
+ skills = join(home, 'skills')
303
+ await mkdir(skills, { recursive: true })
304
+ })
305
+
306
+ afterEach(async () => {
307
+ await rm(dir, { recursive: true, force: true })
308
+ })
309
+
310
+ it('persists the repair atomically and leaves no temp file behind', async () => {
311
+ const path = join(skills, 'needs-fix', 'SKILL.md')
312
+ await mkdir(dirname(path), { recursive: true })
313
+ // Unquoted colon in the description: parseable only after an in-memory
314
+ // repair, which is exactly what the "Fix" action persists.
315
+ await writeFile(path, '---\nname: needs-fix\ndescription: Build for AWS: ECS\n---\nbody', 'utf8')
316
+ const before = await scanDiagnostics('user-dsh', home)
317
+ expect(before).toHaveLength(1)
318
+ expect(before[0]).toMatchObject({ path, fixable: true })
319
+
320
+ expect(await fixDiagnosticFile(path, home)).toBe(path)
321
+
322
+ const parsed = parseFrontmatter(await readFile(path, 'utf8'))
323
+ if ('error' in parsed) throw new Error('repaired file should parse: ' + parsed.error)
324
+ expect(parsed.value.description).toBe('Build for AWS: ECS')
325
+ expect(await scanDiagnostics('user-dsh', home)).toEqual([])
326
+ // Atomic write = sibling temp file + rename, so no .tmp may survive.
327
+ expect(await readdir(dirname(path))).toEqual(['SKILL.md'])
328
+ })
329
+
330
+ it('refuses paths outside the writable roots and files that need no repair', async () => {
331
+ await expect(fixDiagnosticFile(join(dir, 'outside', 'SKILL.md'), home)).rejects.toThrow(/not a hub writable skill path/)
332
+
333
+ const healthy = join(skills, 'fine', 'SKILL.md')
334
+ await mkdir(dirname(healthy), { recursive: true })
335
+ await writeFile(healthy, '---\nname: fine\ndescription: A perfectly fine description\n---\nbody', 'utf8')
336
+ await expect(fixDiagnosticFile(healthy, home)).rejects.toThrow(/not auto-fixable/)
337
+ expect(await readdir(dirname(healthy))).toEqual(['SKILL.md'])
338
+ })
339
+ })
340
+
280
341
  describe('rootOfPath', () => {
281
342
  it('recognizes the writable roots', async () => {
282
343
  const dir = await mkdtemp(join(tmpdir(), 'dsh-skill-hub-root-'))