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
package/lib/index.js CHANGED
@@ -83,29 +83,30 @@ const HUB_CONFIG_DEFAULTS = {
83
83
  statsScanMinutes: 5
84
84
  };
85
85
  /**
86
- * Resolve the effective hub config: saved sidecar overrides win over the
87
- * cordis composition entry (the web card owns runtime config), missing
88
- * booleans fall back to HUB_CONFIG_DEFAULTS, and dot colors pass through
89
- * (saved first, then base) only when set. Numeric stats knobs are clamped to
90
- * their sane ranges (window ≥ 0, scan interval ≥ 1 minute).
86
+ * Fill a raw settings value into the effective hub config: schema defaults for
87
+ * anything missing, and the numeric stats knobs clamped to sane ranges
88
+ * (window ≥ 0, scan interval ≥ 1 minute).
89
+ *
90
+ * There is deliberately no "saved sidecar layer" parameter. Until dsh 0.1.7 the
91
+ * hub kept its own runtime overrides in the sidecar and merged them here; the
92
+ * settings namespace is now the only user layer (`updateConfig` writes through
93
+ * `ctx.settings.mutate`), so a merge against a sidecar layer could never
94
+ * observe anything but `{}` — see the git history of `resolveHubConfig`.
91
95
  */
92
- function resolveHubConfig(saved, base = {}) {
93
- const dotModelColor = saved.dotModelColor !== void 0 ? saved.dotModelColor : base.dotModelColor;
94
- const dotUserColor = saved.dotUserColor !== void 0 ? saved.dotUserColor : base.dotUserColor;
95
- const githubToken = saved.githubToken !== void 0 ? saved.githubToken : base.githubToken;
96
- const windowDays = clampNumber(saved.statsWindowDays ?? base.statsWindowDays, 0) ?? HUB_CONFIG_DEFAULTS.statsWindowDays;
97
- const scanMinutes = clampNumber(saved.statsScanMinutes ?? base.statsScanMinutes, 1) ?? HUB_CONFIG_DEFAULTS.statsScanMinutes;
96
+ function resolveHubConfig(base = {}) {
97
+ const windowDays = clampNumber(base.statsWindowDays, 0) ?? HUB_CONFIG_DEFAULTS.statsWindowDays;
98
+ const scanMinutes = clampNumber(base.statsScanMinutes, 1) ?? HUB_CONFIG_DEFAULTS.statsScanMinutes;
98
99
  return {
99
- enabled: saved.enabled ?? base.enabled ?? HUB_CONFIG_DEFAULTS.enabled,
100
- announceToAgent: saved.announceToAgent ?? base.announceToAgent ?? HUB_CONFIG_DEFAULTS.announceToAgent,
101
- showUseCount: saved.showUseCount ?? base.showUseCount ?? HUB_CONFIG_DEFAULTS.showUseCount,
102
- showUseTime: saved.showUseTime ?? base.showUseTime ?? HUB_CONFIG_DEFAULTS.showUseTime,
103
- showGroupSummary: saved.showGroupSummary ?? base.showGroupSummary ?? HUB_CONFIG_DEFAULTS.showGroupSummary,
100
+ enabled: base.enabled ?? HUB_CONFIG_DEFAULTS.enabled,
101
+ announceToAgent: base.announceToAgent ?? HUB_CONFIG_DEFAULTS.announceToAgent,
102
+ showUseCount: base.showUseCount ?? HUB_CONFIG_DEFAULTS.showUseCount,
103
+ showUseTime: base.showUseTime ?? HUB_CONFIG_DEFAULTS.showUseTime,
104
+ showGroupSummary: base.showGroupSummary ?? HUB_CONFIG_DEFAULTS.showGroupSummary,
104
105
  statsWindowDays: windowDays,
105
106
  statsScanMinutes: scanMinutes,
106
- ...dotModelColor !== void 0 ? { dotModelColor } : {},
107
- ...dotUserColor !== void 0 ? { dotUserColor } : {},
108
- ...githubToken !== void 0 ? { githubToken } : {}
107
+ ...base.dotModelColor !== void 0 ? { dotModelColor: base.dotModelColor } : {},
108
+ ...base.dotUserColor !== void 0 ? { dotUserColor: base.dotUserColor } : {},
109
+ ...base.githubToken !== void 0 ? { githubToken: base.githubToken } : {}
109
110
  };
110
111
  }
111
112
  /** Clamp a numeric override into a valid value; undefined/invalid stays undefined. */
@@ -332,13 +333,13 @@ function hydrateMigratedState(migrated) {
332
333
  ...typeof item.commitSha === "string" && item.commitSha !== "" ? { commitSha: item.commitSha } : {}
333
334
  });
334
335
  }
