dsh-skill-hub 0.3.13 → 0.3.15

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 (60) hide show
  1. package/CONTRIBUTING.md +6 -4
  2. package/README.md +9 -5
  3. package/README.zh.md +9 -5
  4. package/lib/client.js +55 -58
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +529 -315
  7. package/lib/types/client/SkillHubSettingsCard.d.ts +5 -3
  8. package/lib/types/client/icons.d.ts +4 -5
  9. package/lib/types/client/index.d.ts +13 -12
  10. package/lib/types/client/locales/market.d.ts +1 -0
  11. package/lib/types/client/locales.d.ts +1 -0
  12. package/lib/types/client/settings-form.d.ts +9 -3
  13. package/lib/types/client/slash-dots.d.ts +20 -11
  14. package/lib/types/index.d.ts +59 -24
  15. package/lib/types/protocol/api.d.ts +22 -2
  16. package/lib/types/protocol/config.d.ts +30 -4
  17. package/lib/types/protocol/market.d.ts +1 -1
  18. package/lib/types/protocol/repo.d.ts +18 -1
  19. package/lib/types/protocol.d.ts +3 -3
  20. package/lib/types/repo/discovery.d.ts +59 -3
  21. package/lib/types/repo/github-client.d.ts +16 -0
  22. package/lib/types/repo.d.ts +6 -3
  23. package/lib/types/routes/helpers.d.ts +8 -1
  24. package/lib/types/routes/market.d.ts +2 -2
  25. package/lib/types/routes.d.ts +1 -1
  26. package/lib/types/store/store.d.ts +4 -0
  27. package/package.json +31 -41
  28. package/src/client/SkillHubSettingsCard.tsx +5 -3
  29. package/src/client/icons.tsx +4 -5
  30. package/src/client/index.tsx +33 -32
  31. package/src/client/locales/market.ts +2 -0
  32. package/src/client/panel/RepoScanCard.tsx +6 -3
  33. package/src/client/panel/SkillHubPanel.tsx +1 -1
  34. package/src/client/panel/SourcesView.tsx +1 -1
  35. package/src/client/panel/hooks/useGroupFlow.ts +1 -8
  36. package/src/client/settings-form.ts +9 -4
  37. package/src/client/slash-dots.test.ts +19 -36
  38. package/src/client/slash-dots.tsx +30 -60
  39. package/src/index.ts +145 -82
  40. package/src/protocol/api.ts +24 -2
  41. package/src/protocol/config.ts +35 -4
  42. package/src/protocol/market.ts +1 -1
  43. package/src/protocol/repo.ts +22 -1
  44. package/src/protocol.ts +3 -3
  45. package/src/repo/discovery.ts +120 -33
  46. package/src/repo/github-client.ts +18 -1
  47. package/src/repo/install.ts +8 -7
  48. package/src/repo.test.ts +158 -4
  49. package/src/repo.ts +10 -2
  50. package/src/routes/config.ts +19 -2
  51. package/src/routes/helpers.ts +9 -2
  52. package/src/routes/market.ts +49 -37
  53. package/src/routes/repo-import.ts +3 -2
  54. package/src/routes/sources.ts +2 -1
  55. package/src/routes.test.ts +107 -1
  56. package/src/routes.ts +21 -3
  57. package/src/store/migrate.ts +3 -2
  58. package/src/store/store.ts +7 -2
  59. package/src/store.test.ts +50 -0
  60. package/src/update.ts +4 -2
package/lib/index.js CHANGED
@@ -1,13 +1,33 @@
1
1
  import { createRequire } from "node:module";
