dsh-skill-hub 0.3.20 → 0.3.22

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 (42) hide show
  1. package/CONTRIBUTING.md +2 -2
  2. package/README.md +5 -4
  3. package/README.zh.md +5 -4
  4. package/lib/client.js +2 -2
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +362 -74
  7. package/lib/types/client/locales/settings.d.ts +1 -1
  8. package/lib/types/client/locales.d.ts +1 -1
  9. package/lib/types/protocol/config.d.ts +1 -1
  10. package/lib/types/protocol/sources.d.ts +5 -0
  11. package/lib/types/reconcile.d.ts +24 -0
  12. package/lib/types/routes/deps.d.ts +8 -1
  13. package/lib/types/routes/helpers.d.ts +11 -6
  14. package/lib/types/routes/http.d.ts +27 -2
  15. package/lib/types/routes/route-state.d.ts +22 -2
  16. package/lib/types/routes.d.ts +29 -4
  17. package/lib/types/skillfs/scan.d.ts +26 -0
  18. package/lib/types/skillfs.d.ts +9 -1
  19. package/lib/types/store/migrate.d.ts +1 -1
  20. package/lib/types/store/store.d.ts +10 -5
  21. package/package.json +1 -1
  22. package/src/client/locales/settings.ts +2 -2
  23. package/src/index.ts +26 -4
  24. package/src/protocol/config.ts +1 -1
  25. package/src/protocol/sources.ts +5 -0
  26. package/src/reconcile.test.ts +74 -1
  27. package/src/reconcile.ts +50 -1
  28. package/src/routes/catalog.ts +50 -22
  29. package/src/routes/deps.ts +8 -2
  30. package/src/routes/helpers.ts +18 -7
  31. package/src/routes/http.ts +31 -2
  32. package/src/routes/repo-import.ts +16 -26
  33. package/src/routes/route-state.test.ts +4 -4
  34. package/src/routes/route-state.ts +45 -10
  35. package/src/routes/sources.ts +79 -23
  36. package/src/routes.test.ts +302 -4
  37. package/src/routes.ts +49 -5
  38. package/src/skillfs/scan.ts +91 -2
  39. package/src/skillfs.ts +14 -2
  40. package/src/store/migrate.ts +6 -4
  41. package/src/store/store.ts +21 -11
  42. package/src/store.test.ts +17 -1
@@ -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
  }
