dsh-skill-hub 0.3.19 → 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.
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 兜底)、整目录
@@ -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;
@@ -2692,7 +2738,8 @@ async function buildCatalog(deps, cwd) {
2692
2738
  if (iface !== void 0) applyInterface(row, iface);
2693
2739
  return row;
2694
2740
  });
2695
- 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];
2696
2743
  return {
2697
2744
  ok: true,
2698
2745
  pluginVersion: CURRENT_VERSION,
@@ -2881,15 +2928,7 @@ function catalogRoutes(deps) {
2881
2928
  writeError(res, 409, "disabled skill path is outside the hub writable roots");
2882
2929
  return;
2883
2930
  }
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
- };
2931
+ trashResult = await moveIntoTrash(disabled.path.endsWith("SKILL.md.disabled") ? dirname(disabled.path) : disabled.path);
2893
2932
  } else {
2894
2933
  writeError(res, resolved.status, resolved.error);
2895
2934
  return;
@@ -3303,6 +3342,7 @@ function marketRoutes(deps) {
3303
3342
  }
3304
3343
  try {
3305
3344
  const latestTag = await getLatestReleaseTag(source.repo);
3345
+ lastMarketCheck.set(source.repo, now);
3306
3346
  if (source.ref === void 0) {
3307
3347
  results.push({
3308
3348
  ...base,
@@ -3313,7 +3353,6 @@ function marketRoutes(deps) {
3313
3353
  continue;
3314
3354
  }
3315
3355
  const latest = await getLatestCommit(source.repo, source.ref);
3316
- lastMarketCheck.set(source.repo, now);
3317
3356
  const commitMoved = source.commitSha !== void 0 && latest.commitSha !== source.commitSha;
3318
3357
  const newRelease = latestTag !== void 0 && latestTag !== source.ref;
3319
3358
  results.push({
@@ -3358,9 +3397,11 @@ function marketRoutes(deps) {
3358
3397
  return;
3359
3398
  }
3360
3399
  const ref = parsed.ref;
3400
+ const slug = repoSlug(parsed);
3361
3401
  writeJson(res, 200, {
3362
3402
  ok: true,
3363
- repos: await deps.store.addMarketSource(repoSlug(parsed), ref)
3403
+ repo: slug,
3404
+ repos: await deps.store.addMarketSource(slug, ref)
3364
3405
  });
3365
3406
  }
3366
3407
  },
@@ -3554,10 +3595,7 @@ async function clearDisabledPlaceholder(deps, targetRoot, name) {
3554
3595
  const sourceToTrash = record.path.endsWith("SKILL.md.disabled") ? dirname(record.path) : record.path;
3555
3596
  if (sourceToTrash !== dir) return false;
3556
3597
  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);
3598
+ const { path: target } = await moveIntoTrash(sourceToTrash);
3561
3599
  await deps.store.addTrash({
3562
3600
  name,
3563
3601
  path: target,
@@ -4776,7 +4814,7 @@ function apply(ctx, config) {
4776
4814
  }
4777
4815
  return out;
4778
4816
  };
4779
- const current = () => resolveHubConfig({}, readConfig());
4817
+ const current = () => resolveHubConfig(readConfig());
4780
4818
  /** The Settings service, when this deployment mounts it. Absent ⇒ no user layer to read or write. */
4781
4819
  const settingsOf = () => ctx.get("settings");
4782
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). */
@@ -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;
@@ -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';
@@ -30,6 +30,14 @@ export declare function githubAuthHeaders(): Record<string, string>;
30
30
  export declare const NO_COMPRESSION: Record<string, string>;
31
31
  /** JSON API 默认请求头(含鉴权)。 */
32
32
  export declare function apiHeaders(): Record<string, string>;
33
+ /**
34
+ * When the response shows an exhausted GitHub rate limit, the instant it
35
+ * resets; otherwise undefined. Shared by the repo fetch layer and the plugin's
36
+ * own update check so both agree on what counts as a rate-limit rejection
37
+ * (403/429 + `x-ratelimit-remaining: 0` + a parseable `x-ratelimit-reset`)
38
+ * instead of each re-deriving it from the headers.
39
+ */
40
+ export declare function rateLimitResetAt(response: Response): Date | undefined;
33
41
  /**
34
42
  * Build a RepoFetchError; when the response shows an exhausted rate limit,
35
43
  * report the reset time instead of a bare HTTP status.
@@ -46,10 +54,9 @@ export declare function fetchJson(url: string, fetchImpl: typeof fetch, context:
46
54
  response: Response;
47
55
  }>;
48
56
  /**
49
- * Load repo metadata and the recursive git tree at the given ref. The meta
50
- * request always resolves the default branch (used when ref is absent); the
51
- * tree is fetched at `ref ?? default_branch` so an explicit branch/tag scans
52
- * the exact same content a later download would fetch.
57
+ * Load one JSON document from the GitHub API, reusing an ETag when the URL was
58
+ * fetched before. `fetchImpl`/`context` exist so callers can stub the transport
59
+ * and shape the error text; the payload is whatever the endpoint returned.
53
60
  */
54
61
  export declare function fetchJsonCached(url: string, fetchImpl: typeof fetch, context: string): Promise<{
55
62
  json: unknown;
@@ -12,7 +12,7 @@
12
12
  * The implementation now lives under `./repo/` (types, discovery, api,
13
13
  * install) and this module is a barrel that re-exports it.
14
14
  */
15
- export { NO_COMPRESSION, RepoFetchError, apiHeaders, fetchError, fetchJson, fetchJsonCached, githubAuthHeaders, isAbortError, setGithubToken } from './repo/github-client.ts';
15
+ export { NO_COMPRESSION, RepoFetchError, apiHeaders, fetchError, fetchJson, fetchJsonCached, githubAuthHeaders, isAbortError, rateLimitResetAt, setGithubToken } from './repo/github-client.ts';
16
16
  export type { RepoRef, RepoTreeItem, RepoFile } from './repo/types.ts';
17
17
  export { REPO_ROOT, isRepoRoot, repoSlug, normalizeRepoInput, collectRepoSkillFiles, originForRoot, discoverRepoEntries, skillManifest, skillDirOf, skillFileAt, skillPathIn, relativeToSkillDir, diffRemoteSkills, repoSkillEntry, } from './repo/discovery.ts';
18
18
  export { loadRepoTree, getLatestReleaseTag, getRepoStats, listRepoReleases, listRepoBranches, getLatestCommit, loadRepoTreeAt, } from './repo/api.ts';
@@ -37,7 +37,12 @@ export interface SkillHubRouteDeps {
37
37
  }
38
38
  /** The resolved hub config a route sees (the shared resolver fills defaults). */
39
39
  export declare function configOf(deps: SkillHubRouteDeps): HubConfig;
40
- /** The raw saved config layer a route reports (empty when the owner omits it). */
40
+ /**
41
+ * The raw saved config layer as persisted in the sidecar, reported for display
42
+ * only. Since dsh 0.1.7 the settings namespace is the one user layer, so this
43
+ * is legacy data: nothing writes it any more and the effective config above
44
+ * never merges it.
45
+ */
41
46
  export declare function savedOf(deps: SkillHubRouteDeps): Partial<HubConfig>;
42
47
  /** Refuse business routes while the master switch is off (the config route stays up). */
43
48
  export declare function disabledGate(deps: SkillHubRouteDeps, res: ServerResponse): boolean;
@@ -1,4 +1,4 @@
1
- import type { DiagnosticEntry, WritableRoot } from '../protocol.ts';
1
+ import { type DiagnosticEntry, type WritableRoot } from '../protocol.ts';
2
2
  /** One discoverable skill file in a scanned root. */
3
3
  export interface SkillEntry {
4
4
  /** Absolute path of the discovery file (SKILL.md or the flat .md). */
@@ -39,9 +39,10 @@ export declare function readSkillInterface(directory: string): Promise<SkillInte
39
39
  */
40
40
  export declare function findProjectRoot(cwd: string): Promise<string>;
41
41
  /**
42
- * Scan one writable root for files the provider ignores, so the GUI can
43
- * show why a skill never appears — a
44
- * missing frontmatter must be visible, not silent). .disabled files belong
45
- * to the hub and are skipped.
42
+ * Scan one writable root for files the provider ignores, so the GUI can show
43
+ * why a skill never appears — a missing frontmatter, a name that disagrees
44
+ * with the discovery path, and the like must be visible, not silent. Files the
45
+ * hub itself disabled (`.disabled`) are skipped: they are a toggle state, not
46
+ * a defect.
46
47
  */
47
48
  export declare function scanDiagnostics(root: WritableRoot, home?: string): Promise<DiagnosticEntry[]>;
@@ -29,6 +29,22 @@ export declare function createSkill(root: WritableRoot, name: string, descriptio
29
29
  export declare function disableSkill(path: string): Promise<string>;
30
30
  /** Re-enable a previously disabled file (reverse of disableSkill). */
31
31
  export declare function enableSkill(disabledPath: string): Promise<string>;
32
+ /**
33
+ * Move one already-resolved skill (a bundle directory or a flat .md file) into
34
+ * its root's trash as `<root>/.trash/<name>-<timestamp>`.
35
+ *
36
+ * The layout is the restore contract, not an implementation detail:
37
+ * `restoreSkill` renames the entry back and only proves the parent is `.trash`,
38
+ * and `clearTrash` deletes in place. Every producer of a trash entry must go
39
+ * through here — skill delete, the hub-disabled whole-group delete, and repo
40
+ * import's placeholder replacement all move skills for the same recovery flow.
41
+ *
42
+ * @returns the trashed path plus the original path that was moved.
43
+ */
44
+ export declare function moveIntoTrash(source: string): Promise<{
45
+ path: string;
46
+ source: string;
47
+ }>;
32
48
  /**
33
49
  * Move a skill (directory bundle or flat .md file) into the root's trash.
34
50
  * The skill stays recoverable: the source is renamed into
@@ -44,5 +60,13 @@ export declare function trashSkill(sourcePath: string): Promise<{
44
60
  export declare function restoreSkill(entry: TrashEntry, home?: string): Promise<string>;
45
61
  /** Permanently delete one trashed skill (directory or flat file). */
46
62
  export declare function clearTrash(entry: TrashEntry, home?: string): Promise<string>;
47
- /** Repair one file on disk when its frontmatter is auto-fixable. Returns the new text. */
63
+ /**
64
+ * Repair one file on disk when its frontmatter is auto-fixable. Returns the new text.
65
+ *
66
+ * The write is atomic (sibling temp file + rename), matching the sidecar's
67
+ * persist path: this rewrites a real skill file in place, and a crash midway
68
+ * through a plain write would leave a truncated SKILL.md — exactly the failure
69
+ * the store layer already guards against. The `.tmp` sibling is never picked
70
+ * up by discovery (only `SKILL.md` and `*.md` are scanned).
71
+ */
48
72
  export declare function fixDiagnosticFile(path: string, home?: string): Promise<string>;