2
- import z from "schemastery";
2
+ import z from "@deepseek-ai/schemastery";
3
3
  import { mkdir, mkdtemp, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
4
4
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
5
5
  import { homedir } from "node:os";
6
- import { dump, load } from "js-yaml";
7
6
  import { isSkillName } from "@deepseek-ai/dsh-skill";
7
+ import { dump, load } from "js-yaml";
8
8
  import { randomUUID } from "node:crypto";
9
9
  //#region src/protocol/api.ts
10
- /** Browser-facing base paths of the skill-hub API family. */
10
+ /**
11
+ * Root path of the skill-hub API family. The host also registers this as its
12
+ * 404 catch-all prefix, so a mistyped path answers with a plain 404 naming the
13
+ * path instead of falling through to the SPA fallback (which answers 401 and
14
+ * reads like an auth problem).
15
+ */
16
+ const SKILL_HUB_API_ROOT = "/api/skill-hub";
17
+ /**
18
+ * Deprecated path of the market update check, kept routable for one release
19
+ * after the naming unification below. A browser tab that loaded the previous
20
+ * client bundle keeps calling it until it reloads; delete this (and its route
21
+ * in routes/market.ts) in the next minor.
22
+ */
23
+ const SKILL_HUB_API_DEPRECATED_MARKET_CHECK = "/api/skill-hub/market/check";
24
+ /**
25
+ * Browser-facing base paths of the skill-hub API family.
26
+ *
27
+ * Naming rule: a path's segments mirror its scope. Market sources own the
28
+ * `/market/source/*` subtree — add, delete, ref, versions, check, sync — so
29
+ * the update check and the sync that acts on its result sit side by side.
30
+ */
11
31
  const SKILL_HUB_API = {
12
32
  catalog: "/api/skill-hub/catalog",
13
33
  skill: "/api/skill-hub/skill",
@@ -21,7 +41,7 @@ const SKILL_HUB_API = {
21
41
  marketSource: "/api/skill-hub/market/source",
22
42
  marketSourceDelete: "/api/skill-hub/market/source/delete",
23
43
  marketSourceRef: "/api/skill-hub/market/source/ref",
24
- marketCheck: "/api/skill-hub/market/check",
44
+ marketCheck: "/api/skill-hub/market/source/check",
25
45
  marketSync: "/api/skill-hub/market/source/sync",
26
46
  repo: "/api/skill-hub/repo",
27
47
  repoImport: "/api/skill-hub/repo/import",
@@ -48,6 +68,14 @@ const SKILL_HUB_API = {
48
68
  //#endregion
49
69
  //#region src/protocol/config.ts
50
70
  /**
71
+ * This plugin's Loader entry id — the settings namespace dsh serves its config
72
+ * under. It is cordis.patch.yml's insert id, NOT the package name, and it is
73
+ * shared by contract because the browser half addresses the same form through
74
+ * `ctx.configForms.get(HUB_ENTRY_ID)` while the host half writes through
75
+ * `ctx.settings.update(HUB_ENTRY_ID, …)`.
76
+ */
77
+ const HUB_ENTRY_ID = "skill-hub";
78
+ /**
51
79
  * Hub config defaults — the single source every layer reads: the cordis
52
80
  * schema (index.ts), the host's saved-override merge, and the routes'
53
81
  * fallback view. Changing a default here changes all three.
@@ -92,6 +120,16 @@ function clampNumber(value, min) {
92
120
  if (typeof value !== "number" || !Number.isFinite(value) || value < min) return void 0;
93
121
  return Math.floor(value);
94
122
  }
123
+ /**
124
+ * Drop the GitHub token from a config-shaped object. Returns a copy, so the
125
+ * caller's own config layer keeps the token. Shared by the config route's GET
126
+ * and POST responses — neither may echo it back.
127
+ */
128
+ function redactGithubToken(value) {
129
+ const copy = { ...value };
130
+ delete copy.githubToken;
131
+ return copy;
132
+ }
95
133
  /** HEX color validation shared by host routes and the settings card. */
96
134
  const HEX_COLOR_RE = /^#[0-9a-f]{6}$/i;
97
135
  /** GitHub token validation shared by host routes and the settings card. */
@@ -177,6 +215,18 @@ var StoreError = class extends Error {
177
215
  }
178
216
  };
179
217
  //#endregion
218
+ //#region src/protocol/repo.ts
219
+ /** Top-level directory pattern for a skill root: visible, non-dot, safe chars. First char must be alphanum. */
220
+ const REPO_ROOT_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
221
+ /**
222
+ * True when a value is a usable root: a visible safe top-level directory name,
223
+ * or the empty string for the repo root. Guards the persisted store against
224
+ * corrupt roots without rewriting the repo-root sentinel.
225
+ */
226
+ function isValidRepoRoot(value) {
227
+ return typeof value === "string" && (value === "" || REPO_ROOT_RE.test(value));
228
+ }
229
+ //#endregion
180
230
  //#region src/store/migrate.ts
181
231
  /**
182
232
  * Normalize an arbitrary parsed sidecar document to the current schema.
@@ -269,7 +319,7 @@ function hydrateMigratedState(migrated) {
269
319
  const source = entry;
270
320
  if (source !== null && typeof source === "object" && typeof source.repo === "string" && source.repo !== "" && Array.isArray(source.skills)) {
271
321
  const manifest = source.manifest;
272
- const rawRoot = typeof source.root === "string" && source.root !== "" && /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(source.root) ? source.root : "skills";
322
+ const rawRoot = isValidRepoRoot(source.root) ? source.root : "skills";
273
323
  sourcesByRepo.set(source.repo, {
274
324
  repo: source.repo,
275
325
  ...typeof source.ref === "string" && source.ref !== "" ? { ref: source.ref } : {},
@@ -300,7 +350,7 @@ function hydrateMigratedState(migrated) {
300
350
  path: item.path,
301
351
  movedAt: typeof item.movedAt === "number" ? item.movedAt : 0,
302
352
  ...typeof item.sourcePath === "string" && item.sourcePath !== "" ? { sourcePath: item.sourcePath } : {},
303
- ...origin !== null && typeof origin === "object" && typeof origin.repo === "string" && origin.repo !== "" && typeof origin.root === "string" && origin.root !== "" && /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(origin.root) ? { origin: {
353
+ ...origin !== null && typeof origin === "object" && typeof origin.repo === "string" && origin.repo !== "" && isValidRepoRoot(origin.root) ? { origin: {
304
354
  repo: origin.repo,
305
355
  root: origin.root,
306
356
  ...typeof origin.ref === "string" && origin.ref !== "" ? { ref: origin.ref } : {},
@@ -393,6 +443,250 @@ function hydrateMigratedState(migrated) {
393
443
  sourceGroupOrder
394
444
  };
395
445
  }
446
+ /** True when a root denotes the repo root itself (the repo is the skill dir). */
447
+ function isRepoRoot(root) {
448
+ return root === "";
449
+ }
450
+ /**
451
+ * The tree prefix delimiting a skill directory: empty for the repo-root skill
452
+ * (it owns the whole tree), otherwise `<dir>/`. Single source for collect,
453
+ * manifest, diff and the store's baseline cleanup, so they can never disagree.
454
+ */
455
+ function skillDirPrefix(dir) {
456
+ return isRepoRoot(dir) ? "" : dir + "/";
457
+ }
458
+ /**
459
+ * A path inside a skill directory, as it appears in the repo tree. Both
460
+ * directions of this mapping matter: repo paths are always '/'-joined (never
461
+ * node:path's OS separator, these are GitHub paths shown in the UI), and the
462
+ * repo-root case must not grow a leading slash.
463
+ */
464
+ function skillPathIn(dir, relative) {
465
+ return skillDirPrefix(dir) + relative;
466
+ }
467
+ /** The SKILL.md path inside a skill directory. */
468
+ function skillFileAt(dir) {
469
+ return skillPathIn(dir, "SKILL.md");
470
+ }
471
+ /**
472
+ * Top-level dot entries are repo tooling, never part of a skill: `.github/`,
473
+ * `.claude-plugin/`, `.gitignore`. Only the repo-root skill can reach them,
474
+ * since its prefix is empty and would otherwise sweep in the whole repo.
475
+ * Nested skill dirs cannot start with a dot (ROOT_RE), so this is a no-op
476
+ * for them.
477
+ */
478
+ function isRepoTooling(path) {
479
+ return path.startsWith(".");
480
+ }
481
+ /** Every tree blob belonging to a skill directory, sorted by path. */
482
+ function skillTreeFiles(tree, dir) {
483
+ const prefix = skillDirPrefix(dir);
484
+ return tree.filter((item) => item.type === "blob" && item.path.startsWith(prefix) && !isRepoTooling(item.path)).map((item) => ({
485
+ path: item.path,
486
+ size: typeof item.size === "number" ? item.size : 0
487
+ })).sort((a, b) => a.path.localeCompare(b.path));
488
+ }
489
+ /**
490
+ * A repo file's path relative to its skill directory — the path it is written
491
+ * to under the skill's own directory. `dir === ''` means the repo file path
492
+ * *is* the relative path; slicing `dir.length + 1` would eat its first
493
+ * character.
494
+ */
495
+ function relativeToSkillDir(dir, path) {
496
+ return isRepoRoot(dir) ? path : path.slice(dir.length + 1);
497
+ }
498
+ /** `owner/repo` slug for a parsed reference. */
499
+ function repoSlug(ref) {
500
+ return `${ref.owner}/${ref.repo}`;
501
+ }
502
+ /**
503
+ * Normalize a GitHub URL or `owner/repo`/`owner/repo@ref` input.
504
+ * Only github.com URLs are accepted in v1.
505
+ */
506
+ function normalizeRepoInput(input) {
507
+ let value = input.trim();
508
+ if (value === "") return null;
509
+ let ref;
510
+ const urlMatch = /^https?:\/\/github\.com\/([^/]+)\/([^/#?@]+)(?:\/tree\/([^/?#]+))?/.exec(value);
511
+ if (urlMatch !== null) {
512
+ value = `${urlMatch[1]}/${urlMatch[2]}`;
513
+ ref = urlMatch[3];
514
+ } else {
515
+ if (/^https?:\/\//.test(value)) return null;
516
+ const at = value.indexOf("@");
517
+ if (at !== -1) {
518
+ ref = value.slice(at + 1).trim();
519
+ value = value.slice(0, at).trim();
520
+ }
521
+ }
522
+ value = value.replace(/\.git$/, "").replace(/\/+$/, "");
523
+ const parts = value.split("/").filter(Boolean);
524
+ if (parts.length < 2) return null;
525
+ const owner = parts[0];
526
+ const repo = parts[1];
527
+ if (owner === "" || repo === "" || owner.includes("..") || repo.includes("..")) return null;
528
+ return {
529
+ owner,
530
+ repo,
531
+ ...ref !== void 0 && ref !== "" ? { ref } : {}
532
+ };
533
+ }
534
+ /** Collect every file inside a skill directory, including SKILL.md itself. */
535
+ function collectRepoSkillFiles(tree, dir) {
536
+ return skillTreeFiles(tree, dir);
537
+ }
538
+ /**
539
+ * Compute an origin collection name. Multiple roots split by root, one root
540
+ * keeps the repo slug. The repo-root skill's name *is* the repo slug, so it
541
+ * never gets a trailing-slash suffix (`owner/repo/`).
542
+ */
543
+ function originForRoot(repo, rootsPresent, root) {
544
+ if (rootsPresent.size <= 1) return repo;
545
+ return isRepoRoot(root) ? repo : `${repo}/${root}`;
546
+ }
547
+ /** Discover importable skills from a repo tree. Invalid names are ignored. Roots are auto-derived from the top-level directory of each SKILL.md. */
548
+ function discoverRepoEntries(tree, repo, existingNames = /* @__PURE__ */ new Set()) {
549
+ const candidates = [];
550
+ const rootsPresent = /* @__PURE__ */ new Set();
551
+ const repoName = repo.slice(repo.lastIndexOf("/") + 1);
552
+ if (isSkillName(repoName) && tree.some((item) => item.type === "blob" && item.path === "SKILL.md")) {
553
+ rootsPresent.add("");
554
+ candidates.push({
555
+ root: "",
556
+ dir: "",
557
+ name: repoName,
558
+ path: "SKILL.md"
559
+ });
560
+ }
561
+ for (const item of tree) {
562
+ if (item.type !== "blob") continue;
563
+ const slash = item.path.indexOf("/");
564
+ if (slash === -1) continue;
565
+ if (!item.path.endsWith("/SKILL.md")) continue;
566
+ const root = item.path.slice(0, slash);
567
+ if (!REPO_ROOT_RE.test(root)) continue;
568
+ const dir = item.path.slice(0, -9);
569
+ if (dir === root || dir.length <= root.length + 1) continue;
570
+ const name = dir.slice(dir.lastIndexOf("/") + 1);
571
+ if (!isSkillName(name)) continue;
572
+ rootsPresent.add(root);
573
+ candidates.push({
574
+ root,
575
+ dir,
576
+ name,
577
+ path: item.path
578
+ });
579
+ }
580
+ return candidates.map((candidate) => {
581
+ const files = collectRepoSkillFiles(tree, candidate.dir);
582
+ return {
583
+ name: candidate.name,
584
+ dir: candidate.dir,
585
+ path: candidate.path,
586
+ root: candidate.root,
587
+ origin: originForRoot(repo, rootsPresent, candidate.root),
588
+ fileCount: files.length,
589
+ totalBytes: files.reduce((sum, file) => sum + file.size, 0),
590
+ existing: existingNames.has(candidate.name)
591
+ };
592
+ }).sort((a, b) => a.name.localeCompare(b.name));
593
+ }
594
+ /**
595
+ * Build a path→size manifest for one skill directory from a repo tree.
596
+ * Shares `skillTreeFiles` with collectRepoSkillFiles: a manifest that
597
+ * disagreed with the collected file set would make every update diff report
598
+ * "changed" forever.
599
+ */
600
+ function skillManifest(tree, dir) {
601
+ const manifest = {};
602
+ for (const file of skillTreeFiles(tree, dir)) manifest[file.path] = file.size;
603
+ return manifest;
604
+ }
605
+ /**
606
+ * The real upstream directory of one tracked skill. Source records keep only
607
+ * the short name and top-level root (e.g. root "skills", name "ask-matt"),
608
+ * but upstream repos may nest skills under category directories
609
+ * (skills/engineering/ask-matt/) or move them between categories. Resolution
610
+ * order: exact match under the top-level root in the upstream tree, then the
611
+ * manifest's recorded blob path, then any same-name skill in the tree, then
612
+ * the flat root/name fallback. Passing the tree paths makes the lookup
613
+ * resilient to incomplete manifests and upstream moves.
614
+ *
615
+ * A repo-root record (root '') has no directory to resolve: its SKILL.md is a
616
+ * bare top-level path, so every nested lookup below would miss and the final
617
+ * fallback would hand back a bogus '/name'. Answer before them — including
618
+ * when the upstream SKILL.md is gone, so diffRemoteSkills reports a deletion
619
+ * instead of hunting for a skill that was never nested.
620
+ */
621
+ function skillDirOf(source, name, treePaths) {
622
+ if (isRepoRoot(source.root)) return "";
623
+ if (treePaths !== void 0) {
624
+ const rootPrefix = source.root + "/";
625
+ const found = treePaths.find((path) => path.startsWith(rootPrefix) && path.endsWith("/" + name + "/SKILL.md"));
626
+ if (found !== void 0) return found.slice(0, found.lastIndexOf("/"));
627
+ }
628
+ const manifest = source.manifest ?? {};
629
+ const fromManifest = Object.keys(manifest).find((path) => path.endsWith("/" + name + "/SKILL.md"));
630
+ if (fromManifest !== void 0) return fromManifest.slice(0, fromManifest.lastIndexOf("/"));
631
+ if (treePaths !== void 0) {
632
+ const found = treePaths.find((path) => path.endsWith("/" + name + "/SKILL.md"));
633
+ if (found !== void 0) return found.slice(0, found.lastIndexOf("/"));
634
+ }
635
+ return source.root + "/" + name;
636
+ }
637
+ /**
638
+ * Diff one source record against an upstream tree at treeSha: which tracked
639
+ * skills disappeared (no SKILL.md blob) and which changed (manifest baseline
640
+ * differs, or no baseline exists — treated as changed). Pure over the tree.
641
+ * The remote side is read through skillManifest, so the diff compares like
642
+ * with like against the baseline recorded at import time.
643
+ */
644
+ function diffRemoteSkills(tree, source) {
645
+ const treePaths = tree.filter((item) => item.type === "blob").map((item) => item.path);
646
+ const updated = [];
647
+ const deleted = [];
648
+ const baseline = source.manifest ?? {};
649
+ for (const name of source.skills) {
650
+ const dir = skillDirOf(source, name, treePaths);
651
+ const prefix = skillDirPrefix(dir);
652
+ const remote = skillManifest(tree, dir);
653
+ if (!Object.hasOwn(remote, skillFileAt(dir))) {
654
+ deleted.push(name);
655
+ continue;
656
+ }
657
+ const baselineEntries = Object.entries(baseline).filter(([path]) => path.startsWith(prefix));
658
+ if (baselineEntries.length === 0) {
659
+ updated.push(name);
660
+ continue;
661
+ }
662
+ let differs = baselineEntries.length !== Object.keys(remote).length;
663
+ if (!differs) {
664
+ for (const [path, size] of baselineEntries) if (remote[path] !== size) {
665
+ differs = true;
666
+ break;
667
+ }
668
+ }
669
+ if (differs) updated.push(name);
670
+ }
671
+ return {
672
+ updated,
673
+ deleted
674
+ };
675
+ }
676
+ /** Minimal RepoSkillEntry for a tracked skill name (sync re-downloads by name). */
677
+ function repoSkillEntry(name, root, repo) {
678
+ const dir = isRepoRoot(root) ? "" : root + "/" + name;
679
+ return {
680
+ name,
681
+ dir,
682
+ path: skillFileAt(dir),
683
+ root,
684
+ origin: repo,
685
+ fileCount: 0,
686
+ totalBytes: 0,
687
+ existing: false
688
+ };
689
+ }
396
690
  //#endregion
397
691
  //#region src/store/store.ts
398
692
  /** Sidecar state owner. */
@@ -725,14 +1019,18 @@ var SkillHubStore = class {
725
1019
  * When `dir` is given, every baseline path under that skill directory is
726
1020
  * dropped first, so files the upstream removed never linger in the
727
1021
  * baseline and skew later update diffs.
1022
+ *
1023
+ * `dir` may be '' — a skill whose SKILL.md sits at the repo root. Its prefix
1024
+ * is empty, so the whole baseline is replaced: that skill owns the tree, and
1025
+ * keeping stale paths would make every later diff report "changed" forever.
728
1026
  */
729
1027
  async mergeSourceManifest(repo, manifest, dir) {
730
1028
  await this.ensureLoaded();
731
1029
  const existing = this.sourcesByRepo.get(repo);
732
1030
  if (existing === void 0 || Object.keys(manifest).length === 0) return;
733
1031
  const base = { ...existing.manifest ?? {} };
734
- if (dir !== void 0 && dir !== "") {
735
- const prefix = dir + "/";
1032
+ if (dir !== void 0) {
1033
+ const prefix = skillDirPrefix(dir);
736
1034
  for (const path of Object.keys(base)) if (path.startsWith(prefix)) delete base[path];
737
1035
  }
738
1036
  this.sourcesByRepo.set(repo, {
@@ -1584,10 +1882,27 @@ function setGithubToken(token) {
1584
1882
  function githubAuthHeaders() {
1585
1883
  return githubToken === "" ? {} : { authorization: "Bearer " + githubToken };
1586
1884
  }
1885
+ /**
1886
+ * 传输层:显式要求服务端返回未压缩实体。
1887
+ *
1888
+ * 为什么必须加:dsh 会把**启动环境**里的 `HTTP_PROXY`/`HTTPS_PROXY` 装成
1889
+ * undici 的全局 dispatcher(`@deepseek-ai/dsh-http-proxy`),插件里的 `fetch`
1890
+ * 因此走该代理。实测本机 Clash:经代理返回的响应会**丢掉 `content-encoding`
1891
+ * 与 `content-type` 头,而 body 仍是 gzip**;undici 只依据 `content-encoding`
1892
+ * 决定是否解压,于是 `response.json()` 拿到 gzip 二进制并抛错,被上层的
1893
+ * catch 映射成 `invalid github response for <url>` —— 看起来像 GitHub 坏了。
1894
+ * 实测对照(同一 URL、同一代理):默认 1331 字节解析失败,声明 identity 后
1895
+ * 5245 字节解析成功。
1896
+ *
1897
+ * 代价是不走压缩(我们的响应体都很小),换来的是**无论中间代理是否改写头部
1898
+ * 都能正确取到实体**,且用户无需为插件调整启动命令。
1899
+ */
1900
+ const NO_COMPRESSION = { "accept-encoding": "identity" };
1587
1901
  /** JSON API 默认请求头(含鉴权)。 */
1588
1902
  function apiHeaders() {
1589
1903
  return {
1590
1904
  accept: "application/vnd.github+json",
1905
+ ...NO_COMPRESSION,
1591
1906
  ...githubAuthHeaders()
1592
1907
  };
1593
1908
  }
@@ -1677,184 +1992,6 @@ async function fetchJsonCached(url, fetchImpl, context) {
1677
1992
  };
1678
1993
  }
1679
1994
  //#endregion
1680
- //#region src/repo/discovery.ts
1681
- /**
1682
- * 仓库技能发现:从 GitHub 仓库树推断技能根目录、列出可导入技能、
1683
- * 生成目录清单并做上游差异比对。纯函数为主,便于单测。
1684
- * 从 repo.ts 抽出。
1685
- */
1686
- /** Top-level directory pattern for a skill root: visible, non-dot, safe chars. First char must be alphanum. */
1687
- const ROOT_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
1688
- /** `owner/repo` slug for a parsed reference. */
1689
- function repoSlug(ref) {
1690
- return `${ref.owner}/${ref.repo}`;
1691
- }
1692
- /**
1693
- * Normalize a GitHub URL or `owner/repo`/`owner/repo@ref` input.
1694
- * Only github.com URLs are accepted in v1.
1695
- */
1696
- function normalizeRepoInput(input) {
1697
- let value = input.trim();
1698
- if (value === "") return null;
1699
- let ref;
1700
- const urlMatch = /^https?:\/\/github\.com\/([^/]+)\/([^/#?@]+)(?:\/tree\/([^/?#]+))?/.exec(value);
1701
- if (urlMatch !== null) {
1702
- value = `${urlMatch[1]}/${urlMatch[2]}`;
1703
- ref = urlMatch[3];
1704
- } else {
1705
- if (/^https?:\/\//.test(value)) return null;
1706
- const at = value.indexOf("@");
1707
- if (at !== -1) {
1708
- ref = value.slice(at + 1).trim();
1709
- value = value.slice(0, at).trim();
1710
- }
1711
- }
1712
- value = value.replace(/\.git$/, "").replace(/\/+$/, "");
1713
- const parts = value.split("/").filter(Boolean);
1714
- if (parts.length < 2) return null;
1715
- const owner = parts[0];
1716
- const repo = parts[1];
1717
- if (owner === "" || repo === "" || owner.includes("..") || repo.includes("..")) return null;
1718
- return {
1719
- owner,
1720
- repo,
1721
- ...ref !== void 0 && ref !== "" ? { ref } : {}
1722
- };
1723
- }
1724
- /** Collect every file inside a skill directory, including SKILL.md itself. */
1725
- function collectRepoSkillFiles(tree, dir) {
1726
- const prefix = dir + "/";
1727
- return tree.filter((item) => item.type === "blob" && item.path.startsWith(prefix)).map((item) => ({
1728
- path: item.path,
1729
- size: typeof item.size === "number" ? item.size : 0
1730
- })).sort((a, b) => a.path.localeCompare(b.path));
1731
- }
1732
- /** Compute an origin collection name. Multiple roots split by root, one root keeps the repo slug. */
1733
- function originForRoot(repo, rootsPresent, root) {
1734
- return rootsPresent.size > 1 ? `${repo}/${root}` : repo;
1735
- }
1736
- /** Discover importable skills from a repo tree. Invalid names are ignored. Roots are auto-derived from the top-level directory of each SKILL.md. */
1737
- function discoverRepoEntries(tree, repo, existingNames = /* @__PURE__ */ new Set()) {
1738
- const candidates = [];
1739
- const rootsPresent = /* @__PURE__ */ new Set();
1740
- for (const item of tree) {
1741
- if (item.type !== "blob") continue;
1742
- const slash = item.path.indexOf("/");
1743
- if (slash === -1) continue;
1744
- if (!item.path.endsWith("/SKILL.md")) continue;
1745
- const root = item.path.slice(0, slash);
1746
- if (!ROOT_RE.test(root)) continue;
1747
- const dir = item.path.slice(0, -9);
1748
- if (dir === root || dir.length <= root.length + 1) continue;
1749
- const name = dir.slice(dir.lastIndexOf("/") + 1);
1750
- if (!isSkillName(name)) continue;
1751
- rootsPresent.add(root);
1752
- candidates.push({
1753
- root,
1754
- dir,
1755
- name,
1756
- path: item.path
1757
- });
1758
- }
1759
- return candidates.map((candidate) => {
1760
- const files = collectRepoSkillFiles(tree, candidate.dir);
1761
- return {
1762
- name: candidate.name,
1763
- dir: candidate.dir,
1764
- path: candidate.path,
1765
- root: candidate.root,
1766
- origin: originForRoot(repo, rootsPresent, candidate.root),
1767
- fileCount: files.length,
1768
- totalBytes: files.reduce((sum, file) => sum + file.size, 0),
1769
- existing: existingNames.has(candidate.name)
1770
- };
1771
- }).sort((a, b) => a.name.localeCompare(b.name));
1772
- }
1773
- /** Build a path→size manifest for one skill directory from a repo tree. */
1774
- function skillManifest(tree, dir) {
1775
- const prefix = dir + "/";
1776
- const manifest = {};
1777
- for (const item of tree) if (item.type === "blob" && item.path.startsWith(prefix)) manifest[item.path] = typeof item.size === "number" ? item.size : 0;
1778
- return manifest;
1779
- }
1780
- /**
1781
- * The real upstream directory of one tracked skill. Source records keep only
1782
- * the short name and top-level root (e.g. root "skills", name "ask-matt"),
1783
- * but upstream repos may nest skills under category directories
1784
- * (skills/engineering/ask-matt/) or move them between categories. Resolution
1785
- * order: exact match under the top-level root in the upstream tree, then the
1786
- * manifest's recorded blob path, then any same-name skill in the tree, then
1787
- * the flat root/name fallback. Passing the tree paths makes the lookup
1788
- * resilient to incomplete manifests and upstream moves.
1789
- */
1790
- function skillDirOf(source, name, treePaths) {
1791
- if (treePaths !== void 0) {
1792
- const rootPrefix = source.root + "/";
1793
- const found = treePaths.find((path) => path.startsWith(rootPrefix) && path.endsWith("/" + name + "/SKILL.md"));
1794
- if (found !== void 0) return found.slice(0, found.lastIndexOf("/"));
1795
- }
1796
- const manifest = source.manifest ?? {};
1797
- const fromManifest = Object.keys(manifest).find((path) => path.endsWith("/" + name + "/SKILL.md"));
1798
- if (fromManifest !== void 0) return fromManifest.slice(0, fromManifest.lastIndexOf("/"));
1799
- if (treePaths !== void 0) {
1800
- const found = treePaths.find((path) => path.endsWith("/" + name + "/SKILL.md"));
1801
- if (found !== void 0) return found.slice(0, found.lastIndexOf("/"));
1802
- }
1803
- return source.root + "/" + name;
1804
- }
1805
- /**
1806
- * Diff one source record against an upstream tree at treeSha: which tracked
1807
- * skills disappeared (no SKILL.md blob) and which changed (manifest baseline
1808
- * differs, or no baseline exists — treated as changed). Pure over the tree.
1809
- */
1810
- function diffRemoteSkills(tree, source) {
1811
- const blobs = /* @__PURE__ */ new Map();
1812
- for (const item of tree) if (item.type === "blob") blobs.set(item.path, typeof item.size === "number" ? item.size : 0);
1813
- const treePaths = [...blobs.keys()];
1814
- const updated = [];
1815
- const deleted = [];
1816
- for (const name of source.skills) {
1817
- const prefix = skillDirOf(source, name, treePaths) + "/";
1818
- const remote = /* @__PURE__ */ new Map();
1819
- for (const [path, size] of blobs) if (path.startsWith(prefix)) remote.set(path, size);
1820
- if (!remote.has(prefix + "SKILL.md")) {
1821
- deleted.push(name);
1822
- continue;
1823
- }
1824
- const baseline = source.manifest ?? {};
1825
- const baselineEntries = Object.entries(baseline).filter(([path]) => path.startsWith(prefix));
1826
- if (baselineEntries.length === 0) {
1827
- updated.push(name);
1828
- continue;
1829
- }
1830
- let differs = baselineEntries.length !== remote.size;
1831
- if (!differs) {
1832
- for (const [path, size] of baselineEntries) if (remote.get(path) !== size) {
1833
- differs = true;
1834
- break;
1835
- }
1836
- }
1837
- if (differs) updated.push(name);
1838
- }
1839
- return {
1840
- updated,
1841
- deleted
1842
- };
1843
- }
1844
- /** Minimal RepoSkillEntry for a tracked skill name (sync re-downloads by name). */
1845
- function repoSkillEntry(name, root, repo) {
1846
- return {
1847
- name,
1848
- dir: root + "/" + name,
1849
- path: root + "/" + name + "/SKILL.md",
1850
- root,
1851
- origin: repo,
1852
- fileCount: 0,
1853
- totalBytes: 0,
1854
- existing: false
1855
- };
1856
- }
1857
- //#endregion
1858
1995
  //#region src/repo/api.ts
1859
1996
  /**
1860
1997
  * GitHub 仓库读取:仓库树、发布标签、星标/下载量、分支与提交查询。
@@ -1991,7 +2128,10 @@ async function downloadGitHubFile(repo, ref, path, fetchImpl = fetch, signal) {
1991
2128
  let response = null;
1992
2129
  try {
1993
2130
  response = await fetchImpl(rawUrl, {
1994
- headers: githubAuthHeaders(),
2131
+ headers: {
2132
+ ...NO_COMPRESSION,
2133
+ ...githubAuthHeaders()
2134
+ },
1995
2135
  ...signal !== void 0 ? { signal } : {}
1996
2136
  });
1997
2137
  } catch (error) {
@@ -2005,6 +2145,7 @@ async function downloadGitHubFile(repo, ref, path, fetchImpl = fetch, signal) {
2005
2145
  response = await fetchImpl(apiUrl, {
2006
2146
  headers: {
2007
2147
  accept: "application/vnd.github.raw",
2148
+ ...NO_COMPRESSION,
2008
2149
  ...githubAuthHeaders()
2009
2150
  },
2010
2151
  ...signal !== void 0 ? { signal } : {}
@@ -2036,7 +2177,7 @@ async function downloadRepoSkill(repo, ref, entry, files, targetRoot, fetchImpl
2036
2177
  try {
2037
2178
  await mapConcurrent(files, 6, async (file) => {
2038
2179
  if (signal?.aborted) throw new DOMException("aborted", "AbortError");
2039
- const relative = file.path.slice(entry.dir.length + 1);
2180
+ const relative = relativeToSkillDir(entry.dir, file.path);
2040
2181
  if (relative === "" || relative.includes("..")) throw new RepoFetchError("unsafe repo path: " + file.path);
2041
2182
  const target = join(tempDir, relative);
2042
2183
  const buffer = await downloadGitHubFile(repo, ref, file.path, fetchImpl, signal);
@@ -2074,7 +2215,7 @@ async function downloadRepoSkill(repo, ref, entry, files, targetRoot, fetchImpl
2074
2215
  force: true
2075
2216
  });
2076
2217
  } catch (secondError) {
2077
- console.warn(`[skill-hub] cleanup tempDir failed ${tempDir}:`, errorText(secondError), "first:", errorText(firstError));
2218
+ console.warn(`[dsh-skill-hub] cleanup tempDir failed ${tempDir}:`, errorText(secondError), "first:", errorText(firstError));
2078
2219
  }
2079
2220
  }
2080
2221
  }
@@ -2102,10 +2243,10 @@ async function cleanupLeftoverImportDirs(targetRoot) {
2102
2243
  });
2103
2244
  cleaned += 1;
2104
2245
  } catch (error) {
2105
- console.warn(`[skill-hub] startup cleanup failed ${full}:`, errorText(error));
2246
+ console.warn(`[dsh-skill-hub] startup cleanup failed ${full}:`, errorText(error));
2106
2247
  }
2107
2248
  }
2108
- if (cleaned > 0) console.warn(`[skill-hub] startup cleaned ${cleaned} leftover import temp dir(s) in ${targetRoot}`);
2249
+ if (cleaned > 0) console.warn(`[dsh-skill-hub] startup cleaned ${cleaned} leftover import temp dir(s) in ${targetRoot}`);
2109
2250
  return cleaned;
2110
2251
  }
2111
2252
  /** Loopback literal check plus browser same-origin markers. */
@@ -2362,10 +2503,7 @@ async function checkLatestRelease(repo = UPDATE_REPO, fetchImpl = fetch) {
2362
2503
  const url = `https://api.github.com/repos/${repo}/releases/latest`;
2363
2504
  let response;
2364
2505
  try {
2365
- response = await fetchImpl(url, { headers: {
2366
- accept: "application/vnd.github+json",
2367
- ...githubAuthHeaders()
2368
- } });
2506
+ response = await fetchImpl(url, { headers: apiHeaders() });
2369
2507
  } catch (error) {
2370
2508
  return failure(errorText(error));
2371
2509
  }
@@ -2598,7 +2736,7 @@ function toDetail(skill) {
2598
2736
  */
2599
2737
  function createRoute(deps, spec) {
2600
2738
  return {
2601
- kind: "exact",
2739
+ kind: spec.kind ?? "exact",
2602
2740
  path: spec.path,
2603
2741
  handler: async (req, res) => {
2604
2742
  if (!isLoopbackRequest(req)) {
@@ -2962,6 +3100,21 @@ function catalogRoutes(deps) {
2962
3100
  * 配置域路由:config(总开关开着也可用,设置卡自救入口)/ update 自身更新
2963
3101
  * 检查 / diagnosticFix。从 routes.ts 原样搬出,handler 逻辑不变。
2964
3102
  */
3103
+ /**
3104
+ * Build a config payload for either method. The GitHub token never goes out:
3105
+ * the effective config and the raw saved overrides are both stripped, and its
3106
+ * presence is reported as a plain boolean instead.
3107
+ */
3108
+ function configResponse(config, saved) {
3109
+ const token = config.githubToken;
3110
+ return {
3111
+ ok: true,
3112
+ pluginVersion: CURRENT_VERSION,
3113
+ config: redactGithubToken(config),
3114
+ saved: redactGithubToken(saved),
3115
+ githubTokenSet: token !== void 0 && token !== ""
3116
+ };
3117
+ }
2965
3118
  /** 配置域全部路由 spec(由 routes.ts 经 createRoute 包上统一围栏)。 */
2966
3119
  function configRoutes(deps) {
2967
3120
  return [
@@ -2972,12 +3125,7 @@ function configRoutes(deps) {
2972
3125
  skipGate: true,
2973
3126
  handler: async ({ req, res, body }) => {
2974
3127
  if (req.method === "GET") {
2975
- writeJson(res, 200, {
2976
- ok: true,
2977
- pluginVersion: CURRENT_VERSION,
2978
- config: configOf(deps),
2979
- saved: savedOf(deps)
2980
- });
3128
+ writeJson(res, 200, configResponse(configOf(deps), savedOf(deps)));
2981
3129
  return;
2982
3130
  }
2983
3131
  const raw = body;
@@ -3038,12 +3186,7 @@ function configRoutes(deps) {
3038
3186
  else merged[key] = value;
3039
3187
  config = merged;
3040
3188
  } else config = await deps.updateConfig(patch);
3041
- writeJson(res, 200, {
3042
- ok: true,
3043
- pluginVersion: CURRENT_VERSION,
3044
- config,
3045
- saved: savedOf(deps)
3046
- });
3189
+ writeJson(res, 200, configResponse(config, savedOf(deps)));
3047
3190
  }
3048
3191
  },
3049
3192
  {
@@ -3133,11 +3276,65 @@ async function replaceSkillDir(targetDir, download) {
3133
3276
  //#region src/routes/market.ts
3134
3277
  /**
3135
3278
  * 市场域路由:market 列表 / source 增删 / ref 锁定 / versions 选择器 /
3136
- * check 更新检查 / stats 星标统计 / sync 对齐版本。从 routes.ts 原样搬出,
3137
- * handler 逻辑不变。
3279
+ * source/check 更新检查 / stats 星标统计 / source/sync 对齐版本。
3280
+ * 从 routes.ts 原样搬出,handler 逻辑不变。
3138
3281
  */
3139
3282
  /** 市场域全部路由 spec(由 routes.ts 经 createRoute 包上统一围栏)。 */
3140
3283
  function marketRoutes(deps) {
3284
+ const checkMarketSources = async ({ res }) => {
3285
+ const sources = await deps.store.listMarketSources();
3286
+ if (lastMarketCheck.size > 500) lastMarketCheck.clear();
3287
+ const results = [];
3288
+ for (const source of sources) {
3289
+ const base = {
3290
+ repo: source.repo,
3291
+ ...source.ref !== void 0 ? { ref: source.ref } : {}
3292
+ };
3293
+ const now = Date.now();
3294
+ if (now - (lastMarketCheck.get(source.repo) ?? 0) < 3e5) {
3295
+ results.push({
3296
+ ...base,
3297
+ updateAvailable: false,
3298
+ commitSha: source.commitSha ?? "",
3299
+ throttled: true
3300
+ });
3301
+ continue;
3302
+ }
3303
+ try {
3304
+ const latestTag = await getLatestReleaseTag(source.repo);
3305
+ if (source.ref === void 0) {
3306
+ results.push({
3307
+ ...base,
3308
+ updateAvailable: false,
3309
+ commitSha: source.commitSha ?? "",
3310
+ ...latestTag !== void 0 ? { latestTag } : {}
3311
+ });
3312
+ continue;
3313
+ }
3314
+ const latest = await getLatestCommit(source.repo, source.ref);
3315
+ lastMarketCheck.set(source.repo, now);
3316
+ const commitMoved = source.commitSha !== void 0 && latest.commitSha !== source.commitSha;
3317
+ const newRelease = latestTag !== void 0 && latestTag !== source.ref;
3318
+ results.push({
3319
+ ...base,
3320
+ updateAvailable: commitMoved || newRelease,
3321
+ commitSha: latest.commitSha,
3322
+ ...newRelease ? { latestTag } : {}
3323
+ });
3324
+ } catch (error) {
3325
+ results.push({
3326
+ ...base,
3327
+ updateAvailable: false,
3328
+ commitSha: source.commitSha ?? "",
3329
+ error: errorText(error)
3330
+ });
3331
+ }
3332
+ }
3333
+ writeJson(res, 200, {
3334
+ ok: true,
3335
+ results
3336
+ });
3337
+ };
3141
3338
  return [
3142
3339
  {
3143
3340
  path: SKILL_HUB_API.market,
@@ -3235,60 +3432,12 @@ function marketRoutes(deps) {
3235
3432
  {
3236
3433
  path: SKILL_HUB_API.marketCheck,
3237
3434
  methods: ["GET"],
3238
- handler: async ({ res }) => {
3239
- const sources = await deps.store.listMarketSources();
3240
- if (lastMarketCheck.size > 500) lastMarketCheck.clear();
3241
- const results = [];
3242
- for (const source of sources) {
3243
- const base = {
3244
- repo: source.repo,
3245
- ...source.ref !== void 0 ? { ref: source.ref } : {}
3246
- };
3247
- const now = Date.now();
3248
- if (now - (lastMarketCheck.get(source.repo) ?? 0) < 3e5) {
3249
- results.push({
3250
- ...base,
3251
- updateAvailable: false,
3252
- commitSha: source.commitSha ?? "",
3253
- throttled: true
3254
- });
3255
- continue;
3256
- }
3257
- try {
3258
- const latestTag = await getLatestReleaseTag(source.repo);
3259
- if (source.ref === void 0) {
3260
- results.push({
3261
- ...base,
3262
- updateAvailable: false,
3263
- commitSha: source.commitSha ?? "",
3264
- ...latestTag !== void 0 ? { latestTag } : {}
3265
- });
3266
- continue;
3267
- }
3268
- const latest = await getLatestCommit(source.repo, source.ref);
3269
- lastMarketCheck.set(source.repo, now);
3270
- const commitMoved = source.commitSha !== void 0 && latest.commitSha !== source.commitSha;
3271
- const newRelease = latestTag !== void 0 && latestTag !== source.ref;
3272
- results.push({
3273
- ...base,
3274
- updateAvailable: commitMoved || newRelease,
3275
- commitSha: latest.commitSha,
3276
- ...newRelease ? { latestTag } : {}
3277
- });
3278
- } catch (error) {
3279
- results.push({
3280
- ...base,
3281
- updateAvailable: false,
3282
- commitSha: source.commitSha ?? "",
3283
- error: errorText(error)
3284
- });
3285
- }
3286
- }
3287
- writeJson(res, 200, {
3288
- ok: true,
3289
- results
3290
- });
3291
- }
3435
+ handler: checkMarketSources
3436
+ },
3437
+ {
3438
+ path: SKILL_HUB_API_DEPRECATED_MARKET_CHECK,
3439
+ methods: ["GET"],
3440
+ handler: checkMarketSources
3292
3441
  },
3293
3442
  {
3294
3443
  path: SKILL_HUB_API.marketStats,
@@ -3511,7 +3660,7 @@ function repoImportRoutes(deps) {
3511
3660
  for (const entry of selected) {
3512
3661
  if (controller.signal.aborted) break;
3513
3662
  job.current = entry.name;
3514
- job.currentFile = entry.dir + "/SKILL.md";
3663
+ job.currentFile = entry.path;
3515
3664
  if (entry.existing) {
3516
3665
  job.skipped.push({
3517
3666
  name: entry.name,
@@ -3535,7 +3684,7 @@ function repoImportRoutes(deps) {
3535
3684
  try {
3536
3685
  const result = await downloadRepoSkill(repo, resolvedRef, entry, files, targetRoot, fetch, controller.signal, (bytes, file) => {
3537
3686
  job.downloadedBytes += bytes;
3538
- job.currentFile = entry.dir + "/" + file;
3687
+ job.currentFile = skillPathIn(entry.dir, file);
3539
3688
  });
3540
3689
  await deps.store.addSourceSkill(repo, entry.root, commitSha, resolvedRef, entry.name);
3541
3690
  await deps.store.mergeSourceManifest(repo, skillManifest(tree, entry.dir), entry.dir);
@@ -3913,7 +4062,7 @@ function sourceRoutes(deps) {
3913
4062
  try {
3914
4063
  const entry = repoSkillEntry(name, source.root, repo);
3915
4064
  entry.dir = skillDirOf(source, name, tree.map((item) => item.path));
3916
- entry.path = entry.dir + "/SKILL.md";
4065
+ entry.path = skillFileAt(entry.dir);
3917
4066
  const files = collectRepoSkillFiles(tree, entry.dir);
3918
4067
  if (files.length === 0) {
3919
4068
  failed.push({
@@ -4100,17 +4249,25 @@ function sourceRoutes(deps) {
4100
4249
  /**
4101
4250
  * Build every /api/skill-hub route.
4102
4251
  * @param deps - skill registry view + sidecar store.
4103
- * @returns the exact-path routes.
4252
+ * @returns the exact-path routes plus the family's 404 catch-all.
4104
4253
  */
4105
4254
  function makeRoutes(deps) {
4106
- return [
4255
+ return [...[
4107
4256
  ...catalogRoutes(deps),
4108
4257
  ...configRoutes(deps),
4109
4258
  ...marketRoutes(deps),
4110
4259
  ...repoImportRoutes(deps),
4111
4260
  ...groupRoutes(deps),
4112
4261
  ...sourceRoutes(deps)
4113
- ].map((spec) => createRoute(deps, spec));
4262
+ ].map((spec) => createRoute(deps, spec)), createRoute(deps, {
4263
+ kind: "prefix",
4264
+ path: SKILL_HUB_API_ROOT,
4265
+ methods: ["GET", "POST"],
4266
+ skipGate: true,
4267
+ handler: async ({ res, url }) => {
4268
+ writeError(res, 404, "unknown skill-hub route: " + url.pathname + " (see SKILL_HUB_API in src/protocol/api.ts)");
4269
+ }
4270
+ })];
4114
4271
  }
4115
4272
  //#endregion
4116
4273
  //#region src/stats/scan.ts
@@ -4493,56 +4650,100 @@ async function reconcileDisabledSkills(store, home = dshHome()) {
4493
4650
  //#region src/index.ts
4494
4651
  /** Stable cordis plugin name (matches cordis.patch.yml insert id). */
4495
4652
  const name = "skill-hub";
4496
- /** Services required before the skill-hub surfaces can mount. */
4653
+ /** Services required before the skill-hub surfaces can mount. `settings` is deliberately NOT here: the plugin reads its own volatile config refs and only reaches for the settings service when it is present (issue #11). */
4497
4654
  const inject = [
4498
4655
  "webServer",
4499
4656
  "skills",
4500
- "systemPrompt",
4501
- "settings"
4657
+ "systemPrompt"
4502
4658
  ];
4659
+ /**
4660
+ * The durable field schemas, plain (non-volatile) so the same definitions can
4661
+ * back both the live view below and the wire form the settings page renders
4662
+ * from `.toJSON()`. `description` is what the auto-generated page shows with
4663
+ * each row; `role('secret')` makes the settings layer redact the value on
4664
+ * every wire read.
4665
+ */
4666
+ const ConfigFields = {
4667
+ enabled: z.boolean().default(HUB_CONFIG_DEFAULTS.enabled).description("关闭后技能中枢的路由、入口与公告全部下线。"),
4668
+ announceToAgent: z.boolean().default(HUB_CONFIG_DEFAULTS.announceToAgent).description("在系统提示中加入本插件说明,用户提到技能管理时 Agent 知道如何协作。"),
4669
+ dotModelColor: z.string().pattern(HEX_COLOR_RE).description("技能行与聊天「/」菜单中「模型可调」圆点的颜色(#rrggbb)。"),
4670
+ dotUserColor: z.string().pattern(HEX_COLOR_RE).description("技能行与聊天「/」菜单中「仅用户可调」圆点的颜色(#rrggbb)。"),
4671
+ showUseCount: z.boolean().default(HUB_CONFIG_DEFAULTS.showUseCount).description("在技能名旁显示调用次数。"),
4672
+ showUseTime: z.boolean().default(HUB_CONFIG_DEFAULTS.showUseTime).description("在技能名行显示最近调用时间。"),
4673
+ showGroupSummary: z.boolean().default(HUB_CONFIG_DEFAULTS.showGroupSummary).description("在分组标题后汇总调用次数与最近调用时间。"),
4674
+ statsWindowDays: z.number().min(0).max(3650).default(HUB_CONFIG_DEFAULTS.statsWindowDays).description("只统计最近 N 天的使用次数;0 = 全部历史。"),
4675
+ statsScanMinutes: z.number().min(1).max(1440).default(HUB_CONFIG_DEFAULTS.statsScanMinutes).description("后台扫描会话日志的间隔(分钟,最小 1)。"),
4676
+ githubToken: z.string().role("secret").description("市场/来源走 GitHub API:匿名每小时 60 次,填 token 后 5000 次。留空即匿名(或跟随 GITHUB_TOKEN 环境变量)。")
4677
+ };
4678
+ /**
4679
+ * The live plugin config the settings page edits. Field order here is the row
4680
+ * order the auto-generated page renders.
4681
+ */
4503
4682
  const Config = z.object({
4504
- announceToAgent: z.boolean().default(HUB_CONFIG_DEFAULTS.announceToAgent),
4505
- enabled: z.boolean().default(HUB_CONFIG_DEFAULTS.enabled),
4506
- showUseCount: z.boolean().default(HUB_CONFIG_DEFAULTS.showUseCount),
4507
- showUseTime: z.boolean().default(HUB_CONFIG_DEFAULTS.showUseTime),
4508
- showGroupSummary: z.boolean().default(HUB_CONFIG_DEFAULTS.showGroupSummary),
4509
- statsWindowDays: z.number().min(0).max(3650).default(HUB_CONFIG_DEFAULTS.statsWindowDays),
4510
- statsScanMinutes: z.number().min(1).max(1440).default(HUB_CONFIG_DEFAULTS.statsScanMinutes)
4683
+ enabled: ConfigFields.enabled.volatile(),
4684
+ announceToAgent: ConfigFields.announceToAgent.volatile(),
4685
+ dotModelColor: ConfigFields.dotModelColor.volatile(),
4686
+ dotUserColor: ConfigFields.dotUserColor.volatile(),
4687
+ showUseCount: ConfigFields.showUseCount.volatile(),
4688
+ showUseTime: ConfigFields.showUseTime.volatile(),
4689
+ showGroupSummary: ConfigFields.showGroupSummary.volatile(),
4690
+ statsWindowDays: ConfigFields.statsWindowDays.volatile(),
4691
+ statsScanMinutes: ConfigFields.statsScanMinutes.volatile(),
4692
+ githubToken: ConfigFields.githubToken.volatile()
4511
4693
  });
4512
4694
  /**
4513
- * Settings namespace hosting the hub's runtime config. Since dsh rc.7 the
4514
- * host serves every registered settings namespace to the web client (the
4515
- * dsh-host-apiproxy allowlist is gone), so the browser card and the settings
4516
- * page edit this namespace through the official settings transport, and the
4517
- * plugin consumes the same resolved value — one source of truth.
4695
+ * The settings namespace this plugin's config lives under, narrowed to the
4696
+ * settings package's branded type. The browser half resolves the same form
4697
+ * through `ctx.configForms.get(HUB_ENTRY_ID)`.
4518
4698
  */
4519
- const CONFIG_NAMESPACE = "dsh-skill-hub";
4520
- /** Schema of the hub's settings namespace: the card's fields (booleans + optional dot colors). */
4521
- const HubSettingsSchema = z.object({
4522
- enabled: z.boolean().default(HUB_CONFIG_DEFAULTS.enabled),
4523
- announceToAgent: z.boolean().default(HUB_CONFIG_DEFAULTS.announceToAgent),
4524
- showUseCount: z.boolean().default(HUB_CONFIG_DEFAULTS.showUseCount),
4525
- showUseTime: z.boolean().default(HUB_CONFIG_DEFAULTS.showUseTime),
4526
- showGroupSummary: z.boolean().default(HUB_CONFIG_DEFAULTS.showGroupSummary),
4527
- dotModelColor: z.string().pattern(HEX_COLOR_RE),
4528
- dotUserColor: z.string().pattern(HEX_COLOR_RE),
4529
- githubToken: z.string(),
4530
- statsWindowDays: z.number().min(0).max(3650).default(HUB_CONFIG_DEFAULTS.statsWindowDays),
4531
- statsScanMinutes: z.number().min(1).max(1440).default(HUB_CONFIG_DEFAULTS.statsScanMinutes)
4532
- });
4699
+ const ENTRY_ID = HUB_ENTRY_ID;
4700
+ /** Every config field, in the order the settings page renders them (used to walk the volatile refs). */
4701
+ const CONFIG_FIELDS = [
4702
+ "enabled",
4703
+ "announceToAgent",
4704
+ "dotModelColor",
4705
+ "dotUserColor",
4706
+ "showUseCount",
4707
+ "showUseTime",
4708
+ "showGroupSummary",
4709
+ "statsWindowDays",
4710
+ "statsScanMinutes",
4711
+ "githubToken"
4712
+ ];
4533
4713
  /** Order of the announcement section within the tool-guidance band. */
4534
4714
  const SECTION_ORDER = 152;
4535
4715
  /** Model-facing announcement: plugin presence, capabilities, and limits. */
4536
- const SKILL_HUB_GUIDANCE = ["本机已安装 dsh-skill-hub 插件(DSH Web GUI 技能中枢):设置 →「技能」分区为管理主页;设置 → 插件列表中有本插件的配置卡片(启用/公告开关)。能力:完整本地技能目录(项目/自定义/用户/内置全部来源,走官方 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; Settings → Plugins lists this plugin's configuration card (enable / announcement toggles). 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");
4716
+ 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");
4537
4717
  /**
4538
4718
  * Mount the skill hub routes and announcement.
4539
4719
  * @param ctx - host plugin context carrying webServer/skills/systemPrompt/settings.
4540
- * @param config - resolved plugin config (schema defaults applied by the loader).
4541
4720
  */
4542
4721
  function apply(ctx, config) {
4543
- const base = config ?? {};
4544
- const settingsScope = ctx.settings.register(CONFIG_NAMESPACE, HubSettingsSchema, { base });
4545
- const current = () => settingsScope.get();
4722
+ /**
4723
+ * Read the runtime config out of our own volatile config refs.
4724
+ *
4725
+ * This is the whole point of `volatile()`: the Loader updates those values
4726
+ * **in place** (`ConfigEditor.edit` → `resolveConfig(fiber.runtime, next)` →
4727
+ * `updateVolatile`) without rebuilding the fiber, so a held reference always
4728
+ * answers `.get()` with the current value. Consequences worth remembering:
4729
+ * - no `settings.describe()` per request (it walks and projects every entry
4730
+ * in the profile — the reason this used to be slow);
4731
+ * - no subscription or watcher is needed for *reading*;
4732
+ * - the plugin keeps working when the Settings service is absent
4733
+ * (issue #11: business logic must not depend on Settings).
4734
+ */
4735
+ const readConfig = () => {
4736
+ const out = {};
4737
+ for (const field of CONFIG_FIELDS) {
4738
+ const ref = config?.[field];
4739
+ const value = ref !== void 0 && typeof ref.get === "function" ? ref.get() : ref;
4740
+ if (value !== void 0) out[field] = value;
4741
+ }
4742
+ return out;
4743
+ };
4744
+ const current = () => resolveHubConfig({}, readConfig());
4745
+ /** The Settings service, when this deployment mounts it. Absent ⇒ no user layer to read or write. */
4746
+ const settingsOf = () => ctx.get("settings");
4546
4747
  const store = new SkillHubStore();
4547
4748
  let disposeRoutes;
4548
4749
  let disposeSection;
@@ -4550,14 +4751,26 @@ function apply(ctx, config) {
4550
4751
  let providerControl;
4551
4752
  let stats;
4552
4753
  const saved = () => {
4553
- return ctx.settings.describe().find((entry) => entry.ns === "dsh-skill-hub")?.user ?? {};
4754
+ return (settingsOf()?.describe().find((entry) => entry.ns === ENTRY_ID))?.user ?? {};
4554
4755
  };
4555
4756
  const updateConfig = async (patch) => {
4556
- const user = { ...saved() };
4557
- for (const [key, value] of Object.entries(patch)) if (value === void 0) delete user[key];
4558
- else user[key] = value;
4559
- await settingsScope.replace(user);
4560
- return settingsScope.get();
4757
+ const ops = [];
4758
+ for (const [field, value] of Object.entries(patch)) if (value === void 0) ops.push({
4759
+ op: "unset",
4760
+ path: [field]
4761
+ });
4762
+ else ops.push({
4763
+ op: "set",
4764
+ path: [field],
4765
+ value
4766
+ });
4767
+ const settings = settingsOf();
4768
+ if (settings === void 0) throw new Error("this deployment does not mount the settings service; config is read-only");
4769
+ if (ops.length > 0) {
4770
+ await settings.mutate(ENTRY_ID, ops);
4771
+ sync();
4772
+ }
4773
+ return current();
4561
4774
  };
4562
4775
  const sync = () => {
4563
4776
  if (disposeSection !== void 0) {
@@ -4582,6 +4795,7 @@ function apply(ctx, config) {
4582
4795
  return new SkillHubProvider(control);
4583
4796
  }), "dsh-skill-hub: provider");
4584
4797
  }
4798
+ ctx.logger.info(`[dsh-skill-hub] surfaces synced: enabled=${value.enabled} announceToAgent=${value.announceToAgent}`);
4585
4799
  if (disposeRoutes !== void 0) {
4586
4800
  disposeRoutes();
4587
4801
  disposeRoutes = void 0;
@@ -4604,9 +4818,6 @@ function apply(ctx, config) {
4604
4818
  }, "dsh-skill-hub: routes");
4605
4819
  };
4606
4820
  sync();
4607
- ctx.effect(() => settingsScope.watch(() => {
4608
- sync();
4609
- }), "dsh-skill-hub: settings config watch");
4610
4821
  (async () => {
4611
4822
  const home = dshHome();
4612
4823
  const agentsHome = process.env.DSH_AGENTS_HOME ?? join(homedir(), ".agents");
@@ -4626,7 +4837,10 @@ function apply(ctx, config) {
4626
4837
  (async () => {
4627
4838
  try {
4628
4839
  const legacy = await store.getConfig();
4629
- if (Object.keys(legacy).length > 0 && Object.keys(saved()).length === 0) await settingsScope.update(legacy);
4840
+ if (Object.keys(legacy).length > 0 && Object.keys(saved()).length === 0) {
4841
+ const settings = settingsOf();
4842
+ if (settings !== void 0) await settings.update(ENTRY_ID, legacy);
4843
+ }
4630
4844
  } catch (error) {
4631
4845
  ctx.logger.warn("[dsh-skill-hub] sidecar config migration into the settings namespace failed", error);
4632
4846
  }
@@ -4706,4 +4920,4 @@ function apply(ctx, config) {
4706
4920
  });
4707
4921
  }
4708
4922
  //#endregion
4709
- export { CONFIG_NAMESPACE, Config, HubSettingsSchema, SKILL_HUB_GUIDANCE, apply, inject, name };
4923
+ export { Config, ENTRY_ID, SKILL_HUB_GUIDANCE, apply, inject, name };