@khorsheed/dsh-capability-catalog 0.1.97 → 0.1.99

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 (46) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.en.md +31 -7
  3. package/README.i18n.yaml +2 -2
  4. package/README.md +31 -7
  5. package/cordis.patch.yml +1 -1
  6. package/lib/client.js +14790 -902
  7. package/lib/index.js +760 -265
  8. package/lib/typert.host.js +614 -295
  9. package/lib/typert.remote-client.d.ts +27 -4
  10. package/lib/typert.remote-client.js +599 -288
  11. package/lib/types/client/CapabilityCatalogCard.js +20 -5
  12. package/lib/types/client/SkillDetailModal.d.ts +13 -5
  13. package/lib/types/client/SkillDetailModal.js +9 -3
  14. package/lib/types/client/locales.d.ts +0 -4
  15. package/lib/types/client/locales.js +4 -8
  16. package/lib/types/envHint.d.ts +7 -1
  17. package/lib/types/envHint.js +52 -18
  18. package/lib/types/evidence.d.ts +142 -0
  19. package/lib/types/evidence.js +179 -0
  20. package/lib/types/frontmatter.d.ts +78 -0
  21. package/lib/types/frontmatter.js +186 -0
  22. package/lib/types/import.js +10 -19
  23. package/lib/types/index.d.ts +16 -4
  24. package/lib/types/index.js +32 -53
  25. package/lib/types/mcpStore.d.ts +6 -8
  26. package/lib/types/mcpStore.js +18 -43
  27. package/lib/types/mcps.d.ts +1 -6
  28. package/lib/types/mcps.js +0 -9
  29. package/lib/types/namespace.d.ts +2 -3
  30. package/lib/types/namespace.js +2 -3
  31. package/lib/types/remote.d.ts +11 -2
  32. package/lib/types/remote.js +4 -1
  33. package/lib/types/scoped-delivery.d.ts +36 -24
  34. package/lib/types/scoped-delivery.js +136 -100
  35. package/lib/types/scoped-edits.js +2 -1
  36. package/lib/types/shellEnv.d.ts +8 -1
  37. package/lib/types/shellEnv.js +37 -16
  38. package/lib/types/skills.d.ts +41 -0
  39. package/lib/types/skills.js +51 -9
  40. package/lib/types/tool-face.d.ts +68 -0
  41. package/lib/types/tool-face.js +82 -0
  42. package/lib/types/types.d.ts +40 -19
  43. package/package.json +20 -18
  44. package/lib/invariant.js +0 -25
  45. package/lib/types/invariant.d.ts +0 -16
  46. package/lib/types/invariant.js +0 -23
package/lib/index.js CHANGED
@@ -1,11 +1,12 @@
1
1
  import { homedir, tmpdir } from "node:os";
