dsh-skill-hub 0.3.18 → 0.3.20

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 (49) hide show
  1. package/CONTRIBUTING.md +1 -1
  2. package/README.md +1 -1
  3. package/README.zh.md +1 -1
  4. package/lib/client.js +14 -8
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +187 -102
  7. package/lib/types/client/api.d.ts +3 -3
  8. package/lib/types/client/locales/market.d.ts +1 -0
  9. package/lib/types/client/locales.d.ts +1 -0
  10. package/lib/types/protocol/config.d.ts +17 -11
  11. package/lib/types/protocol/market.d.ts +11 -0
  12. package/lib/types/protocol/repo.d.ts +6 -0
  13. package/lib/types/protocol.d.ts +1 -1
  14. package/lib/types/repo/github-client.d.ts +11 -4
  15. package/lib/types/repo.d.ts +1 -1
  16. package/lib/types/routes/catalog-data.d.ts +11 -2
  17. package/lib/types/routes/deps.d.ts +6 -1
  18. package/lib/types/routes/route-state.d.ts +5 -0
  19. package/lib/types/skillfs/scan.d.ts +6 -5
  20. package/lib/types/skillfs.d.ts +25 -1
  21. package/package.json +22 -20
  22. package/src/client/api.ts +4 -3
  23. package/src/client/locales/market.ts +2 -0
  24. package/src/client/panel/RepoScanCard.tsx +2 -1
  25. package/src/client/panel/hooks/useMarketFlow.ts +4 -2
  26. package/src/client/panel/panel.module.css +10 -2
  27. package/src/client/settings-card.tsx +2 -1
  28. package/src/client/settings-form.ts +6 -3
  29. package/src/index.ts +1 -1
  30. package/src/protocol/config.ts +27 -24
  31. package/src/protocol/market.ts +12 -0
  32. package/src/protocol/repo.ts +6 -0
  33. package/src/protocol.ts +1 -0
  34. package/src/repo/github-client.ts +26 -14
  35. package/src/repo/install.ts +5 -2
  36. package/src/repo.test.ts +40 -1
  37. package/src/repo.ts +1 -1
  38. package/src/routes/catalog-data.ts +20 -6
  39. package/src/routes/catalog.ts +4 -9
  40. package/src/routes/deps.ts +7 -2
  41. package/src/routes/market.ts +13 -2
  42. package/src/routes/repo-import.ts +49 -8
  43. package/src/routes/route-state.test.ts +1 -0
  44. package/src/routes/route-state.ts +5 -0
  45. package/src/routes.test.ts +187 -0
  46. package/src/skillfs/scan.ts +30 -17
  47. package/src/skillfs.test.ts +64 -3
  48. package/src/skillfs.ts +39 -8
  49. 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. */
@@ -1373,6 +1374,26 @@ function rootOfPath(path, home = dshHome()) {
1373
1374
  }
1374
1375
  }
1375
1376
  //#endregion
1377
+ //#region src/concurrency.ts
1378
+ /**
1379
+ * 并发工具:有界并发的 map。原先 repo.ts 与 stats.ts 各有一份实现,
1380
+ * 这里统一为一份(采用 stats 版本的 limit 夹取,调用点 limit≥1 行为不变)。
1381
+ */
1382
+ /** Run an async worker over items with a bounded concurrency, preserving index order. */
1383
+ async function mapConcurrent(items, limit, worker) {
1384
+ const results = new Array(items.length);
1385
+ let cursor = 0;
1386
+ const runners = Array.from({ length: Math.min(Math.max(limit, 1), items.length) }, async () => {
1387
+ while (cursor < items.length) {
1388
+ const index = cursor;
1389
+ cursor += 1;
1390
+ results[index] = await worker(items[index], index);
1391
+ }
1392
+ });
1393
+ await Promise.all(runners);
1394
+ return results;
1395
+ }
1396
+ //#endregion
1376
1397
  //#region src/skillfs/scan.ts
1377
1398
  /**
1378
1399
  * Scan one skills root for discovery files: directory bundles (SKILL.md)
@@ -1475,7 +1496,7 @@ async function readSkillInterface(directory) {
1475
1496
  const hexColor = (value) => {
1476
1497
  if (typeof value !== "string") return void 0;
1477
1498
  const trimmed = value.trim();
1478
- return /^#[0-9a-fA-F]{6}$/.test(trimmed) ? trimmed : void 0;
1499
+ return HEX_COLOR_RE.test(trimmed) ? trimmed : void 0;
1479
1500
  };
1480
1501
  const iconPath = (value) => {
1481
1502
  if (typeof value !== "string" || value.trim() === "") return void 0;
@@ -1515,37 +1536,38 @@ async function findProjectRoot(cwd) {
1515
1536
  return resolve(cwd);
1516
1537
  }
1517
1538
  /**
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.
1539
+ * Scan one writable root for files the provider ignores, so the GUI can show
1540
+ * why a skill never appears — a missing frontmatter, a name that disagrees
1541
+ * with the discovery path, and the like must be visible, not silent. Files the
1542
+ * hub itself disabled (`.disabled`) are skipped: they are a toggle state, not
1543
+ * a defect.
1522
1544
  */