@@ -13,7 +13,7 @@ export class SkillHubStore {
13
13
  private tagsById = new Map<string, SkillTag>()
14
14
  private sourcesByRepo = new Map<string, SourceRecord>()
15
15
  private marketSources: MarketSourceRecord[] = []
16
- private trashByName = new Map<string, TrashEntry>()
16
+ private trashByPath = new Map<string, TrashEntry>()
17
17
  private skillStats: SkillStatsCheckpoint | undefined = undefined
18
18
  private marketStats: MarketStatsSnapshot | undefined = undefined
19
19
  private collectionOrder: string[] = []
@@ -41,7 +41,7 @@ export class SkillHubStore {
41
41
  this.tagsById = state.tagsById
42
42
  this.sourcesByRepo = state.sourcesByRepo
43
43
  this.marketSources = state.marketSources
44
- this.trashByName = state.trashByName
44
+ this.trashByPath = state.trashByPath
45
45
  this.skillStats = state.skillStats
46
46
  this.marketStats = state.marketStats
47
47
  this.collectionOrder = state.collectionOrder
@@ -444,26 +444,36 @@ export class SkillHubStore {
444
444
  /** All trashed skills, newest first. */
445
445
  async listTrash(): Promise<TrashEntry[]> {
446
446
  await this.ensureLoaded()
447
- return [...this.trashByName.values()].sort((a, b) => b.movedAt - a.movedAt)
447
+ return [...this.trashByPath.values()].sort((a, b) => b.movedAt - a.movedAt)
448
448
  }
449
449
 
450
- /** One trash entry by skill name (undefined when absent). */
450
+ /** One trash entry by skill name — the newest when a name has several. */
451
451
  async getTrash(name: string): Promise<TrashEntry | undefined> {
452
452
  await this.ensureLoaded()
453
- return this.trashByName.get(name)
453
+ let newest: TrashEntry | undefined
454
+ for (const entry of this.trashByPath.values()) {
455
+ if (entry.name !== name) continue
456
+ if (newest === undefined || entry.movedAt > newest.movedAt) newest = entry
457
+ }
458
+ return newest
454
459
  }
455
460
 
456
- /** Record a trashed skill. */
461
+ /**
462
+ * Record a trashed skill, keyed by its trashed path. Keying by name used to
463
+ * silently drop the previous record when the same name landed in the trash
464
+ * twice (a re-delete, or the two on-disk discovery forms), orphaning its
465
+ * directory with nothing left pointing at it.
466
+ */
457
467
  async addTrash(entry: TrashEntry): Promise<void> {
458
468
  await this.ensureLoaded()
459
- this.trashByName.set(entry.name, entry)
469
+ this.trashByPath.set(entry.path, entry)
460
470
  await this.persist()
461
471
  }
462
472
 
463
- /** Remove a trash record (after restore). */
464
- async removeTrash(name: string): Promise<void> {
473
+ /** Remove a trash record by its trashed path (after restore or permanent clear). */
474
+ async removeTrash(path: string): Promise<void> {
465
475
  await this.ensureLoaded()
466
- if (!this.trashByName.delete(name)) return
476
+ if (!this.trashByPath.delete(path)) return
467
477
  await this.persist()
468
478
  }
469
479
 
@@ -521,7 +531,7 @@ export class SkillHubStore {
521
531
  ...(this.tagsById.size > 0 ? { tags: [...this.tagsById.values()] } : {}),
522
532
  ...(this.sourcesByRepo.size > 0 ? { sources: [...this.sourcesByRepo.values()] } : {}),
523
533
  ...(this.marketSources.length > 0 ? { marketSources: [...this.marketSources] } : {}),
524
- ...(this.trashByName.size > 0 ? { trash: [...this.trashByName.values()] } : {}),
534
+ ...(this.trashByPath.size > 0 ? { trash: [...this.trashByPath.values()] } : {}),
525
535
  ...(this.skillStats !== undefined ? { skillStats: this.skillStats } : {}),
526
536
  ...(this.marketStats !== undefined ? { marketStats: this.marketStats } : {}),
527
537
  ...(this.collectionOrder.length > 0 ? { collectionOrder: [...this.collectionOrder] } : {}),
package/src/store.test.ts CHANGED
@@ -206,12 +206,28 @@ describe('SkillHubStore', () => {
206
206
  await store.addTrash({ name: 'older', path: '/tmp/.trash/older-1', movedAt: 10 })
207
207
  const trash = await store.listTrash()
208
208
  expect(trash.map((entry) => entry.name)).toEqual(['gone-skill', 'older']) // newest first
209
- await store.removeTrash('gone-skill')
209
+ await store.removeTrash('/tmp/.trash/gone-skill-123')
210
210
  const reloaded = new SkillHubStore(file)
211
211
  expect(await reloaded.listTrash()).toHaveLength(1)
212
212
  expect(await reloaded.getTrash('older')).toMatchObject({ name: 'older' })
213
213
  })
214
214
 
215
+ it('keeps several trash entries sharing one skill name (no silent overwrite)', async () => {
216
+ // 同一个名字先后入站两次(重复删除,或磁盘上两种发现形态各进一次):
217
+ // 按名字索引会让后一条覆盖前一条,前一份目录就成了谁都指不到的孤儿。
218
+ await store.addTrash({ name: 'dup', path: '/tmp/.trash/dup-1', movedAt: 1, sourcePath: '/tmp/skills/dup' })
219
+ await store.addTrash({ name: 'dup', path: '/tmp/.trash/dup.md.disabled-2', movedAt: 2, sourcePath: '/tmp/skills/dup.md.disabled' })
220
+ const reloaded = new SkillHubStore(file)
221
+ expect((await reloaded.listTrash()).map((entry) => entry.path).sort()).toEqual([
222
+ '/tmp/.trash/dup-1',
223
+ '/tmp/.trash/dup.md.disabled-2',
224
+ ])
225
+ // getTrash 按名字取最新的一条(恢复入口用)。
226
+ expect(await reloaded.getTrash('dup')).toMatchObject({ path: '/tmp/.trash/dup.md.disabled-2' })
227
+ await reloaded.removeTrash('/tmp/.trash/dup.md.disabled-2')
228
+ expect(await reloaded.getTrash('dup')).toMatchObject({ path: '/tmp/.trash/dup-1' })
229
+ })
230
+
215
231
  it('persists the trash source path', async () => {
216
232
  await store.addTrash({ name: 'flat-gone', path: '/tmp/.trash/flat-gone.md-1', movedAt: 7, sourcePath: '/tmp/skills/flat-gone.md' })
217
233
  const reloaded = new SkillHubStore(file)