2
- import { basename, dirname, join, relative } from "node:path";
2
+ import { basename, dirname, isAbsolute, join, relative, sep } from "node:path";
3
3
  import { Remote, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
4
4
  import { defineTool } from "@deepseek-ai/dsh-tools";
5
- import { cp, mkdir, mkdtemp, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
5
+ import { cp, mkdir, mkdtemp, readFile, readdir, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
6
6
  import { createHash } from "node:crypto";
7
7
  import { execFile } from "node:child_process";
8
8
  import { promisify } from "node:util";
9
+ import yaml from "js-yaml";
9
10
  import { inflateRawSync } from "node:zlib";
10
11
  import { existsSync, watch } from "node:fs";
11
12
  import { createScope } from "@deepseek-ai/dsh-scope";
@@ -248,6 +249,10 @@ async function skillBodySha(registry, name, options) {
248
249
  *
249
250
  * `bodySha` costs one registry load per skill, so it is opt-in: the browser
250
251
  * card wants a listing, and the capability fingerprint wants the bodies.
252
+ *
253
+ * `report`, when given, is filled with the collection's health (see
254
+ * {@link SkillsCollectionReport}); the Remote verbs never pass one, so their
255
+ * rows — and their digests — are exactly what they were.
251
256
  */
252
257
  async function collectSkills$1(registry, workdir, scopes = [void 0], collectOptions = {}) {
253
258
  const byName = /* @__PURE__ */ new Map();
@@ -257,15 +262,19 @@ async function collectSkills$1(registry, workdir, scopes = [void 0], collectOpti
257
262
  ...base,
258
263
  scope
259
264
  };
265
+ if (collectOptions.report !== void 0) collectOptions.report.attemptedScopes += 1;
260
266
  let snapshot;
261
267
  try {
262
268
  snapshot = await registry.snapshot(options);
263
269
  } catch {
270
+ if (collectOptions.report !== void 0) collectOptions.report.failedScopes += 1;
264
271
  continue;
265
272
  }
273
+ if (snapshot.complete === false && collectOptions.report !== void 0) collectOptions.report.incompleteReads += 1;
266
274
  for (const summary of snapshot.skills) {
267
275
  if (byName.has(summary.name)) continue;
268
276
  const bodySha = collectOptions.bodySha === true ? await skillBodySha(registry, summary.name, options) : void 0;
277
+ if (collectOptions.bodySha === true && bodySha === void 0 && collectOptions.report !== void 0) collectOptions.report.unreadableBodies += 1;
269
278
  const row = skillRowFrom(summary, await skillUpdatedAt(summary), bodySha);
270
279
  byName.set(row.name, row);
271
280
  }
@@ -311,6 +320,28 @@ function sanitizeBundlePath(path) {
311
320
  }
312
321
  return safe.length === 0 ? void 0 : safe.join("/");
313
322
  }
323
+ /**
324
+ * Resolve an already-sanitized bundle-relative path to its real on-disk
325
+ * target, refusing anything that escapes the bundle. {@link sanitizeBundlePath}
326
+ * has already removed the lexical escapes (absolute forms, drive letters,
327
+ * `..`); this closes what a lexical check cannot:
328
+ *
329
+ * - a symlink INSIDE the bundle whose target lives outside it (the bundle
330
+ * directory is user content), and
331
+ * - a prefix collision — containment is a separator-terminated comparison on
332
+ * realpaths, so `/root/foobar` never contain-matches `/root/foo`.
333
+ *
334
+ * @param bundleDir - the skill's bundle directory (its resource base).
335
+ * @param safe - a path that already passed {@link sanitizeBundlePath}.
336
+ * @returns the resolved absolute target, or undefined when the file is absent
337
+ * or would escape the bundle.
338
+ */
339
+ async function resolveBundleTarget(bundleDir, safe) {
340
+ const base = await realpath(bundleDir).catch(() => bundleDir);
341
+ const target = await realpath(join(bundleDir, safe)).catch(() => void 0);
342
+ if (target === void 0 || target !== base && !target.startsWith(base + sep)) return void 0;
343
+ return target;
344
+ }
314
345
  /** Read one skill-bundle file's text content, on demand. */
315
346
  async function readSkillFileContent(registry, name, filePath, workdir, scope = void 0, fallback) {
316
347
  const base = workdir === void 0 ? {} : { cwd: workdir };
@@ -325,10 +356,10 @@ async function readSkillFileContent(registry, name, filePath, workdir, scope = v
325
356
  if (bundleDir === void 0) return void 0;
326
357
  const safe = sanitizeBundlePath(filePath);
327
358
  if (safe === void 0) return void 0;
328
- const full = join(bundleDir, safe);
329
- if (!full.startsWith(bundleDir)) return void 0;
359
+ const target = await resolveBundleTarget(bundleDir, safe);
360
+ if (target === void 0) return void 0;
330
361
  try {
331
- return { content: (await readFile(full)).toString("utf8") };
362
+ return { content: (await readFile(target)).toString("utf8") };
332
363
  } catch {
333
364
  return;
334
365
  }
@@ -370,7 +401,8 @@ async function loadSkillDetail(ctx, registry, name, workdir, scope = void 0, fal
370
401
  const def = await registry.get(name, lookup) ?? fallback;
371
402
  if (def === void 0) return void 0;
372
403
  const metadataDecls = decodeCredentialDecls(def.metadata);
373
- const environmentRefs = decodeEnvDecls(def.content).map((decl) => decl.key);
404
+ const declaredKeys = new Set(metadataDecls.map((decl) => decl.key));
405
+ const environmentRefs = decodeEnvDecls(def.content).map((decl) => decl.key).filter((key) => !declaredKeys.has(key));
374
406
  const { credentials } = resolveServices(ctx);
375
407
  let credentialStates = [];
376
408
  if (metadataDecls.length > 0 && credentials !== void 0) credentialStates = await Promise.all(metadataDecls.map(async (decl) => {
@@ -478,6 +510,175 @@ function resolveMcpServerName(name, serverNames) {
478
510
  if (separator > 0) return rest.slice(0, separator);
479
511
  }
480
512
  //#endregion
513
+ //#region lib/types/frontmatter.js
514
+ /**
515
+ * SKILL.md frontmatter: ONE real-YAML parse shared by every catalog reader.
516
+ *
517
+ * Before this module, each reader carried its own line-based scan: a regex
518
+ * split plus per-key patterns that matched at any indentation, so a nested
519
+ * same-named key could shadow a top-level one and a `presetScope` under an
520
+ * unrelated key still counted. Everything here is derived from a single
521
+ * js-yaml parse of the block (js-yaml is the repo's YAML convention and a
522
+ * shipped runtime dependency), with the scalar semantics mirrored from the
523
+ * host's skill loader (`skill-filesystem`): booleans accept true/false, 1/0,
524
+ * yes/no, on/off.
525
+ *
526
+ * Two deliberate degradations, both pinned by specs:
527
+ *
528
+ * - A malformed `metadata:` block must not take the skill down. A bad metadata
529
+ * value usually poisons the whole YAML parse, so on a parse failure the
530
+ * block is retried once with the top-level `metadata:` section stripped:
531
+ * the skill delivers minus metadata rather than being rejected.
532
+ * - A flag value outside the accepted variants reads as ABSENT (the caller's
533
+ * default applies) instead of rejecting the skill the way the host loader
534
+ * does — the catalog is a management/delivery surface, not the loader of
535
+ * record.
536
+ * @module @khorsheed/dsh-capability-catalog/frontmatter
537
+ */
538
+ /** Kebab-case skill / preset id grammar (the registry's own). */
539
+ const KEBAB_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
540
+ /** Whether a string is a usable skill / preset id. */
541
+ function isKebabId(value) {
542
+ return KEBAB_ID.test(value);
543
+ }
544
+ /** Frontmatter block, then the body it precedes (CRLF-tolerant). */
545
+ const FRONTMATTER = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
546
+ /** Split a SKILL.md into its frontmatter block and body. */
547
+ function splitFrontmatter(content) {
548
+ const match = FRONTMATTER.exec(content);
549
+ if (match === null) return void 0;
550
+ return {
551
+ front: match[1] ?? "",
552
+ body: match[2] ?? ""
553
+ };
554
+ }
555
+ /**
556
+ * Parse a SKILL.md's frontmatter block once, with js-yaml.
557
+ * @param content - the whole file text.
558
+ * @returns the parsed fields plus the body, or undefined when there is no
559
+ * frontmatter block or the block is not usable YAML even without its
560
+ * `metadata:` section.
561
+ */
562
+ function parseSkillFrontmatter(content) {
563
+ const split = splitFrontmatter(content);
564
+ if (split === void 0) return void 0;
565
+ const parsed = parseFrontmatterData(split.front);
566
+ if (parsed === void 0) return void 0;
567
+ const { data, metadataDropped } = parsed;
568
+ const rawMetadata = data["metadata"];
569
+ const metadata = !metadataDropped && typeof rawMetadata === "object" && rawMetadata !== null && !Array.isArray(rawMetadata) ? rawMetadata : void 0;
570
+ const name = stringField(data, "name");
571
+ const description = stringField(data, "description");
572
+ const whenToUse = whenToUseOf(data);
573
+ const presetScope = presetScopeOf(data, metadata);
574
+ return {
575
+ ...name === void 0 ? {} : { name },
576
+ ...description === void 0 ? {} : { description },
577
+ ...whenToUse === void 0 ? {} : { whenToUse },
578
+ modelInvocable: frontmatterBoolean(data, "disable-model-invocation") !== true,
579
+ userInvocable: frontmatterBoolean(data, "user-invocable") !== false,
580
+ ...presetScope === void 0 ? {} : { presetScope },
581
+ ...metadata === void 0 ? {} : { metadata },
582
+ body: split.body
583
+ };
584
+ }
585
+ /**
586
+ * A frontmatter boolean with the host skill loader's accepted variants:
587
+ * booleans, 1/0 (number or string), and yes/no/on/off (case-insensitive —
588
+ * js-yaml 4 leaves those as strings, so the string branch carries them).
589
+ * Absent or anything else reads as undefined: the catalog degrades to the
590
+ * caller's default instead of rejecting the skill the way the host loader
591
+ * does (`frontmatter field "<key>" must be a boolean` → file ignored).
592
+ */
593
+ function frontmatterBoolean(data, key) {
594
+ if (!Object.hasOwn(data, key)) return void 0;
595
+ const value = data[key];
596
+ if (typeof value === "boolean") return value;
597
+ if (value === 1 || value === "1") return true;
598
+ if (value === 0 || value === "0") return false;
599
+ if (typeof value === "string") switch (value.toLowerCase()) {
600
+ case "true":
601
+ case "yes":
602
+ case "on": return true;
603
+ case "false":
604
+ case "no":
605
+ case "off": return false;
606
+ }
607
+ }
608
+ /** A frontmatter string field: non-empty strings only (the host's stringField). */
609
+ function stringField(data, key) {
610
+ const value = data[key];
611
+ return typeof value === "string" && value.length > 0 ? value : void 0;
612
+ }
613
+ /** The routing guidance, kebab spelling first (the catalog has always accepted both). */
614
+ function whenToUseOf(data) {
615
+ return stringField(data, "when-to-use") ?? stringField(data, "whenToUse");
616
+ }
617
+ /** The declared preset scope; the canonical metadata-nested key wins over the legacy top-level one. */
618
+ function presetScopeOf(data, metadata) {
619
+ const nested = metadata === void 0 ? void 0 : normalizePresetScope(metadata["presetScope"]);
620
+ if (nested !== void 0) return nested;
621
+ return normalizePresetScope(data["presetScope"]);
622
+ }
623
+ /** Normalize a raw presetScope value: arrays filter to kebab ids, a scalar is a one-item list. */
624
+ function normalizePresetScope(value) {
625
+ if (Array.isArray(value)) return presetIdsOf(value.map(String));
626
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return presetIdsOf([String(value)]);
627
+ }
628
+ /** Normalize YAML scalars onto the preset-id grammar (deduped, order preserved). */
629
+ function presetIdsOf(values) {
630
+ const ids = [];
631
+ for (const value of values) {
632
+ const id = value.trim().replace(/^['"]|['"]$/g, "");
633
+ if (id === "" || !KEBAB_ID.test(id)) continue;
634
+ if (!ids.includes(id)) ids.push(id);
635
+ }
636
+ return ids;
637
+ }
638
+ /** Parse the block as a YAML mapping; on a YAML failure retry once without the
639
+ * top-level `metadata:` section (a malformed metadata value degrades to
640
+ * no-metadata instead of rejecting the file). */
641
+ function parseFrontmatterData(front) {
642
+ try {
643
+ const data = loadYamlMapping(front);
644
+ return data === void 0 ? void 0 : {
645
+ data,
646
+ metadataDropped: false
647
+ };
648
+ } catch {
649
+ const stripped = stripTopLevelBlock(front, "metadata");
650
+ if (stripped === void 0) return void 0;
651
+ try {
652
+ const data = loadYamlMapping(stripped);
653
+ return data === void 0 ? void 0 : {
654
+ data,
655
+ metadataDropped: true
656
+ };
657
+ } catch {
658
+ return;
659
+ }
660
+ }
661
+ }
662
+ /** js-yaml parse of the block; only a plain object is a usable frontmatter map. */
663
+ function loadYamlMapping(front) {
664
+ const parsed = yaml.load(front);
665
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return void 0;
666
+ return parsed;
667
+ }
668
+ /** Remove a top-level `key:` line and its indented block; undefined when the key is absent. */
669
+ function stripTopLevelBlock(front, key) {
670
+ const lines = front.split("\n");
671
+ const start = lines.findIndex((line) => new RegExp(`^${key}\\s*:`).test(line));
672
+ if (start < 0) return void 0;
673
+ let end = start + 1;
674
+ while (end < lines.length) {
675
+ const line = lines[end] ?? "";
676
+ if (line.trim() !== "" && !/^\s/.test(line)) break;
677
+ end += 1;
678
+ }
679
+ return [...lines.slice(0, start), ...lines.slice(end)].join("\n");
680
+ }
681
+ //#endregion
481
682
  //#region lib/types/zip.js
482
683
  /**
483
684
  * Minimal, dependency-free ZIP reader: enough to unpack a skill archive
@@ -569,7 +770,6 @@ function findEocd(buf) {
569
770
  * @module @khorsheed/dsh-capability-catalog/import
570
771
  */
571
772
  const execFileAsync = promisify(execFile);
572
- const FRONTMATTER$1 = /^---\n([\s\S]*?)\n---\n([\s\S]*)$/;
573
773
  /** First bytes of a ZIP archive (local file header / empty / spanned). */
574
774
  const ZIP_MAGIC_PK = 80;
575
775
  const ZIP_MAGIC_K = 75;
@@ -579,12 +779,10 @@ function managedRoot(root, dshHome) {
579
779
  }
580
780
  /** Extract the kebab-case `name` and `description` from a SKILL.md body. */
581
781
  function resolveSkillNameFromContent(content) {
582
- const m = FRONTMATTER$1.exec(content);
583
- if (m === null) return void 0;
584
- const front = m[1] ?? "";
585
- const name = /^name:\s*(.+)$/m.exec(front)?.[1]?.trim();
586
- const description = /^description:\s*(.+)$/m.exec(front)?.[1]?.trim();
587
- if (name === void 0 || description === void 0 || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name)) return void 0;
782
+ const parsed = parseSkillFrontmatter(content);
783
+ if (parsed === void 0) return void 0;
784
+ const { name, description } = parsed;
785
+ if (name === void 0 || description === void 0 || !isKebabId(name)) return void 0;
588
786
  return {
589
787
  name,
590
788
  description
@@ -596,10 +794,7 @@ function decodeBase64(payload) {
596
794
  }
597
795
  /** Read the frontmatter `disable-model-invocation` flag (default model-invocable true). */
598
796
  function inferModelInvocable(content) {
599
- const m = FRONTMATTER$1.exec(content);
600
- if (m === null) return true;
601
- const front = m[1] ?? "";
602
- return /^disable-model-invocation:\s*(true|false)$/m.exec(front)?.[1] !== "true";
797
+ return parseSkillFrontmatter(content)?.modelInvocable ?? true;
603
798
  }
604
799
  /** Whether a filesystem path exists (dir or file). */
605
800
  async function pathExists(p) {
@@ -633,13 +828,11 @@ function sanitizeRelPath(p) {
633
828
  }
634
829
  /** Apply the frontmatter `disable-model-invocation` line to match the request. */
635
830
  function finalizeSkillText(content, modelInvocable) {
636
- const m = FRONTMATTER$1.exec(content);
637
- if (m === null) return content;
638
- const front = String(m[1] ?? "");
639
- const body = String(m[2] ?? "");
831
+ const split = splitFrontmatter(content);
832
+ if (split === void 0) return content;
640
833
  if (inferModelInvocable(content) === modelInvocable) return content;
641
- const without = front.split("\n").filter((line) => !line.trim().startsWith("disable-model-invocation:")).join("\n");
642
- return `---\n${modelInvocable ? without : `${without}${without.endsWith("\n") ? "" : "\n"}disable-model-invocation: true`}\n---\n${body}`;
834
+ const without = split.front.split("\n").filter((line) => !line.trim().startsWith("disable-model-invocation:")).join("\n");
835
+ return `---\n${modelInvocable ? without : `${without}${without.endsWith("\n") ? "" : "\n"}disable-model-invocation: true`}\n---\n${split.body}`;
643
836
  }
644
837
  /**
645
838
  * Add a skill from a base64 SKILL.md text payload into the managed root.
@@ -1302,7 +1495,17 @@ function projectTools(schemas, mcpServers, appearedAfterApply, toolOrigins = /*
1302
1495
  */
1303
1496
  async function catalogSnapshot(workdir, registry, toolsSchemas, mcpServers, appearedAfterApply, scopes = [void 0], toolOrigins = /* @__PURE__ */ new Map(), options = {}) {
1304
1497
  const fingerprint = options.fingerprint === true;
1305
- const base = await collectSkills$1(registry, workdir, scopes, { bodySha: fingerprint });
1498
+ const skillsReport = {
1499
+ attemptedScopes: 0,
1500
+ failedScopes: 0,
1501
+ incompleteReads: 0,
1502
+ unreadableBodies: 0
1503
+ };
1504
+ const base = await collectSkills$1(registry, workdir, scopes, {
1505
+ bodySha: fingerprint,
1506
+ report: skillsReport
1507
+ });
1508
+ if (options.report !== void 0) options.report.skills = skillsReport;
1306
1509
  const tools = projectTools(toolsSchemas, mcpServers, appearedAfterApply, toolOrigins);
1307
1510
  const snapshot = {
1308
1511
  skills: base.skills,
@@ -1388,6 +1591,124 @@ async function catalogListDirSkills(dirPath) {
1388
1591
  return listDirSkills(dirPath);
1389
1592
  }
1390
1593
  //#endregion
1594
+ //#region lib/types/tool-origin.js
1595
+ /**
1596
+ * Community tool-origin convention: a host-side tag a plugin attaches to a tool
1597
+ * definition BEFORE `ctx.tools.register`, so the catalog can attribute the tool
1598
+ * to `plugin` (and any channel) instead of relying on heuristics.
1599
+ *
1600
+ * The harness `ToolSchema` carries no source/owner field and the system-prompt
1601
+ * assembly projects tools to `{ name, description, parameters }`, so the tag must
1602
+ * live on the retained `ToolDefinition` and be read via `ctx.tools.get(name)`
1603
+ * (the registry keeps the definition by reference, so the symbol survives).
1604
+ * The key is `Symbol.for('dsh.tool.origin')` (global symbol registry) — a plugin
1605
+ * may tag without importing anything, using `def[Symbol.for('dsh.tool.origin')]`.
1606
+ * `setToolOrigin` is the convenience form exported for the catalog's own plugins.
1607
+ * @module @khorsheed/dsh-capability-catalog/tool-origin
1608
+ */
1609
+ /** The global origin-tag key. `Symbol.for` returns the same symbol everywhere. */
1610
+ const TOOL_ORIGIN = Symbol.for("dsh.tool.origin");
1611
+ /** Convenience: attach an origin tag to a tool definition (before `register`). */
1612
+ function setToolOrigin(definition, origin) {
1613
+ definition[TOOL_ORIGIN] = origin;
1614
+ return definition;
1615
+ }
1616
+ /** Read the origin tag off a tool definition (host-side; never on the model wire). */
1617
+ function toolOrigin(definition) {
1618
+ return definition[TOOL_ORIGIN];
1619
+ }
1620
+ /** Whether an origin's `channel` is one the catalog can honor (plugin/builtin/mcp). */
1621
+ function isValidOrigin(origin) {
1622
+ return origin?.channel === "plugin" || origin?.channel === "builtin" || origin?.channel === "mcp";
1623
+ }
1624
+ //#endregion
1625
+ //#region lib/types/tool-face.js
1626
+ /**
1627
+ * Reading the tools side of the capability face: the visible schemas in a
1628
+ * scope, the author-declared origin tags behind them, and the MCP server
1629
+ * names the `mcp__` prefixes imply.
1630
+ *
1631
+ * Extracted from the service so the Remote verbs and the pure-data evidence
1632
+ * producer (`evidence.ts`) read the tools registry through ONE
1633
+ * implementation — the digest parity between them is by construction, not by
1634
+ * two copies agreeing today. Nothing here catches a `schemas()` failure:
1635
+ * whether a failed read rejects the call (the Remote verbs' choice) or is
1636
+ * recorded as an unavailable section (the evidence producer's choice) is the
1637
+ * caller's policy.
1638
+ * @module @khorsheed/dsh-capability-catalog/tool-face
1639
+ */
1640
+ /**
1641
+ * The visible tool schemas in a scope, or the global layer when no scope is
1642
+ * given; an empty list when the registry is absent.
1643
+ */
1644
+ function toolSchemasFacing(tools, scope) {
1645
+ if (tools?.schemas === void 0) return [];
1646
+ return scope === void 0 ? tools.schemas() : tools.schemas(scope);
1647
+ }
1648
+ /**
1649
+ * The origin tags behind the given schemas, read via `tools.get(name)` so the
1650
+ * `Symbol.for('dsh.tool.origin')` tag on the retained definition survives
1651
+ * (the system-prompt assembly strips it, but `get()` returns the full
1652
+ * definition). A schema the registry cannot hand back contributes no tag —
1653
+ * the tool falls back to heuristics and is named in `untaggedOrigins`.
1654
+ */
1655
+ function toolOriginsFacing(tools, schemas, scope, warn) {
1656
+ const out = /* @__PURE__ */ new Map();
1657
+ const untagged = [];
1658
+ if (tools?.get === void 0) return {
1659
+ origins: out,
1660
+ untaggedOrigins: untagged
1661
+ };
1662
+ for (const schema of schemas) {
1663
+ const def = scope === void 0 ? tools.get(schema.name) : tools.get(schema.name, scope);
1664
+ if (def === void 0) {
1665
+ warn(`capability-catalog: tool "${schema.name}" is visible but ctx.tools.get() is undefined in this scope — its origin tag cannot be read; it will be classified by heuristics`);
1666
+ untagged.push(schema.name);
1667
+ continue;
1668
+ }
1669
+ const origin = toolOrigin(def);
1670
+ if (origin !== void 0) {
1671
+ if (!isValidOrigin(origin)) {
1672
+ warn(`capability-catalog: tool "${schema.name}" origin channel "${String(origin.channel)}" is not a known channel (plugin/builtin/mcp) — ignoring the tag, falling back to heuristics`);
1673
+ untagged.push(schema.name);
1674
+ continue;
1675
+ }
1676
+ out.set(schema.name, origin);
1677
+ }
1678
+ }
1679
+ return {
1680
+ origins: out,
1681
+ untaggedOrigins: untagged
1682
+ };
1683
+ }
1684
+ /** MCP server names from live `mcp__`-prefixed tools (prefix-derived baseline). */
1685
+ function mcpServerNamesFacing(schemas) {
1686
+ const names = [];
1687
+ for (const schema of schemas) {
1688
+ if (!schema.name.startsWith("mcp__")) continue;
1689
+ const rest = schema.name.slice(5);
1690
+ const sep = rest.indexOf("__");
1691
+ const server = sep > 0 ? rest.slice(0, sep) : rest;
1692
+ if (server.length > 0 && !names.includes(server)) names.push(server);
1693
+ }
1694
+ return names;
1695
+ }
1696
+ /**
1697
+ * Collect the whole tools side of one reading position with ONE `schemas()`
1698
+ * call, so the rows, the origin tags and the MCP names cannot disagree about
1699
+ * which registry state they read.
1700
+ */
1701
+ function collectToolFace(tools, scope, warn) {
1702
+ const schemas = toolSchemasFacing(tools, scope);
1703
+ const { origins, untaggedOrigins } = toolOriginsFacing(tools, schemas, scope, warn);
1704
+ return {
1705
+ schemas,
1706
+ origins,
1707
+ mcpServerNames: mcpServerNamesFacing(schemas),
1708
+ untaggedOrigins
1709
+ };
1710
+ }
1711
+ //#endregion
1391
1712
  //#region lib/types/preset-scope.js
1392
1713
  /**
1393
1714
  * Resolving WHICH scope the catalog reads — and the one place where a listing
@@ -1558,106 +1879,37 @@ const MANAGED_SKILL_RANK = 300;
1558
1879
  const SCOPED_SKILLS_DIR = join("capability-catalog", "skills");
1559
1880
  /** Debounce for filesystem-triggered rescans. */
1560
1881
  const RESCAN_DEBOUNCE_MS = 150;
1561
- /** Frontmatter block, then the body it precedes. */
1562
- const FRONTMATTER = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
1563
- /** Kebab-case skill / preset id grammar (the registry's own). */
1564
- const KEBAB_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
1565
1882
  /** The managed root for one harness home. */
1566
1883
  function scopedSkillsRoot(dshHome) {
1567
1884
  return join(dshHome, SCOPED_SKILLS_DIR);
1568
1885
  }
1569
1886
  /**
1570
- * Read the `presetScope` list out of a frontmatter block.
1571
- *
1572
- * Accepted forms, at any indentation (the canonical one is
1573
- * `metadata.presetScope`):
1574
- *
1575
- * ```yaml
1576
- * metadata:
1577
- * presetScope: [dsh-writing, dev]
1578
- * presetScope:
1579
- * - dsh-writing
1580
- * ```
1581
- * @param front - the frontmatter text without its `---` fences.
1582
- * @returns the declared ids, or undefined when the key is absent.
1583
- */
1584
- function parsePresetScopeFrontmatter(front) {
1585
- const lines = front.split("\n");
1586
- for (let index = 0; index < lines.length; index += 1) {
1587
- const match = /^(\s*)presetScope\s*:\s*(.*)$/.exec(lines[index] ?? "");
1588
- if (match === null) continue;
1589
- const indent = (match[1] ?? "").length;
1590
- const rest = (match[2] ?? "").trim();
1591
- if (rest.startsWith("[")) return presetIdsOf(rest.slice(1, rest.lastIndexOf("]") === -1 ? void 0 : rest.lastIndexOf("]")).split(","));
1592
- if (rest !== "") return presetIdsOf([rest]);
1593
- const block = [];
1594
- for (let item = index + 1; item < lines.length; item += 1) {
1595
- const entry = /^(\s*)-\s*(.*?)\s*$/.exec(lines[item] ?? "");
1596
- if (entry === null) break;
1597
- if ((entry[1] ?? "").length <= indent) break;
1598
- block.push(entry[2] ?? "");
1599
- }
1600
- return presetIdsOf(block);
1601
- }
1602
- }
1603
- /** Normalize YAML scalars onto the preset-id grammar. */
1604
- function presetIdsOf(values) {
1605
- const ids = [];
1606
- for (const value of values) {
1607
- const id = value.trim().replace(/^['"]|['"]$/g, "");
1608
- if (id === "" || !KEBAB_ID.test(id)) continue;
1609
- if (!ids.includes(id)) ids.push(id);
1610
- }
1611
- return ids;
1612
- }
1613
- /** Whether a string is a usable skill / preset id. */
1614
- function isKebabId(value) {
1615
- return KEBAB_ID.test(value);
1616
- }
1617
- /** Split a SKILL.md into its frontmatter block and body. */
1618
- function splitFrontmatter(content) {
1619
- const match = FRONTMATTER.exec(content);
1620
- if (match === null) return void 0;
1621
- return {
1622
- front: match[1] ?? "",
1623
- body: match[2] ?? ""
1624
- };
1625
- }
1626
- /** Read a boolean frontmatter flag (`key: true|false`). */
1627
- function frontmatterFlag(front, key) {
1628
- const match = new RegExp(`^\\s*${key}\\s*:\\s*(true|false)\\s*$`, "m").exec(front);
1629
- if (match === null) return void 0;
1630
- return match[1] === "true";
1631
- }
1632
- /** Read a scalar frontmatter value, unquoting a simple quoted or plain form. */
1633
- function frontmatterScalar(front, key) {
1634
- const match = new RegExp(`^\\s*${key}\\s*:\\s*(.+)$`, "m").exec(front);
1635
- if (match === null) return void 0;
1636
- const value = (match[1] ?? "").trim().replace(/^['"]|['"]$/g, "");
1637
- return value === "" ? void 0 : value;
1638
- }
1639
- /**
1640
1887
  * Parse one managed `SKILL.md` into its delivery record.
1888
+ *
1889
+ * The frontmatter is ONE real-YAML parse (see `./frontmatter.ts`): name and
1890
+ * description read top-level only, the invocation flags accept the host
1891
+ * loader's boolean variants, and the scope list comes from the canonical
1892
+ * `metadata.presetScope` (falling back to a legacy top-level key). A malformed
1893
+ * `metadata:` block degrades to no-metadata — the skill still delivers —
1894
+ * rather than rejecting a file the rest of the block could still serve.
1641
1895
  * @param content - the file text.
1642
1896
  * @param dir - the skill's directory (its resource base).
1643
1897
  * @param path - the absolute `SKILL.md` path.
1644
1898
  * @returns the record, or undefined when the frontmatter is unusable.
1645
1899
  */
1646
1900
  function managedSkillFrom(content, dir, path) {
1647
- const split = splitFrontmatter(content);
1648
- if (split === void 0) return void 0;
1649
- const name = frontmatterScalar(split.front, "name");
1650
- const description = frontmatterScalar(split.front, "description");
1651
- if (name === void 0 || description === void 0 || !KEBAB_ID.test(name)) return void 0;
1652
- const whenToUse = frontmatterScalar(split.front, "when-to-use") ?? frontmatterScalar(split.front, "whenToUse");
1653
- const presetScope = parsePresetScopeFrontmatter(split.front);
1901
+ const parsed = parseSkillFrontmatter(content);
1902
+ if (parsed === void 0) return void 0;
1903
+ const { name, description } = parsed;
1904
+ if (name === void 0 || description === void 0 || !isKebabId(name)) return void 0;
1654
1905
  return {
1655
1906
  name,
1656
1907
  description,
1657
- ...whenToUse === void 0 ? {} : { whenToUse },
1658
- modelInvocable: frontmatterFlag(split.front, "disable-model-invocation") !== true,
1659
- userInvocable: frontmatterFlag(split.front, "user-invocable") !== false,
1660
- presetScope,
1908
+ ...parsed.whenToUse === void 0 ? {} : { whenToUse: parsed.whenToUse },
1909
+ modelInvocable: parsed.modelInvocable,
1910
+ userInvocable: parsed.userInvocable,
1911
+ presetScope: parsed.presetScope,
1912
+ ...parsed.metadata === void 0 ? {} : { metadata: parsed.metadata },
1661
1913
  dir,
1662
1914
  path,
1663
1915
  content
@@ -1682,7 +1934,10 @@ function candidateFor(skill) {
1682
1934
  rank: 300,
1683
1935
  locator: skill.path,
1684
1936
  path: skill.path,
1685
- metadata: { presetScope: [...skill.presetScope ?? []] },
1937
+ metadata: {
1938
+ ...skill.metadata,
1939
+ presetScope: [...skill.presetScope ?? []]
1940
+ },
1686
1941
  resourceBase: {
1687
1942
  kind: "directory",
1688
1943
  path: skill.dir
@@ -1738,7 +1993,7 @@ async function scanManagedSkills(root) {
1738
1993
  }
1739
1994
  const found = [];
1740
1995
  for (const child of children) {
1741
- if (!child.isDirectory() || !KEBAB_ID.test(child.name)) continue;
1996
+ if (!child.isDirectory() || !isKebabId(child.name)) continue;
1742
1997
  const dir = join(root, child.name);
1743
1998
  const path = join(dir, "SKILL.md");
1744
1999
  try {
@@ -1775,6 +2030,9 @@ var ScopedSkillDelivery = class {
1775
2030
  failure;
1776
2031
  started = false;
1777
2032
  disposed = false;
2033
+ /** Reconcile gate: one pass in flight, one queued rerun (see reconcile). */
2034
+ reconcileInFlight;
2035
+ reconcileQueued = false;
1778
2036
  /** The probed registry's mount list (undefined until start()'s probe resolves). */
1779
2037
  presetMounts;
1780
2038
  /**
@@ -1797,8 +2055,46 @@ var ScopedSkillDelivery = class {
1797
2055
  });
1798
2056
  await this.reconcile();
1799
2057
  }
1800
- /** Re-read policy and files, then converge the delivery registrations. */
2058
+ /**
2059
+ * Re-read policy and files, then converge the delivery registrations.
2060
+ *
2061
+ * Triggers arrive concurrently — the filesystem watcher, the
2062
+ * `agent-preset/selected` event, and the management Remotes — and two
2063
+ * overlapping passes can both decide a preset has no registration and each
2064
+ * register one, leaving two same-named providers serving one preset. The
2065
+ * gate allows ONE pass in flight and coalesces every trigger that arrives
2066
+ * during it into ONE queued rerun (the pass re-reads policy and files from
2067
+ * scratch, so a rerun loses no update). A caller that finds a pass in
2068
+ * flight waits for the chain its flag provoked, so an awaited reconcile
2069
+ * still reflects the write that triggered it.
2070
+ */
1801
2071
  async reconcile() {
2072
+ if (this.disposed) return;
2073
+ if (this.reconcileInFlight !== void 0) {
2074
+ this.reconcileQueued = true;
2075
+ let run = this.reconcileInFlight;
2076
+ while (run !== void 0) {
2077
+ await run;
2078
+ if (this.disposed) return;
2079
+ run = this.reconcileInFlight;
2080
+ }
2081
+ return;
2082
+ }
2083
+ const run = (async () => {
2084
+ try {
2085
+ do {
2086
+ this.reconcileQueued = false;
2087
+ await this.reconcileNow();
2088
+ } while (this.reconcileQueued && !this.disposed);
2089
+ } finally {
2090
+ this.reconcileInFlight = void 0;
2091
+ }
2092
+ })();
2093
+ this.reconcileInFlight = run;
2094
+ return run;
2095
+ }
2096
+ /** One reconcile pass (call only through {@link reconcile}'s gate). */
2097
+ async reconcileNow() {
1802
2098
  if (this.disposed) return;
1803
2099
  const registry = this.deps.registry();
1804
2100
  const roster = this.deps.roster();
@@ -1985,6 +2281,7 @@ var ScopedSkillDelivery = class {
1985
2281
  invalidate = control.invalidate;
1986
2282
  return this.providerFor(presetId, control);
1987
2283
  });
2284
+ ((await injectableScope(injected, TOOLS_SERVICE))?.get(TOOLS_SERVICE))?.register?.(this.bundleFileTool(presetId));
1988
2285
  this.entries.push({
1989
2286
  presetId,
1990
2287
  key,
@@ -2039,6 +2336,62 @@ var ScopedSkillDelivery = class {
2039
2336
  skillsFor(presetId) {
2040
2337
  return this.managed.filter((skill) => !this.conflicts.has(skill.name) && (presetId === GLOBAL_PRESET ? !isScoped(skill) : (skill.presetScope ?? []).includes(presetId)));
2041
2338
  }
2339
+ /** The managed skills readable through one preset's reader: its own scoped
2340
+ * delivery plus the unscoped ones every preset sees through the global layer. */
2341
+ readableFor(presetId) {
2342
+ return [...this.skillsFor(presetId), ...this.skillsFor(GLOBAL_PRESET)];
2343
+ }
2344
+ /** The read-only bundle-file reader served with one preset's delivery. */
2345
+ bundleFileTool(presetId) {
2346
+ return setToolOrigin(defineTool({
2347
+ name: READ_SKILL_FILE_TOOL,
2348
+ description: "Read a file from the bundle of a skill delivered to this mode — the reference documents and scripts beside its SKILL.md (a SKILL.md may point at them, e.g. \"see search.md\"). Omit path to list the bundle files.",
2349
+ parameters: {
2350
+ name: {
2351
+ type: "string",
2352
+ required: true,
2353
+ description: "The exact skill name from the available skills list."
2354
+ },
2355
+ path: {
2356
+ type: "string",
2357
+ description: "The bundle-relative file path (e.g. search.md). Omit it to list the bundle files."
2358
+ }
2359
+ },
2360
+ output: {
2361
+ schema: { type: "string" },
2362
+ render: (_args, value) => [{
2363
+ type: "text",
2364
+ text: String(value)
2365
+ }]
2366
+ },
2367
+ execute: async (args) => this.readBundleFile(presetId, args.name, args.path)
2368
+ }), {
2369
+ channel: "plugin",
2370
+ owner: "@khorsheed/dsh-capability-catalog"
2371
+ });
2372
+ }
2373
+ /** Serve one bundle file (or the bundle's file list) to a delivered skill's caller. */
2374
+ async readBundleFile(presetId, name, filePath) {
2375
+ const skill = this.readableFor(presetId).find((entry) => entry.name === name);
2376
+ if (skill === void 0) return `skill "${name}" is not delivered to this mode by the capability catalog`;
2377
+ if (filePath === void 0 || filePath.trim() === "") {
2378
+ const files = [];
2379
+ await collectSkillFiles(skill.dir, skill.dir, files, 0);
2380
+ return files.length === 0 ? `skill "${name}" has no bundle files` : files.sort().join("\n");
2381
+ }
2382
+ const safe = sanitizeBundlePath(filePath);
2383
+ if (safe === void 0) return `refused: "${filePath}" is not a bundle-relative path`;
2384
+ const target = await resolveBundleTarget(skill.dir, safe);
2385
+ if (target === void 0) return `no such bundle file: ${safe}`;
2386
+ let content;
2387
+ try {
2388
+ content = await readFile(target, "utf8");
2389
+ } catch {
2390
+ return `no such bundle file: ${safe}`;
2391
+ }
2392
+ if (content.length > MAX_BUNDLE_FILE_CHARS) return `${content.slice(0, MAX_BUNDLE_FILE_CHARS)}\n…(truncated at ${MAX_BUNDLE_FILE_CHARS} characters)`;
2393
+ return content;
2394
+ }
2042
2395
  /** Invalidate every registration's completed catalogs. */
2043
2396
  invalidateAll() {
2044
2397
  for (const entry of this.entries) entry.invalidate();
@@ -2069,6 +2422,12 @@ function describeError(error) {
2069
2422
  }
2070
2423
  /** The registry every delivery registration is made through. */
2071
2424
  const SKILLS_SERVICE = "skills";
2425
+ /** The tools registry the bundle-file reader registers through (optional). */
2426
+ const TOOLS_SERVICE = "tools";
2427
+ /** The scoped bundle-file reader's tool name. */
2428
+ const READ_SKILL_FILE_TOOL = "read_skill_file";
2429
+ /** A bundle file's served text is capped so a giant asset cannot flood a turn. */
2430
+ const MAX_BUNDLE_FILE_CHARS = 1e5;
2072
2431
  /** How long an injection may take before the registration is abandoned. */
2073
2432
  const INJECT_TIMEOUT_MS = 500;
2074
2433
  /**
@@ -2079,9 +2438,10 @@ const INJECT_TIMEOUT_MS = 500;
2079
2438
  * await, which gives `reconcile()` a settled answer instead of a registration
2080
2439
  * that appears at some later tick.
2081
2440
  * @param scopeCtx - the context minted for one preset's standing key.
2441
+ * @param service - the service the injected context must reach.
2082
2442
  * @returns the injected context, or undefined when it never became available.
2083
2443
  */
2084
- function injectableScope(scopeCtx) {
2444
+ function injectableScope(scopeCtx, service = SKILLS_SERVICE) {
2085
2445
  return new Promise((resolve) => {
2086
2446
  let settled = false;
2087
2447
  const finish = (value) => {
@@ -2091,7 +2451,7 @@ function injectableScope(scopeCtx) {
2091
2451
  resolve(value);
2092
2452
  };
2093
2453
  const timer = setTimeout(() => finish(void 0), INJECT_TIMEOUT_MS);
2094
- scopeCtx.inject([SKILLS_SERVICE], (injected) => finish(injected));
2454
+ scopeCtx.inject([service], (injected) => finish(injected));
2095
2455
  });
2096
2456
  }
2097
2457
  //#endregion
@@ -2352,6 +2712,13 @@ function messageOf(error) {
2352
2712
  * model's context; it only becomes visible if the model (or a command) actively
2353
2713
  * prints it — a default-hide, not a hard secret boundary.
2354
2714
  *
2715
+ * Coverage: the registry snapshot is read at the catalog's preset scope, which
2716
+ * a preset-scoped managed skill never appears in — so the managed root is ALSO
2717
+ * scanned directly (its skills' declared keys join the same set). The injected
2718
+ * variables are instance-wide per execution: preset scope is a delivery policy,
2719
+ * not a secret boundary, and a value leaves the shell env only if a command
2720
+ * prints it.
2721
+ *
2355
2722
  * Constraint handling (shell-env/src/index.ts):
2356
2723
  * - keys must start `DSH_` and match `/^[A-Z][A-Z0-9_]*$/`; one owner per key;
2357
2724
  * reserved built-ins (`DSH_HOME`/`DSH_SHELL`/`DSH_SESSION_ID`) are not ownable.
@@ -2402,7 +2769,7 @@ const RESERVED_ENV_KEYS = /* @__PURE__ */ new Set([
2402
2769
  * the impl only while its fiber is active) immediately before calling `register`,
2403
2770
  * and self-retries on `INACTIVE_EFFECT` so it self-heals once boot stabilizes.
2404
2771
  */
2405
- function installSkillEnvInjection(ctx, getScope) {
2772
+ function installSkillEnvInjection(ctx, getScope, getDshHome) {
2406
2773
  let disposed = false;
2407
2774
  let envCache = /* @__PURE__ */ new Map();
2408
2775
  let disposeEnv;
@@ -2458,15 +2825,8 @@ function installSkillEnvInjection(ctx, getScope) {
2458
2825
  const occupied = new Set((shellEnv.list?.() ?? []).filter((e) => e.contributor !== "capability-catalog").map((e) => e.key));
2459
2826
  const keys = /* @__PURE__ */ new Set();
2460
2827
  const values = /* @__PURE__ */ new Map();
2461
- for (const row of snapshot.skills) {
2462
- if (row.invocation?.modelInvocable === false) continue;
2463
- let def;
2464
- try {
2465
- def = await skills.get(row.name, snapshotOptions);
2466
- } catch {
2467
- continue;
2468
- }
2469
- if (def === void 0) continue;
2828
+ /** Fold one definition's declared env keys into the contributor's set. */
2829
+ const collectDecls = async (def) => {
2470
2830
  const decls = mergeCredentialDecls(decodeCredentialDecls(def.metadata), decodeEnvDecls(def.content ?? ""));
2471
2831
  for (const decl of decls) {
2472
2832
  if (!ENV_KEY_SUFFIX.test(decl.key)) continue;
@@ -2476,6 +2836,21 @@ function installSkillEnvInjection(ctx, getScope) {
2476
2836
  const resolved = await credentials.resolve(decl.key).catch(() => void 0);
2477
2837
  if (resolved !== void 0 && resolved.value.length > 0) values.set(dshKey, resolved.value);
2478
2838
  }
2839
+ };
2840
+ for (const row of snapshot.skills) {
2841
+ if (row.invocation?.modelInvocable === false) continue;
2842
+ let def;
2843
+ try {
2844
+ def = await skills.get(row.name, snapshotOptions);
2845
+ } catch {
2846
+ continue;
2847
+ }
2848
+ if (def === void 0) continue;
2849
+ await collectDecls(def);
2850
+ }
2851
+ for (const skill of await scanManagedSkills(scopedSkillsRoot(getDshHome())).catch(() => [])) {
2852
+ if (!skill.modelInvocable) continue;
2853
+ await collectDecls(definitionFor(skill));
2479
2854
  }
2480
2855
  if (disposed || gen !== refreshGen) return;
2481
2856
  envCache = values;
@@ -2618,8 +2993,7 @@ var McpStore = class {
2618
2993
  this.servers.set(server.config.serverName, {
2619
2994
  config: server.config,
2620
2995
  credentials: existing?.credentials ?? /* @__PURE__ */ new Map(),
2621
- tools,
2622
- error: void 0
2996
+ tools
2623
2997
  });
2624
2998
  }
2625
2999
  for (const [ref, value] of Object.entries(state.credentials ?? {})) {
@@ -2674,8 +3048,7 @@ var McpStore = class {
2674
3048
  this.servers.set(config.serverName, {
2675
3049
  config,
2676
3050
  credentials: existing?.credentials ?? /* @__PURE__ */ new Map(),
2677
- tools: existing?.tools ?? [],
2678
- error: existing?.error
3051
+ tools: existing?.tools ?? []
2679
3052
  });
2680
3053
  this.persist();
2681
3054
  }
@@ -2717,51 +3090,38 @@ var McpStore = class {
2717
3090
  if (s === void 0) return [];
2718
3091
  try {
2719
3092
  const { connectMcpServer } = await import("./mcpConnector-C2TbkPBY.js");
2720
- const connection = await connectMcpServer(await resolveConfigSecrets(s.config, async (ref) => {
2721
- const candidates = [
2722
- `mcp.${serverName}.env.${ref}`,
2723
- `mcp.${serverName}.header.${ref}`,
2724
- `mcp.${serverName}.query.${ref}`
2725
- ];
2726
- for (const candidate of candidates) {
2727
- const value = s.credentials.get(candidate);
2728
- if (value !== void 0) return value;
2729
- }
2730
- }));
3093
+ const connection = await connectMcpServer(await resolveConfigSecrets(s.config, this.secretResolver(serverName, s)));
2731
3094
  const prevEnabled = new Map(s.tools.map((t) => [t.name, t.enabled]));
2732
3095
  s.tools = connection.tools.map((t) => ({
2733
3096
  ...t,
2734
3097
  enabled: prevEnabled.get(t.name) ?? true
2735
3098
  }));
2736
- s.error = void 0;
2737
3099
  await connection.close();
2738
3100
  this.persist();
2739
3101
  return s.tools;
2740
- } catch (error) {
2741
- s.error = String(error);
3102
+ } catch {
2742
3103
  this.persist();
2743
3104
  return s.tools;
2744
3105
  }
2745
3106
  }
2746
- /** Re-connect + re-discover every enabled server — boot-time refill of the
2747
- * discovered descriptions/parameters (the persisted block stores only
2748
- * name+enabled). Preserves per-tool enable flags; per-server errors are
2749
- * recorded and do not stop the others. */
2750
- async reconnectAll() {
2751
- for (const server of this.list()) {
2752
- if (!server.enabled) continue;
2753
- await this.discover(server.serverName);
2754
- }
2755
- }
2756
- /** A server's most recent connection/dev error. */
2757
- errorOf(serverName) {
2758
- return this.servers.get(serverName)?.error;
2759
- }
2760
3107
  /** Forward one tool call to a server (connect → call → close). Resolves secrets. */
2761
3108
  async callTool(serverName, rawName, args) {
2762
3109
  const s = this.servers.get(serverName);
2763
3110
  if (s === void 0) throw new Error(`unknown MCP server: ${serverName}`);
2764
- const resolved = await resolveConfigSecrets(s.config, async (ref) => {
3111
+ const resolved = await resolveConfigSecrets(s.config, this.secretResolver(serverName, s));
3112
+ const { connectMcpServer } = await import("./mcpConnector-C2TbkPBY.js");
3113
+ const connection = await connectMcpServer(resolved);
3114
+ try {
3115
+ return await connection.call(rawName, args);
3116
+ } finally {
3117
+ await connection.close();
3118
+ }
3119
+ }
3120
+ /** The `resolveConfigSecrets` callback for one server: a bare secret ref
3121
+ * resolves to the stored credential under the server's env/header/query
3122
+ * namespace (the same namespaces {@link credentialRefsOf} writes). */
3123
+ secretResolver(serverName, s) {
3124
+ return async (ref) => {
2765
3125
  const candidates = [
2766
3126
  `mcp.${serverName}.env.${ref}`,
2767
3127
  `mcp.${serverName}.header.${ref}`,
@@ -2771,14 +3131,7 @@ var McpStore = class {
2771
3131
  const value = s.credentials.get(candidate);
2772
3132
  if (value !== void 0) return value;
2773
3133
  }
2774
- });
2775
- const { connectMcpServer } = await import("./mcpConnector-C2TbkPBY.js");
2776
- const connection = await connectMcpServer(resolved);
2777
- try {
2778
- return await connection.call(rawName, args);
2779
- } finally {
2780
- await connection.close();
2781
- }
3134
+ };
2782
3135
  }
2783
3136
  /** Extract credential reference paths from a config (env/header/url-query).
2784
3137
  * Namespaced `mcp.<serverName>.env.<KEY>` so multiple servers don't collide. */
@@ -2928,7 +3281,8 @@ function desiredMcpTools(servers, toolsByServer) {
2928
3281
  //#region lib/types/envHint.js
2929
3282
  /**
2930
3283
  * Skill env hint via `tools/post-execute` — a runtime companion context that
2931
- * tells the model about any skill's configured credential env mappings.
3284
+ * tells the model about any skill's configured credential env mappings, and a
3285
+ * scoped-delivered skill's bundle files.
2932
3286
  *
2933
3287
  * The catalog injects credentials as trusted per-execution `DSH_<KEY>` env
2934
3288
  * vars (see shellEnv.ts), but a skill's own text references the original
@@ -2937,6 +3291,11 @@ function desiredMcpTools(servers, toolsByServer) {
2937
3291
  * a generic, per-skill note listing each configured mapping, so the model can
2938
3292
  * use the value without us modifying the user's SKILL.md.
2939
3293
  *
3294
+ * A second block covers scoped-delivered skills (provider `capability-catalog`):
3295
+ * their SKILL.md may reference bundle files (search.md etc.) in a mode that
3296
+ * mounts no fs tools, so the note lists those files and names the scoped
3297
+ * `read_skill_file` reader — only when the caller's own scope carries it.
3298
+ *
2940
3299
  * Generic: it derives the mappings from the loaded skill's own env-decl keys
2941
3300
  * (decodeEnvDecls) + the credential store, so any future key / skill is
2942
3301
  * covered. It never touches the skill file or the provider's returned content.
@@ -2952,9 +3311,9 @@ function installSkillEnvHint(ctx, getScope) {
2952
3311
  if (exec?.name !== "skill") return next();
2953
3312
  const skillName = exec.arguments?.name;
2954
3313
  if (typeof skillName !== "string" || skillName.length === 0) return next();
2955
- const scope = await getScope().catch(() => void 0);
3314
+ const scope = exec.agent ?? await getScope().catch(() => void 0);
2956
3315
  const { registry, credentials } = resolveServices(ctx);
2957
- if (registry === void 0 || credentials === void 0) return next();
3316
+ if (registry === void 0) return next();
2958
3317
  const lookup = scope === void 0 ? {} : { scope };
2959
3318
  let def;
2960
3319
  try {
@@ -2963,15 +3322,27 @@ function installSkillEnvHint(ctx, getScope) {
2963
3322
  return next();
2964
3323
  }
2965
3324
  if (def === void 0) return next();
2966
- const decls = mergeCredentialDecls(decodeCredentialDecls(def.metadata), decodeEnvDecls(def.content ?? ""));
2967
- const configured = [];
2968
- for (const decl of decls) if ((await credentials.describe(decl.key).catch(() => void 0))?.configured === true) configured.push(decl.key);
2969
- if (configured.length === 0) return next();
2970
- const hint = [
2971
- `Runtime credential mapping for ${skillName}:`,
2972
- ...configured.map((k) => `- ${k} is configured; in the model shell it is available as ${DSH_PREFIX}${k}. Use it as ${k}="${DSH_PREFIX}${k}" <command>.`),
2973
- `Do not search dotenv files or ask the user for this credential, and do not print either variable.`
2974
- ].join("\n");
3325
+ const blocks = [];
3326
+ if (credentials !== void 0) {
3327
+ const decls = mergeCredentialDecls(decodeCredentialDecls(def.metadata), decodeEnvDecls(def.content ?? ""));
3328
+ const configured = [];
3329
+ for (const decl of decls) if ((await credentials.describe(decl.key).catch(() => void 0))?.configured === true) configured.push(decl.key);
3330
+ if (configured.length > 0) blocks.push([
3331
+ `Runtime credential mapping for ${skillName}:`,
3332
+ ...configured.map((k) => `- ${k} is configured; in the model shell it is available as ${DSH_PREFIX}${k}. Use it as ${k}="${DSH_PREFIX}${k}" <command>.`),
3333
+ `Do not search dotenv files or ask the user for this credential, and do not print either variable.`
3334
+ ].join("\n"));
3335
+ }
3336
+ const bundleDir = def.resourceBase?.kind === "directory" ? def.resourceBase.path : void 0;
3337
+ if (def.provider === "capability-catalog" && bundleDir !== void 0 && exec.agent !== void 0) {
3338
+ if ((ctx.get?.("tools"))?.schemas?.(exec.agent)?.some((schema) => schema.name === "read_skill_file") === true) {
3339
+ const files = [];
3340
+ await collectSkillFiles(bundleDir, bundleDir, files, 0);
3341
+ const rest = files.filter((file) => file !== "SKILL.md").sort();
3342
+ if (rest.length > 0) blocks.push([`Bundle files for ${skillName}: ${rest.join(", ")}`, `Read them with ${READ_SKILL_FILE_TOOL} (name: "${skillName}", path: "<file>") — this mode may have no fs tools.`].join("\n"));
3343
+ }
3344
+ }
3345
+ if (blocks.length === 0) return next();
2975
3346
  return {
2976
3347
  kind: "accept",
2977
3348
  additionalContexts: [{
@@ -2979,7 +3350,7 @@ function installSkillEnvHint(ctx, getScope) {
2979
3350
  role: "user",
2980
3351
  content: [{
2981
3352
  type: "text",
2982
- text: hint
3353
+ text: blocks.join("\n\n")
2983
3354
  }],
2984
3355
  source: {
2985
3356
  kind: "capability-catalog",
@@ -2992,43 +3363,11 @@ function installSkillEnvHint(ctx, getScope) {
2992
3363
  });
2993
3364
  }
2994
3365
  //#endregion
2995
- //#region lib/types/tool-origin.js
2996
- /**
2997
- * Community tool-origin convention: a host-side tag a plugin attaches to a tool
2998
- * definition BEFORE `ctx.tools.register`, so the catalog can attribute the tool
2999
- * to `plugin` (and any channel) instead of relying on heuristics.
3000
- *
3001
- * The harness `ToolSchema` carries no source/owner field and the system-prompt
3002
- * assembly projects tools to `{ name, description, parameters }`, so the tag must
3003
- * live on the retained `ToolDefinition` and be read via `ctx.tools.get(name)`
3004
- * (the registry keeps the definition by reference, so the symbol survives).
3005
- * The key is `Symbol.for('dsh.tool.origin')` (global symbol registry) — a plugin
3006
- * may tag without importing anything, using `def[Symbol.for('dsh.tool.origin')]`.
3007
- * `setToolOrigin` is the convenience form exported for the catalog's own plugins.
3008
- * @module @khorsheed/dsh-capability-catalog/tool-origin
3009
- */
3010
- /** The global origin-tag key. `Symbol.for` returns the same symbol everywhere. */
3011
- const TOOL_ORIGIN = Symbol.for("dsh.tool.origin");
3012
- /** Convenience: attach an origin tag to a tool definition (before `register`). */
3013
- function setToolOrigin(definition, origin) {
3014
- definition[TOOL_ORIGIN] = origin;
3015
- return definition;
3016
- }
3017
- /** Read the origin tag off a tool definition (host-side; never on the model wire). */
3018
- function toolOrigin(definition) {
3019
- return definition[TOOL_ORIGIN];
3020
- }
3021
- /** Whether an origin's `channel` is one the catalog can honor (plugin/builtin/mcp). */
3022
- function isValidOrigin(origin) {
3023
- return origin?.channel === "plugin" || origin?.channel === "builtin" || origin?.channel === "mcp";
3024
- }
3025
- //#endregion
3026
3366
  //#region lib/types/namespace.js
3027
3367
  /**
3028
3368
  * The settings namespace owned by the capability-catalog plugin. Plain string
3029
- * so both the host half (settings registration) and the browser half (the
3030
- * settings.plugin.item card key) import it without dragging host-only
3031
- * dependencies into the client bundle.
3369
+ * so both the host half (settings registration) and the browser half import
3370
+ * it without dragging host-only dependencies into the client bundle.
3032
3371
  */
3033
3372
  /** Settings namespace owned by the capability-catalog plugin. */
3034
3373
  const CAPABILITY_CATALOG_NS = "capability-catalog";
@@ -3144,6 +3483,191 @@ async function readModeFaces(options, readFace) {
3144
3483
  return faces;
3145
3484
  }
3146
3485
  //#endregion
3486
+ //#region lib/types/evidence.js
3487
+ /**
3488
+ * The PRODUCER side of capability-composition evidence: pure data, computed
3489
+ * where the composition runs, for a consumer in another process to verify.
3490
+ *
3491
+ * {@link produceCapabilityEvidence} answers "what is the capability face of
3492
+ * THIS composition, and how completely was it measured?" — the composition
3493
+ * digest plus a per-section completeness descriptor, the measurement object
3494
+ * (what claims to have been measured), and the caller's coverage declaration
3495
+ * (what directories/services were read). It reuses this package's own
3496
+ * snapshot and digest algorithms (`catalogSnapshot`, `hashOf` — via
3497
+ * `remote.ts` and `capabilities.ts`), so the digest it reports is the digest
3498
+ * `snapshotFor` would stamp over the same registries: parity is by
3499
+ * construction, and a test pins it.
3500
+ *
3501
+ * Two deliberate absences, both by design:
3502
+ *
3503
+ * - **No standing-scope mount.** The function takes no roster and no cordis
3504
+ * Context, so it CANNOT resolve a preset's standing scope — resolving one
3505
+ * mounts the preset (see `preset-scope.ts`), and a target-side producer
3506
+ * must not pay that side effect to measure. The normal target-side
3507
+ * topology needs no scope at all: the producer's own registries already
3508
+ * ARE the composition's face. A caller holding an already-resolved scope
3509
+ * may pass it as `scope`; resolution and release stay the caller's
3510
+ * business.
3511
+ * - **No service registration.** Nothing is mounted, provided or injected as
3512
+ * a side effect; the function reads the slices it is handed and returns
3513
+ * plain JSON-safe data. It runs in a bare process (node builtins and this
3514
+ * package's existing dependencies only).
3515
+ *
3516
+ * The digest algorithm is owned by THIS package. A consumer MUST NOT
3517
+ * recompute it from the rows, and MUST treat a `partial` or `unavailable`
3518
+ * section as "not verified" — never as complete, and never as "the section
3519
+ * is empty". An empty-but-complete section (scanned, zero found) and an
3520
+ * unmeasured section produce distinguishable descriptors, which is the
3521
+ * point: an empty digest is not a complete one unless every section says so.
3522
+ * @module @khorsheed/dsh-capability-catalog/evidence
3523
+ */
3524
+ /** The registry that measures as "no skills", for a composition without a skills service. */
3525
+ const EMPTY_REGISTRY = {
3526
+ snapshot: async () => ({
3527
+ skills: [],
3528
+ complete: true
3529
+ }),
3530
+ get: async () => void 0
3531
+ };
3532
+ /**
3533
+ * Wrap the caller's tools slice so a `get()` that throws degrades to an
3534
+ * unreadable origin tag (a partial section) instead of failing the whole
3535
+ * measurement. Member-call semantics are preserved — the slice may be a
3536
+ * traceable proxy whose methods need their receiver.
3537
+ */
3538
+ function guardToolReads(tools) {
3539
+ if (tools.get === void 0) return tools;
3540
+ const get = tools.get;
3541
+ return {
3542
+ schemas: (scope) => tools.schemas(scope),
3543
+ get: (name, scope) => {
3544
+ try {
3545
+ return get.call(tools, name, scope);
3546
+ } catch {
3547
+ return;
3548
+ }
3549
+ }
3550
+ };
3551
+ }
3552
+ /** The skills section's descriptor, from the collection's own report. */
3553
+ function skillsCompleteness(skills, report) {
3554
+ if (skills === void 0) return {
3555
+ state: "unavailable",
3556
+ reason: "the skills service is not installed"
3557
+ };
3558
+ if (report === void 0 || report.attemptedScopes === 0) return {
3559
+ state: "unavailable",
3560
+ reason: "no skill scope was read"
3561
+ };
3562
+ if (report.failedScopes === report.attemptedScopes) return {
3563
+ state: "unavailable",
3564
+ reason: `the skill registry read failed for every scope (${report.attemptedScopes} attempted)`
3565
+ };
3566
+ const problems = [];
3567
+ if (report.failedScopes > 0) problems.push(`the skill registry read failed for ${report.failedScopes} of ${report.attemptedScopes} scopes`);
3568
+ if (report.incompleteReads > 0) problems.push("the registry reported incomplete skill discovery");
3569
+ if (report.unreadableBodies > 0) problems.push(`${report.unreadableBodies} skill bodies could not be read`);
3570
+ return problems.length === 0 ? { state: "complete" } : {
3571
+ state: "partial",
3572
+ reason: problems.join("; ")
3573
+ };
3574
+ }
3575
+ /** The tools section's descriptor, from how the tools-side read went. */
3576
+ function toolsCompleteness(tools, readFailed, face) {
3577
+ if (tools === void 0) return {
3578
+ state: "unavailable",
3579
+ reason: "the tools service is not installed"
3580
+ };
3581
+ if (readFailed || face === void 0) return {
3582
+ state: "unavailable",
3583
+ reason: "the tool registry read failed"
3584
+ };
3585
+ if (face.untaggedOrigins.length > 0) return {
3586
+ state: "partial",
3587
+ reason: `origin tags unreadable for ${face.untaggedOrigins.length} visible tools — channel attribution fell back to heuristics`
3588
+ };
3589
+ return { state: "complete" };
3590
+ }
3591
+ /** The MCP section is derived from the tools section, so it inherits its failures. */
3592
+ function mcpCompleteness(tools) {
3593
+ if (tools.state === "unavailable") return {
3594
+ state: "unavailable",
3595
+ reason: "MCP servers are derived from the tools section, which is unavailable"
3596
+ };
3597
+ if (tools.state === "partial") return {
3598
+ state: "partial",
3599
+ reason: "derived from a partially measured tools section"
3600
+ };
3601
+ return { state: "complete" };
3602
+ }
3603
+ /** The channel summary is derived from the skills and tools rows, so it is only as strong as they are. */
3604
+ function channelsCompleteness(skills, tools) {
3605
+ if (skills.state === "unavailable" && tools.state === "unavailable") return {
3606
+ state: "unavailable",
3607
+ reason: "neither the skills nor the tools section could be measured"
3608
+ };
3609
+ if (skills.state !== "complete" || tools.state !== "complete") return {
3610
+ state: "partial",
3611
+ reason: `summarizes sections that are not fully measured (skills: ${skills.state}, tools: ${tools.state})`
3612
+ };
3613
+ return { state: "complete" };
3614
+ }
3615
+ /**
3616
+ * Produce the capability-composition evidence of the composition THIS
3617
+ * process runs, as plain data another process can verify.
3618
+ *
3619
+ * The measurement reuses the package's own algorithms end to end: the tools
3620
+ * side is read by `tool-face.ts` (the same reads the Remote verbs make), the
3621
+ * snapshot is `catalogSnapshot` in fingerprint mode, and the digest is its
3622
+ * stamped `sha` — nothing is re-implemented, so the answer is comparable
3623
+ * with a `snapshotFor` digest taken over the same registries.
3624
+ * @param options - every input, explicit; see {@link ProduceCapabilityEvidenceOptions}.
3625
+ * @returns the evidence bundle — digest, completeness, measurement, coverage.
3626
+ * @throws when `workdir` is given but not absolute, when `presetId` is given
3627
+ * but blank, or when the coverage declaration is missing — the caller
3628
+ * verifies identity and coverage; this function only measures.
3629
+ */
3630
+ async function produceCapabilityEvidence(options) {
3631
+ if (options.workdir !== void 0 && !isAbsolute(options.workdir)) throw new Error(`produceCapabilityEvidence needs an absolute workdir (caller-verified); got ${JSON.stringify(options.workdir)}`);
3632
+ if (options.presetId !== void 0 && options.presetId.trim() === "") throw new Error(`produceCapabilityEvidence needs a non-empty presetId when one is given; got ${JSON.stringify(options.presetId)}`);
3633
+ if (options.coverage === void 0 || options.coverage === null) throw new Error("produceCapabilityEvidence needs the caller's coverage declaration (what directories/services were measured)");
3634
+ const warn = options.warn ?? (() => {});
3635
+ let toolFace;
3636
+ let toolsReadFailed = false;
3637
+ if (options.tools !== void 0) try {
3638
+ toolFace = collectToolFace(guardToolReads(options.tools), options.scope, warn);
3639
+ } catch {
3640
+ toolsReadFailed = true;
3641
+ }
3642
+ const report = {};
3643
+ const snapshot = await catalogSnapshot(options.workdir, options.skills ?? EMPTY_REGISTRY, toolFace?.schemas ?? [], toolFace?.mcpServerNames ?? [], options.appearedAfterApply ?? /* @__PURE__ */ new Set(), [options.scope], toolFace?.origins ?? /* @__PURE__ */ new Map(), {
3644
+ fingerprint: true,
3645
+ report,
3646
+ ...options.presetId !== void 0 ? { preset: options.presetId } : {}
3647
+ });
3648
+ const skills = skillsCompleteness(options.skills, report.skills);
3649
+ const tools = toolsCompleteness(options.tools, toolsReadFailed, toolFace);
3650
+ return {
3651
+ measurement: {
3652
+ kind: "preset-composition",
3653
+ ...options.presetId !== void 0 ? { presetId: options.presetId } : {},
3654
+ ...options.workdir !== void 0 ? { workdir: options.workdir } : {},
3655
+ ...options.revision !== void 0 ? { revision: options.revision } : {}
3656
+ },
3657
+ sha: snapshot.sha,
3658
+ completeness: {
3659
+ skills,
3660
+ tools,
3661
+ mcpServers: mcpCompleteness(tools),
3662
+ channels: channelsCompleteness(skills, tools)
3663
+ },
3664
+ coverage: {
3665
+ ...options.coverage.directories !== void 0 ? { directories: [...options.coverage.directories] } : {},
3666
+ ...options.coverage.services !== void 0 ? { services: [...options.coverage.services] } : {}
3667
+ }
3668
+ };
3669
+ }
3670
+ //#endregion
3147
3671
  //#region lib/types/index.js
3148
3672
  /**
3149
3673
  * Capability catalog host service: a Typert Remote over the running skill and
@@ -3227,7 +3751,6 @@ let CapabilityCatalogService = (() => {
3227
3751
  let _addSkill_decorators;
3228
3752
  let _deleteSkill_decorators;
3229
3753
  let _pickDirectory_decorators;
3230
- let _mcpList_decorators;
3231
3754
  let _mcpAdd_decorators;
3232
3755
  let _mcpRemove_decorators;
3233
3756
  let _mcpSetEnabled_decorators;
@@ -3254,7 +3777,6 @@ let CapabilityCatalogService = (() => {
3254
3777
  _addSkill_decorators = [Remote("addSkill")];
3255
3778
  _deleteSkill_decorators = [Remote("deleteSkill")];
3256
3779
  _pickDirectory_decorators = [Remote("pickDirectory")];
3257
- _mcpList_decorators = [Remote("mcpList")];
3258
3780
  _mcpAdd_decorators = [Remote("mcpAdd")];
3259
3781
  _mcpRemove_decorators = [Remote("mcpRemove")];
3260
3782
  _mcpSetEnabled_decorators = [Remote("mcpSetEnabled")];
@@ -3438,17 +3960,6 @@ let CapabilityCatalogService = (() => {
3438
3960
  },
3439
3961
  metadata: _metadata
3440
3962
  }, null, _instanceExtraInitializers);
3441
- __esDecorate(this, null, _mcpList_decorators, {
3442
- kind: "method",
3443
- name: "mcpList",
3444
- static: false,
3445
- private: false,
3446
- access: {
3447
- has: (obj) => "mcpList" in obj,
3448
- get: (obj) => obj.mcpList
3449
- },
3450
- metadata: _metadata
3451
- }, null, _instanceExtraInitializers);
3452
3963
  __esDecorate(this, null, _mcpAdd_decorators, {
3453
3964
  kind: "method",
3454
3965
  name: "mcpAdd",
@@ -3600,7 +4111,7 @@ let CapabilityCatalogService = (() => {
3600
4111
  "credentials",
3601
4112
  "skills"
3602
4113
  ], () => {
3603
- installSkillEnvInjection(ctx, () => this.catalogScope());
4114
+ installSkillEnvInjection(ctx, () => this.catalogScope(), () => this.dshHome());
3604
4115
  });
3605
4116
  installSkillEnvHint(ctx, () => this.catalogScope());
3606
4117
  ctx.inject(["skills"], (skillsCtx) => {
@@ -3711,13 +4222,23 @@ let CapabilityCatalogService = (() => {
3711
4222
  /** Record tools that appeared after the apply-time baseline (reads the live
3712
4223
  * registry each time, so a change event never sees a stale/undefined ref). */
3713
4224
  markNewTools() {
3714
- const tools = this.ctx.get?.("tools");
4225
+ const tools = this.toolsRegistry();
3715
4226
  const current = new Set(tools?.schemas().map((t) => t.name) ?? []);
3716
4227
  for (const name of current) if (!this.baseline.has(name)) this.appearedAfterApply.add(name);
3717
4228
  }
3718
- /** The managed skills root under `$DSH_HOME`. */
4229
+ /**
4230
+ * The managed skills root under the harness home. Mirrors the official
4231
+ * precedence (`resolveDshHome` in `@deepseek-ai/dsh-home-paths`):
4232
+ * the settings service's home, then a non-blank `$DSH_HOME`, then the
4233
+ * `~/.dsh` installation default. The fallback was once hardcoded to
4234
+ * `.dsh-official`, which silently pointed a desktop instance (no settings
4235
+ * home, no env) at ANOTHER instance's managed skills (2026-09-30).
4236
+ */
3719
4237
  dshHome() {
3720
- return (this.ctx.get?.("settings"))?.home ?? process.env.DSH_HOME ?? join(homedir(), ".dsh-official");
4238
+ const settings = this.ctx.get?.("settings");
4239
+ const fromEnv = process.env.DSH_HOME;
4240
+ const envHome = fromEnv !== void 0 && fromEnv.trim().length > 0 ? fromEnv : void 0;
4241
+ return settings?.home ?? envHome ?? join(homedir(), ".dsh");
3721
4242
  }
3722
4243
  /**
3723
4244
  * The standing skill view scope of ONE preset. In the web bundle the
@@ -3899,9 +4420,6 @@ let CapabilityCatalogService = (() => {
3899
4420
  async pickDirectory() {
3900
4421
  return catalogPickDirectory(this.ctx);
3901
4422
  }
3902
- async mcpList() {
3903
- return this.mcp.list().map(maskConfig);
3904
- }
3905
4423
  async mcpAdd(config) {
3906
4424
  if (config.serverName.trim() === "") return false;
3907
4425
  this.mcp.add(config);
@@ -3937,51 +4455,28 @@ let CapabilityCatalogService = (() => {
3937
4455
  credentials: this.mcp.credentials()
3938
4456
  };
3939
4457
  }
4458
+ /** The live tools registry, when the composition provides one. */
4459
+ toolsRegistry() {
4460
+ return this.ctx.get?.("tools");
4461
+ }
3940
4462
  /** The visible tool schemas in the standing scope (the set the model sees),
3941
4463
  * or the global layer when no preset standing key resolves. Skills already
3942
4464
  * enumerate through the same scope; tools must use it too so the
3943
4465
  * settings reader sees the official/plugin/MCP tools the model actually has. */
3944
4466
  toolSchemasIn(scope) {
3945
- const tools = this.ctx.get?.("tools");
3946
- if (tools?.schemas === void 0) return [];
3947
- return scope === void 0 ? tools.schemas() : tools.schemas(scope);
4467
+ return toolSchemasFacing(this.toolsRegistry(), scope);
3948
4468
  }
3949
4469
  /** tool name → author-declared origin, read via `ctx.tools.get(name)` so the
3950
4470
  * `Symbol.for('dsh.tool.origin')` tag on the retained definition survives (the
3951
4471
  * system-prompt assembly strips it, but `get()` returns the full definition). */
3952
4472
  toolOriginsMap(scope) {
3953
- const tools = this.ctx.get?.("tools");
3954
- if (tools?.get === void 0) return /* @__PURE__ */ new Map();
3955
- const schemas = scope === void 0 ? tools.schemas() : tools.schemas(scope);
3956
- const out = /* @__PURE__ */ new Map();
3957
- for (const schema of schemas) {
3958
- const def = scope === void 0 ? tools.get(schema.name) : tools.get(schema.name, scope);
3959
- if (def === void 0) {
3960
- this.ctx.logger.warn(`capability-catalog: tool "${schema.name}" is visible but ctx.tools.get() is undefined in this scope — its origin tag cannot be read; it will be classified by heuristics`);
3961
- continue;
3962
- }
3963
- const origin = toolOrigin(def);
3964
- if (origin !== void 0) {
3965
- if (!isValidOrigin(origin)) {
3966
- this.ctx.logger.warn(`capability-catalog: tool "${schema.name}" origin channel "${String(origin.channel)}" is not a known channel (plugin/builtin/mcp) — ignoring the tag, falling back to heuristics`);
3967
- continue;
3968
- }
3969
- out.set(schema.name, origin);
3970
- }
3971
- }
3972
- return out;
4473
+ const tools = this.toolsRegistry();
4474
+ const { origins } = toolOriginsFacing(tools, toolSchemasFacing(tools, scope), scope, (message) => this.ctx.logger.warn(message));
4475
+ return origins;
3973
4476
  }
3974
4477
  /** MCP server names from live `mcp__`-prefixed tools (prefix-derived baseline). */
3975
4478
  mcpServerNamesIn(scope) {
3976
- const names = [];
3977
- for (const schema of this.toolSchemasIn(scope)) {
3978
- if (!schema.name.startsWith("mcp__")) continue;
3979
- const rest = schema.name.slice(5);
3980
- const sep = rest.indexOf("__");
3981
- const server = sep > 0 ? rest.slice(0, sep) : rest;
3982
- if (server.length > 0 && !names.includes(server)) names.push(server);
3983
- }
3984
- return names;
4479
+ return mcpServerNamesFacing(this.toolSchemasIn(scope));
3985
4480
  }
3986
4481
  /**
3987
4482
  * Reconcile which discovered MCP tools are registered on `ctx.tools` so the
@@ -4019,4 +4514,4 @@ let CapabilityCatalogService = (() => {
4019
4514
  };
4020
4515
  })();
4021
4516
  //#endregion
4022
- export { CAPS_TAG_PREFIX, CapabilityCatalogService, CapabilityCatalogService as default, MANAGED_SKILL_RANK, REMOTE_NS, SCOPED_PROVIDER_NAME, ScopedSkillDelivery, TOOL_ORIGIN, adoptManagedSkill, canonicalCapabilities, canonicalJson, capsTag, defaultSkillRoots, detectConflicts, findSkillSource, hashOf, hashSkillBody, isDirectory, managedLocation, managedSkillFrom, modeOptions, parsePresetScopeFrontmatter, readModeFaces, releaseManagedSkill, resolvePresetScope, resolveSkillNameFromContent, scanManagedSkills, scopedSkillsRoot, setManagedPresetScope, setToolOrigin, toolOrigin, withPresetScope };
4517
+ export { CAPS_TAG_PREFIX, CapabilityCatalogService, CapabilityCatalogService as default, MANAGED_SKILL_RANK, REMOTE_NS, SCOPED_PROVIDER_NAME, ScopedSkillDelivery, TOOL_ORIGIN, adoptManagedSkill, canonicalCapabilities, canonicalJson, capsTag, defaultSkillRoots, detectConflicts, findSkillSource, hashOf, hashSkillBody, isDirectory, managedLocation, managedSkillFrom, modeOptions, produceCapabilityEvidence, readModeFaces, releaseManagedSkill, resolvePresetScope, resolveSkillNameFromContent, scanManagedSkills, scopedSkillsRoot, setManagedPresetScope, setToolOrigin, toolOrigin, withPresetScope };