1523
1545
  async function scanDiagnostics(root, home = dshHome()) {
1524
- const diagnostics = [];
1525
- for (const entry of await listSkillEntries(root, home)) {
1546
+ return (await mapConcurrent(await listSkillEntries(root, home), 8, async (entry) => {
1547
+ const found = [];
1526
1548
  let text;
1527
1549
  try {
1528
1550
  text = await readFile(entry.path, "utf8");
1529
1551
  } catch (error) {
1530
- if (error.code !== "ENOENT") diagnostics.push({
1552
+ if (error.code !== "ENOENT") found.push({
1531
1553
  path: entry.path,
1532
1554
  root,
1533
1555
  reason: "unreadable: " + errorText(error)
1534
1556
  });
1535
- continue;
1557
+ return found;
1536
1558
  }
1537
1559
  const parsed = parseFrontmatter(text);
1538
1560
  if ("error" in parsed) {
1539
1561
  const fixable = repairFrontmatterFileText(text) !== null;
1540
- diagnostics.push({
1562
+ found.push({
1541
1563
  path: entry.path,
1542
1564
  root,
1543
1565
  reason: parsed.error,
1544
1566
  ...fixable ? { fixable: true } : {}
1545
1567
  });
1546
- continue;
1568
+ return found;
1547
1569
  }
1548
- if (repairFrontmatterFileText(text) !== null) diagnostics.push({
1570
+ if (repairFrontmatterFileText(text) !== null) found.push({
1549
1571
  path: entry.path,
1550
1572
  root,
1551
1573
  reason: "frontmatter contains unquoted colon/bracket (auto-repaired in memory; click Fix to persist)",
@@ -1553,18 +1575,18 @@ async function scanDiagnostics(root, home = dshHome()) {
1553
1575
  });
1554
1576
  const { value } = parsed;
1555
1577
  const pathName = entry.kind === "directory" ? basename(entry.directory) : basename(entry.path, ".md");
1556
- if (value.name !== pathName) diagnostics.push({
1578
+ if (value.name !== pathName) found.push({
1557
1579
  path: entry.path,
1558
1580
  root,
1559
1581
  reason: `frontmatter name "${value.name}" does not match the discovery path "${pathName}" (the provider registers by path)`
1560
1582
  });
1561
- if (value.description.length < 10) diagnostics.push({
1583
+ if (value.description.length < 10) found.push({
1562
1584
  path: entry.path,
1563
1585
  root,
1564
1586
  reason: `description is only ${value.description.length} chars; write a one-line description so agents can auto-activate this skill`
1565
1587
  });
1566
- }
1567
- return diagnostics;
1588
+ return found;
1589
+ })).flat();
1568
1590
  }
1569
1591
  //#endregion
1570
1592
  //#region src/skillfs.ts
@@ -1624,14 +1646,18 @@ async function enableSkill(disabledPath) {
1624
1646
  return original;
1625
1647
  }
1626
1648
  /**
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.
1649
+ * Move one already-resolved skill (a bundle directory or a flat .md file) into
1650
+ * its root's trash as `<root>/.trash/<name>-<timestamp>`.
1651
+ *
1652
+ * The layout is the restore contract, not an implementation detail:
1653
+ * `restoreSkill` renames the entry back and only proves the parent is `.trash`,
1654
+ * and `clearTrash` deletes in place. Every producer of a trash entry must go
1655
+ * through here — skill delete, the hub-disabled whole-group delete, and repo
1656
+ * import's placeholder replacement all move skills for the same recovery flow.
1657
+ *
1631
1658
  * @returns the trashed path plus the original path that was moved.
1632
1659
  */
1633
- async function trashSkill(sourcePath) {
1634
- const source = basename(sourcePath) === "SKILL.md" ? dirname(sourcePath) : sourcePath;
1660
+ async function moveIntoTrash(source) {
1635
1661
  const trashDir = join(dirname(source), ".trash");
1636
1662
  await mkdir(trashDir, { recursive: true });
1637
1663
  const target = join(trashDir, basename(source) + "-" + Date.now());
@@ -1641,6 +1667,16 @@ async function trashSkill(sourcePath) {
1641
1667
  source
1642
1668
  };
1643
1669
  }
1670
+ /**
1671
+ * Move a skill (directory bundle or flat .md file) into the root's trash.
1672
+ * The skill stays recoverable: the source is renamed into
1673
+ * <root>/.trash/<name>-<timestamp>. A directory bundle is addressed by its
1674
+ * SKILL.md path and moved as a whole directory.
1675
+ * @returns the trashed path plus the original path that was moved.
1676
+ */
1677
+ async function trashSkill(sourcePath) {
1678
+ return moveIntoTrash(basename(sourcePath) === "SKILL.md" ? dirname(sourcePath) : sourcePath);
1679
+ }
1644
1680
  /** Restore a trashed skill (directory or flat file) to its original location. */
1645
1681
  async function restoreSkill(entry, home = dshHome()) {
1646
1682
  if (basename(dirname(entry.path)) !== ".trash") throw new TypeError("not a trashed skill path: " + entry.path);
@@ -1663,14 +1699,29 @@ function skillFileKind(path) {
1663
1699
  if (basename(path) === "SKILL.md") return "directory";
1664
1700
  if (path.endsWith(".md")) return "flat";
1665
1701
  }
1666
- /** Repair one file on disk when its frontmatter is auto-fixable. Returns the new text. */
1702
+ /**
1703
+ * Repair one file on disk when its frontmatter is auto-fixable. Returns the new text.
1704
+ *
1705
+ * The write is atomic (sibling temp file + rename), matching the sidecar's
1706
+ * persist path: this rewrites a real skill file in place, and a crash midway
1707
+ * through a plain write would leave a truncated SKILL.md — exactly the failure
1708
+ * the store layer already guards against. The `.tmp` sibling is never picked
1709
+ * up by discovery (only `SKILL.md` and `*.md` are scanned).
1710
+ */
1667
1711
  async function fixDiagnosticFile(path, home = dshHome()) {
1668
1712
  if (rootOfPath(path, home) === void 0) throw new TypeError("not a hub writable skill path: " + path);
1669
1713
  const repairedText = repairFrontmatterFileText(await readFile(path, "utf8"));
1670
1714
  if (repairedText === null) throw new TypeError("diagnostic is not auto-fixable: " + path);
1671
1715
  const parsed = parseFrontmatter(repairedText);
1672
1716
  if ("error" in parsed) throw new TypeError("repaired frontmatter still invalid: " + parsed.error);
1673
- await writeFile(path, repairedText, "utf8");
1717
+ const tmp = path + ".tmp";
1718
+ await writeFile(tmp, repairedText, "utf8");
1719
+ try {
1720
+ await rename(tmp, path);
1721
+ } catch (error) {
1722
+ await rm(tmp, { force: true }).catch(() => {});
1723
+ throw error;
1724
+ }
1674
1725
  return path;
1675
1726
  }
1676
1727
  //#endregion
@@ -1913,15 +1964,27 @@ function etagCacheSet(url, etag, json) {
1913
1964
  });
1914
1965
  }
1915
1966
  /**
1967
+ * When the response shows an exhausted GitHub rate limit, the instant it
1968
+ * resets; otherwise undefined. Shared by the repo fetch layer and the plugin's
1969
+ * own update check so both agree on what counts as a rate-limit rejection
1970
+ * (403/429 + `x-ratelimit-remaining: 0` + a parseable `x-ratelimit-reset`)
1971
+ * instead of each re-deriving it from the headers.
1972
+ */
1973
+ function rateLimitResetAt(response) {
1974
+ if (response.status !== 403 && response.status !== 429) return void 0;
1975
+ if (response.headers.get("x-ratelimit-remaining") !== "0") return void 0;
1976
+ const reset = response.headers.get("x-ratelimit-reset");
1977
+ if (reset === null) return void 0;
1978
+ const at = /* @__PURE__ */ new Date(Number(reset) * 1e3);
1979
+ return Number.isNaN(at.getTime()) ? void 0 : at;
1980
+ }
1981
+ /**
1916
1982
  * Build a RepoFetchError; when the response shows an exhausted rate limit,
1917
1983
  * report the reset time instead of a bare HTTP status.
1918
1984
  */
1919
1985
  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
- }
1986
+ const resetAt = rateLimitResetAt(response);
1987
+ if (resetAt !== void 0) return new RepoFetchError("github rate limit reached (anonymous quota exhausted); retry after " + resetAt.toLocaleTimeString() + " or set GITHUB_TOKEN", 403);
1925
1988
  return new RepoFetchError(context + " (HTTP " + response.status + ")", response.status === 404 ? 404 : fallbackStatus);
1926
1989
  }
1927
1990
  /** 是否限流/中断类错误(调用方可直接透出,不再包装)。 */
@@ -1951,10 +2014,9 @@ async function fetchJson(url, fetchImpl, context, headers = apiHeaders()) {
1951
2014
  }
1952
2015
  }
1953
2016
  /**
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.
2017
+ * Load one JSON document from the GitHub API, reusing an ETag when the URL was
2018
+ * fetched before. `fetchImpl`/`context` exist so callers can stub the transport
2019
+ * and shape the error text; the payload is whatever the endpoint returned.
1958
2020
  */
1959
2021
  async function fetchJsonCached(url, fetchImpl, context) {
1960
2022
  const cached = etagCache.get(url);
@@ -2081,26 +2143,6 @@ async function loadRepoTreeAt(repo, treeSha, fetchImpl = fetch) {
2081
2143
  return Array.isArray(record.tree) ? record.tree : [];
2082
2144
  }
2083
2145
  //#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
2146
  //#region src/repo/install.ts
2105
2147
  /**
2106
2148
  * 技能目录下载与落地:单文件下载(raw 优先、contents 兜底)、整目录
@@ -2133,7 +2175,7 @@ async function downloadGitHubFile(repo, ref, path, fetchImpl = fetch, signal) {
2133
2175
  }
2134
2176
  if (response === null || !response.ok) {
2135
2177
  if (signal?.aborted) throw new DOMException("aborted", "AbortError");
2136
- const apiUrl = `https://api.github.com/repos/${repo}/contents/${encodedPath}`;
2178
+ const apiUrl = `https://api.github.com/repos/${repo}/contents/${encodedPath}?ref=${encodeURIComponent(ref)}`;
2137
2179
  try {
2138
2180
  response = await fetchImpl(apiUrl, {
2139
2181
  headers: {
@@ -2352,9 +2394,14 @@ function isWritableSource(source) {
2352
2394
  }
2353
2395
  /** The resolved hub config a route sees (the shared resolver fills defaults). */
2354
2396
  function configOf(deps) {
2355
- return resolveHubConfig({}, deps.config?.() ?? {});
2397
+ return resolveHubConfig(deps.config?.() ?? {});
2356
2398
  }
2357
- /** The raw saved config layer a route reports (empty when the owner omits it). */
2399
+ /**
2400
+ * The raw saved config layer as persisted in the sidecar, reported for display
2401
+ * only. Since dsh 0.1.7 the settings namespace is the one user layer, so this
2402
+ * is legacy data: nothing writes it any more and the effective config above
2403
+ * never merges it.
2404
+ */
2358
2405
  function savedOf(deps) {
2359
2406
  return deps.saved?.() ?? {};
2360
2407
  }
@@ -2502,9 +2549,8 @@ async function checkLatestRelease(repo = UPDATE_REPO, fetchImpl = fetch) {
2502
2549
  }
2503
2550
  if (response.status === 404) return failure("no release published yet");
2504
2551
  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");
2552
+ const resetAt = rateLimitResetAt(response);
2553
+ if (resetAt !== void 0) return failure("github rate limit reached; retry after " + resetAt.toLocaleTimeString() + " or set GITHUB_TOKEN");
2508
2554
  return failure("GitHub HTTP " + response.status);
2509
2555
  }
2510
2556
  let payload;
@@ -2539,10 +2585,18 @@ async function checkLatestRelease(repo = UPDATE_REPO, fetchImpl = fetch) {
2539
2585
  * routes 共享层 · 目录装配:已知工作区清单、完整目录响应(时间戳/interface
2540
2586
  * 元数据/重名诊断)与详情映射。从 helpers.ts 原样搬出,行为不变。
2541
2587
  */
2542
- /** 目录中存在的技能名集合(tag 成员校验用):启用目录 ∪ 已禁用名单,避免成员因禁用而丢失。 */
2543
- async function knownSkillNames(deps) {
2588
+ /**
2589
+ * 目录中存在的技能名集合。默认(`includeDisabled` 未显式关闭)为「启用目录 ∪
2590
+ * 已禁用名单」——tag 成员校验用:技能被禁用后不该从 tag 里掉出去。
2591
+ *
2592
+ * 导入去重必须传 `{ includeDisabled: false }`:禁用只意味着「暂不启用」,把它
2593
+ * 算作「已存在」会让一次禁用产生「永久占名」的副作用,真技能再也导不进来
2594
+ * (issue #16)。
2595
+ */
2596
+ async function knownSkillNames(deps, options) {
2544
2597
  const snapshot = await deps.skills.snapshot();
2545
2598
  const names = new Set(snapshot.skills.map((skill) => skill.name));
2599
+ if (options?.includeDisabled === false) return names;
2546
2600
  for (const disabled of await deps.store.listDisabled()) names.add(disabled.name);
2547
2601
  return names;
2548
2602
  }
@@ -2684,7 +2738,8 @@ async function buildCatalog(deps, cwd) {
2684
2738
  if (iface !== void 0) applyInterface(row, iface);
2685
2739
  return row;
2686
2740
  });
2687
- const diagnostics = [...await scanDiagnostics("user-dsh", home), ...await scanDiagnostics("user-agents", home)];
2741
+ const [userDshDiagnostics, userAgentsDiagnostics] = await Promise.all([scanDiagnostics("user-dsh", home), scanDiagnostics("user-agents", home)]);
2742
+ const diagnostics = [...userDshDiagnostics, ...userAgentsDiagnostics];
2688
2743
  return {
2689
2744
  ok: true,
2690
2745
  pluginVersion: CURRENT_VERSION,
@@ -2873,15 +2928,7 @@ function catalogRoutes(deps) {
2873
2928
  writeError(res, 409, "disabled skill path is outside the hub writable roots");
2874
2929
  return;
2875
2930
  }
2876
- const sourceToTrash = disabled.path.endsWith("SKILL.md.disabled") ? dirname(disabled.path) : disabled.path;
2877
- const trashDir = join(dirname(sourceToTrash), ".trash");
2878
- await mkdir(trashDir, { recursive: true });
2879
- const target = join(trashDir, basename(sourceToTrash) + "-" + Date.now());
2880
- await rename(sourceToTrash, target);
2881
- trashResult = {
2882
- path: target,
2883
- source: sourceToTrash
2884
- };
2931
+ trashResult = await moveIntoTrash(disabled.path.endsWith("SKILL.md.disabled") ? dirname(disabled.path) : disabled.path);
2885
2932
  } else {
2886
2933
  writeError(res, resolved.status, resolved.error);
2887
2934
  return;
@@ -3295,6 +3342,7 @@ function marketRoutes(deps) {
3295
3342
  }
3296
3343
  try {
3297
3344
  const latestTag = await getLatestReleaseTag(source.repo);
3345
+ lastMarketCheck.set(source.repo, now);
3298
3346
  if (source.ref === void 0) {
3299
3347
  results.push({
3300
3348
  ...base,
@@ -3305,7 +3353,6 @@ function marketRoutes(deps) {
3305
3353
  continue;
3306
3354
  }
3307
3355
  const latest = await getLatestCommit(source.repo, source.ref);
3308
- lastMarketCheck.set(source.repo, now);
3309
3356
  const commitMoved = source.commitSha !== void 0 && latest.commitSha !== source.commitSha;
3310
3357
  const newRelease = latestTag !== void 0 && latestTag !== source.ref;
3311
3358
  results.push({
@@ -3350,9 +3397,11 @@ function marketRoutes(deps) {
3350
3397
  return;
3351
3398
  }
3352
3399
  const ref = parsed.ref;
3400
+ const slug = repoSlug(parsed);
3353
3401
  writeJson(res, 200, {
3354
3402
  ok: true,
3355
- repos: await deps.store.addMarketSource(repoSlug(parsed), ref)
3403
+ repo: slug,
3404
+ repos: await deps.store.addMarketSource(slug, ref)
3356
3405
  });
3357
3406
  }
3358
3407
  },
@@ -3531,6 +3580,31 @@ function marketRoutes(deps) {
3531
3580
  * 仓库导入域路由:repo 发现 / repoImport 异步导入任务 / progress 轮询 /
3532
3581
  * cancel 取消。从 routes.ts 原样搬出,handler 逻辑不变。
3533
3582
  */
3583
+ /**
3584
+ * 导入去重只认「已启用」的目录,但磁盘上仍可能躺着一个已禁用的同名占位 ——
3585
+ * 禁用只是把 SKILL.md 改名成 SKILL.md.disabled,目录本身还在。这种占位不该
3586
+ * 堵住真技能:把它连同禁用记录一起移入回收站让位(issue #16)。
3587
+ *
3588
+ * 只有「禁用记录确实指向 `targetRoot/<name>` 的整目录禁用」才处理,且该目录
3589
+ * 必须已经没有 SKILL.md;其余不确定情形一律返回 false,交回原本的「跳过」。
3590
+ */
3591
+ 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;
3607
+ }
3534
3608
  /** 仓库导入域全部路由 spec(由 routes.ts 经 createRoute 包上统一围栏)。 */
3535
3609
  function repoImportRoutes(deps) {
3536
3610
  return [
@@ -3576,7 +3650,7 @@ function repoImportRoutes(deps) {
3576
3650
  ok: true,
3577
3651
  repo,
3578
3652
  ref: resolvedRef,
3579
- entries: discoverRepoEntries(tree, repo, await knownSkillNames(deps)),
3653
+ entries: discoverRepoEntries(tree, repo, await knownSkillNames(deps, { includeDisabled: false })),
3580
3654
  ...truncated ? { truncated: true } : {}
3581
3655
  });
3582
3656
  }
@@ -3600,7 +3674,7 @@ function repoImportRoutes(deps) {
3600
3674
  const repo = repoSlug(parsed);
3601
3675
  const requestedRef = readString(body, "ref");
3602
3676
  const { ref: resolvedRef, tree } = await loadRepoTree(repo, requestedRef !== "" ? requestedRef : parsed.ref);
3603
- const entries = discoverRepoEntries(tree, repo, await knownSkillNames(deps));
3677
+ const entries = discoverRepoEntries(tree, repo, await knownSkillNames(deps, { includeDisabled: false }));
3604
3678
  const byPath = new Map(entries.map((entry) => [entry.path, entry]));
3605
3679
  const selected = paths.map((path) => byPath.get(path)).filter((entry) => entry !== void 0);
3606
3680
  if (selected.length === 0) {
@@ -3633,6 +3707,7 @@ function repoImportRoutes(deps) {
3633
3707
  imported: [],
3634
3708
  skipped: [],
3635
3709
  failed: [],
3710
+ replaced: [],
3636
3711
  status: "running",
3637
3712
  controller,
3638
3713
  createdAt: now
@@ -3659,13 +3734,22 @@ function repoImportRoutes(deps) {
3659
3734
  continue;
3660
3735
  }
3661
3736
  if (await pathExists(join(targetRoot, entry.name))) {
3662
- job.skipped.push({
3663
- name: entry.name,
3664
- reason: "exists"
3665
- });
3666
- job.downloadedBytes += entry.totalBytes;
3667
- job.done += 1;
3668
- continue;
3737
+ let cleared = false;
3738
+ try {
3739
+ cleared = await clearDisabledPlaceholder(deps, targetRoot, entry.name);
3740
+ } catch {
3741
+ cleared = false;
3742
+ }
3743
+ if (!cleared) {
3744
+ job.skipped.push({
3745
+ name: entry.name,
3746
+ reason: "exists"
3747
+ });
3748
+ job.downloadedBytes += entry.totalBytes;
3749
+ job.done += 1;
3750
+ continue;
3751
+ }
3752
+ job.replaced.push(entry.name);
3669
3753
  }
3670
3754
  if (controller.signal.aborted) break;
3671
3755
  const files = collectRepoSkillFiles(tree, entry.dir);
@@ -3748,6 +3832,7 @@ function repoImportRoutes(deps) {
3748
3832
  imported: [...job.imported],
3749
3833
  skipped: [...job.skipped],
3750
3834
  failed: [...job.failed],
3835
+ ...job.replaced.length > 0 ? { replaced: [...job.replaced] } : {},
3751
3836
  ...job.error !== void 0 ? { error: job.error } : {}
3752
3837
  });
3753
3838
  }
@@ -4729,7 +4814,7 @@ function apply(ctx, config) {
4729
4814
  }
4730
4815
  return out;
4731
4816
  };
4732
- const current = () => resolveHubConfig({}, readConfig());
4817
+ const current = () => resolveHubConfig(readConfig());
4733
4818
  /** The Settings service, when this deployment mounts it. Absent ⇒ no user layer to read or write. */
4734
4819
  const settingsOf = () => ctx.get("settings");
4735
4820
  const store = new SkillHubStore();
@@ -2,7 +2,7 @@
2
2
  * Browser-side API client for the /api/skill-hub route family. The only
3
3
  * data access path the panel uses — plain fetch, same origin.
4
4
  */
5
- import { type CatalogResponse, type ConfigResponse, type CreateRequest, type CreateResponse, type DiagnosticFixResponse, type GroupsResponse, type MarketCheckResponse, type MarketSourcesResponse, type MarketSourceVersionsResponse, type MarketStatsResponse, type MarketSyncResponse, type RepoDiscoverResponse, type RepoImportCancelResponse, type RepoImportProgressResponse, type RepoImportResponse, type SkillDetail, type SkillDeleteResponse, type SkillTag, type SourceCheckResponse, type SourceDeleteResponse, type SourceRestoreResponse, type SourceTrashClearResponse, type SourcesResponse, type SourceSyncResponse, type StatsResponse, type TagSaveRequest, type ToggleBatchResponse, type UpdateCheckResponse } from '../protocol.ts';
5
+ import { type CatalogResponse, type ConfigResponse, type CreateRequest, type CreateResponse, type DiagnosticFixResponse, type GroupsResponse, type MarketCheckResponse, type MarketSourceAddResponse, type MarketSourcesResponse, type MarketSourceVersionsResponse, type MarketStatsResponse, type MarketSyncResponse, type RepoDiscoverResponse, type RepoImportCancelResponse, type RepoImportProgressResponse, type RepoImportResponse, type SkillDetail, type SkillDeleteResponse, type SkillTag, type SourceCheckResponse, type SourceDeleteResponse, type SourceRestoreResponse, type SourceTrashClearResponse, type SourcesResponse, type SourceSyncResponse, type StatsResponse, type TagSaveRequest, type ToggleBatchResponse, type UpdateCheckResponse } from '../protocol.ts';
6
6
  /** Error carrying the route's JSON error message. */
7
7
  export declare class SkillHubApiError extends Error {
8
8
  constructor(message: string);
@@ -31,8 +31,8 @@ export declare class SkillHubApi {
31
31
  toggleBatch(names: string[], enabled: boolean): Promise<ToggleBatchResponse>;
32
32
  /** The user's added market sources. */
33
33
  market(): Promise<MarketSourcesResponse>;
34
- /** Add a market source repo; resolves with the fresh list. */
35
- addMarketSource(repo: string): Promise<MarketSourcesResponse>;
34
+ /** Add a market source repo; resolves with the fresh list plus the resolved slug. */
35
+ addMarketSource(repo: string): Promise<MarketSourceAddResponse>;
36
36
  /** Remove a market source repo; resolves with the fresh list. */
37
37
  removeMarketSource(repo: string): Promise<MarketSourcesResponse>;
38
38
  /** Stars + downloads per market source (hourly server-side cache; refresh=1 refetches stale entries). */
@@ -70,6 +70,7 @@ export declare const zhMarket: {
70
70
  readonly 'repo.files': "{count} 个文件 · {size}";
71
71
  readonly 'repo.imported': "导入成功 {count} 个";
72
72
  readonly 'repo.skippedExisting': "跳过已存在 {count} 个";
73
+ readonly 'repo.replaced': "已替换禁用占位 {count} 个(旧目录已移入回收站)";
73
74
  readonly 'repo.failed': "失败 {count} 个";
74
75
  readonly 'repo.cancel': "取消";
75
76
  readonly 'repo.cancelled': "已取消 · 已导入 {imported}/{total} 已保留,临时文件已清理";
@@ -153,6 +153,7 @@ export declare const zh: {
153
153
  readonly 'repo.files': "{count} 个文件 · {size}";
154
154
  readonly 'repo.imported': "导入成功 {count} 个";
155
155
  readonly 'repo.skippedExisting': "跳过已存在 {count} 个";
156
+ readonly 'repo.replaced': "已替换禁用占位 {count} 个(旧目录已移入回收站)";
156
157
  readonly 'repo.failed': "失败 {count} 个";
157
158
  readonly 'repo.cancel': "取消";
158
159
  readonly 'repo.cancelled': "已取消 · 已导入 {imported}/{total} 已保留,临时文件已清理";
@@ -83,13 +83,17 @@ export declare const HUB_CONFIG_DEFAULTS: {
83
83
  readonly 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
- export declare function resolveHubConfig(saved: Partial<HubConfig>, base?: Partial<HubConfig>): HubConfig;
96
+ export declare function resolveHubConfig(base?: Partial<HubConfig>): HubConfig;
93
97
  /**
94
98
  * Drop the GitHub token from a config-shaped object. Returns a copy, so the
95
99
  * caller's own config layer keeps the token. Shared by the config route's GET
@@ -112,7 +116,13 @@ export interface ConfigResponse {
112
116
  /** True when a GitHub token is in effect (saved override or the base/env layer). */
113
117
  githubTokenSet: boolean;
114
118
  }
115
- /** POST /api/skill-hub/config — a partial patch; omitted fields keep their values. */
119
+ /**
120
+ * POST /api/skill-hub/config — a partial patch; omitted fields keep their values.
121
+ *
122
+ * Only the fields the handler actually parses belong here: the stats knobs are
123
+ * owned by the settings namespace (`statsWindowDays` / `statsScanMinutes` are
124
+ * still part of {@link HubSettingsValue}), not by this route.
125
+ */
116
126
  export interface ConfigRequest {
117
127
  /** Set the field; null clears the saved override so it re-inherits the default. */
118
128
  enabled?: boolean | null;
@@ -128,10 +138,6 @@ export interface ConfigRequest {
128
138
  dotModelColor?: string | null;
129
139
  /** Set the dot color; null clears the saved override so it re-inherits the default. */
130
140
  dotUserColor?: string | null;
131
- /** 统计滚动窗口天数(0 = 全部历史);null 清除覆盖回默认。 */
132
- statsWindowDays?: number | null;
133
- /** 自动统计扫描间隔(分钟);null 清除覆盖回默认。 */
134
- statsScanMinutes?: number | null;
135
141
  /** Set the GitHub token; null/empty clears it back to anonymous. */
136
142
  githubToken?: string | null;
137
143
  }
@@ -23,6 +23,17 @@ export interface MarketSourceResponse {
23
23
  ok: true;
24
24
  repos: MarketSourceRecord[];
25
25
  }
26
+ /**
27
+ * POST /api/skill-hub/market/source — same list, plus the slug the host
28
+ * resolved from the input. Clients must scan *this* slug rather than assume
29
+ * the acted-on source sits at some position in `repos`: re-adding a repo the
30
+ * user already tracks only updates its `ref` in place and never reorders the
31
+ * list.
32
+ */
33
+ export interface MarketSourceAddResponse extends MarketSourceResponse {
34
+ /** Normalized `owner/repo` the host recorded for the submitted input. */
35
+ repo: string;
36
+ }
26
37
  /** POST /api/skill-hub/market/source/ref — pin a market source to an explicit ref. */
27
38
  export interface MarketSourceRefRequest {
28
39
  repo: string;
@@ -96,6 +96,12 @@ export interface RepoImportProgressResponse {
96
96
  name: string;
97
97
  error: string;
98
98
  }>;
99
+ /**
100
+ * 本次导入中「让位的已禁用同名占位」名字(issue #16):这些条目的旧目录已
101
+ * 移入回收站,新技能随后落地。旧目录挪走即计入,下载失败时也在列。老客户端
102
+ * 不认这个字段时忽略即可,故为可选。
103
+ */
104
+ replaced?: string[];
99
105
  /** error 状态时的原因 */
100
106
  error?: string;
101
107
  }
@@ -12,7 +12,7 @@ export type { WritableRoot, HubInvocation, CatalogSkill, DisabledSkill, Diagnost
12
12
  export type { SkillStat, StatsResponse, SkillStatsCheckpoint } from './protocol/stats.ts';
13
13
  export type { ErrorResponse, HubConfig, HubSettingsValue, RedactedHubConfig, ConfigResponse, ConfigRequest } from './protocol/config.ts';
14
14
  export { HUB_CONFIG_DEFAULTS, HUB_ENTRY_ID, HEX_COLOR_RE, GITHUB_TOKEN_RE, resolveHubConfig, redactGithubToken } from './protocol/config.ts';
15
- export type { MarketSourceRecord, MarketSourcesResponse, MarketSourceRequest, MarketSourceResponse, MarketSourceRefRequest, MarketSourceVersionsResponse, MarketStatsSnapshot, MarketStatsResponse, MarketCheckResponse, MarketSyncResponse, } from './protocol/market.ts';
15
+ export type { MarketSourceRecord, MarketSourcesResponse, MarketSourceRequest, MarketSourceAddResponse, MarketSourceResponse, MarketSourceRefRequest, MarketSourceVersionsResponse, MarketStatsSnapshot, MarketStatsResponse, MarketCheckResponse, MarketSyncResponse, } from './protocol/market.ts';
16
16
  export type { RepoSkillEntry, RepoRoot, RepoDiscoverResponse, RepoImportRequest, RepoImportResponse, RepoImportProgressResponse, RepoImportCancelRequest, RepoImportCancelResponse, UpdateCheckResponse, } from './protocol/repo.ts';
17
17
  export type { SkillTag, CollectionGroup, GroupsResponse, TagSaveRequest, TagSaveResponse, TagDeleteRequest, TagDeleteResponse, TagMembersRequest, TagMembersResponse, TagReorderRequest, TagReorderResponse, CollectionReorderRequest, CollectionReorderResponse, SourceGroupReorderRequest, SourceGroupReorderResponse, } from './protocol/groups.ts';
18
18
  export type { SourceRecord, TrashEntry, SourcesResponse, SourceCheckRequest, SourceCheckResult, SourceCheckResponse, SourceSyncRequest, SourceSyncResponse, SourceDeleteRequest, SourceDeleteResponse, SourceRestoreRequest, SourceRestoreResponse, SourceTrashClearResponse, } from './protocol/sources.ts';