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.
package/lib/index.js CHANGED
@@ -333,13 +333,13 @@ function hydrateMigratedState(migrated) {
333
333
  ...typeof item.commitSha === "string" && item.commitSha !== "" ? { commitSha: item.commitSha } : {}
334
334
  });
335
335
  }
336
- const trashByName = /* @__PURE__ */ new Map();
336
+ const trashByPath = /* @__PURE__ */ new Map();
337
337
  if (Array.isArray(migrated.trash)) for (const entry of migrated.trash) {
338
338
  const item = entry;
339
339
  if (item !== null && typeof item === "object" && typeof item.name === "string" && typeof item.path === "string") {
340
340
  const origin = item.origin;
341
341
  const tagIds = item.tagIds;
342
- trashByName.set(item.name, {
342
+ trashByPath.set(item.path, {
343
343
  name: item.name,
344
344
  path: item.path,
345
345
  movedAt: typeof item.movedAt === "number" ? item.movedAt : 0,
@@ -430,7 +430,7 @@ function hydrateMigratedState(migrated) {
430
430
  tagsById,
431
431
  sourcesByRepo,
432
432
  marketSources,
433
- trashByName,
433
+ trashByPath,
434
434
  skillStats,
435
435
  marketStats,
436
436
  collectionOrder,
@@ -691,7 +691,7 @@ var SkillHubStore = class {
691
691
  tagsById = /* @__PURE__ */ new Map();
692
692
  sourcesByRepo = /* @__PURE__ */ new Map();
693
693
  marketSources = [];
694
- trashByName = /* @__PURE__ */ new Map();
694
+ trashByPath = /* @__PURE__ */ new Map();
695
695
  skillStats = void 0;
696
696
  marketStats = void 0;
697
697
  collectionOrder = [];
@@ -717,7 +717,7 @@ var SkillHubStore = class {
717
717
  this.tagsById = state.tagsById;
718
718
  this.sourcesByRepo = state.sourcesByRepo;
719
719
  this.marketSources = state.marketSources;
720
- this.trashByName = state.trashByName;
720
+ this.trashByPath = state.trashByPath;
721
721
  this.skillStats = state.skillStats;
722
722
  this.marketStats = state.marketStats;
723
723
  this.collectionOrder = state.collectionOrder;
@@ -1095,23 +1095,33 @@ var SkillHubStore = class {
1095
1095
  /** All trashed skills, newest first. */
1096
1096
  async listTrash() {
1097
1097
  await this.ensureLoaded();
1098
- return [...this.trashByName.values()].sort((a, b) => b.movedAt - a.movedAt);
1098
+ return [...this.trashByPath.values()].sort((a, b) => b.movedAt - a.movedAt);
1099
1099
  }
1100
- /** One trash entry by skill name (undefined when absent). */
1100
+ /** One trash entry by skill name — the newest when a name has several. */
1101
1101
  async getTrash(name) {
1102
1102
  await this.ensureLoaded();
1103
- return this.trashByName.get(name);
1103
+ let newest;
1104
+ for (const entry of this.trashByPath.values()) {
1105
+ if (entry.name !== name) continue;
1106
+ if (newest === void 0 || entry.movedAt > newest.movedAt) newest = entry;
1107
+ }
1108
+ return newest;
1104
1109
  }
1105
- /** Record a trashed skill. */
1110
+ /**
1111
+ * Record a trashed skill, keyed by its trashed path. Keying by name used to
1112
+ * silently drop the previous record when the same name landed in the trash
1113
+ * twice (a re-delete, or the two on-disk discovery forms), orphaning its
1114
+ * directory with nothing left pointing at it.
1115
+ */
1106
1116
  async addTrash(entry) {
1107
1117
  await this.ensureLoaded();
1108
- this.trashByName.set(entry.name, entry);
1118
+ this.trashByPath.set(entry.path, entry);
1109
1119
  await this.persist();
1110
1120
  }
1111
- /** Remove a trash record (after restore). */
1112
- async removeTrash(name) {
1121
+ /** Remove a trash record by its trashed path (after restore or permanent clear). */
1122
+ async removeTrash(path) {
1113
1123
  await this.ensureLoaded();
1114
- if (!this.trashByName.delete(name)) return;
1124
+ if (!this.trashByPath.delete(path)) return;
1115
1125
  await this.persist();
1116
1126
  }
1117
1127
  /** The persisted usage-statistics checkpoint (undefined until first saved). */
@@ -1163,7 +1173,7 @@ var SkillHubStore = class {
1163
1173
  ...this.tagsById.size > 0 ? { tags: [...this.tagsById.values()] } : {},
1164
1174
  ...this.sourcesByRepo.size > 0 ? { sources: [...this.sourcesByRepo.values()] } : {},
1165
1175
  ...this.marketSources.length > 0 ? { marketSources: [...this.marketSources] } : {},
1166
- ...this.trashByName.size > 0 ? { trash: [...this.trashByName.values()] } : {},
1176
+ ...this.trashByPath.size > 0 ? { trash: [...this.trashByPath.values()] } : {},
1167
1177
  ...this.skillStats !== void 0 ? { skillStats: this.skillStats } : {},
1168
1178
  ...this.marketStats !== void 0 ? { marketStats: this.marketStats } : {},
1169
1179
  ...this.collectionOrder.length > 0 ? { collectionOrder: [...this.collectionOrder] } : {},
@@ -1464,7 +1474,7 @@ async function scanDisabledRoot(base) {
1464
1474
  try {
1465
1475
  if ((await stat(candidate)).isFile()) paths.push(candidate);
1466
1476
  } catch {}
1467
- } else if (name.endsWith(".md.disabled") && name !== "SKILL.md.disabled") paths.push(absolute);
1477
+ } else if (name.endsWith(".md.disabled")) paths.push(absolute);
1468
1478
  }
1469
1479
  return paths.sort((a, b) => a < b ? -1 : a > b ? 1 : 0);
1470
1480
  }
@@ -1543,7 +1553,9 @@ async function findProjectRoot(cwd) {
1543
1553
  * a defect.
1544
1554
  */
1545
1555
  async function scanDiagnostics(root, home = dshHome()) {
1546
- return (await mapConcurrent(await listSkillEntries(root, home), 8, async (entry) => {
1556
+ const entries = await listSkillEntries(root, home);
1557
+ const discovered = [];
1558
+ const perEntry = await mapConcurrent(entries, 8, async (entry) => {
1547
1559
  const found = [];
1548
1560
  let text;
1549
1561
  try {
@@ -1575,6 +1587,10 @@ async function scanDiagnostics(root, home = dshHome()) {
1575
1587
  });
1576
1588
  const { value } = parsed;
1577
1589
  const pathName = entry.kind === "directory" ? basename(entry.directory) : basename(entry.path, ".md");
1590
+ discovered.push({
1591
+ path: entry.path,
1592
+ keys: [.../* @__PURE__ */ new Set([pathName, value.name])]
1593
+ });
1578
1594
  if (value.name !== pathName) found.push({
1579
1595
  path: entry.path,
1580
1596
  root,
@@ -1586,7 +1602,65 @@ async function scanDiagnostics(root, home = dshHome()) {
1586
1602
  reason: `description is only ${value.description.length} chars; write a one-line description so agents can auto-activate this skill`
1587
1603
  });
1588
1604
  return found;
1589
- })).flat();
1605
+ });
1606
+ const owners = /* @__PURE__ */ new Map();
1607
+ for (const item of discovered) for (const key of item.keys) {
1608
+ const list = owners.get(key);
1609
+ if (list === void 0) owners.set(key, [item.path]);
1610
+ else list.push(item.path);
1611
+ }
1612
+ const collisions = [];
1613
+ for (const item of discovered) {
1614
+ const shared = item.keys.filter((key) => (owners.get(key)?.length ?? 0) > 1);
1615
+ if (shared.length === 0) continue;
1616
+ collisions.push({
1617
+ path: item.path,
1618
+ root,
1619
+ 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`
1620
+ });
1621
+ }
1622
+ return [...perEntry.flat(), ...collisions];
1623
+ }
1624
+ /**
1625
+ * 某个名字在一个 root 下现存的禁用发现文件(两种形态都认),没有则 undefined。
1626
+ * 目录包形态优先(`<name>/SKILL.md.disabled`),其次扁平 `<name>.md.disabled`。
1627
+ * 单点定义「禁用态在磁盘上长什么样」,避免各处再拼一次路径(issue #16 类)。
1628
+ */
1629
+ async function disabledOccupancyIn(base, name) {
1630
+ return (await occupancyOf(base, name)).disabled[0];
1631
+ }
1632
+ /** stat 一次给出形态;不存在或不可读时 undefined。 */
1633
+ async function statKind(path) {
1634
+ try {
1635
+ return (await stat(path)).isDirectory() ? "directory" : "file";
1636
+ } catch {
1637
+ return;
1638
+ }
1639
+ }
1640
+ /** 读一个名字在某个 skills root 下的全部占位。只读,不修改磁盘。 */
1641
+ async function occupancyOf(base, name) {
1642
+ const bundleDir = join(base, name);
1643
+ const bundleSkill = join(bundleDir, "SKILL.md");
1644
+ const bundleDisabled = join(bundleDir, "SKILL.md.disabled");
1645
+ const flatSkill = join(base, name + ".md");
1646
+ const flatDisabled = join(base, name + ".md.disabled");
1647
+ const [bundleSkillKind, bundleDisabledKind, flatSkillKind, flatDisabledKind, bundleDirKind] = await Promise.all([
1648
+ statKind(bundleSkill),
1649
+ statKind(bundleDisabled),
1650
+ statKind(flatSkill),
1651
+ statKind(flatDisabled),
1652
+ statKind(bundleDir)
1653
+ ]);
1654
+ const result = {
1655
+ enabled: [],
1656
+ disabled: []
1657
+ };
1658
+ if (bundleSkillKind === "file") result.enabled.push(bundleSkill);
1659
+ if (flatSkillKind === "file") result.enabled.push(flatSkill);
1660
+ if (bundleDisabledKind === "file") result.disabled.push(bundleDisabled);
1661
+ if (flatDisabledKind === "file") result.disabled.push(flatDisabled);
1662
+ if (bundleDirKind === "directory" && bundleSkillKind !== "file" && bundleDisabledKind !== "file") result.bareDir = bundleDir;
1663
+ return result;
1590
1664
  }
1591
1665
  //#endregion
1592
1666
  //#region src/skillfs.ts
@@ -1668,14 +1742,25 @@ async function moveIntoTrash(source) {
1668
1742
  };
1669
1743
  }
1670
1744
  /**
1745
+ * The filesystem entry behind a discovery file: a directory bundle is addressed
1746
+ * by its `SKILL.md` (or `SKILL.md.disabled`) and moved as a whole directory, a
1747
+ * flat `<name>.md` file is moved as itself. Single definition of that mapping —
1748
+ * delete, restore, sync-replace and placeholder retirement must all agree on it,
1749
+ * or one of them moves a bundle's discovery file and leaves an empty directory.
1750
+ */
1751
+ function trashSourceOf(discoveryPath) {
1752
+ const base = basename(discoveryPath);
1753
+ return base === "SKILL.md" || base === "SKILL.md.disabled" ? dirname(discoveryPath) : discoveryPath;
1754
+ }
1755
+ /**
1671
1756
  * Move a skill (directory bundle or flat .md file) into the root's trash.
1672
1757
  * The skill stays recoverable: the source is renamed into
1673
1758
  * <root>/.trash/<name>-<timestamp>. A directory bundle is addressed by its
1674
- * SKILL.md path and moved as a whole directory.
1759
+ * SKILL.md (or SKILL.md.disabled) path and moved as a whole directory.
1675
1760
  * @returns the trashed path plus the original path that was moved.
1676
1761
  */
1677
1762
  async function trashSkill(sourcePath) {
1678
- return moveIntoTrash(basename(sourcePath) === "SKILL.md" ? dirname(sourcePath) : sourcePath);
1763
+ return moveIntoTrash(trashSourceOf(sourcePath));
1679
1764
  }
1680
1765
  /** Restore a trashed skill (directory or flat file) to its original location. */
1681
1766
  async function restoreSkill(entry, home = dshHome()) {
@@ -2928,7 +3013,18 @@ function catalogRoutes(deps) {
2928
3013
  writeError(res, 409, "disabled skill path is outside the hub writable roots");
2929
3014
  return;
2930
3015
  }
2931
- trashResult = await moveIntoTrash(disabled.path.endsWith("SKILL.md.disabled") ? dirname(disabled.path) : disabled.path);
3016
+ if (!await pathExists(disabled.path)) {
3017
+ await deps.store.removeDisabled(name);
3018
+ await deps.store.removeSkillFromTags(name);
3019
+ deps.invalidate?.();
3020
+ writeJson(res, 200, {
3021
+ ok: true,
3022
+ name,
3023
+ path: disabled.path
3024
+ });
3025
+ return;
3026
+ }
3027
+ trashResult = await trashSkill(disabled.path);
2932
3028
  } else {
2933
3029
  writeError(res, resolved.status, resolved.error);
2934
3030
  return;
@@ -2979,11 +3075,23 @@ function catalogRoutes(deps) {
2979
3075
  writeError(res, 404, "skill is not hub-disabled: " + name);
2980
3076
  return;
2981
3077
  }
3078
+ let disabledPath = record.path;
3079
+ if (!await pathExists(disabledPath)) {
3080
+ const root = rootOfPath(disabledPath, homeOf(deps));
3081
+ const occupancy = root === void 0 ? void 0 : await disabledOccupancyIn(rootPath(root, homeOf(deps)), name);
3082
+ if (occupancy === void 0) {
3083
+ await deps.store.removeDisabled(name);
3084
+ deps.invalidate?.();
3085
+ writeError(res, 409, "disabled record was stale (its file no longer exists); the record has been removed — retry now");
3086
+ return;
3087
+ }
3088
+ disabledPath = occupancy;
3089
+ }
2982
3090
  try {
2983
- await enableSkill(record.path);
3091
+ await enableSkill(disabledPath);
2984
3092
  } catch (error) {
2985
3093
  if (error.code === "ENOENT") {
2986
- writeError(res, 409, "disabled skill file is missing on disk: " + record.path);
3094
+ writeError(res, 409, "disabled skill file is missing on disk: " + disabledPath);
2987
3095
  return;
2988
3096
  }
2989
3097
  throw error;
@@ -3087,8 +3195,13 @@ function catalogRoutes(deps) {
3087
3195
  writeError(res, 409, "skill name is disabled: re-enable it from the disabled list first");
3088
3196
  return;
3089
3197
  }
3090
- if (await pathExists(join(rootPath(root, homeOf(deps)), name))) {
3091
- writeError(res, 409, "skill directory already exists on disk: " + name + " (check the discovery diagnostics)");
3198
+ const occupancy = await occupancyOf(rootPath(root, homeOf(deps)), name);
3199
+ if (occupancy.enabled.length > 0) {
3200
+ writeError(res, 409, "a skill file already occupies this name on disk: " + name + " (check the discovery diagnostics)");
3201
+ return;
3202
+ }
3203
+ if (occupancy.disabled.length > 0 || occupancy.bareDir !== void 0) {
3204
+ writeError(res, 409, "name is occupied by a disabled placeholder or an empty directory on disk: " + name + " (check the discovery diagnostics)");
3092
3205
  return;
3093
3206
  }
3094
3207
  const path = await createSkill(root, name, readString(body, "description"), homeOf(deps));
@@ -3295,15 +3408,23 @@ function gcImportJobs() {
3295
3408
  for (let i = 0; i < finished.length && importJobs.size > 100; i++) importJobs.delete(finished[i][0]);
3296
3409
  }
3297
3410
  }
3298
- /** Replace one skill directory with a fresh download; restores the old dir on failure. */
3299
- async function replaceSkillDir(targetDir, download) {
3300
- const backup = join(dirname(targetDir), "." + basename(targetDir) + ".sync-bak-" + Date.now());
3301
- await rename(targetDir, backup);
3411
+ /**
3412
+ * Replace whatever currently occupies a skill slot with a fresh download;
3413
+ * restores the previous state on failure.
3414
+ *
3415
+ * `target` is the entry to move aside — a bundle **directory** or a flat
3416
+ * `<name>.md` file, whichever the download is replacing (the rename/rm below
3417
+ * are form-neutral). The backup is dot-prefixed (discovery skips dot entries),
3418
+ * so even a failed cleanup can never register as a duplicate skill.
3419
+ */
3420
+ async function replaceInPlace(target, download) {
3421
+ const backup = join(dirname(target), "." + basename(target) + ".sync-bak-" + Date.now());
3422
+ await rename(target, backup);
3302
3423
  try {
3303
3424
  await download();
3304
3425
  } catch (error) {
3305
- await rename(backup, targetDir).catch((rollbackError) => {
3306
- console.warn("[dsh-skill-hub] restoring " + targetDir + " from " + backup + " failed:", errorText(rollbackError));
3426
+ await rename(backup, target).catch((rollbackError) => {
3427
+ console.warn("[dsh-skill-hub] restoring " + target + " from " + backup + " failed:", errorText(rollbackError));
3307
3428
  });
3308
3429
  throw error;
3309
3430
  }
@@ -3312,6 +3433,35 @@ async function replaceSkillDir(targetDir, download) {
3312
3433
  force: true
3313
3434
  });
3314
3435
  }
3436
+ /**
3437
+ * 让位:把一个名字下的「非启用态占位」移入回收站并清掉它的 sidecar 禁用记录。
3438
+ * 只动这两类,**启用的技能一律不碰**:
3439
+ * - 禁用发现文件(`<name>/SKILL.md.disabled` 或 `<name>.md.disabled`,两种形态都认);
3440
+ * - 同名目录存在但既无 `SKILL.md` 也无 `SKILL.md.disabled`(导入中断/手工删除的空壳)。
3441
+ *
3442
+ * 回收站按名字索引,所以多形态只会记下最后一条的恢复指针;其余的那份会在
3443
+ * 「清空回收站」时被孤儿回收扫掉(见 sourceTrashClear)。
3444
+ *
3445
+ * @returns 被移入回收站的路径列表,没有可让位的东西时 undefined。
3446
+ */
3447
+ async function retireNamePlaceholders(deps, base, name) {
3448
+ const occupancy = await occupancyOf(base, name);
3449
+ const sources = [.../* @__PURE__ */ new Set([...occupancy.disabled.map((path) => trashSourceOf(path)), ...occupancy.bareDir !== void 0 ? [occupancy.bareDir] : []])];
3450
+ if (sources.length === 0) return void 0;
3451
+ const trashed = [];
3452
+ for (const source of sources) {
3453
+ const moved = await moveIntoTrash(source);
3454
+ await deps.store.addTrash({
3455
+ name,
3456
+ path: moved.path,
3457
+ movedAt: Date.now(),
3458
+ sourcePath: moved.source
3459
+ });
3460
+ trashed.push(moved.path);
3461
+ }
3462
+ await deps.store.removeDisabled(name);
3463
+ return trashed;
3464
+ }
3315
3465
  //#endregion
3316
3466
  //#region src/routes/market.ts
3317
3467
  /**
@@ -3581,29 +3731,17 @@ function marketRoutes(deps) {
3581
3731
  * cancel 取消。从 routes.ts 原样搬出,handler 逻辑不变。
3582
3732
  */
3583
3733
  /**
3584
- * 导入去重只认「已启用」的目录,但磁盘上仍可能躺着一个已禁用的同名占位 ——
3585
- * 禁用只是把 SKILL.md 改名成 SKILL.md.disabled,目录本身还在。这种占位不该
3586
- * 堵住真技能:把它连同禁用记录一起移入回收站让位(issue #16)。
3734
+ * 导入让位(issue #16):磁盘上已有同名占位但**没有启用的技能**时,把占位移入
3735
+ * 回收站,把名字让给即将导入的真技能。占位有两种 —— 已禁用的发现文件(目录包或
3736
+ * 扁平,后者曾长期漏处理:`clearDisabledPlaceholder` 只认 `<name>` 这个目录,
3737
+ * 扁平占位占的是 `<name>.md.disabled`,判据为假,分支根本进不去)和没有 `SKILL.md`
3738
+ * 的空壳目录。判据统一走 `occupancyOf`,形态判断集中在 `retireNamePlaceholders`。
3587
3739
  *
3588
- * 只有「禁用记录确实指向 `targetRoot/<name>` 的整目录禁用」才处理,且该目录
3589
- * 必须已经没有 SKILL.md;其余不确定情形一律返回 false,交回原本的「跳过」。
3740
+ * 返回 false = 磁盘上有启用的同名技能(真货),一律不动,交回原本的「跳过」。
3590
3741
  */
3591
3742
  async function clearDisabledPlaceholder(deps, targetRoot, name) {
3592
- const record = await deps.store.getDisabled(name);
3593
- if (record === void 0) return false;
3594
- const dir = join(targetRoot, name);
3595
- const sourceToTrash = record.path.endsWith("SKILL.md.disabled") ? dirname(record.path) : record.path;
3596
- if (sourceToTrash !== dir) return false;
3597
- if (await pathExists(join(dir, "SKILL.md"))) return false;
3598
- const { path: target } = await moveIntoTrash(sourceToTrash);
3599
- await deps.store.addTrash({
3600
- name,
3601
- path: target,
3602
- movedAt: Date.now(),
3603
- sourcePath: sourceToTrash
3604
- });
3605
- await deps.store.removeDisabled(name);
3606
- return true;
3743
+ if ((await occupancyOf(targetRoot, name)).enabled.length > 0) return false;
3744
+ return await retireNamePlaceholders(deps, targetRoot, name) !== void 0;
3607
3745
  }
3608
3746
  /** 仓库导入域全部路由 spec(由 routes.ts 经 createRoute 包上统一围栏)。 */
3609
3747
  function repoImportRoutes(deps) {
@@ -3733,7 +3871,8 @@ function repoImportRoutes(deps) {
3733
3871
  job.done += 1;
3734
3872
  continue;
3735
3873
  }
3736
- if (await pathExists(join(targetRoot, entry.name))) {
3874
+ const occupancy = await occupancyOf(targetRoot, entry.name);
3875
+ if (occupancy.enabled.length > 0 || occupancy.disabled.length > 0 || occupancy.bareDir !== void 0) {
3737
3876
  let cleared = false;
3738
3877
  try {
3739
3878
  cleared = await clearDisabledPlaceholder(deps, targetRoot, entry.name);
@@ -4145,13 +4284,21 @@ function sourceRoutes(deps) {
4145
4284
  continue;
4146
4285
  }
4147
4286
  const targetDir = join(targetRoot, name);
4148
- const wasDisabled = await deps.store.getDisabled(name) !== void 0;
4149
- if (await pathExists(targetDir)) await replaceSkillDir(targetDir, async () => {
4150
- await downloadRepoSkill(repo, latest.commitSha, entry, files, targetRoot);
4151
- });
4152
- else await downloadRepoSkill(repo, latest.commitSha, entry, files, targetRoot);
4153
- if (wasDisabled) await rename(join(targetDir, "SKILL.md"), join(targetDir, "SKILL.md.disabled"));
4154
- else await deps.store.removeDisabled(name);
4287
+ const record = await deps.store.getDisabled(name);
4288
+ const occupancy = await occupancyOf(targetRoot, name);
4289
+ const download = () => downloadRepoSkill(repo, latest.commitSha, entry, files, targetRoot);
4290
+ await retireNamePlaceholders(deps, targetRoot, name);
4291
+ const enabled = occupancy.enabled[0];
4292
+ if (enabled !== void 0) await replaceInPlace(trashSourceOf(enabled), download);
4293
+ else await download();
4294
+ if (record !== void 0) {
4295
+ const disabledPath = join(targetDir, "SKILL.md.disabled");
4296
+ await rename(join(targetDir, "SKILL.md"), disabledPath);
4297
+ await deps.store.addDisabled({
4298
+ ...record,
4299
+ path: disabledPath
4300
+ });
4301
+ } else await deps.store.removeDisabled(name);
4155
4302
  await deps.store.mergeSourceManifest(repo, skillManifest(tree, entry.dir), entry.dir);
4156
4303
  synced.push(name);
4157
4304
  } catch (error) {
@@ -4204,29 +4351,30 @@ function sourceRoutes(deps) {
4204
4351
  });
4205
4352
  continue;
4206
4353
  }
4207
- const sourcePath = join(rootPath("user-dsh", home), name);
4208
- if (rootOfPath(sourcePath, home) === void 0) {
4354
+ const occupancy = await occupancyOf(rootPath("user-dsh", home), name);
4355
+ const existing = occupancy.enabled[0] ?? occupancy.disabled[0] ?? occupancy.bareDir;
4356
+ if (existing === void 0) {
4209
4357
  failed.push({
4210
4358
  name,
4211
- error: "skill path is outside the hub writable roots"
4359
+ error: "skill not found on disk"
4212
4360
  });
4213
4361
  continue;
4214
4362
  }
4215
- if (!await pathExists(sourcePath)) {
4363
+ if (rootOfPath(existing, home) === void 0) {
4216
4364
  failed.push({
4217
4365
  name,
4218
- error: "skill directory not found"
4366
+ error: "skill path is outside the hub writable roots"
4219
4367
  });
4220
4368
  continue;
4221
4369
  }
4222
4370
  try {
4223
4371
  const tagIds = (await deps.store.listTags()).filter((tag) => tag.skillNames.includes(name)).map((tag) => tag.id);
4224
- const { path } = await trashSkill(sourcePath);
4372
+ const { path, source: trashSource } = await trashSkill(existing);
4225
4373
  await deps.store.addTrash({
4226
4374
  name,
4227
4375
  path,
4228
4376
  movedAt: Date.now(),
4229
- sourcePath,
4377
+ sourcePath: trashSource,
4230
4378
  origin: {
4231
4379
  repo: source.repo,
4232
4380
  root: source.root,
@@ -4279,7 +4427,7 @@ function sourceRoutes(deps) {
4279
4427
  const path = await restoreSkill(entry, home);
4280
4428
  if (entry.origin !== void 0) await deps.store.addSourceSkill(entry.origin.repo, entry.origin.root, entry.origin.commitSha, entry.origin.ref, name);
4281
4429
  for (const tagId of entry.tagIds ?? []) await deps.store.addSkillToTag(tagId, name);
4282
- await deps.store.removeTrash(name);
4430
+ await deps.store.removeTrash(entry.path);
4283
4431
  deps.invalidate?.();
4284
4432
  writeJson(res, 200, {
4285
4433
  ok: true,
@@ -4295,28 +4443,67 @@ function sourceRoutes(deps) {
4295
4443
  const home = homeOf(deps);
4296
4444
  const entries = await deps.store.listTrash();
4297
4445
  const deleted = [];
4446
+ const removedPaths = [];
4298
4447
  const failed = [];
4299
4448
  for (const entry of entries) try {
4300
4449
  await clearTrash(entry, home);
4301
4450
  await deps.store.removeSkillFromTags(entry.name);
4302
4451
  deleted.push(entry.name);
4452
+ removedPaths.push(entry.path);
4303
4453
  } catch (error) {
4304
4454
  failed.push({
4305
4455
  name: entry.name,
4306
4456
  error: errorText(error)
4307
4457
  });
4308
4458
  }
4309
- for (const name of deleted) await deps.store.removeTrash(name);
4310
- if (deleted.length > 0) deps.invalidate?.();
4459
+ for (const path of removedPaths) await deps.store.removeTrash(path);
4460
+ const orphans = await sweepOrphanTrash(deps, home, failed);
4461
+ if (deleted.length > 0 || orphans.length > 0) deps.invalidate?.();
4311
4462
  writeJson(res, 200, {
4312
4463
  ok: true,
4313
4464
  deleted,
4314
- failed
4465
+ failed,
4466
+ ...orphans.length > 0 ? { orphans } : {}
4315
4467
  });
4316
4468
  }
4317
4469
  }
4318
4470
  ];
4319
4471
  }
4472
+ /**
4473
+ * 删除两个可写根 `.trash` 下不被任何回收站记录引用的残留目录。
4474
+ * 只扫 `.trash` 的直接子项、跳过点开头项,失败项并入 `failed`(保留可重试)。
4475
+ */
4476
+ async function sweepOrphanTrash(deps, home, failed) {
4477
+ const referenced = new Set((await deps.store.listTrash()).map((entry) => entry.path));
4478
+ const orphans = [];
4479
+ for (const root of WRITABLE_ROOTS) {
4480
+ const trashDir = join(rootPath(root, home), ".trash");
4481
+ let names;
4482
+ try {
4483
+ names = await readdir(trashDir);
4484
+ } catch {
4485
+ continue;
4486
+ }
4487
+ for (const name of names) {
4488
+ if (name.startsWith(".")) continue;
4489
+ const full = join(trashDir, name);
4490
+ if (referenced.has(full)) continue;
4491
+ try {
4492
+ await rm(full, {
4493
+ recursive: true,
4494
+ force: true
4495
+ });
4496
+ orphans.push(full);
4497
+ } catch (error) {
4498
+ failed.push({
4499
+ name,
4500
+ error: errorText(error)
4501
+ });
4502
+ }
4503
+ }
4504
+ }
4505
+ return orphans;
4506
+ }
4320
4507
  //#endregion
4321
4508
  //#region src/routes.ts
4322
4509
  /**
@@ -4719,6 +4906,47 @@ async function reconcileDisabledSkills(store, home = dshHome()) {
4719
4906
  }
4720
4907
  return added;
4721
4908
  }
4909
+ /**
4910
+ * 反向对账:`reconcileDisabledSkills` 只处理「磁盘有 `.disabled`、sidecar 没记录」。
4911
+ * 反方向(sidecar 有记录、磁盘上那个文件已经不在了)以前永不清理,于是留下一条
4912
+ * 干掉不掉的记录:`/toggle` 启用会 rename 失败,`/create` 被 409 挡住,`/skill/delete`
4913
+ * 也 rename 失败 —— 名字被永久锁死(issue #16 类,实测 G5)。
4914
+ *
4915
+ * 两种处理,都不动启用态技能:
4916
+ * - 名字在该 root 下仍有 `.disabled` 占位(目录包或扁平形态)→ 把记录指向真实文件;
4917
+ * - 一个都没有 → 删记录,把名字还给用户。
4918
+ *
4919
+ * 只处理合法可写 root 的记录;root 值异常(历史损坏数据)时原样保留,不擅自删。
4920
+ */
4921
+ async function pruneStaleDisabledSkills(store, home = dshHome()) {
4922
+ const result = {
4923
+ repaired: [],
4924
+ removed: []
4925
+ };
4926
+ for (const record of await store.listDisabled()) {
4927
+ let exists = false;
4928
+ try {
4929
+ exists = (await stat(record.path)).isFile();
4930
+ } catch {
4931
+ exists = false;
4932
+ }
4933
+ if (exists) continue;
4934
+ if (!WRITABLE_ROOTS.includes(record.root)) continue;
4935
+ const occupancy = await disabledOccupancyIn(rootPath(record.root, home), record.name);
4936
+ if (occupancy === void 0) {
4937
+ await store.removeDisabled(record.name);
4938
+ result.removed.push(record.name);
4939
+ continue;
4940
+ }
4941
+ const repaired = {
4942
+ ...record,
4943
+ path: occupancy
4944
+ };
4945
+ await store.addDisabled(repaired);
4946
+ result.repaired.push(repaired);
4947
+ }
4948
+ return result;
4949
+ }
4722
4950
  //#endregion
4723
4951
  //#region src/index.ts
4724
4952
  /** Stable cordis plugin name (matches cordis.patch.yml insert id). */
@@ -4906,6 +5134,12 @@ function apply(ctx, config) {
4906
5134
  } catch (error) {
4907
5135
  ctx.logger.warn("[dsh-skill-hub] startup disabled-skill reconcile failed", error);
4908
5136
  }
5137
+ try {
5138
+ const pruned = await pruneStaleDisabledSkills(store, home);
5139
+ if (pruned.repaired.length > 0 || pruned.removed.length > 0) ctx.logger.info(`[dsh-skill-hub] startup pruned disabled records: repaired ${pruned.repaired.length} (${pruned.repaired.map((entry) => entry.name).join(", ") || "-"}), removed ${pruned.removed.length} (${pruned.removed.join(", ") || "-"})`);
5140
+ } catch (error) {
5141
+ ctx.logger.warn("[dsh-skill-hub] startup disabled-record prune failed", error);
5142
+ }
4909
5143
  })();
4910
5144
  (async () => {
4911
5145
  try {
@@ -134,6 +134,11 @@ export interface SourceTrashClearResponse {
134
134
  name: string;
135
135
  error: string;
136
136
  }>;
137
+ /**
138
+ * `.trash` 里没有对应记录的残留目录(同名技能先后两次入站时,旧记录被覆盖,
139
+ * 目录却留了下来)。清空回收站时一并回收,这里上报它们的路径;没有则省略。
140
+ */
141
+ orphans?: string[];
137
142
  }
138
143
  /** 项目级技能来源(它们有 workspace 归属,不属于「个人」组)。 */
139
144
  export declare function isProjectSource(source: string): boolean;
@@ -15,6 +15,10 @@ export interface DisabledReconcileStore {
15
15
  listDisabled(): Promise<DisabledSkill[]>;
16
16
  addDisabled(entry: DisabledSkill): Promise<void>;
17
17
  }
18
+ /** Narrow store view used by the reverse reconcile (adds record removal). */
19
+ export interface DisabledPruneStore extends DisabledReconcileStore {
20
+ removeDisabled(name: string): Promise<void>;
21
+ }
18
22
  /**
19
23
  * Add sidecar disabled records for every `.disabled` discovery file that has
20
24
  * none yet. Existing records (matched by path or by name) win, so this never
@@ -22,3 +26,23 @@ export interface DisabledReconcileStore {
22
26
  * in the diagnostics scan instead). Returns the records added.
23
27
  */
24
28
  export declare function reconcileDisabledSkills(store: DisabledReconcileStore, home?: string): Promise<DisabledSkill[]>;
29
+ /** 反向对账的结果:修好路径的记录 + 被清掉的死记录。 */
30
+ export interface DisabledPruneResult {
31
+ /** 记录还在、但 path 指向的文件已不在:已改指向现存的 `.disabled` 文件。 */
32
+ repaired: DisabledSkill[];
33
+ /** 文件与禁用占位都已不存在:记录已删除,名字被释放。 */
34
+ removed: string[];
35
+ }
36
+ /**
37
+ * 反向对账:`reconcileDisabledSkills` 只处理「磁盘有 `.disabled`、sidecar 没记录」。
38
+ * 反方向(sidecar 有记录、磁盘上那个文件已经不在了)以前永不清理,于是留下一条
39
+ * 干掉不掉的记录:`/toggle` 启用会 rename 失败,`/create` 被 409 挡住,`/skill/delete`
40
+ * 也 rename 失败 —— 名字被永久锁死(issue #16 类,实测 G5)。
41
+ *
42
+ * 两种处理,都不动启用态技能:
43
+ * - 名字在该 root 下仍有 `.disabled` 占位(目录包或扁平形态)→ 把记录指向真实文件;
44
+ * - 一个都没有 → 删记录,把名字还给用户。
45
+ *
46
+ * 只处理合法可写 root 的记录;root 值异常(历史损坏数据)时原样保留,不擅自删。
47
+ */
48
+ export declare function pruneStaleDisabledSkills(store: DisabledPruneStore, home?: string): Promise<DisabledPruneResult>;
@@ -54,5 +54,25 @@ export declare const importJobs: Map<string, ImportJob>;
54
54
  export declare const IMPORT_JOB_TTL_MS: number;
55
55
  export declare const IMPORT_JOB_MAX = 100;
56
56
  export declare function gcImportJobs(): void;
57
- /** Replace one skill directory with a fresh download; restores the old dir on failure. */
58
- export declare function replaceSkillDir(targetDir: string, download: () => Promise<void>): Promise<void>;
57
+ /**
58
+ * Replace whatever currently occupies a skill slot with a fresh download;
59
+ * restores the previous state on failure.
60
+ *
61
+ * `target` is the entry to move aside — a bundle **directory** or a flat
62
+ * `<name>.md` file, whichever the download is replacing (the rename/rm below
63
+ * are form-neutral). The backup is dot-prefixed (discovery skips dot entries),
64
+ * so even a failed cleanup can never register as a duplicate skill.
65
+ */
66
+ export declare function replaceInPlace(target: string, download: () => Promise<unknown>): Promise<void>;
67
+ /**
68
+ * 让位:把一个名字下的「非启用态占位」移入回收站并清掉它的 sidecar 禁用记录。
69
+ * 只动这两类,**启用的技能一律不碰**:
70
+ * - 禁用发现文件(`<name>/SKILL.md.disabled` 或 `<name>.md.disabled`,两种形态都认);
71
+ * - 同名目录存在但既无 `SKILL.md` 也无 `SKILL.md.disabled`(导入中断/手工删除的空壳)。
72
+ *
73
+ * 回收站按名字索引,所以多形态只会记下最后一条的恢复指针;其余的那份会在
74
+ * 「清空回收站」时被孤儿回收扫掉(见 sourceTrashClear)。
75
+ *
76
+ * @returns 被移入回收站的路径列表,没有可让位的东西时 undefined。
77
+ */
78
+ export declare function retireNamePlaceholders(deps: SkillHubRouteDeps, base: string, name: string): Promise<string[] | undefined>;