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
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()) {
@@ -2306,6 +2391,15 @@ function isLoopbackRequest(request) {
2306
2391
  return false;
2307
2392
  }
2308
2393
  }
2394
+ /**
2395
+ * Fallback fence for a host that mounts no Connection service (unit tests, and
2396
+ * any composition serving the webserver alone): the historical literal-loopback
2397
+ * check, so a missing service degrades to the previous behaviour instead of an
2398
+ * open or a dead route family.
2399
+ */
2400
+ function loopbackOnlyFence(request) {
2401
+ return isLoopbackRequest(request) ? void 0 : 403;
2402
+ }
2309
2403
  /** One JSON response. */
2310
2404
  function writeJson(res, status, body) {
2311
2405
  const payload = JSON.stringify(body);
@@ -2778,17 +2872,24 @@ function toDetail(skill) {
2778
2872
  //#region src/routes/helpers.ts
2779
2873
  /**
2780
2874
  * Wrap one handler in the shared request fences. Order matches the
2781
- * original per-handler prologue: loopback trust → HTTP method → master
2875
+ * original per-handler prologue: trust fence → HTTP method → master
2782
2876
  * switch → JSON body. The outer catch maps errors onto the JSON error
2783
2877
  * body (preserving RepoFetchError status codes).
2878
+ *
2879
+ * The trust fence is the host's `/api` model (`deps.fence`) whenever the
2880
+ * composition mounts Connection, so a reverse-proxied deployment is judged by
2881
+ * the same authorities dsh already trusts; without that service the historical
2882
+ * literal-loopback check stands in.
2784
2883
  */
2785
2884
  function createRoute(deps, spec) {
2885
+ const fence = deps.fence ?? loopbackOnlyFence;
2786
2886
  return {
2787
2887
  kind: spec.kind ?? "exact",
2788
2888
  path: spec.path,
2789
2889
  handler: async (req, res) => {
2790
- if (!isLoopbackRequest(req)) {
2791
- writeError(res, 403, "forbidden: loopback-only");
2890
+ const rejection = fence(req);
2891
+ if (rejection !== void 0) {
2892
+ writeError(res, rejection, rejection === 401 ? "unauthorized: browser session required" : "forbidden: untrusted authority");
2792
2893
  return;
2793
2894
  }
2794
2895
  if (!spec.methods.includes(req.method)) {
@@ -2928,7 +3029,18 @@ function catalogRoutes(deps) {
2928
3029
  writeError(res, 409, "disabled skill path is outside the hub writable roots");
2929
3030
  return;
2930
3031
  }
2931
- trashResult = await moveIntoTrash(disabled.path.endsWith("SKILL.md.disabled") ? dirname(disabled.path) : disabled.path);
3032
+ if (!await pathExists(disabled.path)) {
3033
+ await deps.store.removeDisabled(name);
3034
+ await deps.store.removeSkillFromTags(name);
3035
+ deps.invalidate?.();
3036
+ writeJson(res, 200, {
3037
+ ok: true,
3038
+ name,
3039
+ path: disabled.path
3040
+ });
3041
+ return;
3042
+ }
3043
+ trashResult = await trashSkill(disabled.path);
2932
3044
  } else {
2933
3045
  writeError(res, resolved.status, resolved.error);
2934
3046
  return;
@@ -2979,11 +3091,23 @@ function catalogRoutes(deps) {
2979
3091
  writeError(res, 404, "skill is not hub-disabled: " + name);
2980
3092
  return;
2981
3093
  }
3094
+ let disabledPath = record.path;
3095
+ if (!await pathExists(disabledPath)) {
3096
+ const root = rootOfPath(disabledPath, homeOf(deps));
3097
+ const occupancy = root === void 0 ? void 0 : await disabledOccupancyIn(rootPath(root, homeOf(deps)), name);
3098
+ if (occupancy === void 0) {
3099
+ await deps.store.removeDisabled(name);
3100
+ deps.invalidate?.();
3101
+ writeError(res, 409, "disabled record was stale (its file no longer exists); the record has been removed — retry now");
3102
+ return;
3103
+ }
3104
+ disabledPath = occupancy;
3105
+ }
2982
3106
  try {
2983
- await enableSkill(record.path);
3107
+ await enableSkill(disabledPath);
2984
3108
  } catch (error) {
2985
3109
  if (error.code === "ENOENT") {
2986
- writeError(res, 409, "disabled skill file is missing on disk: " + record.path);
3110
+ writeError(res, 409, "disabled skill file is missing on disk: " + disabledPath);
2987
3111
  return;
2988
3112
  }
2989
3113
  throw error;
@@ -3087,8 +3211,13 @@ function catalogRoutes(deps) {
3087
3211
  writeError(res, 409, "skill name is disabled: re-enable it from the disabled list first");
3088
3212
  return;
3089
3213
  }
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)");
3214
+ const occupancy = await occupancyOf(rootPath(root, homeOf(deps)), name);
3215
+ if (occupancy.enabled.length > 0) {
3216
+ writeError(res, 409, "a skill file already occupies this name on disk: " + name + " (check the discovery diagnostics)");
3217
+ return;
3218
+ }
3219
+ if (occupancy.disabled.length > 0 || occupancy.bareDir !== void 0) {
3220
+ writeError(res, 409, "name is occupied by a disabled placeholder or an empty directory on disk: " + name + " (check the discovery diagnostics)");
3092
3221
  return;
3093
3222
  }
3094
3223
  const path = await createSkill(root, name, readString(body, "description"), homeOf(deps));
@@ -3295,15 +3424,23 @@ function gcImportJobs() {
3295
3424
  for (let i = 0; i < finished.length && importJobs.size > 100; i++) importJobs.delete(finished[i][0]);
3296
3425
  }
3297
3426
  }
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);
3427
+ /**
3428
+ * Replace whatever currently occupies a skill slot with a fresh download;
3429
+ * restores the previous state on failure.
3430
+ *
3431
+ * `target` is the entry to move aside — a bundle **directory** or a flat
3432
+ * `<name>.md` file, whichever the download is replacing (the rename/rm below
3433
+ * are form-neutral). The backup is dot-prefixed (discovery skips dot entries),
3434
+ * so even a failed cleanup can never register as a duplicate skill.
3435
+ */
3436
+ async function replaceInPlace(target, download) {
3437
+ const backup = join(dirname(target), "." + basename(target) + ".sync-bak-" + Date.now());
3438
+ await rename(target, backup);
3302
3439
  try {
3303
3440
  await download();
3304
3441
  } catch (error) {
3305
- await rename(backup, targetDir).catch((rollbackError) => {
3306
- console.warn("[dsh-skill-hub] restoring " + targetDir + " from " + backup + " failed:", errorText(rollbackError));
3442
+ await rename(backup, target).catch((rollbackError) => {
3443
+ console.warn("[dsh-skill-hub] restoring " + target + " from " + backup + " failed:", errorText(rollbackError));
3307
3444
  });
3308
3445
  throw error;
3309
3446
  }
@@ -3312,6 +3449,35 @@ async function replaceSkillDir(targetDir, download) {
3312
3449
  force: true
3313
3450
  });
3314
3451
  }
3452
+ /**
3453
+ * 让位:把一个名字下的「非启用态占位」移入回收站并清掉它的 sidecar 禁用记录。
3454
+ * 只动这两类,**启用的技能一律不碰**:
3455
+ * - 禁用发现文件(`<name>/SKILL.md.disabled` 或 `<name>.md.disabled`,两种形态都认);
3456
+ * - 同名目录存在但既无 `SKILL.md` 也无 `SKILL.md.disabled`(导入中断/手工删除的空壳)。
3457
+ *
3458
+ * 回收站按名字索引,所以多形态只会记下最后一条的恢复指针;其余的那份会在
3459
+ * 「清空回收站」时被孤儿回收扫掉(见 sourceTrashClear)。
3460
+ *
3461
+ * @returns 被移入回收站的路径列表,没有可让位的东西时 undefined。
3462
+ */
3463
+ async function retireNamePlaceholders(deps, base, name) {
3464
+ const occupancy = await occupancyOf(base, name);
3465
+ const sources = [.../* @__PURE__ */ new Set([...occupancy.disabled.map((path) => trashSourceOf(path)), ...occupancy.bareDir !== void 0 ? [occupancy.bareDir] : []])];
3466
+ if (sources.length === 0) return void 0;
3467
+ const trashed = [];
3468
+ for (const source of sources) {
3469
+ const moved = await moveIntoTrash(source);
3470
+ await deps.store.addTrash({
3471
+ name,
3472
+ path: moved.path,
3473
+ movedAt: Date.now(),
3474
+ sourcePath: moved.source
3475
+ });
3476
+ trashed.push(moved.path);
3477
+ }
3478
+ await deps.store.removeDisabled(name);
3479
+ return trashed;
3480
+ }
3315
3481
  //#endregion
3316
3482
  //#region src/routes/market.ts
3317
3483
  /**
@@ -3581,29 +3747,17 @@ function marketRoutes(deps) {
3581
3747
  * cancel 取消。从 routes.ts 原样搬出,handler 逻辑不变。
3582
3748
  */
3583
3749
  /**
3584
- * 导入去重只认「已启用」的目录,但磁盘上仍可能躺着一个已禁用的同名占位 ——
3585
- * 禁用只是把 SKILL.md 改名成 SKILL.md.disabled,目录本身还在。这种占位不该
3586
- * 堵住真技能:把它连同禁用记录一起移入回收站让位(issue #16)。
3750
+ * 导入让位(issue #16):磁盘上已有同名占位但**没有启用的技能**时,把占位移入
3751
+ * 回收站,把名字让给即将导入的真技能。占位有两种 —— 已禁用的发现文件(目录包或
3752
+ * 扁平,后者曾长期漏处理:`clearDisabledPlaceholder` 只认 `<name>` 这个目录,
3753
+ * 扁平占位占的是 `<name>.md.disabled`,判据为假,分支根本进不去)和没有 `SKILL.md`
3754
+ * 的空壳目录。判据统一走 `occupancyOf`,形态判断集中在 `retireNamePlaceholders`。
3587
3755
  *
3588
- * 只有「禁用记录确实指向 `targetRoot/<name>` 的整目录禁用」才处理,且该目录
3589
- * 必须已经没有 SKILL.md;其余不确定情形一律返回 false,交回原本的「跳过」。
3756
+ * 返回 false = 磁盘上有启用的同名技能(真货),一律不动,交回原本的「跳过」。
3590
3757
  */
3591
3758
  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;
3759
+ if ((await occupancyOf(targetRoot, name)).enabled.length > 0) return false;
3760
+ return await retireNamePlaceholders(deps, targetRoot, name) !== void 0;
3607
3761
  }
3608
3762
  /** 仓库导入域全部路由 spec(由 routes.ts 经 createRoute 包上统一围栏)。 */
3609
3763
  function repoImportRoutes(deps) {
@@ -3733,7 +3887,8 @@ function repoImportRoutes(deps) {
3733
3887
  job.done += 1;
3734
3888
  continue;
3735
3889
  }
3736
- if (await pathExists(join(targetRoot, entry.name))) {
3890
+ const occupancy = await occupancyOf(targetRoot, entry.name);
3891
+ if (occupancy.enabled.length > 0 || occupancy.disabled.length > 0 || occupancy.bareDir !== void 0) {
3737
3892
  let cleared = false;
3738
3893
  try {
3739
3894
  cleared = await clearDisabledPlaceholder(deps, targetRoot, entry.name);
@@ -4145,13 +4300,21 @@ function sourceRoutes(deps) {
4145
4300
  continue;
4146
4301
  }
4147
4302
  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);
4303
+ const record = await deps.store.getDisabled(name);
4304
+ const occupancy = await occupancyOf(targetRoot, name);
4305
+ const download = () => downloadRepoSkill(repo, latest.commitSha, entry, files, targetRoot);
4306
+ await retireNamePlaceholders(deps, targetRoot, name);
4307
+ const enabled = occupancy.enabled[0];
4308
+ if (enabled !== void 0) await replaceInPlace(trashSourceOf(enabled), download);
4309
+ else await download();
4310
+ if (record !== void 0) {
4311
+ const disabledPath = join(targetDir, "SKILL.md.disabled");
4312
+ await rename(join(targetDir, "SKILL.md"), disabledPath);
4313
+ await deps.store.addDisabled({
4314
+ ...record,
4315
+ path: disabledPath
4316
+ });
4317
+ } else await deps.store.removeDisabled(name);
4155
4318
  await deps.store.mergeSourceManifest(repo, skillManifest(tree, entry.dir), entry.dir);
4156
4319
  synced.push(name);
4157
4320
  } catch (error) {
@@ -4204,29 +4367,30 @@ function sourceRoutes(deps) {
4204
4367
  });
4205
4368
  continue;
4206
4369
  }
4207
- const sourcePath = join(rootPath("user-dsh", home), name);
4208
- if (rootOfPath(sourcePath, home) === void 0) {
4370
+ const occupancy = await occupancyOf(rootPath("user-dsh", home), name);
4371
+ const existing = occupancy.enabled[0] ?? occupancy.disabled[0] ?? occupancy.bareDir;
4372
+ if (existing === void 0) {
4209
4373
  failed.push({
4210
4374
  name,
4211
- error: "skill path is outside the hub writable roots"
4375
+ error: "skill not found on disk"
4212
4376
  });
4213
4377
  continue;
4214
4378
  }
4215
- if (!await pathExists(sourcePath)) {
4379
+ if (rootOfPath(existing, home) === void 0) {
4216
4380
  failed.push({
4217
4381
  name,
4218
- error: "skill directory not found"
4382
+ error: "skill path is outside the hub writable roots"
4219
4383
  });
4220
4384
  continue;
4221
4385
  }
4222
4386
  try {
4223
4387
  const tagIds = (await deps.store.listTags()).filter((tag) => tag.skillNames.includes(name)).map((tag) => tag.id);
4224
- const { path } = await trashSkill(sourcePath);
4388
+ const { path, source: trashSource } = await trashSkill(existing);
4225
4389
  await deps.store.addTrash({
4226
4390
  name,
4227
4391
  path,
4228
4392
  movedAt: Date.now(),
4229
- sourcePath,
4393
+ sourcePath: trashSource,
4230
4394
  origin: {
4231
4395
  repo: source.repo,
4232
4396
  root: source.root,
@@ -4279,7 +4443,7 @@ function sourceRoutes(deps) {
4279
4443
  const path = await restoreSkill(entry, home);
4280
4444
  if (entry.origin !== void 0) await deps.store.addSourceSkill(entry.origin.repo, entry.origin.root, entry.origin.commitSha, entry.origin.ref, name);
4281
4445
  for (const tagId of entry.tagIds ?? []) await deps.store.addSkillToTag(tagId, name);
4282
- await deps.store.removeTrash(name);
4446
+ await deps.store.removeTrash(entry.path);
4283
4447
  deps.invalidate?.();
4284
4448
  writeJson(res, 200, {
4285
4449
  ok: true,
@@ -4295,33 +4459,109 @@ function sourceRoutes(deps) {
4295
4459
  const home = homeOf(deps);
4296
4460
  const entries = await deps.store.listTrash();
4297
4461
  const deleted = [];
4462
+ const removedPaths = [];
4298
4463
  const failed = [];
4299
4464
  for (const entry of entries) try {
4300
4465
  await clearTrash(entry, home);
4301
4466
  await deps.store.removeSkillFromTags(entry.name);
4302
4467
  deleted.push(entry.name);
4468
+ removedPaths.push(entry.path);
4303
4469
  } catch (error) {
4304
4470
  failed.push({
4305
4471
  name: entry.name,
4306
4472
  error: errorText(error)
4307
4473
  });
4308
4474
  }
4309
- for (const name of deleted) await deps.store.removeTrash(name);
4310
- if (deleted.length > 0) deps.invalidate?.();
4475
+ for (const path of removedPaths) await deps.store.removeTrash(path);
4476
+ const orphans = await sweepOrphanTrash(deps, home, failed);
4477
+ if (deleted.length > 0 || orphans.length > 0) deps.invalidate?.();
4311
4478
  writeJson(res, 200, {
4312
4479
  ok: true,
4313
4480
  deleted,
4314
- failed
4481
+ failed,
4482
+ ...orphans.length > 0 ? { orphans } : {}
4315
4483
  });
4316
4484
  }
4317
4485
  }
4318
4486
  ];
4319
4487
  }
4488
+ /**
4489
+ * 删除两个可写根 `.trash` 下不被任何回收站记录引用的残留目录。
4490
+ * 只扫 `.trash` 的直接子项、跳过点开头项,失败项并入 `failed`(保留可重试)。
4491
+ */
4492
+ async function sweepOrphanTrash(deps, home, failed) {
4493
+ const referenced = new Set((await deps.store.listTrash()).map((entry) => entry.path));
4494
+ const orphans = [];
4495
+ for (const root of WRITABLE_ROOTS) {
4496
+ const trashDir = join(rootPath(root, home), ".trash");
4497
+ let names;
4498
+ try {
4499
+ names = await readdir(trashDir);
4500
+ } catch {
4501
+ continue;
4502
+ }
4503
+ for (const name of names) {
4504
+ if (name.startsWith(".")) continue;
4505
+ const full = join(trashDir, name);
4506
+ if (referenced.has(full)) continue;
4507
+ try {
4508
+ await rm(full, {
4509
+ recursive: true,
4510
+ force: true
4511
+ });
4512
+ orphans.push(full);
4513
+ } catch (error) {
4514
+ failed.push({
4515
+ name,
4516
+ error: errorText(error)
4517
+ });
4518
+ }
4519
+ }
4520
+ }
4521
+ return orphans;
4522
+ }
4320
4523
  //#endregion
4321
4524
  //#region src/routes.ts
4322
4525
  /**
4526
+ * Build the family's trust fence from the host's `/api` model.
4527
+ *
4528
+ * `ctx.connection.requestRejection` is the documented hook for fencing another
4529
+ * Web route; with no Connection service (a webserver-only composition, or a
4530
+ * unit test) the literal-loopback check stands in, so the fallback is the old
4531
+ * behaviour rather than an open route family.
4532
+ *
4533
+ * The service is read **per request**, not once at mount: plugins start in
4534
+ * composition order, so a one-shot read at `apply()` time would latch the
4535
+ * fallback whenever this plugin happens to come up before Connection. A
4536
+ * persistent absence is almost certainly an upstream composition change rather
4537
+ * than a transient startup order, so the first miss fires `onMissing` once —
4538
+ * without that signal the fallback would silently reproduce issue #17 after
4539
+ * any future upstream rename of the service.
4540
+ * @param read - resolves `ctx.connection`, or `undefined` when absent.
4541
+ * @param onMissing - called once, the first time the service reads absent.
4542
+ * @returns the fence every route in this family runs first.
4543
+ */
4544
+ function hostTrustFence(read, onMissing) {
4545
+ let missingReported = false;
4546
+ return (request) => {
4547
+ const connection = read();
4548
+ if (connection === void 0) {
4549
+ if (!missingReported) {
4550
+ missingReported = true;
4551
+ onMissing?.();
4552
+ }
4553
+ return loopbackOnlyFence(request);
4554
+ }
4555
+ try {
4556
+ return connection.requestRejection(request);
4557
+ } catch {
4558
+ return 403;
4559
+ }
4560
+ };
4561
+ }
4562
+ /**
4323
4563
  * Build every /api/skill-hub route.
4324
- * @param deps - skill registry view + sidecar store.
4564
+ * @param deps - skill registry view + sidecar store + trust fence.
4325
4565
  * @returns the exact-path routes plus the family's 404 catch-all.
4326
4566
  */
4327
4567
  function makeRoutes(deps) {
@@ -4719,6 +4959,47 @@ async function reconcileDisabledSkills(store, home = dshHome()) {
4719
4959
  }
4720
4960
  return added;
4721
4961
  }
4962
+ /**
4963
+ * 反向对账:`reconcileDisabledSkills` 只处理「磁盘有 `.disabled`、sidecar 没记录」。
4964
+ * 反方向(sidecar 有记录、磁盘上那个文件已经不在了)以前永不清理,于是留下一条
4965
+ * 干掉不掉的记录:`/toggle` 启用会 rename 失败,`/create` 被 409 挡住,`/skill/delete`
4966
+ * 也 rename 失败 —— 名字被永久锁死(issue #16 类,实测 G5)。
4967
+ *
4968
+ * 两种处理,都不动启用态技能:
4969
+ * - 名字在该 root 下仍有 `.disabled` 占位(目录包或扁平形态)→ 把记录指向真实文件;
4970
+ * - 一个都没有 → 删记录,把名字还给用户。
4971
+ *
4972
+ * 只处理合法可写 root 的记录;root 值异常(历史损坏数据)时原样保留,不擅自删。
4973
+ */
4974
+ async function pruneStaleDisabledSkills(store, home = dshHome()) {
4975
+ const result = {
4976
+ repaired: [],
4977
+ removed: []
4978
+ };
4979
+ for (const record of await store.listDisabled()) {
4980
+ let exists = false;
4981
+ try {
4982
+ exists = (await stat(record.path)).isFile();
4983
+ } catch {
4984
+ exists = false;
4985
+ }
4986
+ if (exists) continue;
4987
+ if (!WRITABLE_ROOTS.includes(record.root)) continue;
4988
+ const occupancy = await disabledOccupancyIn(rootPath(record.root, home), record.name);
4989
+ if (occupancy === void 0) {
4990
+ await store.removeDisabled(record.name);
4991
+ result.removed.push(record.name);
4992
+ continue;
4993
+ }
4994
+ const repaired = {
4995
+ ...record,
4996
+ path: occupancy
4997
+ };
4998
+ await store.addDisabled(repaired);
4999
+ result.repaired.push(repaired);
5000
+ }
5001
+ return result;
5002
+ }
4722
5003
  //#endregion
4723
5004
  //#region src/index.ts
4724
5005
  /** Stable cordis plugin name (matches cordis.patch.yml insert id). */
@@ -4786,7 +5067,7 @@ const CONFIG_FIELDS = [
4786
5067
  /** Order of the announcement section within the tool-guidance band. */
4787
5068
  const SECTION_ORDER = 152;
4788
5069
  /** Model-facing announcement: plugin presence, capabilities, and limits. */
4789
- const SKILL_HUB_GUIDANCE = ["本机已安装 dsh-skill-hub 插件(DSH Web GUI 技能中枢):设置 →「技能」分区为管理主页;本插件的配置页(启用/公告开关、圆点颜色、GitHub token、统计窗口)在插件管理页——侧边栏「插件」→ 本插件,是本插件自己注册的配置卡片(默认折叠,点标题展开)。能力:完整本地技能目录(项目/自定义/用户/内置全部来源,走官方 ctx.skills 注册表,含第三方 provider);按来源与自定义分组浏览,分组/来源头部的滑动开关可一键启用/禁用整组(跨组冲突时询问);市场:内置市场目录(精选仓库一键添加)加自定义仓库源,扫描后勾选安装,每个市场源行显示已装/可更新/上游已删数量,支持「检查全部」与「全部更新」;来源跟踪:从 GitHub 仓库(市场源或直接地址)导入的技能记录上游 repo/commit 快照,可检查更新、选择同步、上游删除时跟进删除(移入回收站可恢复,恢复后保留来源与场景归属);个人技能(无来源记录)不跟踪;调用次数与最近使用时间统计;查看技能正文;发现诊断;新建技能向导(写入 ~/.dsh/skills 或 ~/.agents/skills)。限制:仅用户级技能(user-dsh/user-agents 根目录)可写,项目/内置/运行时技能只读展示;路由仅回环可访问。用户提到「技能管理 / 技能列表 / 技能开关 / 技能同步 / 技能市场 / 更新技能 / 新建技能」时即指本插件,请据此协作。", "The dsh-skill-hub plugin is installed (the DSH Web GUI skill hub): Settings → \"Skills\" is the management page; the plugin's configuration page (enable / announcement toggles, dot colors, GitHub token, stats window) is registered by the plugin itself on its own page in the Plugins manager (sidebar → 插件 → the plugin; collapsed until you expand the title). Capabilities: full local skill catalog (project / custom / user / bundled roots via the official ctx.skills registry, including third-party providers); browsing by source and custom groups, each group header carrying a sliding switch to enable/disable the whole group in one click (cross-group conflicts prompt the user); market: a built-in catalog of curated repos (one-click add) plus custom repo sources, scan-and-install import, per-source installed / updatable / deleted-upstream badges with \"check all\" and \"update all\" actions; upstream source tracking: skills imported from GitHub repos (market sources or direct URLs) record the repo/commit snapshot, support update checks, selective sync, and follow-up deletion when the upstream removes a skill (moves it into a restorable trash; restoring keeps the source and scene membership); personal skills (no source record) are never tracked; invocation counts and last-used times; skill body inspection; discovery diagnostics; new-skill wizard (writes to ~/.dsh/skills or ~/.agents/skills). Limits: only user-level skills (user-dsh/user-agents roots) are writable; project/bundled/runtime skills are read-only; routes are loopback-only. When the user mentions \"skill management / skill list / skill toggle / skill sync / skill market / update skills / new skill\", this plugin is what they mean — collaborate accordingly."].join("\n\n");
5070
+ const SKILL_HUB_GUIDANCE = ["本机已安装 dsh-skill-hub 插件(DSH Web GUI 技能中枢):设置 →「技能」分区为管理主页;本插件的配置页(启用/公告开关、圆点颜色、GitHub token、统计窗口)在插件管理页——侧边栏「插件」→ 本插件,是本插件自己注册的配置卡片(默认折叠,点标题展开)。能力:完整本地技能目录(项目/自定义/用户/内置全部来源,走官方 ctx.skills 注册表,含第三方 provider);按来源与自定义分组浏览,分组/来源头部的滑动开关可一键启用/禁用整组(跨组冲突时询问);市场:内置市场目录(精选仓库一键添加)加自定义仓库源,扫描后勾选安装,每个市场源行显示已装/可更新/上游已删数量,支持「检查全部」与「全部更新」;来源跟踪:从 GitHub 仓库(市场源或直接地址)导入的技能记录上游 repo/commit 快照,可检查更新、选择同步、上游删除时跟进删除(移入回收站可恢复,恢复后保留来源与场景归属);个人技能(无来源记录)不跟踪;调用次数与最近使用时间统计;查看技能正文;发现诊断;新建技能向导(写入 ~/.dsh/skills 或 ~/.agents/skills)。限制:仅用户级技能(user-dsh/user-agents 根目录)可写,项目/内置/运行时技能只读展示;路由受 dsh 自身的 /api 信任栅栏保护(回环、监听器绑定地址、--trusted-host 声明的 authority,外加浏览器会话认证),因此反代部署下与 GUI 同一套放行规则。用户提到「技能管理 / 技能列表 / 技能开关 / 技能同步 / 技能市场 / 更新技能 / 新建技能」时即指本插件,请据此协作。", "The dsh-skill-hub plugin is installed (the DSH Web GUI skill hub): Settings → \"Skills\" is the management page; the plugin's configuration page (enable / announcement toggles, dot colors, GitHub token, stats window) is registered by the plugin itself on its own page in the Plugins manager (sidebar → 插件 → the plugin; collapsed until you expand the title). Capabilities: full local skill catalog (project / custom / user / bundled roots via the official ctx.skills registry, including third-party providers); browsing by source and custom groups, each group header carrying a sliding switch to enable/disable the whole group in one click (cross-group conflicts prompt the user); market: a built-in catalog of curated repos (one-click add) plus custom repo sources, scan-and-install import, per-source installed / updatable / deleted-upstream badges with \"check all\" and \"update all\" actions; upstream source tracking: skills imported from GitHub repos (market sources or direct URLs) record the repo/commit snapshot, support update checks, selective sync, and follow-up deletion when the upstream removes a skill (moves it into a restorable trash; restoring keeps the source and scene membership); personal skills (no source record) are never tracked; invocation counts and last-used times; skill body inspection; discovery diagnostics; new-skill wizard (writes to ~/.dsh/skills or ~/.agents/skills). Limits: only user-level skills (user-dsh/user-agents roots) are writable; project/bundled/runtime skills are read-only; the routes sit behind dsh's own /api trust fence (loopback, the listener's bind address, any --trusted-host authority, plus browser-session authentication), so a reverse-proxied deployment is judged exactly like the GUI around it. When the user mentions \"skill management / skill list / skill toggle / skill sync / skill market / update skills / new skill\", this plugin is what they mean — collaborate accordingly."].join("\n\n");
4790
5071
  /**
4791
5072
  * Mount the skill hub routes and announcement.
4792
5073
  * @param ctx - host plugin context carrying webServer/skills/systemPrompt/settings.
@@ -4877,6 +5158,7 @@ function apply(ctx, config) {
4877
5158
  const disposers = makeRoutes({
4878
5159
  skills: ctx.skills,
4879
5160
  store,
5161
+ fence: hostTrustFence(() => ctx.get("connection"), () => ctx.logger.warn("[dsh-skill-hub] Connection service absent: routes fall back to literal-loopback; reverse-proxy deployments will 403 until it mounts")),
4880
5162
  invalidate: () => {
4881
5163
  providerControl?.invalidate();
4882
5164
  },
@@ -4906,6 +5188,12 @@ function apply(ctx, config) {
4906
5188
  } catch (error) {
4907
5189
  ctx.logger.warn("[dsh-skill-hub] startup disabled-skill reconcile failed", error);
4908
5190
  }
5191
+ try {
5192
+ const pruned = await pruneStaleDisabledSkills(store, home);
5193
+ 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(", ") || "-"})`);
5194
+ } catch (error) {
5195
+ ctx.logger.warn("[dsh-skill-hub] startup disabled-record prune failed", error);
5196
+ }
4909
5197
  })();
4910
5198
  (async () => {
4911
5199
  try {