dsh-skill-hub 0.3.20 → 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.
@@ -4,7 +4,7 @@
4
4
  * handler 逻辑不变。
5
5
  */
6
6
 
7
- import { mkdir, rename } from 'node:fs/promises'
7
+ import { mkdir, readdir, rename, rm } from 'node:fs/promises'
8
8
  import { join } from 'node:path'
9
9
  import { isSkillName } from '@deepseek-ai/dsh-skill'
10
10
  import {
@@ -30,7 +30,7 @@ import {
30
30
  skillFileAt,
31
31
  skillManifest,
32
32
  } from '../repo.ts'
33
- import { clearTrash, restoreSkill, rootOfPath, rootPath, trashSkill } from '../skillfs.ts'
33
+ import { clearTrash, occupancyOf, restoreSkill, rootOfPath, rootPath, trashSkill, trashSourceOf, WRITABLE_ROOTS } from '../skillfs.ts'
34
34
  import { errorText } from '../error-text.ts'
35
35
  import {
36
36
  buildCollections,
@@ -43,7 +43,7 @@ import {
43
43
  type RouteSpec,
44
44
  type SkillHubRouteDeps,
45
45
  } from './helpers.ts'
46
- import { MIN_CHECK_INTERVAL_MS, lastSourceCheck, replaceSkillDir } from './route-state.ts'
46
+ import { MIN_CHECK_INTERVAL_MS, lastSourceCheck, replaceInPlace, retireNamePlaceholders } from './route-state.ts'
47
47
 
48
48
  /** 来源跟踪域全部路由 spec(由 routes.ts 经 createRoute 包上统一围栏)。 */
49
49
  export function sourceRoutes(deps: SkillHubRouteDeps): RouteSpec[] {
@@ -149,18 +149,28 @@ export function sourceRoutes(deps: SkillHubRouteDeps): RouteSpec[] {
149
149
  const files = collectRepoSkillFiles(tree, entry.dir)
150
150
  if (files.length === 0) { failed.push({ name, error: 'skill missing upstream' }); continue }
151
151
  const targetDir = join(targetRoot, name)
152
- const wasDisabled = (await deps.store.getDisabled(name)) !== undefined
153
- if (await pathExists(targetDir)) {
154
- await replaceSkillDir(targetDir, async () => {
155
- await downloadRepoSkill(repo, latest.commitSha, entry, files, targetRoot)
156
- })
152
+ const record = await deps.store.getDisabled(name)
153
+ const occupancy = await occupancyOf(targetRoot, name)
154
+ // 落盘位置永远是目录包,所以本地无论是目录包还是扁平文件(启用或禁用)
155
+ // 都得先让开,否则同名两形态会并存(issue #16 类)。
156
+ const download = () => downloadRepoSkill(repo, latest.commitSha, entry, files, targetRoot)
157
+ // 1) 禁用件与空壳让位(顺带清掉 sidecar 禁用记录)。
158
+ await retireNamePlaceholders(deps, targetRoot, name)
159
+ // 2) 启用件按「替换」语义处理:先挪到点前缀备份,下载失败则原样恢复。
160
+ const enabled = occupancy.enabled[0]
161
+ if (enabled !== undefined) {
162
+ await replaceInPlace(trashSourceOf(enabled), download)
157
163
  } else {
158
- await downloadRepoSkill(repo, latest.commitSha, entry, files, targetRoot)
164
+ await download()
159
165
  }
160
- if (wasDisabled) {
166
+ if (record !== undefined) {
161
167
  // Preserve the disabled state: the fresh SKILL.md must not
162
- // re-enter discovery.
163
- await rename(join(targetDir, 'SKILL.md'), join(targetDir, 'SKILL.md.disabled'))
168
+ // re-enter discovery. 记录必须**无条件写回** —— 上面的
169
+ // retireNamePlaceholders 已经把它删过一遍,只按「path 变了才写」
170
+ // 判断会让「同步一个已禁用的技能」丢掉禁用状态与记录(活体实测过)。
171
+ const disabledPath = join(targetDir, 'SKILL.md.disabled')
172
+ await rename(join(targetDir, 'SKILL.md'), disabledPath)
173
+ await deps.store.addDisabled({ ...record, path: disabledPath })
164
174
  } else {
165
175
  await deps.store.removeDisabled(name)
166
176
  }
@@ -206,24 +216,29 @@ export function sourceRoutes(deps: SkillHubRouteDeps): RouteSpec[] {
206
216
  failed.push({ name, error: 'skill is not tracked by this source' })
207
217
  continue
208
218
  }
209
- const sourcePath = join(rootPath('user-dsh', home), name)
210
- if (rootOfPath(sourcePath, home) === undefined) {
211
- failed.push({ name, error: 'skill path is outside the hub writable roots' })
219
+ // Resolve the on-disk occupation instead of assuming a `<root>/<name>`
220
+ // directory: the tracked skill can equally be a flat `<name>.md` file
221
+ // or a hub-disabled placeholder (issue #16 类).
222
+ const base = rootPath('user-dsh', home)
223
+ const occupancy = await occupancyOf(base, name)
224
+ const existing = occupancy.enabled[0] ?? occupancy.disabled[0] ?? occupancy.bareDir
225
+ if (existing === undefined) {
226
+ failed.push({ name, error: 'skill not found on disk' })
212
227
  continue
213
228
  }
214
- if (!await pathExists(sourcePath)) {
215
- failed.push({ name, error: 'skill directory not found' })
229
+ if (rootOfPath(existing, home) === undefined) {
230
+ failed.push({ name, error: 'skill path is outside the hub writable roots' })
216
231
  continue
217
232
  }
218
233
  try {
219
234
  // 入回收站前快照来源与场景归属,恢复时挂回(见 sourceRestore)。
220
235
  const tagIds = (await deps.store.listTags()).filter((tag) => tag.skillNames.includes(name)).map((tag) => tag.id)
221
- const { path } = await trashSkill(sourcePath)
236
+ const { path, source: trashSource } = await trashSkill(existing)
222
237
  await deps.store.addTrash({
223
238
  name,
224
239
  path,
225
240
  movedAt: Date.now(),
226
- sourcePath,
241
+ sourcePath: trashSource,
227
242
  origin: { repo: source.repo, root: source.root, ...(source.ref !== undefined && source.ref !== '' ? { ref: source.ref } : {}), commitSha: source.commitSha },
228
243
  ...(tagIds.length > 0 ? { tagIds } : {}),
229
244
  })
@@ -267,7 +282,7 @@ export function sourceRoutes(deps: SkillHubRouteDeps): RouteSpec[] {
267
282
  for (const tagId of entry.tagIds ?? []) {
268
283
  await deps.store.addSkillToTag(tagId, name)
269
284
  }
270
- await deps.store.removeTrash(name)
285
+ await deps.store.removeTrash(entry.path)
271
286
  deps.invalidate?.()
272
287
  writeJson(res, 200, { ok: true, name, path } satisfies SourceRestoreResponse)
273
288
  },
@@ -281,6 +296,7 @@ export function sourceRoutes(deps: SkillHubRouteDeps): RouteSpec[] {
281
296
  const home = homeOf(deps)
282
297
  const entries = await deps.store.listTrash()
283
298
  const deleted: string[] = []
299
+ const removedPaths: string[] = []
284
300
  const failed: Array<{ name: string; error: string }> = []
285
301
  for (const entry of entries) {
286
302
  try {
@@ -288,14 +304,54 @@ export function sourceRoutes(deps: SkillHubRouteDeps): RouteSpec[] {
288
304
  // A permanently deleted skill must not linger in user groups.
289
305
  await deps.store.removeSkillFromTags(entry.name)
290
306
  deleted.push(entry.name)
307
+ removedPaths.push(entry.path)
291
308
  } catch (error) {
292
309
  failed.push({ name: entry.name, error: errorText(error) })
293
310
  }
294
311
  }
295
- for (const name of deleted) await deps.store.removeTrash(name)
296
- if (deleted.length > 0) deps.invalidate?.()
297
- writeJson(res, 200, { ok: true, deleted, failed } satisfies SourceTrashClearResponse)
312
+ for (const path of removedPaths) await deps.store.removeTrash(path)
313
+ // 回收站记录按名字索引:同名技能先后两次入站时旧记录被覆盖,但它的
314
+ // `<name>-<时间戳>` 目录还留在盘上,任何记录都指不到它 → 永久孤儿。
315
+ // 「清空回收站」正是它的回收时机,顺手扫掉并如实上报。
316
+ const orphans = await sweepOrphanTrash(deps, home, failed)
317
+ if (deleted.length > 0 || orphans.length > 0) deps.invalidate?.()
318
+ writeJson(res, 200, {
319
+ ok: true,
320
+ deleted,
321
+ failed,
322
+ ...(orphans.length > 0 ? { orphans } : {}),
323
+ } satisfies SourceTrashClearResponse)
298
324
  },
299
325
  },
300
326
  ]
301
327
  }
328
+
329
+ /**
330
+ * 删除两个可写根 `.trash` 下不被任何回收站记录引用的残留目录。
331
+ * 只扫 `.trash` 的直接子项、跳过点开头项,失败项并入 `failed`(保留可重试)。
332
+ */
333
+ async function sweepOrphanTrash(deps: SkillHubRouteDeps, home: string, failed: Array<{ name: string; error: string }>): Promise<string[]> {
334
+ const referenced = new Set((await deps.store.listTrash()).map((entry) => entry.path))
335
+ const orphans: string[] = []
336
+ for (const root of WRITABLE_ROOTS) {
337
+ const trashDir = join(rootPath(root, home), '.trash')
338
+ let names: string[]
339
+ try {
340
+ names = await readdir(trashDir)
341
+ } catch {
342
+ continue // 该根没有回收站目录
343
+ }
344
+ for (const name of names) {
345
+ if (name.startsWith('.')) continue
346
+ const full = join(trashDir, name)
347
+ if (referenced.has(full)) continue
348
+ try {
349
+ await rm(full, { recursive: true, force: true })
350
+ orphans.push(full)
351
+ } catch (error) {
352
+ failed.push({ name, error: errorText(error) })
353
+ }
354
+ }
355
+ }
356
+ return orphans
357
+ }
@@ -1117,6 +1117,238 @@ describe('skill-hub routes', () => {
1117
1117
  expect(invalidate).toHaveBeenCalledTimes(1)
1118
1118
  })
1119
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
+
1120
1352
  it('patches display toggles on the config route', async () => {
1121
1353
  const res = new FakeResponse()
1122
1354
  await routeFor(SKILL_HUB_API.config).handler(fakeReq('POST', SKILL_HUB_API.config, { showUseCount: false }), res as never)
@@ -85,7 +85,9 @@ export async function scanDisabledRoot(base: string): Promise<string[]> {
85
85
  } catch {
86
86
  // 目录里没有禁用的发现文件,跳过
87
87
  }
88
- } 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` 文件当作一个扁平技能,它的禁用对应物就该被对账到。
89
91
  paths.push(absolute)
90
92
  }
91
93
  }
@@ -178,6 +180,12 @@ export async function findProjectRoot(cwd: string): Promise<string> {
178
180
  */
179
181
  export async function scanDiagnostics(root: WritableRoot, home = dshHome()): Promise<DiagnosticEntry[]> {
180
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[] }> = []
181
189
  // Each entry is read and parsed independently, and the panel polls /catalog
182
190
  // every 5 seconds. A serial read would make the response time grow linearly
183
191
  // with the skill count — the same reason the per-skill `stat` collection in
@@ -210,6 +218,7 @@ export async function scanDiagnostics(root: WritableRoot, home = dshHome()): Pro
210
218
  // flat file name), so a frontmatter name that diverges from the path makes
211
219
  // the skill show up under a different identity than its metadata claims.
212
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])] })
213
222
  if (value.name !== pathName) {
214
223
  found.push({ path: entry.path, root, reason: `frontmatter name "${value.name}" does not match the discovery path "${pathName}" (the provider registers by path)` })
215
224
  }
@@ -220,7 +229,87 @@ export async function scanDiagnostics(root: WritableRoot, home = dshHome()): Pro
220
229
  }
221
230
  return found
222
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)
239
+ }
240
+ }
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
+ }
223
251
  // mapConcurrent preserves index order, so flattening keeps the previous
224
252
  // per-entry ordering of the diagnostics.
225
- return perEntry.flat()
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
226
315
  }
package/src/skillfs.ts CHANGED
@@ -102,15 +102,27 @@ export async function moveIntoTrash(source: string): Promise<{ path: string; sou
102
102
  return { path: target, source }
103
103
  }
104
104
 
105
+ /**
106
+ * The filesystem entry behind a discovery file: a directory bundle is addressed
107
+ * by its `SKILL.md` (or `SKILL.md.disabled`) and moved as a whole directory, a
108
+ * flat `<name>.md` file is moved as itself. Single definition of that mapping —
109
+ * delete, restore, sync-replace and placeholder retirement must all agree on it,
110
+ * or one of them moves a bundle's discovery file and leaves an empty directory.
111
+ */
112
+ export function trashSourceOf(discoveryPath: string): string {
113
+ const base = basename(discoveryPath)
114
+ return base === 'SKILL.md' || base === 'SKILL.md.disabled' ? dirname(discoveryPath) : discoveryPath
115
+ }
116
+
105
117
  /**
106
118
  * Move a skill (directory bundle or flat .md file) into the root's trash.
107
119
  * The skill stays recoverable: the source is renamed into
108
120
  * <root>/.trash/<name>-<timestamp>. A directory bundle is addressed by its
109
- * SKILL.md path and moved as a whole directory.
121
+ * SKILL.md (or SKILL.md.disabled) path and moved as a whole directory.
110
122
  * @returns the trashed path plus the original path that was moved.
111
123
  */
112
124
  export async function trashSkill(sourcePath: string): Promise<{ path: string; source: string }> {
113
- return moveIntoTrash(basename(sourcePath) === 'SKILL.md' ? dirname(sourcePath) : sourcePath)
125
+ return moveIntoTrash(trashSourceOf(sourcePath))
114
126
  }
115
127
 
116
128
  /** Restore a trashed skill (directory or flat file) to its original location. */
@@ -90,7 +90,7 @@ export interface HydratedState {
90
90
  tagsById: Map<string, SkillTag>
91
91
  sourcesByRepo: Map<string, SourceRecord>
92
92
  marketSources: MarketSourceRecord[]
93
- trashByName: Map<string, TrashEntry>
93
+ trashByPath: Map<string, TrashEntry>
94
94
  skillStats: SkillStatsCheckpoint | undefined
95
95
  marketStats: MarketStatsSnapshot | undefined
96
96
  collectionOrder: string[]
@@ -179,14 +179,16 @@ export function hydrateMigratedState(migrated: MigratedStore): HydratedState {
179
179
  }
180
180
  }
181
181
 
182
- const trashByName = new Map<string, TrashEntry>()
182
+ const trashByPath = new Map<string, TrashEntry>()
183
183
  if (Array.isArray(migrated.trash)) {
184
184
  for (const entry of migrated.trash as unknown[]) {
185
185
  const item = entry as { name?: unknown; path?: unknown; movedAt?: unknown; sourcePath?: unknown } | null
186
186
  if (item !== null && typeof item === 'object' && typeof item.name === 'string' && typeof item.path === 'string') {
187
187
  const origin = (item as Record<string, unknown>).origin as { repo?: unknown; root?: unknown; ref?: unknown; commitSha?: unknown } | undefined
188
188
  const tagIds = (item as Record<string, unknown>).tagIds
189
- trashByName.set(item.name, {
189
+ // 按 path 建索引:回收站里同名可以有多条(跨形态/先后两次入站),按名字
190
+ // 索引会让后一条静默覆盖前一条、把前一份目录留成谁都指不到的孤儿。
191
+ trashByPath.set(item.path, {
190
192
  name: item.name,
191
193
  path: item.path,
192
194
  movedAt: typeof item.movedAt === 'number' ? item.movedAt : 0,
@@ -294,5 +296,5 @@ export function hydrateMigratedState(migrated: MigratedStore): HydratedState {
294
296
  const collectionOrder = cleanStringList(migrated.collectionOrder)
295
297
  const sourceGroupOrder = cleanStringList(migrated.sourceGroupOrder)
296
298
 
297
- return { entries, config, tagsById, sourcesByRepo, marketSources, trashByName, skillStats, marketStats, collectionOrder, sourceGroupOrder }
299
+ return { entries, config, tagsById, sourcesByRepo, marketSources, trashByPath, skillStats, marketStats, collectionOrder, sourceGroupOrder }
298
300
  }