335
- const trashByName = /* @__PURE__ */ new Map();
336
+ const trashByPath = /* @__PURE__ */ new Map();
336
337
  if (Array.isArray(migrated.trash)) for (const entry of migrated.trash) {
337
338
  const item = entry;
338
339
  if (item !== null && typeof item === "object" && typeof item.name === "string" && typeof item.path === "string") {
339
340
  const origin = item.origin;
340
341
  const tagIds = item.tagIds;
341
- trashByName.set(item.name, {
342
+ trashByPath.set(item.path, {
342
343
  name: item.name,
343
344
  path: item.path,
344
345
  movedAt: typeof item.movedAt === "number" ? item.movedAt : 0,
@@ -429,7 +430,7 @@ function hydrateMigratedState(migrated) {
429
430
  tagsById,
430
431
  sourcesByRepo,
431
432
  marketSources,
432
- trashByName,
433
+ trashByPath,
433
434
  skillStats,
434
435
  marketStats,
435
436
  collectionOrder,
@@ -690,7 +691,7 @@ var SkillHubStore = class {
690
691
  tagsById = /* @__PURE__ */ new Map();
691
692
  sourcesByRepo = /* @__PURE__ */ new Map();
692
693
  marketSources = [];
693
- trashByName = /* @__PURE__ */ new Map();
694
+ trashByPath = /* @__PURE__ */ new Map();
694
695
  skillStats = void 0;
695
696
  marketStats = void 0;
696
697
  collectionOrder = [];
@@ -716,7 +717,7 @@ var SkillHubStore = class {
716
717
  this.tagsById = state.tagsById;
717
718
  this.sourcesByRepo = state.sourcesByRepo;
718
719
  this.marketSources = state.marketSources;
719
- this.trashByName = state.trashByName;
720
+ this.trashByPath = state.trashByPath;
720
721
  this.skillStats = state.skillStats;
721
722
  this.marketStats = state.marketStats;
722
723
  this.collectionOrder = state.collectionOrder;
@@ -1094,23 +1095,33 @@ var SkillHubStore = class {
1094
1095
  /** All trashed skills, newest first. */
1095
1096
  async listTrash() {
1096
1097
  await this.ensureLoaded();
1097
- return [...this.trashByName.values()].sort((a, b) => b.movedAt - a.movedAt);
1098
+ return [...this.trashByPath.values()].sort((a, b) => b.movedAt - a.movedAt);
1098
1099
  }
1099
- /** One trash entry by skill name (undefined when absent). */
1100
+ /** One trash entry by skill name — the newest when a name has several. */
1100
1101
  async getTrash(name) {
1101
1102
  await this.ensureLoaded();
1102
- 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;
1103
1109
  }
1104
- /** 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
+ */
1105
1116
  async addTrash(entry) {
1106
1117
  await this.ensureLoaded();
1107
- this.trashByName.set(entry.name, entry);
1118
+ this.trashByPath.set(entry.path, entry);
1108
1119
  await this.persist();
1109
1120
  }
1110
- /** Remove a trash record (after restore). */
1111
- async removeTrash(name) {
1121
+ /** Remove a trash record by its trashed path (after restore or permanent clear). */
1122
+ async removeTrash(path) {
1112
1123
  await this.ensureLoaded();
1113
- if (!this.trashByName.delete(name)) return;
1124
+ if (!this.trashByPath.delete(path)) return;
1114
1125
  await this.persist();
1115
1126
  }
1116
1127
  /** The persisted usage-statistics checkpoint (undefined until first saved). */
@@ -1162,7 +1173,7 @@ var SkillHubStore = class {
1162
1173
  ...this.tagsById.size > 0 ? { tags: [...this.tagsById.values()] } : {},
1163
1174
  ...this.sourcesByRepo.size > 0 ? { sources: [...this.sourcesByRepo.values()] } : {},
1164
1175
  ...this.marketSources.length > 0 ? { marketSources: [...this.marketSources] } : {},
1165
- ...this.trashByName.size > 0 ? { trash: [...this.trashByName.values()] } : {},
1176
+ ...this.trashByPath.size > 0 ? { trash: [...this.trashByPath.values()] } : {},
1166
1177
  ...this.skillStats !== void 0 ? { skillStats: this.skillStats } : {},
1167
1178
  ...this.marketStats !== void 0 ? { marketStats: this.marketStats } : {},
1168
1179
  ...this.collectionOrder.length > 0 ? { collectionOrder: [...this.collectionOrder] } : {},
@@ -1373,6 +1384,26 @@ function rootOfPath(path, home = dshHome()) {
1373
1384
  }
1374
1385
  }
1375
1386
  //#endregion
1387
+ //#region src/concurrency.ts
1388
+ /**
1389
+ * 并发工具:有界并发的 map。原先 repo.ts 与 stats.ts 各有一份实现,
1390
+ * 这里统一为一份(采用 stats 版本的 limit 夹取,调用点 limit≥1 行为不变)。
1391
+ */
1392
+ /** Run an async worker over items with a bounded concurrency, preserving index order. */
1393
+ async function mapConcurrent(items, limit, worker) {
1394
+ const results = new Array(items.length);
1395
+ let cursor = 0;
1396
+ const runners = Array.from({ length: Math.min(Math.max(limit, 1), items.length) }, async () => {
1397
+ while (cursor < items.length) {
1398
+ const index = cursor;
1399
+ cursor += 1;
1400
+ results[index] = await worker(items[index], index);
1401
+ }
1402
+ });
1403
+ await Promise.all(runners);
1404
+ return results;
1405
+ }
1406
+ //#endregion
1376
1407
  //#region src/skillfs/scan.ts
1377
1408
  /**
1378
1409
  * Scan one skills root for discovery files: directory bundles (SKILL.md)
@@ -1443,7 +1474,7 @@ async function scanDisabledRoot(base) {
1443
1474
  try {
1444
1475
  if ((await stat(candidate)).isFile()) paths.push(candidate);
1445
1476
  } catch {}
1446
- } else if (name.endsWith(".md.disabled") && name !== "SKILL.md.disabled") paths.push(absolute);
1477
+ } else if (name.endsWith(".md.disabled")) paths.push(absolute);
1447
1478
  }
1448
1479
  return paths.sort((a, b) => a < b ? -1 : a > b ? 1 : 0);
1449
1480
  }
@@ -1475,7 +1506,7 @@ async function readSkillInterface(directory) {
1475
1506
  const hexColor = (value) => {
1476
1507
  if (typeof value !== "string") return void 0;
1477
1508
  const trimmed = value.trim();
1478
- return /^#[0-9a-fA-F]{6}$/.test(trimmed) ? trimmed : void 0;
1509
+ return HEX_COLOR_RE.test(trimmed) ? trimmed : void 0;
1479
1510
  };
1480
1511
  const iconPath = (value) => {
1481
1512
  if (typeof value !== "string" || value.trim() === "") return void 0;
@@ -1515,37 +1546,40 @@ async function findProjectRoot(cwd) {
1515
1546
  return resolve(cwd);
1516
1547
  }
1517
1548
  /**
1518
- * Scan one writable root for files the provider ignores, so the GUI can
1519
- * show why a skill never appears — a
1520
- * missing frontmatter must be visible, not silent). .disabled files belong
1521
- * to the hub and are skipped.
1549
+ * Scan one writable root for files the provider ignores, so the GUI can show
1550
+ * why a skill never appears — a missing frontmatter, a name that disagrees
1551
+ * with the discovery path, and the like must be visible, not silent. Files the
1552
+ * hub itself disabled (`.disabled`) are skipped: they are a toggle state, not
1553
+ * a defect.
1522
1554
  */
1523
1555
  async function scanDiagnostics(root, home = dshHome()) {
1524
- const diagnostics = [];
1525
- for (const entry of await listSkillEntries(root, home)) {
1556
+ const entries = await listSkillEntries(root, home);
1557
+ const discovered = [];
1558
+ const perEntry = await mapConcurrent(entries, 8, async (entry) => {
1559
+ const found = [];
1526
1560
  let text;
1527
1561
  try {
1528
1562
  text = await readFile(entry.path, "utf8");
1529
1563
  } catch (error) {
1530
- if (error.code !== "ENOENT") diagnostics.push({
1564
+ if (error.code !== "ENOENT") found.push({
1531
1565
  path: entry.path,
1532
1566
  root,
1533
1567
  reason: "unreadable: " + errorText(error)
1534
1568
  });
1535
- continue;
1569
+ return found;
1536
1570
  }
1537
1571
  const parsed = parseFrontmatter(text);
1538
1572
  if ("error" in parsed) {
1539
1573
  const fixable = repairFrontmatterFileText(text) !== null;
1540
- diagnostics.push({
1574
+ found.push({
1541
1575
  path: entry.path,
1542
1576
  root,
1543
1577
  reason: parsed.error,
1544
1578
  ...fixable ? { fixable: true } : {}
1545
1579
  });
1546
- continue;
1580
+ return found;
1547
1581
  }
1548
- if (repairFrontmatterFileText(text) !== null) diagnostics.push({
1582
+ if (repairFrontmatterFileText(text) !== null) found.push({
1549
1583
  path: entry.path,
1550
1584
  root,
1551
1585
  reason: "frontmatter contains unquoted colon/bracket (auto-repaired in memory; click Fix to persist)",
@@ -1553,18 +1587,80 @@ async function scanDiagnostics(root, home = dshHome()) {
1553
1587
  });
1554
1588
  const { value } = parsed;
1555
1589
  const pathName = entry.kind === "directory" ? basename(entry.directory) : basename(entry.path, ".md");
1556
- if (value.name !== pathName) diagnostics.push({
1590
+ discovered.push({
1591
+ path: entry.path,
1592
+ keys: [.../* @__PURE__ */ new Set([pathName, value.name])]
1593
+ });
1594
+ if (value.name !== pathName) found.push({
1557
1595
  path: entry.path,
1558
1596
  root,
1559
1597
  reason: `frontmatter name "${value.name}" does not match the discovery path "${pathName}" (the provider registers by path)`
1560
1598
  });
1561
- if (value.description.length < 10) diagnostics.push({
1599
+ if (value.description.length < 10) found.push({
1562
1600
  path: entry.path,
1563
1601
  root,
1564
1602
  reason: `description is only ${value.description.length} chars; write a one-line description so agents can auto-activate this skill`
1565
1603
  });
1604
+ return found;
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
+ });
1566
1621
  }
1567
- return diagnostics;
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;
1568
1664
  }
1569
1665
  //#endregion
1570
1666
  //#region src/skillfs.ts
@@ -1624,14 +1720,18 @@ async function enableSkill(disabledPath) {
1624
1720
  return original;
1625
1721
  }
1626
1722
  /**
1627
- * Move a skill (directory bundle or flat .md file) into the root's trash.
1628
- * The skill stays recoverable: the source is renamed into
1629
- * <root>/.trash/<name>-<timestamp>. A directory bundle is addressed by its
1630
- * SKILL.md path and moved as a whole directory.
1723
+ * Move one already-resolved skill (a bundle directory or a flat .md file) into
1724
+ * its root's trash as `<root>/.trash/<name>-<timestamp>`.
1725
+ *
1726
+ * The layout is the restore contract, not an implementation detail:
1727
+ * `restoreSkill` renames the entry back and only proves the parent is `.trash`,
1728
+ * and `clearTrash` deletes in place. Every producer of a trash entry must go
1729
+ * through here — skill delete, the hub-disabled whole-group delete, and repo
1730
+ * import's placeholder replacement all move skills for the same recovery flow.
1731
+ *
1631
1732
  * @returns the trashed path plus the original path that was moved.
1632
1733
  */
1633
- async function trashSkill(sourcePath) {
1634
- const source = basename(sourcePath) === "SKILL.md" ? dirname(sourcePath) : sourcePath;
1734
+ async function moveIntoTrash(source) {
1635
1735
  const trashDir = join(dirname(source), ".trash");
1636
1736
  await mkdir(trashDir, { recursive: true });
1637
1737
  const target = join(trashDir, basename(source) + "-" + Date.now());
@@ -1641,6 +1741,27 @@ async function trashSkill(sourcePath) {
1641
1741
  source
1642
1742
  };
1643
1743
  }
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
+ /**
1756
+ * Move a skill (directory bundle or flat .md file) into the root's trash.
1757
+ * The skill stays recoverable: the source is renamed into
1758
+ * <root>/.trash/<name>-<timestamp>. A directory bundle is addressed by its
1759
+ * SKILL.md (or SKILL.md.disabled) path and moved as a whole directory.
1760
+ * @returns the trashed path plus the original path that was moved.
1761
+ */
1762
+ async function trashSkill(sourcePath) {
1763
+ return moveIntoTrash(trashSourceOf(sourcePath));
1764
+ }
1644
1765
  /** Restore a trashed skill (directory or flat file) to its original location. */
1645
1766
  async function restoreSkill(entry, home = dshHome()) {
1646
1767
  if (basename(dirname(entry.path)) !== ".trash") throw new TypeError("not a trashed skill path: " + entry.path);
@@ -1663,14 +1784,29 @@ function skillFileKind(path) {
1663
1784
  if (basename(path) === "SKILL.md") return "directory";
1664
1785
  if (path.endsWith(".md")) return "flat";
1665
1786
  }
1666
- /** Repair one file on disk when its frontmatter is auto-fixable. Returns the new text. */
1787
+ /**
1788
+ * Repair one file on disk when its frontmatter is auto-fixable. Returns the new text.
1789
+ *
1790
+ * The write is atomic (sibling temp file + rename), matching the sidecar's
1791
+ * persist path: this rewrites a real skill file in place, and a crash midway
1792
+ * through a plain write would leave a truncated SKILL.md — exactly the failure
1793
+ * the store layer already guards against. The `.tmp` sibling is never picked
1794
+ * up by discovery (only `SKILL.md` and `*.md` are scanned).
1795
+ */
1667
1796
  async function fixDiagnosticFile(path, home = dshHome()) {
1668
1797
  if (rootOfPath(path, home) === void 0) throw new TypeError("not a hub writable skill path: " + path);
1669
1798
  const repairedText = repairFrontmatterFileText(await readFile(path, "utf8"));
1670
1799
  if (repairedText === null) throw new TypeError("diagnostic is not auto-fixable: " + path);
1671
1800
  const parsed = parseFrontmatter(repairedText);
1672
1801
  if ("error" in parsed) throw new TypeError("repaired frontmatter still invalid: " + parsed.error);
1673
- await writeFile(path, repairedText, "utf8");
1802
+ const tmp = path + ".tmp";
1803
+ await writeFile(tmp, repairedText, "utf8");
1804
+ try {
1805
+ await rename(tmp, path);
1806
+ } catch (error) {
1807
+ await rm(tmp, { force: true }).catch(() => {});
1808
+ throw error;
1809
+ }
1674
1810
  return path;
1675
1811
  }
1676
1812
  //#endregion
@@ -1913,15 +2049,27 @@ function etagCacheSet(url, etag, json) {
1913
2049
  });
1914
2050
  }
1915
2051
  /**
2052
+ * When the response shows an exhausted GitHub rate limit, the instant it
2053
+ * resets; otherwise undefined. Shared by the repo fetch layer and the plugin's
2054
+ * own update check so both agree on what counts as a rate-limit rejection
2055
+ * (403/429 + `x-ratelimit-remaining: 0` + a parseable `x-ratelimit-reset`)
2056
+ * instead of each re-deriving it from the headers.
2057
+ */
2058
+ function rateLimitResetAt(response) {
2059
+ if (response.status !== 403 && response.status !== 429) return void 0;
2060
+ if (response.headers.get("x-ratelimit-remaining") !== "0") return void 0;
2061
+ const reset = response.headers.get("x-ratelimit-reset");
2062
+ if (reset === null) return void 0;
2063
+ const at = /* @__PURE__ */ new Date(Number(reset) * 1e3);
2064
+ return Number.isNaN(at.getTime()) ? void 0 : at;
2065
+ }
2066
+ /**
1916
2067
  * Build a RepoFetchError; when the response shows an exhausted rate limit,
1917
2068
  * report the reset time instead of a bare HTTP status.
1918
2069
  */
1919
2070
  function fetchError(context, response, fallbackStatus = 502) {
1920
- const remaining = response.headers.get("x-ratelimit-remaining");
1921
- const reset = response.headers.get("x-ratelimit-reset");
1922
- if (response.status === 403 || response.status === 429) {
1923
- if (remaining === "0" && reset !== null) return new RepoFetchError("github rate limit reached (anonymous quota exhausted); retry after " + (/* @__PURE__ */ new Date(Number(reset) * 1e3)).toLocaleTimeString() + " or set GITHUB_TOKEN", 403);
1924
- }
2071
+ const resetAt = rateLimitResetAt(response);
2072
+ if (resetAt !== void 0) return new RepoFetchError("github rate limit reached (anonymous quota exhausted); retry after " + resetAt.toLocaleTimeString() + " or set GITHUB_TOKEN", 403);
1925
2073
  return new RepoFetchError(context + " (HTTP " + response.status + ")", response.status === 404 ? 404 : fallbackStatus);
1926
2074
  }
1927
2075
  /** 是否限流/中断类错误(调用方可直接透出,不再包装)。 */
@@ -1951,10 +2099,9 @@ async function fetchJson(url, fetchImpl, context, headers = apiHeaders()) {
1951
2099
  }
1952
2100
  }
1953
2101
  /**
1954
- * Load repo metadata and the recursive git tree at the given ref. The meta
1955
- * request always resolves the default branch (used when ref is absent); the
1956
- * tree is fetched at `ref ?? default_branch` so an explicit branch/tag scans
1957
- * the exact same content a later download would fetch.
2102
+ * Load one JSON document from the GitHub API, reusing an ETag when the URL was
2103
+ * fetched before. `fetchImpl`/`context` exist so callers can stub the transport
2104
+ * and shape the error text; the payload is whatever the endpoint returned.
1958
2105
  */
1959
2106
  async function fetchJsonCached(url, fetchImpl, context) {
1960
2107
  const cached = etagCache.get(url);
@@ -2081,26 +2228,6 @@ async function loadRepoTreeAt(repo, treeSha, fetchImpl = fetch) {
2081
2228
  return Array.isArray(record.tree) ? record.tree : [];
2082
2229
  }
2083
2230
  //#endregion
2084
- //#region src/concurrency.ts
2085
- /**
2086
- * 并发工具:有界并发的 map。原先 repo.ts 与 stats.ts 各有一份实现,
2087
- * 这里统一为一份(采用 stats 版本的 limit 夹取,调用点 limit≥1 行为不变)。
2088
- */
2089
- /** Run an async worker over items with a bounded concurrency, preserving index order. */
2090
- async function mapConcurrent(items, limit, worker) {
2091
- const results = new Array(items.length);
2092
- let cursor = 0;
2093
- const runners = Array.from({ length: Math.min(Math.max(limit, 1), items.length) }, async () => {
2094
- while (cursor < items.length) {
2095
- const index = cursor;
2096
- cursor += 1;
2097
- results[index] = await worker(items[index], index);
2098
- }
2099
- });
2100
- await Promise.all(runners);
2101
- return results;
2102
- }
2103
- //#endregion
2104
2231
  //#region src/repo/install.ts
2105
2232
  /**
2106
2233
  * 技能目录下载与落地:单文件下载(raw 优先、contents 兜底)、整目录
@@ -2352,9 +2479,14 @@ function isWritableSource(source) {
2352
2479
  }
2353
2480
  /** The resolved hub config a route sees (the shared resolver fills defaults). */
2354
2481
  function configOf(deps) {
2355
- return resolveHubConfig({}, deps.config?.() ?? {});
2482
+ return resolveHubConfig(deps.config?.() ?? {});
2356
2483
  }
2357
- /** The raw saved config layer a route reports (empty when the owner omits it). */
2484
+ /**
2485
+ * The raw saved config layer as persisted in the sidecar, reported for display
2486
+ * only. Since dsh 0.1.7 the settings namespace is the one user layer, so this
2487
+ * is legacy data: nothing writes it any more and the effective config above
2488
+ * never merges it.
2489
+ */
2358
2490
  function savedOf(deps) {
2359
2491
  return deps.saved?.() ?? {};
2360
2492
  }
@@ -2502,9 +2634,8 @@ async function checkLatestRelease(repo = UPDATE_REPO, fetchImpl = fetch) {
2502
2634
  }
2503
2635
  if (response.status === 404) return failure("no release published yet");
2504
2636
  if (!response.ok) {
2505
- const remaining = response.headers.get("x-ratelimit-remaining");
2506
- const reset = response.headers.get("x-ratelimit-reset");
2507
- if ((response.status === 403 || response.status === 429) && remaining === "0" && reset !== null) return failure("github rate limit reached; retry after " + (/* @__PURE__ */ new Date(Number(reset) * 1e3)).toLocaleTimeString() + " or set GITHUB_TOKEN");
2637
+ const resetAt = rateLimitResetAt(response);
2638
+ if (resetAt !== void 0) return failure("github rate limit reached; retry after " + resetAt.toLocaleTimeString() + " or set GITHUB_TOKEN");
2508
2639
  return failure("GitHub HTTP " + response.status);
2509
2640
  }
2510
2641
  let payload;
@@ -2692,7 +2823,8 @@ async function buildCatalog(deps, cwd) {
2692
2823
  if (iface !== void 0) applyInterface(row, iface);
2693
2824
  return row;
2694
2825
  });
2695
- const diagnostics = [...await scanDiagnostics("user-dsh", home), ...await scanDiagnostics("user-agents", home)];
2826
+ const [userDshDiagnostics, userAgentsDiagnostics] = await Promise.all([scanDiagnostics("user-dsh", home), scanDiagnostics("user-agents", home)]);
2827
+ const diagnostics = [...userDshDiagnostics, ...userAgentsDiagnostics];
2696
2828
  return {
2697
2829
  ok: true,
2698
2830
  pluginVersion: CURRENT_VERSION,
@@ -2881,15 +3013,18 @@ function catalogRoutes(deps) {
2881
3013
  writeError(res, 409, "disabled skill path is outside the hub writable roots");
2882
3014
  return;
2883
3015
  }
2884
- const sourceToTrash = disabled.path.endsWith("SKILL.md.disabled") ? dirname(disabled.path) : disabled.path;
2885
- const trashDir = join(dirname(sourceToTrash), ".trash");
2886
- await mkdir(trashDir, { recursive: true });
2887
- const target = join(trashDir, basename(sourceToTrash) + "-" + Date.now());
2888
- await rename(sourceToTrash, target);
2889
- trashResult = {
2890
- path: target,
2891
- source: sourceToTrash
2892
- };
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);
2893
3028
  } else {
2894
3029
  writeError(res, resolved.status, resolved.error);
2895
3030
  return;
@@ -2940,11 +3075,23 @@ function catalogRoutes(deps) {
2940
3075
  writeError(res, 404, "skill is not hub-disabled: " + name);
2941
3076
  return;
2942
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
+ }
2943
3090
  try {
2944
- await enableSkill(record.path);
3091
+ await enableSkill(disabledPath);
2945
3092
  } catch (error) {
2946
3093
  if (error.code === "ENOENT") {
2947
- writeError(res, 409, "disabled skill file is missing on disk: " + record.path);
3094
+ writeError(res, 409, "disabled skill file is missing on disk: " + disabledPath);
2948
3095
  return;
2949
3096
  }
2950
3097
  throw error;
@@ -3048,8 +3195,13 @@ function catalogRoutes(deps) {
3048
3195
  writeError(res, 409, "skill name is disabled: re-enable it from the disabled list first");
3049
3196
  return;
3050
3197
  }
3051
- if (await pathExists(join(rootPath(root, homeOf(deps)), name))) {
3052
- 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)");
3053
3205
  return;
3054
3206
  }
3055
3207
  const path = await createSkill(root, name, readString(body, "description"), homeOf(deps));
@@ -3256,15 +3408,23 @@ function gcImportJobs() {
3256
3408
  for (let i = 0; i < finished.length && importJobs.size > 100; i++) importJobs.delete(finished[i][0]);
3257
3409
  }
3258
3410
  }
3259
- /** Replace one skill directory with a fresh download; restores the old dir on failure. */
3260
- async function replaceSkillDir(targetDir, download) {
3261
- const backup = join(dirname(targetDir), "." + basename(targetDir) + ".sync-bak-" + Date.now());
3262
- 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);
3263
3423
  try {
3264
3424
  await download();
3265
3425
  } catch (error) {
3266
- await rename(backup, targetDir).catch((rollbackError) => {
3267
- 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));
3268
3428
  });
3269
3429
  throw error;
3270
3430
  }
@@ -3273,6 +3433,35 @@ async function replaceSkillDir(targetDir, download) {
3273
3433
  force: true
3274
3434
  });
3275
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
+ }
3276
3465
  //#endregion
3277
3466
  //#region src/routes/market.ts
3278
3467
  /**
@@ -3303,6 +3492,7 @@ function marketRoutes(deps) {
3303
3492
  }
3304
3493
  try {
3305
3494
  const latestTag = await getLatestReleaseTag(source.repo);
3495
+ lastMarketCheck.set(source.repo, now);
3306
3496
  if (source.ref === void 0) {
3307
3497
  results.push({
3308
3498
  ...base,
@@ -3313,7 +3503,6 @@ function marketRoutes(deps) {
3313
3503
  continue;
3314
3504
  }
3315
3505
  const latest = await getLatestCommit(source.repo, source.ref);
3316
- lastMarketCheck.set(source.repo, now);
3317
3506
  const commitMoved = source.commitSha !== void 0 && latest.commitSha !== source.commitSha;
3318
3507
  const newRelease = latestTag !== void 0 && latestTag !== source.ref;
3319
3508
  results.push({
@@ -3358,9 +3547,11 @@ function marketRoutes(deps) {
3358
3547
  return;
3359
3548
  }
3360
3549
  const ref = parsed.ref;
3550
+ const slug = repoSlug(parsed);
3361
3551
  writeJson(res, 200, {
3362
3552
  ok: true,
3363
- repos: await deps.store.addMarketSource(repoSlug(parsed), ref)
3553
+ repo: slug,
3554
+ repos: await deps.store.addMarketSource(slug, ref)
3364
3555
  });
3365
3556
  }
3366
3557
  },
@@ -3540,32 +3731,17 @@ function marketRoutes(deps) {
3540
3731
  * cancel 取消。从 routes.ts 原样搬出,handler 逻辑不变。
3541
3732
  */
3542
3733
  /**
3543
- * 导入去重只认「已启用」的目录,但磁盘上仍可能躺着一个已禁用的同名占位 ——
3544
- * 禁用只是把 SKILL.md 改名成 SKILL.md.disabled,目录本身还在。这种占位不该
3545
- * 堵住真技能:把它连同禁用记录一起移入回收站让位(issue #16)。
3734
+ * 导入让位(issue #16):磁盘上已有同名占位但**没有启用的技能**时,把占位移入
3735
+ * 回收站,把名字让给即将导入的真技能。占位有两种 —— 已禁用的发现文件(目录包或
3736
+ * 扁平,后者曾长期漏处理:`clearDisabledPlaceholder` 只认 `<name>` 这个目录,
3737
+ * 扁平占位占的是 `<name>.md.disabled`,判据为假,分支根本进不去)和没有 `SKILL.md`
3738
+ * 的空壳目录。判据统一走 `occupancyOf`,形态判断集中在 `retireNamePlaceholders`。
3546
3739
  *
3547
- * 只有「禁用记录确实指向 `targetRoot/<name>` 的整目录禁用」才处理,且该目录
3548
- * 必须已经没有 SKILL.md;其余不确定情形一律返回 false,交回原本的「跳过」。
3740
+ * 返回 false = 磁盘上有启用的同名技能(真货),一律不动,交回原本的「跳过」。
3549
3741
  */
3550
3742
  async function clearDisabledPlaceholder(deps, targetRoot, name) {
3551
- const record = await deps.store.getDisabled(name);
3552
- if (record === void 0) return false;
3553
- const dir = join(targetRoot, name);
3554
- const sourceToTrash = record.path.endsWith("SKILL.md.disabled") ? dirname(record.path) : record.path;
3555
- if (sourceToTrash !== dir) return false;
3556
- if (await pathExists(join(dir, "SKILL.md"))) return false;
3557
- const trashDir = join(targetRoot, ".trash");
3558
- await mkdir(trashDir, { recursive: true });
3559
- const target = join(trashDir, name + "-" + Date.now());
3560
- await rename(sourceToTrash, target);
3561
- await deps.store.addTrash({
3562
- name,
3563
- path: target,
3564
- movedAt: Date.now(),
3565
- sourcePath: sourceToTrash
3566
- });
3567
- await deps.store.removeDisabled(name);
3568
- return true;
3743
+ if ((await occupancyOf(targetRoot, name)).enabled.length > 0) return false;
3744
+ return await retireNamePlaceholders(deps, targetRoot, name) !== void 0;
3569
3745
  }
3570
3746
  /** 仓库导入域全部路由 spec(由 routes.ts 经 createRoute 包上统一围栏)。 */
3571
3747
  function repoImportRoutes(deps) {
@@ -3695,7 +3871,8 @@ function repoImportRoutes(deps) {
3695
3871
  job.done += 1;
3696
3872
  continue;
3697
3873
  }
3698
- 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) {
3699
3876
  let cleared = false;
3700
3877
  try {
3701
3878
  cleared = await clearDisabledPlaceholder(deps, targetRoot, entry.name);
@@ -4107,13 +4284,21 @@ function sourceRoutes(deps) {
4107
4284
  continue;
4108
4285
  }
4109
4286
  const targetDir = join(targetRoot, name);
4110
- const wasDisabled = await deps.store.getDisabled(name) !== void 0;
4111
- if (await pathExists(targetDir)) await replaceSkillDir(targetDir, async () => {
4112
- await downloadRepoSkill(repo, latest.commitSha, entry, files, targetRoot);
4113
- });
4114
- else await downloadRepoSkill(repo, latest.commitSha, entry, files, targetRoot);
4115
- if (wasDisabled) await rename(join(targetDir, "SKILL.md"), join(targetDir, "SKILL.md.disabled"));
4116
- 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);
4117
4302
  await deps.store.mergeSourceManifest(repo, skillManifest(tree, entry.dir), entry.dir);
4118
4303
  synced.push(name);
4119
4304
  } catch (error) {
@@ -4166,29 +4351,30 @@ function sourceRoutes(deps) {
4166
4351
  });
4167
4352
  continue;
4168
4353
  }
4169
- const sourcePath = join(rootPath("user-dsh", home), name);
4170
- 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) {
4171
4357
  failed.push({
4172
4358
  name,
4173
- error: "skill path is outside the hub writable roots"
4359
+ error: "skill not found on disk"
4174
4360
  });
4175
4361
  continue;
4176
4362
  }
4177
- if (!await pathExists(sourcePath)) {
4363
+ if (rootOfPath(existing, home) === void 0) {
4178
4364
  failed.push({
4179
4365
  name,
4180
- error: "skill directory not found"
4366
+ error: "skill path is outside the hub writable roots"
4181
4367
  });
4182
4368
  continue;
4183
4369
  }
4184
4370
  try {
4185
4371
  const tagIds = (await deps.store.listTags()).filter((tag) => tag.skillNames.includes(name)).map((tag) => tag.id);
4186
- const { path } = await trashSkill(sourcePath);
4372
+ const { path, source: trashSource } = await trashSkill(existing);
4187
4373
  await deps.store.addTrash({
4188
4374
  name,
4189
4375
  path,
4190
4376
  movedAt: Date.now(),
4191
- sourcePath,
4377
+ sourcePath: trashSource,
4192
4378
  origin: {
4193
4379
  repo: source.repo,
4194
4380
  root: source.root,
@@ -4241,7 +4427,7 @@ function sourceRoutes(deps) {
4241
4427
  const path = await restoreSkill(entry, home);
4242
4428
  if (entry.origin !== void 0) await deps.store.addSourceSkill(entry.origin.repo, entry.origin.root, entry.origin.commitSha, entry.origin.ref, name);
4243
4429
  for (const tagId of entry.tagIds ?? []) await deps.store.addSkillToTag(tagId, name);
4244
- await deps.store.removeTrash(name);
4430
+ await deps.store.removeTrash(entry.path);
4245
4431
  deps.invalidate?.();
4246
4432
  writeJson(res, 200, {
4247
4433
  ok: true,
@@ -4257,28 +4443,67 @@ function sourceRoutes(deps) {
4257
4443
  const home = homeOf(deps);
4258
4444
  const entries = await deps.store.listTrash();
4259
4445
  const deleted = [];
4446
+ const removedPaths = [];
4260
4447
  const failed = [];
4261
4448
  for (const entry of entries) try {
4262
4449
  await clearTrash(entry, home);
4263
4450
  await deps.store.removeSkillFromTags(entry.name);
4264
4451
  deleted.push(entry.name);
4452
+ removedPaths.push(entry.path);
4265
4453
  } catch (error) {
4266
4454
  failed.push({
4267
4455
  name: entry.name,
4268
4456
  error: errorText(error)
4269
4457
  });
4270
4458
  }
4271
- for (const name of deleted) await deps.store.removeTrash(name);
4272
- 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?.();
4273
4462
  writeJson(res, 200, {
4274
4463
  ok: true,
4275
4464
  deleted,
4276
- failed
4465
+ failed,
4466
+ ...orphans.length > 0 ? { orphans } : {}
4277
4467
  });
4278
4468
  }
4279
4469
  }
4280
4470
  ];
4281
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
+ }
4282
4507
  //#endregion
4283
4508
  //#region src/routes.ts
4284
4509
  /**
@@ -4681,6 +4906,47 @@ async function reconcileDisabledSkills(store, home = dshHome()) {
4681
4906
  }
4682
4907
  return added;
4683
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
+ }
4684
4950
  //#endregion
4685
4951
  //#region src/index.ts
4686
4952
  /** Stable cordis plugin name (matches cordis.patch.yml insert id). */
@@ -4776,7 +5042,7 @@ function apply(ctx, config) {
4776
5042
  }
4777
5043
  return out;
4778
5044
  };
4779
- const current = () => resolveHubConfig({}, readConfig());
5045
+ const current = () => resolveHubConfig(readConfig());
4780
5046
  /** The Settings service, when this deployment mounts it. Absent ⇒ no user layer to read or write. */
4781
5047
  const settingsOf = () => ctx.get("settings");
4782
5048
  const store = new SkillHubStore();
@@ -4868,6 +5134,12 @@ function apply(ctx, config) {
4868
5134
  } catch (error) {
4869
5135
  ctx.logger.warn("[dsh-skill-hub] startup disabled-skill reconcile failed", error);
4870
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
+ }
4871
5143
  })();
4872
5144
  (async () => {
4873
5145
  try {