@khorsheed/dsh-capability-catalog 0.1.95

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 (109) hide show
  1. package/LICENSE +21 -0
  2. package/README.i18n.yaml +6 -0
  3. package/README.md +286 -0
  4. package/README.zh.md +138 -0
  5. package/cordis.patch.yml +8 -0
  6. package/lib/client.js +10100 -0
  7. package/lib/index.js +4022 -0
  8. package/lib/invariant.js +25 -0
  9. package/lib/mcpConnector-C2TbkPBY.js +69 -0
  10. package/lib/typert.host.d.ts +3 -0
  11. package/lib/typert.host.js +1417 -0
  12. package/lib/typert.remote-client.d.ts +68 -0
  13. package/lib/typert.remote-client.js +1136 -0
  14. package/lib/types/capabilities.d.ts +103 -0
  15. package/lib/types/capabilities.js +116 -0
  16. package/lib/types/channels.d.ts +41 -0
  17. package/lib/types/channels.js +75 -0
  18. package/lib/types/client/AddMcpDialog.d.ts +13 -0
  19. package/lib/types/client/AddMcpDialog.js +79 -0
  20. package/lib/types/client/AddSkillModal.d.ts +17 -0
  21. package/lib/types/client/AddSkillModal.js +138 -0
  22. package/lib/types/client/BundleFileTree.d.ts +7 -0
  23. package/lib/types/client/BundleFileTree.js +74 -0
  24. package/lib/types/client/CapabilityCatalogCard.d.ts +57 -0
  25. package/lib/types/client/CapabilityCatalogCard.js +479 -0
  26. package/lib/types/client/CredentialField.d.ts +14 -0
  27. package/lib/types/client/CredentialField.js +9 -0
  28. package/lib/types/client/McpCards.d.ts +13 -0
  29. package/lib/types/client/McpCards.js +12 -0
  30. package/lib/types/client/McpServerManageModal.d.ts +18 -0
  31. package/lib/types/client/McpServerManageModal.js +63 -0
  32. package/lib/types/client/MetadataRow.d.ts +10 -0
  33. package/lib/types/client/MetadataRow.js +6 -0
  34. package/lib/types/client/ModalShell.d.ts +16 -0
  35. package/lib/types/client/ModalShell.js +27 -0
  36. package/lib/types/client/ModeChips.d.ts +38 -0
  37. package/lib/types/client/ModeChips.js +42 -0
  38. package/lib/types/client/SchemaView.d.ts +11 -0
  39. package/lib/types/client/SchemaView.js +143 -0
  40. package/lib/types/client/SkillCards.d.ts +28 -0
  41. package/lib/types/client/SkillCards.js +43 -0
  42. package/lib/types/client/SkillDetailModal.d.ts +69 -0
  43. package/lib/types/client/SkillDetailModal.js +167 -0
  44. package/lib/types/client/ToolCards.d.ts +28 -0
  45. package/lib/types/client/ToolCards.js +52 -0
  46. package/lib/types/client/ToolDetailModal.d.ts +12 -0
  47. package/lib/types/client/ToolDetailModal.js +29 -0
  48. package/lib/types/client/icons.d.ts +26 -0
  49. package/lib/types/client/icons.js +10 -0
  50. package/lib/types/client/index.d.ts +21 -0
  51. package/lib/types/client/index.js +196 -0
  52. package/lib/types/client/locales.d.ts +392 -0
  53. package/lib/types/client/locales.js +395 -0
  54. package/lib/types/client/mcp-model.d.ts +24 -0
  55. package/lib/types/client/mcp-model.js +87 -0
  56. package/lib/types/client/mode-model.d.ts +108 -0
  57. package/lib/types/client/mode-model.js +140 -0
  58. package/lib/types/client/preset-display.d.ts +21 -0
  59. package/lib/types/client/preset-display.js +20 -0
  60. package/lib/types/client/settle.d.ts +40 -0
  61. package/lib/types/client/settle.js +57 -0
  62. package/lib/types/client/slots.d.ts +86 -0
  63. package/lib/types/client/slots.js +1 -0
  64. package/lib/types/envHint.d.ts +22 -0
  65. package/lib/types/envHint.js +76 -0
  66. package/lib/types/import.d.ts +106 -0
  67. package/lib/types/import.js +635 -0
  68. package/lib/types/index.d.ts +216 -0
  69. package/lib/types/index.js +739 -0
  70. package/lib/types/invariant.d.ts +16 -0
  71. package/lib/types/invariant.js +23 -0
  72. package/lib/types/mcpConnector.d.ts +24 -0
  73. package/lib/types/mcpConnector.js +73 -0
  74. package/lib/types/mcpStore.d.ts +68 -0
  75. package/lib/types/mcpStore.js +235 -0
  76. package/lib/types/mcpTools.d.ts +45 -0
  77. package/lib/types/mcpTools.js +125 -0
  78. package/lib/types/mcps.d.ts +64 -0
  79. package/lib/types/mcps.js +157 -0
  80. package/lib/types/modes.d.ts +65 -0
  81. package/lib/types/modes.js +97 -0
  82. package/lib/types/namespace.d.ts +9 -0
  83. package/lib/types/namespace.js +8 -0
  84. package/lib/types/official-tools.d.ts +3 -0
  85. package/lib/types/official-tools.js +44 -0
  86. package/lib/types/preset-scope.d.ts +105 -0
  87. package/lib/types/preset-scope.js +117 -0
  88. package/lib/types/remote.d.ts +48 -0
  89. package/lib/types/remote.js +120 -0
  90. package/lib/types/scoped-delivery.d.ts +243 -0
  91. package/lib/types/scoped-delivery.js +686 -0
  92. package/lib/types/scoped-edits.d.ts +90 -0
  93. package/lib/types/scoped-edits.js +234 -0
  94. package/lib/types/settings.d.ts +28 -0
  95. package/lib/types/settings.js +28 -0
  96. package/lib/types/shellEnv.d.ts +65 -0
  97. package/lib/types/shellEnv.js +232 -0
  98. package/lib/types/skills.d.ts +103 -0
  99. package/lib/types/skills.js +303 -0
  100. package/lib/types/tool-origin.d.ts +29 -0
  101. package/lib/types/tool-origin.js +30 -0
  102. package/lib/types/types.d.ts +315 -0
  103. package/lib/types/types.js +8 -0
  104. package/lib/types/zip.d.ts +24 -0
  105. package/lib/types/zip.js +80 -0
  106. package/locale/en.json +6 -0
  107. package/locale/zh.json +6 -0
  108. package/package.json +119 -0
  109. package/scripts/gen-official-tools.mts +52 -0
package/lib/index.js ADDED
@@ -0,0 +1,4022 @@
1
+ import { homedir, tmpdir } from "node:os";
2
+ import { basename, dirname, join, relative } from "node:path";
3
+ import { Remote, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
4
+ import { defineTool } from "@deepseek-ai/dsh-tools";
5
+ import { cp, mkdir, mkdtemp, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
6
+ import { createHash } from "node:crypto";
7
+ import { execFile } from "node:child_process";
8
+ import { promisify } from "node:util";
9
+ import { inflateRawSync } from "node:zlib";
10
+ import { existsSync, watch } from "node:fs";
11
+ import { createScope } from "@deepseek-ai/dsh-scope";
12
+ import z from "@deepseek-ai/schemastery";
13
+ //#region lib/types/capabilities.js
14
+ /**
15
+ * The capability FINGERPRINT: a canonical form of a catalog snapshot, and the
16
+ * sha256 over it.
17
+ *
18
+ * A catalog snapshot is a listing — ordered by registration, worded for a
19
+ * human, stamped with file mtimes. None of that is the capability face. Two
20
+ * instances that register the same tools in a different order have the same
21
+ * capabilities; an instance whose tool description was reworded has the same
22
+ * capabilities; an instance whose tool gained a parameter does NOT. This
23
+ * module decides which of those differences count, so a condition document
24
+ * can claim `preset: X` and something can check the claim instead of
25
+ * believing it.
26
+ *
27
+ * What enters the fingerprint, and why:
28
+ *
29
+ * | Row | Fields | Left out |
30
+ * |---|---|---|
31
+ * | skill | `name`, `source`, `body` (sha256 of SKILL.md) | `description`, `whenToUse`, `provider`, `updatedAt` |
32
+ * | tool | `name`, `channel`, `parameters` | `description`, `confidence`, `owner` |
33
+ * | mcpServer | `name`, `tools` (its tool names) | tool count (derived) |
34
+ * | channel | the names | the counts (derived from the rows) |
35
+ *
36
+ * The exclusions are deliberate and each one is a claim: **prose is not a
37
+ * capability**. Rewording a tool description, or a skill's routing blurb,
38
+ * changes what the model reads but not what it can do — and a factor that
39
+ * moves when someone fixes a typo is a factor nobody can hold still across a
40
+ * run. A skill's BODY is the opposite: it is the procedure the model
41
+ * executes, so it enters as a sha. `updatedAt` is a filesystem fact, not a
42
+ * capability: touching a file must not mint a new subject.
43
+ *
44
+ * Determinism is the whole product. Every list is sorted by name, and the
45
+ * digest is taken over canonical JSON (sorted keys, no whitespace), so the
46
+ * same capability face hashes the same on two machines and in two processes.
47
+ * @module @khorsheed/dsh-capability-catalog/capabilities
48
+ */
49
+ /** Prefix of the human-facing capability tag (`caps:<sha256>`). */
50
+ const CAPS_TAG_PREFIX = "caps:";
51
+ /**
52
+ * Canonical JSON: object keys sorted (code-unit order), no whitespace, arrays
53
+ * in document order. Two documents differing only in key order canonicalize
54
+ * alike.
55
+ *
56
+ * Deliberately duplicated rather than imported from `@khorsheed/dsh-eval`:
57
+ * community plugins never depend on siblings, and the two copies are pinned
58
+ * against each other by the eval contract tests that consume a `caps:` tag.
59
+ * @param value - any JSON value.
60
+ * @returns its canonical serialization.
61
+ */
62
+ function canonicalJson(value) {
63
+ if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null";
64
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
65
+ const obj = value;
66
+ return `{${Object.keys(obj).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(obj[key])}`).join(",")}}`;
67
+ }
68
+ /** Sort by `name`, code-unit order (the one ordering rule of this module). */
69
+ function byName(rows) {
70
+ return [...rows].sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
71
+ }
72
+ /**
73
+ * Project a snapshot onto its canonical capability face.
74
+ *
75
+ * Pure and total: it reads nothing but the snapshot, and a row missing an
76
+ * optional field contributes `null` rather than disappearing — a tool with no
77
+ * parameter schema is a different capability from one whose schema is `{}`,
78
+ * and both are different from the tool being absent.
79
+ * @param snapshot - a catalog snapshot (its own `sha` field, if any, is ignored).
80
+ * @returns the canonical form the digest is taken over.
81
+ */
82
+ function canonicalCapabilities(snapshot) {
83
+ const tools = byName(snapshot.tools);
84
+ return {
85
+ skills: byName(snapshot.skills).map((skill) => ({
86
+ name: skill.name,
87
+ source: skill.source,
88
+ body: skill.bodySha ?? null
89
+ })),
90
+ tools: tools.map((tool) => ({
91
+ name: tool.name,
92
+ channel: tool.channel,
93
+ parameters: tool.parameters ?? null
94
+ })),
95
+ mcpServers: byName(snapshot.mcpServers).map((server) => ({
96
+ name: server.name,
97
+ tools: tools.filter((tool) => tool.serverName === server.name).map((tool) => tool.name)
98
+ })),
99
+ channels: [...snapshot.channels].map((channel) => channel.channel).sort()
100
+ };
101
+ }
102
+ /**
103
+ * The capability hash: sha256 hex of the canonical form's canonical JSON.
104
+ * @param snapshot - a catalog snapshot.
105
+ * @returns the 64-hex digest.
106
+ */
107
+ function hashOf(snapshot) {
108
+ return createHash("sha256").update(canonicalJson(canonicalCapabilities(snapshot))).digest("hex");
109
+ }
110
+ /**
111
+ * The human-facing tag form of a capability hash.
112
+ * @param sha - a sha256 hex digest (or the hash of a snapshot).
113
+ * @returns `caps:<sha>`.
114
+ */
115
+ function capsTag(sha) {
116
+ return `${CAPS_TAG_PREFIX}${sha}`;
117
+ }
118
+ /** sha256 hex of a skill body, the form {@link CanonicalSkill.body} carries. */
119
+ function hashSkillBody(content) {
120
+ return createHash("sha256").update(content, "utf8").digest("hex");
121
+ }
122
+ //#endregion
123
+ //#region lib/types/skills.js
124
+ /**
125
+ * Skill collection: project `ctx.skills.snapshot()` output into catalog rows,
126
+ * decode the frontmatter `metadata.credentials` declaration (the plugin's own
127
+ * convention), and load per-skill detail on demand via `ctx.skills.get()`.
128
+ *
129
+ * The registry already performs root discovery, de-duplication, and rank
130
+ * resolution; this module only projects the winning summaries onto wire rows
131
+ * and reads credentials state through the optional `ctx.credentials` service.
132
+ * @module @khorsheed/dsh-capability-catalog/skills
133
+ */
134
+ /** POSIX identifier — the minimal shape a credential reference name must match
135
+ * (the env keys the catalog handles are a strict subset of this). */
136
+ const CREDENTIAL_REF_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
137
+ /** Skill `source` buckets that resolve to a local file the catalog may delete.
138
+ * Built-in / plugin-provided skills (`runtime`, `bundled`, official `skill-badge`)
139
+ * have no catalog-owned file and are never deletable. */
140
+ const DELETABLE_SOURCES = /* @__PURE__ */ new Set([
141
+ "user-dsh",
142
+ "user-agents",
143
+ "project-dsh",
144
+ "project-agents",
145
+ "custom"
146
+ ]);
147
+ /** Decode `metadata.credentials` as an array of { key, label? }. */
148
+ function decodeCredentialDecls(metadata) {
149
+ const raw = metadata?.credentials;
150
+ if (!Array.isArray(raw)) return [];
151
+ const out = [];
152
+ for (const item of raw) {
153
+ if (typeof item !== "object" || item === null) continue;
154
+ const key = item.key;
155
+ if (typeof key !== "string" || key.length === 0) continue;
156
+ const label = item.label;
157
+ out.push({
158
+ key,
159
+ ...typeof label === "string" ? { label } : {}
160
+ });
161
+ }
162
+ return out;
163
+ }
164
+ /** Scan a skill body for env-var references (`$X`, `process.env.X`, `env['X']`,
165
+ * `{{env:X}}`) and surface them as generic credential decls — envs are not
166
+ * assumed to be API keys; they may be tokens, ids, or any secret. */
167
+ function decodeEnvDecls(content) {
168
+ const seen = /* @__PURE__ */ new Set();
169
+ const out = [];
170
+ const add = (raw) => {
171
+ if (raw === void 0) return;
172
+ const key = raw.replace(/^\$\{?/, "").replace(/\}?$/, "").trim();
173
+ if (!/^[A-Z][A-Z0-9_]*$/.test(key) || seen.has(key)) return;
174
+ seen.add(key);
175
+ out.push({ key });
176
+ };
177
+ for (const p of [
178
+ /\$\{?([A-Z][A-Z0-9_]*)\}?/g,
179
+ /process\.env\.([A-Z][A-Z0-9_]*)/g,
180
+ /env\[\s*['"]?([A-Z][A-Z0-9_]+)['"]?\s*\]/g,
181
+ /\{\{\s*env:([A-Z][A-Z0-9_]*)\s*\}\}/g
182
+ ]) {
183
+ let m;
184
+ while ((m = p.exec(content)) !== null) add(m[1]);
185
+ }
186
+ return out;
187
+ }
188
+ /** Merge metadata.credentials and env-derived decls, deduped by key. */
189
+ function mergeCredentialDecls(metadataDecls, envDecls) {
190
+ const byKey = /* @__PURE__ */ new Map();
191
+ for (const decl of [...metadataDecls, ...envDecls]) {
192
+ const existing = byKey.get(decl.key);
193
+ byKey.set(decl.key, existing !== void 0 && existing.label !== void 0 ? existing : decl);
194
+ }
195
+ return [...byKey.values()];
196
+ }
197
+ /** Project one summary onto a wire skill row. */
198
+ function skillRowFrom(summary, updatedAt, bodySha) {
199
+ return {
200
+ name: summary.name,
201
+ description: summary.description,
202
+ source: summary.source,
203
+ provider: summary.provider,
204
+ modelInvocable: summary.invocation.modelInvocable,
205
+ userInvocable: summary.invocation.userInvocable,
206
+ ...summary.whenToUse !== void 0 ? { whenToUse: summary.whenToUse } : {},
207
+ ...updatedAt !== void 0 ? { updatedAt } : {},
208
+ ...bodySha !== void 0 ? { bodySha } : {}
209
+ };
210
+ }
211
+ /** Last-modified time (epoch ms) of a skill's body: the SKILL.md in its bundle
212
+ * directory for a directory skill, else the directory itself. Remote
213
+ * (`url`/`opaque`) skills have no local file and yield `undefined`. */
214
+ async function skillUpdatedAt(summary) {
215
+ const base = summary.resourceBase;
216
+ if (base?.kind !== "directory" || base.path === void 0) return void 0;
217
+ try {
218
+ return (await stat(join(base.path, "SKILL.md")).catch(() => void 0) ?? await stat(base.path)).mtimeMs;
219
+ } catch {
220
+ return;
221
+ }
222
+ }
223
+ /** Resolve the optional services on a context. */
224
+ function resolveServices(ctx) {
225
+ return {
226
+ registry: ctx.get?.("skills"),
227
+ credentials: ctx.get?.("credentials")
228
+ };
229
+ }
230
+ /**
231
+ * sha256 of one skill's body, loaded through the registry so the digest is
232
+ * taken over what the MODEL would be handed rather than over whichever file
233
+ * happens to sit in the bundle directory. A skill the registry declines to
234
+ * load (remote, opaque, or raced away) contributes no digest — the
235
+ * fingerprint records `null` for it rather than guessing.
236
+ */
237
+ async function skillBodySha(registry, name, options) {
238
+ try {
239
+ const definition = await registry.get(name, options);
240
+ if (typeof definition?.content !== "string") return void 0;
241
+ return hashSkillBody(definition.content);
242
+ } catch {
243
+ return;
244
+ }
245
+ }
246
+ /**
247
+ * Collect the skills portion of the catalog across the given view scopes.
248
+ *
249
+ * `bodySha` costs one registry load per skill, so it is opt-in: the browser
250
+ * card wants a listing, and the capability fingerprint wants the bodies.
251
+ */
252
+ async function collectSkills$1(registry, workdir, scopes = [void 0], collectOptions = {}) {
253
+ const byName = /* @__PURE__ */ new Map();
254
+ for (const scope of scopes) {
255
+ const base = workdir === void 0 ? {} : { cwd: workdir };
256
+ const options = scope === void 0 ? base : {
257
+ ...base,
258
+ scope
259
+ };
260
+ let snapshot;
261
+ try {
262
+ snapshot = await registry.snapshot(options);
263
+ } catch {
264
+ continue;
265
+ }
266
+ for (const summary of snapshot.skills) {
267
+ if (byName.has(summary.name)) continue;
268
+ const bodySha = collectOptions.bodySha === true ? await skillBodySha(registry, summary.name, options) : void 0;
269
+ const row = skillRowFrom(summary, await skillUpdatedAt(summary), bodySha);
270
+ byName.set(row.name, row);
271
+ }
272
+ }
273
+ const skills = [...byName.values()];
274
+ return {
275
+ skills,
276
+ tools: [],
277
+ mcpServers: [],
278
+ channels: [{
279
+ channel: "skill",
280
+ count: skills.length
281
+ }]
282
+ };
283
+ }
284
+ /** Directory names never listed as skill-bundle resources. */
285
+ const SKIP_SKILL_DIRS = /* @__PURE__ */ new Set([".git", "node_modules"]);
286
+ /** Recursively collect a directory's file paths relative to `root`. */
287
+ async function collectSkillFiles(dir, root, out, depth) {
288
+ if (depth > 16 || out.length > 1e3) return;
289
+ let entries;
290
+ try {
291
+ entries = await readdir(dir, { withFileTypes: true });
292
+ } catch {
293
+ return;
294
+ }
295
+ for (const entry of entries) {
296
+ const full = join(dir, entry.name);
297
+ if (entry.isDirectory()) {
298
+ if (SKIP_SKILL_DIRS.has(entry.name)) continue;
299
+ await collectSkillFiles(full, root, out, depth + 1);
300
+ } else if (entry.isFile()) out.push(relative(root, full));
301
+ }
302
+ }
303
+ /** Reject unsafe bundle-relative paths (absolute, drive-letter, or `..`). */
304
+ function sanitizeBundlePath(path) {
305
+ if (path === "" || path.startsWith("/") || /^[a-zA-Z]:/.test(path)) return void 0;
306
+ const safe = [];
307
+ for (const segment of path.split("/")) {
308
+ if (segment === "" || segment === ".") continue;
309
+ if (segment === "..") return void 0;
310
+ safe.push(segment);
311
+ }
312
+ return safe.length === 0 ? void 0 : safe.join("/");
313
+ }
314
+ /** Read one skill-bundle file's text content, on demand. */
315
+ async function readSkillFileContent(registry, name, filePath, workdir, scope = void 0, fallback) {
316
+ const base = workdir === void 0 ? {} : { cwd: workdir };
317
+ const lookup = scope === void 0 ? base : {
318
+ ...base,
319
+ scope
320
+ };
321
+ const def = await registry.get(name, lookup) ?? fallback;
322
+ if (def === void 0) return void 0;
323
+ const resourceBase = def.resourceBase;
324
+ const bundleDir = resourceBase?.kind === "directory" ? resourceBase.path : void 0;
325
+ if (bundleDir === void 0) return void 0;
326
+ const safe = sanitizeBundlePath(filePath);
327
+ if (safe === void 0) return void 0;
328
+ const full = join(bundleDir, safe);
329
+ if (!full.startsWith(bundleDir)) return void 0;
330
+ try {
331
+ return { content: (await readFile(full)).toString("utf8") };
332
+ } catch {
333
+ return;
334
+ }
335
+ }
336
+ /** Delete a file skill's directory. Returns an error for built-in / plugin-provided
337
+ * skills (`runtime`, `bundled`, official `skill-badge`) that have no catalog-owned
338
+ * file, and for any skill whose bundle directory cannot be resolved. */
339
+ async function deleteSkillDir(def) {
340
+ if (!DELETABLE_SOURCES.has(def.source)) return {
341
+ ok: false,
342
+ error: "not a deletable file skill"
343
+ };
344
+ const dir = def.resourceBase?.kind === "directory" ? def.resourceBase.path : void 0;
345
+ if (dir === void 0) return {
346
+ ok: false,
347
+ error: "skill has no bundle directory"
348
+ };
349
+ try {
350
+ await stat(dir);
351
+ await rm(dir, {
352
+ recursive: true,
353
+ force: true
354
+ });
355
+ return { ok: true };
356
+ } catch (error) {
357
+ return {
358
+ ok: false,
359
+ error: String(error)
360
+ };
361
+ }
362
+ }
363
+ /** Load one skill's full detail (content + metadata + credentials state). */
364
+ async function loadSkillDetail(ctx, registry, name, workdir, scope = void 0, fallback) {
365
+ const base = workdir === void 0 ? {} : { cwd: workdir };
366
+ const lookup = scope === void 0 ? base : {
367
+ ...base,
368
+ scope
369
+ };
370
+ const def = await registry.get(name, lookup) ?? fallback;
371
+ if (def === void 0) return void 0;
372
+ const metadataDecls = decodeCredentialDecls(def.metadata);
373
+ const environmentRefs = decodeEnvDecls(def.content).map((decl) => decl.key);
374
+ const { credentials } = resolveServices(ctx);
375
+ let credentialStates = [];
376
+ if (metadataDecls.length > 0 && credentials !== void 0) credentialStates = await Promise.all(metadataDecls.map(async (decl) => {
377
+ const configured = CREDENTIAL_REF_NAME.test(decl.key) ? (await credentials.describe(decl.key).catch(() => void 0))?.configured === true : false;
378
+ return {
379
+ key: decl.key,
380
+ ...decl.label !== void 0 ? { label: decl.label } : {},
381
+ configured
382
+ };
383
+ }));
384
+ const resourceBase = def.resourceBase;
385
+ const bundleDir = resourceBase?.kind === "directory" ? resourceBase.path : void 0;
386
+ let files = [];
387
+ if (bundleDir !== void 0) {
388
+ const collected = [];
389
+ await collectSkillFiles(bundleDir, bundleDir, collected, 0);
390
+ files = collected.sort();
391
+ }
392
+ return {
393
+ name: def.name,
394
+ description: def.description,
395
+ source: def.source,
396
+ provider: def.provider,
397
+ modelInvocable: def.invocation.modelInvocable,
398
+ userInvocable: def.invocation.userInvocable,
399
+ ...def.path !== void 0 ? { path: def.path } : {},
400
+ ...def.whenToUse !== void 0 ? { whenToUse: def.whenToUse } : {},
401
+ content: def.content,
402
+ ...def.metadata !== void 0 ? { metadataText: JSON.stringify(def.metadata) } : {},
403
+ ...credentialStates.length > 0 ? { credentials: credentialStates } : {},
404
+ ...environmentRefs.length > 0 ? { environmentRefs } : {},
405
+ ...files.length > 0 ? { files } : {}
406
+ };
407
+ }
408
+ //#endregion
409
+ //#region lib/types/channels.js
410
+ /**
411
+ * Tool channel attribution and skill source labels.
412
+ *
413
+ * Tools have no source field on `ToolDefinition`, so the catalog infers the
414
+ * channel from three signals (see the proposal "工具归因" section):
415
+ * 1. the `mcp__` prefix (exact),
416
+ * 2. the generated official-tools whitelist (exact),
417
+ * 3. baseline-diff inference (`builtin?` / `plugin`).
418
+ *
419
+ * MCP serverName is resolved by longest-match: the mcp-client server-name
420
+ * charset permits underscores (`[A-Za-z0-9_-]{1,32}`), so `mcp__a__b__tool`
421
+ * could belong to server `a` or `a__b`; the longest configured prefix that
422
+ * leaves a non-empty raw tool name wins.
423
+ * @module @khorsheed/dsh-capability-catalog/channels
424
+ */
425
+ /** The `mcp__` prefix mcp-client stamps on every tool it registers. */
426
+ const MCP_TOOL_PREFIX = "mcp__";
427
+ /** Tool names this plugin itself registers (self-attribution). */
428
+ const SELF_TOOL_NAMES = ["list_capabilities"];
429
+ /**
430
+ * Resolve the channel for one tool name.
431
+ * @param name - wire tool name.
432
+ * @param officialTools - official-tools whitelist (Set).
433
+ * @param mcpServerNames - configured MCP serverNames (longest first).
434
+ * @param appearedAfterApply - whether the tool appeared after apply-time baseline.
435
+ */
436
+ function attributeToolChannel(name, officialTools, mcpServerNames, appearedAfterApply, toolOrigins = /* @__PURE__ */ new Map()) {
437
+ if (name.startsWith("mcp__")) {
438
+ const serverName = resolveMcpServerName(name, mcpServerNames);
439
+ return {
440
+ channel: "mcp",
441
+ confidence: "exact",
442
+ ...serverName !== void 0 ? { serverName } : {}
443
+ };
444
+ }
445
+ if (SELF_TOOL_NAMES.includes(name)) return {
446
+ channel: "plugin",
447
+ confidence: "exact",
448
+ owner: "@khorsheed/dsh-capability-catalog"
449
+ };
450
+ const origin = toolOrigins.get(name);
451
+ if (origin !== void 0) return {
452
+ channel: origin.channel,
453
+ confidence: "exact",
454
+ ...origin.owner !== void 0 ? { owner: origin.owner } : {}
455
+ };
456
+ if (officialTools.has(name)) return {
457
+ channel: "builtin",
458
+ confidence: "exact"
459
+ };
460
+ if (!appearedAfterApply) return {
461
+ channel: "builtin",
462
+ confidence: "inferred"
463
+ };
464
+ return {
465
+ channel: "plugin",
466
+ confidence: "inferred"
467
+ };
468
+ }
469
+ /** Longest-match MCP serverName resolution. */
470
+ function resolveMcpServerName(name, serverNames) {
471
+ const candidates = [...serverNames].sort((a, b) => b.length - a.length);
472
+ for (const server of candidates) {
473
+ const prefix = `${MCP_TOOL_PREFIX}${server}__`;
474
+ if (name.startsWith(prefix) && name.length > prefix.length) return server;
475
+ }
476
+ const rest = name.slice(5);
477
+ const separator = rest.indexOf("__");
478
+ if (separator > 0) return rest.slice(0, separator);
479
+ }
480
+ //#endregion
481
+ //#region lib/types/zip.js
482
+ /**
483
+ * Minimal, dependency-free ZIP reader: enough to unpack a skill archive
484
+ * (dirs, stored and deflated entries) into `{ path, data }` pairs.
485
+ *
486
+ * Why hand-rolled: the host face is analyzed by the typert generator against a
487
+ * scratch overlay that only resolves `@deepseek-ai/*` source-plane packages and
488
+ * the harness's own node_modules — a third-party zip library resolves there but
489
+ * not at publish time, and no host edit is allowed. Using only node:zlib keeps
490
+ * the boundary to node built-ins, which the overlay resolves natively, and
491
+ * keeps the published package free of a zip runtime dependency.
492
+ *
493
+ * Skips directories; throws on a malformed or unsupported (e.g. zip64, encrypted,
494
+ * or data-descriptor-only) archive. Skill zips are small and conventional, so
495
+ * the classic (non-zip64) central directory is the only layout needed.
496
+ * @module @khorsheed/dsh-capability-catalog/zip
497
+ */
498
+ const SIG_LOCAL = 67324752;
499
+ const SIG_CENTRAL = 33639248;
500
+ const SIG_EOCD = 101010256;
501
+ /** Unpack a zip archive into its non-directory entries. */
502
+ function extractZip(buf) {
503
+ const eocd = findEocd(buf);
504
+ if (eocd === void 0) throw new Error("end of central directory not found");
505
+ const count = buf.readUInt16LE(eocd + 10);
506
+ if (count === 65535) throw new Error("zip64 archives are not supported");
507
+ let offset = buf.readUInt32LE(eocd + 16);
508
+ const entries = [];
509
+ for (let i = 0; i < count; i++) offset = readCentralDirEntry(buf, offset, entries);
510
+ return entries;
511
+ }
512
+ /** Walk one central-directory entry, appending it when it is a file. */
513
+ function readCentralDirEntry(buf, offset, entries) {
514
+ if (buf.readUInt32LE(offset) !== SIG_CENTRAL) throw new Error("bad central directory entry");
515
+ const method = buf.readUInt16LE(offset + 10);
516
+ const compSize = buf.readUInt32LE(offset + 20);
517
+ if (compSize === 4294967295) throw new Error("zip64 entries are not supported");
518
+ const nameLen = buf.readUInt16LE(offset + 28);
519
+ const extraLen = buf.readUInt16LE(offset + 30);
520
+ const commentLen = buf.readUInt16LE(offset + 32);
521
+ const localOffset = buf.readUInt32LE(offset + 42);
522
+ const name = buf.toString("utf8", offset + 46, offset + 46 + nameLen);
523
+ if (!name.endsWith("/")) {
524
+ const { dataOffset } = readLocalHeader(buf, localOffset);
525
+ entries.push({
526
+ path: name,
527
+ data: inflateEntry(buf, dataOffset, method, compSize)
528
+ });
529
+ }
530
+ return offset + 46 + nameLen + extraLen + commentLen;
531
+ }
532
+ /** Read a local file header, returning the offset where the data begins. */
533
+ function readLocalHeader(buf, offset) {
534
+ if (buf.readUInt32LE(offset) !== SIG_LOCAL) throw new Error("bad local file header");
535
+ const nameLen = buf.readUInt16LE(offset + 26);
536
+ const extraLen = buf.readUInt16LE(offset + 28);
537
+ return { dataOffset: offset + 30 + nameLen + extraLen };
538
+ }
539
+ /** Decompress one entry: stored (0) or raw-deflated (8). */
540
+ function inflateEntry(buf, dataOffset, method, compSize) {
541
+ const compressed = buf.subarray(dataOffset, dataOffset + compSize);
542
+ if (method === 0) return Buffer.from(compressed);
543
+ if (method === 8) return inflateRawSync(compressed);
544
+ throw new Error(`unsupported compression method ${method}`);
545
+ }
546
+ /** Locate the end-of-central-directory record (last 64 KiB window). */
547
+ function findEocd(buf) {
548
+ const min = Math.max(0, buf.length - 22 - 65535);
549
+ for (let i = buf.length - 22; i >= min; i--) if (buf.readUInt32LE(i) === SIG_EOCD) return i;
550
+ }
551
+ //#endregion
552
+ //#region lib/types/import.js
553
+ /**
554
+ * Add-skill import: write a skill from a pasted SKILL.md payload or an uploaded
555
+ * zip archive into the managed skills root and extract its name/frontmatter.
556
+ *
557
+ * A skill is a directory bundle (`<name>/SKILL.md` plus optional
558
+ * `references`/`scripts`/`assets`) or a single flat file, discovered by the
559
+ * skill-filesystem watcher from one of the managed roots. So "installing" a
560
+ * skill is just placing it under the root; nothing executes the payload.
561
+ *
562
+ * The zip path reads the archive with the dependency-free zip reader
563
+ * (`./zip.ts`, node:zlib only), locates the `SKILL.md`, derives the kebab-case
564
+ * `name` from frontmatter, and extracts the bundle under `<root>/<name>/`
565
+ * (strip the archive's own wrapping folder if present). The clone path resolves
566
+ * a repo spec — including a whole `npx skills add <repo> --skill <name>` command
567
+ * copied from a skill's README — clones it into a scratch directory, and lifts
568
+ * the selected skill bundle(s) into the root.
569
+ * @module @khorsheed/dsh-capability-catalog/import
570
+ */
571
+ const execFileAsync = promisify(execFile);
572
+ const FRONTMATTER$1 = /^---\n([\s\S]*?)\n---\n([\s\S]*)$/;
573
+ /** First bytes of a ZIP archive (local file header / empty / spanned). */
574
+ const ZIP_MAGIC_PK = 80;
575
+ const ZIP_MAGIC_K = 75;
576
+ /** Resolve the managed root directory. */
577
+ function managedRoot(root, dshHome) {
578
+ return root === "user" ? join(dshHome, "skills") : join(dshHome, ".agents", "skills");
579
+ }
580
+ /** Extract the kebab-case `name` and `description` from a SKILL.md body. */
581
+ 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;
588
+ return {
589
+ name,
590
+ description
591
+ };
592
+ }
593
+ /** Decode base64 bytes to a UTF-8 string. */
594
+ function decodeBase64(payload) {
595
+ return Buffer.from(payload, "base64").toString("utf8");
596
+ }
597
+ /** Read the frontmatter `disable-model-invocation` flag (default model-invocable true). */
598
+ 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";
603
+ }
604
+ /** Whether a filesystem path exists (dir or file). */
605
+ async function pathExists(p) {
606
+ try {
607
+ await stat(p);
608
+ return true;
609
+ } catch {
610
+ return false;
611
+ }
612
+ }
613
+ /** True when a base64 payload decodes to a ZIP archive (magic bytes). */
614
+ function isZipPayload(payload) {
615
+ let bytes;
616
+ try {
617
+ bytes = Buffer.from(payload, "base64");
618
+ } catch {
619
+ return false;
620
+ }
621
+ return bytes.length >= 4 && bytes[0] === ZIP_MAGIC_PK && bytes[1] === ZIP_MAGIC_K && (bytes[2] === 3 || bytes[2] === 5 || bytes[2] === 7);
622
+ }
623
+ /** Reject unsafe relative paths (absolute, drive-letter, or `..` traversal). */
624
+ function sanitizeRelPath(p) {
625
+ if (p === "" || p.startsWith("/") || /^[a-zA-Z]:/.test(p)) return void 0;
626
+ const safe = [];
627
+ for (const seg of p.split("/")) {
628
+ if (seg === "" || seg === ".") continue;
629
+ if (seg === "..") return void 0;
630
+ safe.push(seg);
631
+ }
632
+ return safe.length === 0 ? void 0 : safe.join("/");
633
+ }
634
+ /** Apply the frontmatter `disable-model-invocation` line to match the request. */
635
+ 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] ?? "");
640
+ 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}`;
643
+ }
644
+ /**
645
+ * Add a skill from a base64 SKILL.md text payload into the managed root.
646
+ * @param request - the add-skill request.
647
+ * @param dshHome - the dsh home root.
648
+ */
649
+ async function addSkillFromText(request, dshHome) {
650
+ let text;
651
+ try {
652
+ text = decodeBase64(request.payload);
653
+ } catch {
654
+ return {
655
+ ok: false,
656
+ error: "invalid payload: not base64 text"
657
+ };
658
+ }
659
+ const parsed = resolveSkillNameFromContent(text);
660
+ if (parsed === void 0) return {
661
+ ok: false,
662
+ error: "SKILL.md missing or missing name/description frontmatter"
663
+ };
664
+ const finalText = finalizeSkillText(text, request.modelInvocable);
665
+ const target = join(managedRoot(request.root, dshHome), parsed.name);
666
+ if (!request.overwrite && await pathExists(target)) return {
667
+ ok: false,
668
+ exists: true,
669
+ name: parsed.name
670
+ };
671
+ try {
672
+ await mkdir(target, { recursive: true });
673
+ await writeFile(join(target, "SKILL.md"), finalText, "utf8");
674
+ } catch (error) {
675
+ return {
676
+ ok: false,
677
+ error: `failed to write skill: ${String(error)}`
678
+ };
679
+ }
680
+ return {
681
+ ok: true,
682
+ name: parsed.name
683
+ };
684
+ }
685
+ /** Pick the SKILL.md entry: the shallowest absolute path ending in `SKILL.md`. */
686
+ function pickSkillEntry(entries) {
687
+ let best;
688
+ let bestDepth = Number.POSITIVE_INFINITY;
689
+ for (const entry of entries) {
690
+ const parts = entry.path.split("/");
691
+ if (parts[parts.length - 1] !== "SKILL.md") continue;
692
+ const depth = parts.length - 1;
693
+ if (depth < bestDepth) {
694
+ best = entry;
695
+ bestDepth = depth;
696
+ }
697
+ }
698
+ return best;
699
+ }
700
+ /** The archive's wrapping folder prefix ('' when SKILL.md is at the root). */
701
+ function skillDirPrefix(entryName) {
702
+ const idx = entryName.lastIndexOf("/");
703
+ return idx >= 0 ? entryName.slice(0, idx + 1) : "";
704
+ }
705
+ /**
706
+ * Add a skill from a base64 ZIP archive payload into the managed root.
707
+ *
708
+ * The archive should contain a skill bundle. If the `SKILL.md` sits directly at
709
+ * the archive root it is extracted as-is; if it sits under a single wrapping
710
+ * folder that folder is stripped so the bundle lands at `<root>/<name>/`. Path
711
+ * traversal entries are skipped; the frontmatter name drives the target folder.
712
+ * @param request - the add-skill request.
713
+ * @param dshHome - the dsh home root.
714
+ */
715
+ async function addSkillFromZip(request, dshHome) {
716
+ let bytes;
717
+ try {
718
+ bytes = Buffer.from(request.payload, "base64");
719
+ } catch {
720
+ return {
721
+ ok: false,
722
+ error: "invalid payload: not base64"
723
+ };
724
+ }
725
+ let entries;
726
+ try {
727
+ entries = extractZip(bytes);
728
+ } catch (error) {
729
+ return {
730
+ ok: false,
731
+ error: `invalid zip archive: ${String(error)}`
732
+ };
733
+ }
734
+ const skillEntry = pickSkillEntry(entries);
735
+ if (skillEntry === void 0) return {
736
+ ok: false,
737
+ error: "archive did not contain a SKILL.md"
738
+ };
739
+ const original = skillEntry.data.toString("utf8");
740
+ const parsed = resolveSkillNameFromContent(original);
741
+ if (parsed === void 0) return {
742
+ ok: false,
743
+ error: "SKILL.md missing name/description frontmatter"
744
+ };
745
+ const name = parsed.name;
746
+ const prefix = skillDirPrefix(skillEntry.path);
747
+ const finalContent = finalizeSkillText(original, request.modelInvocable);
748
+ const target = join(managedRoot(request.root, dshHome), name);
749
+ if (!request.overwrite && await pathExists(target)) return {
750
+ ok: false,
751
+ exists: true,
752
+ name
753
+ };
754
+ let wrote = false;
755
+ try {
756
+ if (request.overwrite === true) await rm(target, {
757
+ recursive: true,
758
+ force: true
759
+ });
760
+ await mkdir(target, { recursive: true });
761
+ } catch (error) {
762
+ return {
763
+ ok: false,
764
+ error: `failed to create skill dir: ${String(error)}`
765
+ };
766
+ }
767
+ for (const entry of entries) {
768
+ const rel = entry.path;
769
+ if (prefix !== "" && !rel.startsWith(prefix)) continue;
770
+ const safe = sanitizeRelPath(prefix !== "" ? rel.slice(prefix.length) : rel);
771
+ if (safe === void 0) continue;
772
+ const outPath = join(target, safe);
773
+ if (!outPath.startsWith(target)) continue;
774
+ const data = safe === "SKILL.md" ? Buffer.from(finalContent, "utf8") : entry.data;
775
+ try {
776
+ await mkdir(dirname(outPath), { recursive: true });
777
+ await writeFile(outPath, data);
778
+ wrote = true;
779
+ } catch (error) {
780
+ return {
781
+ ok: false,
782
+ error: `failed to write skill file: ${String(error)}`
783
+ };
784
+ }
785
+ }
786
+ return wrote ? {
787
+ ok: true,
788
+ name
789
+ } : {
790
+ ok: false,
791
+ error: "archive contained no reusable skill files"
792
+ };
793
+ }
794
+ /**
795
+ * Add a skill from a base64 payload into the managed root, auto-detecting a
796
+ * ZIP archive (magic bytes) from a raw SKILL.md text payload.
797
+ * @param request - the add-skill request.
798
+ * @param dshHome - the dsh home root.
799
+ */
800
+ async function addSkillFromPayload(request, dshHome) {
801
+ if (isZipPayload(request.payload)) return addSkillFromZip(request, dshHome);
802
+ return addSkillFromText(request, dshHome);
803
+ }
804
+ /** `skills add` flags whose following non-flag values dsh consumes and ignores. */
805
+ const DROPPED_VALUE_FLAGS = /* @__PURE__ */ new Set([
806
+ "-a",
807
+ "--agent",
808
+ "--subagent"
809
+ ]);
810
+ /** `skills add` flags that take exactly one value dsh consumes and ignores. */
811
+ const DROPPED_SINGLE_VALUE_FLAGS = /* @__PURE__ */ new Set(["--metadata"]);
812
+ /** `skills add` boolean flags dsh consumes and ignores (`-g` is meaningless for a root dsh owns). */
813
+ const DROPPED_BOOL_FLAGS = /* @__PURE__ */ new Set([
814
+ "-g",
815
+ "--global",
816
+ "-y",
817
+ "--yes",
818
+ "-l",
819
+ "--list",
820
+ "--all",
821
+ "--full-depth",
822
+ "--json",
823
+ "--copy"
824
+ ]);
825
+ /** `skills add` flags that select sub-skills; their greedy values are kept. */
826
+ const SKILL_FLAGS = /* @__PURE__ */ new Set(["-s", "--skill"]);
827
+ /**
828
+ * Parse a `command`-channel spec — `owner/repo`, a git URL, or a whole
829
+ * `npx skills add <repo> [--skill <name>]… [-g]` command — into its source
830
+ * reference and `--skill` selections.
831
+ *
832
+ * Every flag token is consumed here, never left in the string: the spec used to
833
+ * be handed to `git clone` after only a prefix and a trailing `-g` were
834
+ * stripped, so the README form `npx skills add owner/repo --skill name` became
835
+ * the clone URL `https://github.com/owner/repo --skill name`, which git rejects
836
+ * as a malformed URL. The recognized set mirrors the `skills` CLI's own `add`
837
+ * parser (vercel-labs/skills) so a command copied from a skill's README installs
838
+ * what it says: a flag's value is consumed with it, never glued onto the
839
+ * reference.
840
+ * @param spec - the raw spec from the add-skill command channel.
841
+ */
842
+ function parseRepoSpec(spec) {
843
+ const tokens = spec.trim().replace(/^npx\s+skills\s+add\s+/i, "").trim().split(/\s+/);
844
+ const skills = [];
845
+ let ref;
846
+ for (let i = 0; i < tokens.length; i++) {
847
+ const token = tokens[i] ?? "";
848
+ if (token === "") continue;
849
+ if (SKILL_FLAGS.has(token)) {
850
+ while (i + 1 < tokens.length && !tokens[i + 1].startsWith("-")) skills.push(tokens[++i]);
851
+ continue;
852
+ }
853
+ if (DROPPED_VALUE_FLAGS.has(token)) {
854
+ while (i + 1 < tokens.length && !tokens[i + 1].startsWith("-")) i++;
855
+ continue;
856
+ }
857
+ if (DROPPED_SINGLE_VALUE_FLAGS.has(token)) {
858
+ i++;
859
+ continue;
860
+ }
861
+ if (DROPPED_BOOL_FLAGS.has(token) || token.startsWith("-")) continue;
862
+ if (ref === void 0) ref = token;
863
+ }
864
+ return ref === void 0 ? void 0 : {
865
+ ref,
866
+ skills
867
+ };
868
+ }
869
+ /** A copy-pasted SCP-style remote (`git@host:owner/repo`): a clone URL, never `owner/repo`. */
870
+ const SCP_STYLE_RE = /^[^/@\s]+@[^/\s:]+:/;
871
+ /** A scheme-prefixed spec, which must carry a path after its host. */
872
+ const SCHEME_RE = /^[A-Za-z][A-Za-z0-9+.-]*:\/\//;
873
+ /** The only host whose URL shape is known to be `owner/repo[/path…]`. */
874
+ const GITHUB_HOST = "github.com";
875
+ /** Segments a copied browser URL puts in front of the real in-repo path. */
876
+ const BROWSER_PATH_MARKERS = /* @__PURE__ */ new Set(["tree", "blob"]);
877
+ /** Local-path shapes: `stat` already ran, so a miss here is a missing directory, not a repo spec. */
878
+ const LOCAL_PATH_RE = /^(?:~\/|\/|\.\.?\/|\.\.?$)/;
879
+ /** Whether a spec's first segment names a host rather than an owner (`github.com`, `localhost:3000`). */
880
+ function isHostSegment(segment) {
881
+ return segment !== "." && segment !== ".." && (segment.includes(".") || segment.includes(":"));
882
+ }
883
+ /**
884
+ * Build a target from `owner/repo` plus whatever path followed it, dropping the
885
+ * copy-paste noise a browser URL carries: a `tree/<branch>`/`blob/<branch>`
886
+ * marker, and a trailing `SKILL.md` naming the file rather than its directory.
887
+ */
888
+ function targetFromParts(host, owner, repo, rest) {
889
+ let path = rest;
890
+ if (path.length >= 2 && BROWSER_PATH_MARKERS.has(path[0] ?? "")) path = path.slice(2);
891
+ if (path[path.length - 1] === "SKILL.md") path = path.slice(0, -1);
892
+ return {
893
+ ok: true,
894
+ url: `${host === void 0 ? `https://${GITHUB_HOST}` : `https://${host}`}/${owner}/${repo}`,
895
+ subpath: path.join("/")
896
+ };
897
+ }
898
+ /**
899
+ * Resolve a source reference into a clone URL plus an optional in-repo subpath.
900
+ *
901
+ * Accepted: a git URL with a transport, `owner/repo`, and either of those behind
902
+ * a host — `github.com/owner/repo`, `github.com/owner/repo/skills/name`, a copied
903
+ * browser URL's `tree/<branch>/…` or `blob/<branch>/SKILL.md`. Only `github.com`
904
+ * gets the `owner/repo` split; on any other host the whole path is the repository
905
+ * (GitLab subgroups are real paths, not repo + subpath), so it is handed to git
906
+ * untouched.
907
+ *
908
+ * Getting this wrong is what the host rule exists for: the old code matched the
909
+ * `owner/repo` shape anywhere in the string and kept only the FIRST TWO segments,
910
+ * so the everyday paste `github.com/larashero3-dotcom/lieflat-charts` cloned
911
+ * `https://github.com/github.com/larashero3-dotcom` — the host taken for the
912
+ * owner, the real repo name silently dropped — and failed with GitHub's
913
+ * "Repository not found", which reads like the skill is unsupported.
914
+ * @param ref - the source reference from `parseRepoSpec`.
915
+ */
916
+ function resolveCloneTarget(ref) {
917
+ const spec = (ref.trim().split(/[?#]/)[0] ?? "").replace(/\.git$/, "");
918
+ if (spec === "") return {
919
+ ok: false,
920
+ error: "the spec is empty — use owner/repo or a git URL"
921
+ };
922
+ const urlMatch = /^[A-Za-z][A-Za-z0-9+.-]*:\/\/([^/]+)\/(.*)$/.exec(spec);
923
+ if (urlMatch !== null) {
924
+ const host = urlMatch[1];
925
+ const rest = urlMatch[2].split("/").filter((part) => part !== "");
926
+ if (host.toLowerCase() !== GITHUB_HOST || rest.length <= 2) return {
927
+ ok: true,
928
+ url: spec,
929
+ subpath: ""
930
+ };
931
+ return targetFromParts(host, rest[0], rest[1], rest.slice(2));
932
+ }
933
+ if (SCP_STYLE_RE.test(spec)) return {
934
+ ok: true,
935
+ url: spec,
936
+ subpath: ""
937
+ };
938
+ if (SCHEME_RE.test(spec)) return {
939
+ ok: false,
940
+ error: `${spec} has no repository path`
941
+ };
942
+ const parts = spec.split("/").filter((part) => part !== "");
943
+ const first = parts[0];
944
+ const host = first !== void 0 && isHostSegment(first) ? parts.shift() : void 0;
945
+ if (parts.length < 2) return {
946
+ ok: false,
947
+ error: `${[host, ...parts].filter((part) => part !== void 0).join("/")} is not a repository — a source needs owner/repo (a host or a user page alone has nothing to clone)`
948
+ };
949
+ if (host !== void 0 && host.toLowerCase() !== GITHUB_HOST) return {
950
+ ok: true,
951
+ url: `https://${host}/${parts.join("/")}`,
952
+ subpath: ""
953
+ };
954
+ return targetFromParts(host, parts[0], parts[1], parts.slice(2));
955
+ }
956
+ /** An actionable line appended to a failed clone, keyed off git's own stderr. */
957
+ function cloneFailureHint(text) {
958
+ if (/Repository not found/i.test(text)) return "\ncheck the owner and repo name: GitHub answers \"Repository not found\" for a misspelled repo and for a private one alike.";
959
+ if (/Could not resolve host/i.test(text)) return "\ncheck the host in the clone URL.";
960
+ return "";
961
+ }
962
+ /** Every skill bundle at or under `dir` (depth ≤ 4, `.git` skipped), shallowest first. */
963
+ async function collectSkills(dir, depth = 0) {
964
+ if (depth > 4) return [];
965
+ const out = [];
966
+ const content = await readFile(join(dir, "SKILL.md"), "utf8").catch(() => void 0);
967
+ const parsed = content === void 0 ? void 0 : resolveSkillNameFromContent(content);
968
+ if (content !== void 0 && parsed !== void 0) out.push({
969
+ dir,
970
+ dirName: basename(dir),
971
+ name: parsed.name,
972
+ content
973
+ });
974
+ const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
975
+ for (const entry of entries) {
976
+ if (!entry.isDirectory() || entry.name === ".git") continue;
977
+ out.push(...await collectSkills(join(dir, entry.name), depth + 1));
978
+ }
979
+ return out;
980
+ }
981
+ /**
982
+ * Resolve `--skill` selections against the skills a repo carries. A selection
983
+ * matches a skill's directory name or its frontmatter `name`, case-insensitively
984
+ * — the `skills` CLI's own rule — and `*` selects every skill.
985
+ * @returns the chosen skills; `[]` when nothing was selected but the repo is
986
+ * ambiguous (more than one skill); `undefined` when a selection matched nothing.
987
+ */
988
+ function selectSkills(skills, selections) {
989
+ if (selections.length === 0) return skills.length > 1 ? [] : [...skills];
990
+ if (selections.includes("*")) return [...skills];
991
+ const chosen = skills.filter((s) => selections.some((sel) => sel.toLowerCase() === s.dirName.toLowerCase() || sel.toLowerCase() === s.name.toLowerCase()));
992
+ return chosen.length === 0 ? void 0 : chosen;
993
+ }
994
+ /** Expand a leading `~` to the OS home directory. */
995
+ function expandHome(p) {
996
+ if (p === "~") return homedir();
997
+ if (p.startsWith("~/")) return join(homedir(), p.slice(2));
998
+ return p;
999
+ }
1000
+ /** Sub-directories of `dir` that contain a SKILL.md (a skills container). */
1001
+ async function listSkillDirs(dir) {
1002
+ try {
1003
+ const entries = await readdir(dir, { withFileTypes: true });
1004
+ const out = [];
1005
+ for (const entry of entries) {
1006
+ if (!entry.isDirectory()) continue;
1007
+ try {
1008
+ await stat(join(dir, entry.name, "SKILL.md"));
1009
+ out.push(entry.name);
1010
+ } catch {}
1011
+ }
1012
+ return out;
1013
+ } catch {
1014
+ return [];
1015
+ }
1016
+ }
1017
+ /**
1018
+ * Install a skill by cloning a source repo (owner/repo, a git URL, a host-prefixed
1019
+ * `github.com/owner/repo[/path]`, a copied browser URL, or a whole
1020
+ * `npx skills add <repo> [--skill <name>]` command) into the managed root. This
1021
+ * is the dsh-native "install from source" — a plain `npx skills add` writes into
1022
+ * an external skills dir the dsh filesystem watcher does not scan.
1023
+ *
1024
+ * The clone lands in a scratch directory; the chosen skill bundle (the repo's
1025
+ * only one, `request.skills`, or the command's `--skill` values) is then lifted
1026
+ * to `<managed root>/<frontmatter name>/`. A repo carrying several skills and no
1027
+ * selection is refused with the list rather than installing the shallowest one.
1028
+ * A spec naming a path inside the repo (`…/skills/<name>`) searches only that
1029
+ * path.
1030
+ * @param request - the add-skill request (channel 'command', repo carries the spec).
1031
+ * @param dshHome - the dsh home root.
1032
+ */
1033
+ async function commandInstall(request, dshHome) {
1034
+ const spec = request.repo ?? "";
1035
+ const trimmed = spec.trim();
1036
+ const expanded = expandHome(trimmed);
1037
+ let dirStat;
1038
+ try {
1039
+ dirStat = await stat(expanded);
1040
+ } catch {
1041
+ dirStat = void 0;
1042
+ }
1043
+ if (dirStat?.isDirectory() === true) {
1044
+ const copyInto = async (src, skillName) => {
1045
+ const skillMd = join(src, "SKILL.md");
1046
+ let content;
1047
+ try {
1048
+ content = await readFile(skillMd, "utf8");
1049
+ } catch {
1050
+ return {
1051
+ ok: false,
1052
+ error: "directory has no SKILL.md"
1053
+ };
1054
+ }
1055
+ const parsed = resolveSkillNameFromContent(content);
1056
+ if (parsed === void 0) return {
1057
+ ok: false,
1058
+ error: "SKILL.md missing name/description frontmatter"
1059
+ };
1060
+ const name = skillName ?? parsed.name;
1061
+ const target = join(managedRoot(request.root, dshHome), name);
1062
+ if (!request.overwrite && await pathExists(target)) return {
1063
+ ok: false,
1064
+ exists: true,
1065
+ name
1066
+ };
1067
+ try {
1068
+ await rm(target, {
1069
+ recursive: true,
1070
+ force: true
1071
+ });
1072
+ } catch {}
1073
+ await cp(src, target, { recursive: true });
1074
+ await writeFile(join(target, "SKILL.md"), finalizeSkillText(content, request.modelInvocable), "utf8");
1075
+ return {
1076
+ ok: true,
1077
+ name
1078
+ };
1079
+ };
1080
+ const children = await listSkillDirs(expanded);
1081
+ if (request.skills !== void 0 && request.skills.length > 0) {
1082
+ const results = await Promise.all(request.skills.map(async (s) => copyInto(join(expanded, s), s)));
1083
+ const existing = results.find((r) => !r.ok && r.exists === true);
1084
+ if (existing !== void 0) return existing;
1085
+ const ok = results.filter((r) => r.ok);
1086
+ if (ok.length > 0) return {
1087
+ ok: true,
1088
+ name: ok.map((r) => r.name).join(", ")
1089
+ };
1090
+ return results.find((r) => !r.ok) ?? {
1091
+ ok: false,
1092
+ error: "no skills installed"
1093
+ };
1094
+ }
1095
+ if (children.length > 1) return {
1096
+ ok: false,
1097
+ error: `directory has ${children.length} skills — pick one: ${children.join(", ")}`
1098
+ };
1099
+ const only = children[0];
1100
+ if (children.length === 1 && only !== void 0) return copyInto(join(expanded, only), only);
1101
+ return copyInto(expanded);
1102
+ }
1103
+ if (LOCAL_PATH_RE.test(trimmed)) return {
1104
+ ok: false,
1105
+ error: `local directory not found: ${expanded}`
1106
+ };
1107
+ const source = parseRepoSpec(spec);
1108
+ if (source === void 0) return {
1109
+ ok: false,
1110
+ error: `unrecognized repo spec: ${spec || "(empty)"} — use owner/repo, a git URL, or an existing local directory`
1111
+ };
1112
+ const resolved = resolveCloneTarget(source.ref);
1113
+ if (!resolved.ok) return {
1114
+ ok: false,
1115
+ error: `unrecognized repo spec: ${resolved.error}`
1116
+ };
1117
+ const { url, subpath } = resolved;
1118
+ const root = managedRoot(request.root, dshHome);
1119
+ const repoName = url.replace(/\.git$/, "").split("/").pop() ?? "skill";
1120
+ const scratchRoot = await mkdtemp(join(tmpdir(), "dsh-skill-clone-"));
1121
+ const scratch = join(scratchRoot, repoName);
1122
+ try {
1123
+ try {
1124
+ await execFileAsync("git", [
1125
+ "clone",
1126
+ "--depth",
1127
+ "1",
1128
+ url,
1129
+ scratch
1130
+ ]);
1131
+ } catch (error) {
1132
+ const text = String(error);
1133
+ return {
1134
+ ok: false,
1135
+ error: `install failed: ${text}${cloneFailureHint(text)}`
1136
+ };
1137
+ }
1138
+ const searchRoot = subpath === "" ? scratch : join(scratch, subpath);
1139
+ if (searchRoot !== scratch && !await pathExists(searchRoot)) return {
1140
+ ok: false,
1141
+ error: `no such path in the repo: ${subpath} — the clone succeeded, but it has nothing there`
1142
+ };
1143
+ const skills = await collectSkills(searchRoot);
1144
+ if (skills.length === 0) return {
1145
+ ok: false,
1146
+ error: subpath === "" ? "cloned repo has no SKILL.md" : `cloned repo has no SKILL.md under ${subpath}`
1147
+ };
1148
+ const selections = request.skills !== void 0 && request.skills.length > 0 ? request.skills : source.skills;
1149
+ const chosen = selectSkills(skills, selections);
1150
+ if (chosen === void 0) return {
1151
+ ok: false,
1152
+ error: `no matching skill for: ${selections.join(", ")} — available: ${skills.map((s) => s.dirName).join(", ")}`
1153
+ };
1154
+ if (chosen.length === 0) return {
1155
+ ok: false,
1156
+ error: `directory has ${skills.length} skills — pick one: ${skills.map((s) => s.dirName).join(", ")}`
1157
+ };
1158
+ const plans = chosen.map((skill) => ({
1159
+ skill,
1160
+ target: join(root, skill.name)
1161
+ }));
1162
+ for (const { skill, target } of plans) if (!request.overwrite && await pathExists(target)) return {
1163
+ ok: false,
1164
+ exists: true,
1165
+ name: skill.name
1166
+ };
1167
+ const installed = [];
1168
+ for (const { skill, target } of plans) {
1169
+ await rm(target, {
1170
+ recursive: true,
1171
+ force: true
1172
+ }).catch(() => {});
1173
+ await cp(skill.dir, target, { recursive: true });
1174
+ await writeFile(join(target, "SKILL.md"), finalizeSkillText(skill.content, request.modelInvocable), "utf8");
1175
+ installed.push(skill.name);
1176
+ }
1177
+ return {
1178
+ ok: true,
1179
+ name: installed.join(", ")
1180
+ };
1181
+ } finally {
1182
+ await rm(scratchRoot, {
1183
+ recursive: true,
1184
+ force: true
1185
+ }).catch(() => {});
1186
+ }
1187
+ }
1188
+ /**
1189
+ * List the skills inside a local dir for the add-skill chooser. If the dir is a
1190
+ * **skill container** (a set of `<name>/SKILL.md` sub-dirs) the sub-skills are
1191
+ * listed as `kind:'child'`; if the dir is **itself a single skill bundle** (a
1192
+ * `SKILL.md` at its root) it is listed as one `kind:'self'` entry so the user can
1193
+ * install the directory as-is. Both shapes are returned when a dir has a root
1194
+ * `SKILL.md` AND child skill dirs.
1195
+ * @param dirPath - the local directory path (may be `~`-prefixed).
1196
+ */
1197
+ async function listDirSkills(dirPath) {
1198
+ const expanded = expandHome(dirPath);
1199
+ const out = [];
1200
+ const selfContent = await readFile(join(expanded, "SKILL.md"), "utf8").catch(() => void 0);
1201
+ if (selfContent !== void 0) {
1202
+ const parsed = resolveSkillNameFromContent(selfContent);
1203
+ if (parsed !== void 0) out.push({
1204
+ name: parsed.name,
1205
+ description: parsed.description,
1206
+ kind: "self"
1207
+ });
1208
+ }
1209
+ for (const name of await listSkillDirs(expanded)) {
1210
+ const parsed = resolveSkillNameFromContent(await readFile(join(expanded, name, "SKILL.md"), "utf8").catch(() => ""));
1211
+ out.push({
1212
+ name,
1213
+ description: parsed?.description ?? "",
1214
+ kind: "child"
1215
+ });
1216
+ }
1217
+ return out;
1218
+ }
1219
+ //#endregion
1220
+ //#region lib/types/official-tools.js
1221
+ /** Generated from the pinned harness checkout — do not edit by hand. Run scripts/gen-official-tools.mts. */
1222
+ const OFFICIAL_TOOLS = [
1223
+ "ask_user_question",
1224
+ "bash",
1225
+ "cordis_define",
1226
+ "cordis_inspect_list",
1227
+ "cordis_inspect_query",
1228
+ "cordis_inspect_self",
1229
+ "cordis_run",
1230
+ "cordis_stop",
1231
+ "cordis_undefine",
1232
+ "create_goal",
1233
+ "edit",
1234
+ "get_goal",
1235
+ "interrupt_agent",
1236
+ "job_kill",
1237
+ "job_list",
1238
+ "job_output",
1239
+ "list_agents",
1240
+ "list_subagent_models",
1241
+ "lsp",
1242
+ "pwsh",
1243
+ "ralph",
1244
+ "read",
1245
+ "read_image",
1246
+ "send_message",
1247
+ "session_event_read",
1248
+ "session_event_search",
1249
+ "session_event_trace",
1250
+ "session_search",
1251
+ "session_trace",
1252
+ "str_replace_editor",
1253
+ "terminal_close",
1254
+ "terminal_list",
1255
+ "terminal_open",
1256
+ "terminal_read",
1257
+ "terminal_send",
1258
+ "terminal_signal",
1259
+ "todo_write",
1260
+ "update_goal",
1261
+ "web_fetch",
1262
+ "web_search",
1263
+ "write"
1264
+ ];
1265
+ //#endregion
1266
+ //#region lib/types/remote.js
1267
+ /**
1268
+ * Catalog data layer: pure functions the Remote service calls. Kept separate
1269
+ * from the service so the snapshot/add/detail/credential logic is unit-testable
1270
+ * without a Cordis context and stays independent of the typert decorators.
1271
+ * @module @khorsheed/dsh-capability-catalog/remote
1272
+ */
1273
+ /** Load the cached official-tools names. */
1274
+ function officialToolsSet() {
1275
+ return new Set(OFFICIAL_TOOLS);
1276
+ }
1277
+ /** Project visible tools onto catalog rows with channel attribution. */
1278
+ function projectTools(schemas, mcpServers, appearedAfterApply, toolOrigins = /* @__PURE__ */ new Map()) {
1279
+ const official = officialToolsSet();
1280
+ const rows = [];
1281
+ for (const schema of schemas) {
1282
+ const attributed = attributeToolChannel(schema.name, official, mcpServers, appearedAfterApply.has(schema.name), toolOrigins);
1283
+ rows.push({
1284
+ name: schema.name,
1285
+ description: schema.description ?? "",
1286
+ channel: attributed.channel,
1287
+ confidence: attributed.confidence,
1288
+ ...attributed.serverName !== void 0 ? { serverName: attributed.serverName } : {},
1289
+ ...attributed.owner !== void 0 ? { owner: attributed.owner } : {},
1290
+ ...schema.parameters !== void 0 ? { parameters: schema.parameters } : {}
1291
+ });
1292
+ }
1293
+ return rows;
1294
+ }
1295
+ /**
1296
+ * Build the full catalog snapshot (skills + tools).
1297
+ *
1298
+ * `fingerprint` decides whether the snapshot is a LISTING or an IDENTITY: it
1299
+ * loads every skill body (one registry read each) so the rows carry
1300
+ * `bodySha`, and stamps `sha` — the capability hash. The browser card wants
1301
+ * the listing; a condition's capability record wants the identity.
1302
+ */
1303
+ async function catalogSnapshot(workdir, registry, toolsSchemas, mcpServers, appearedAfterApply, scopes = [void 0], toolOrigins = /* @__PURE__ */ new Map(), options = {}) {
1304
+ const fingerprint = options.fingerprint === true;
1305
+ const base = await collectSkills$1(registry, workdir, scopes, { bodySha: fingerprint });
1306
+ const tools = projectTools(toolsSchemas, mcpServers, appearedAfterApply, toolOrigins);
1307
+ const snapshot = {
1308
+ skills: base.skills,
1309
+ tools,
1310
+ mcpServers: mcpServers.map((name) => ({
1311
+ name,
1312
+ toolCount: tools.filter((t) => t.serverName === name).length
1313
+ })),
1314
+ channels: [{
1315
+ channel: "skill",
1316
+ count: base.skills.length
1317
+ }, {
1318
+ channel: "tool",
1319
+ count: tools.length
1320
+ }],
1321
+ ...options.preset !== void 0 ? { preset: options.preset } : {}
1322
+ };
1323
+ return fingerprint ? {
1324
+ ...snapshot,
1325
+ sha: hashOf(snapshot)
1326
+ } : snapshot;
1327
+ }
1328
+ /** Load one skill detail. */
1329
+ async function catalogDetail(ctx, registry, name, workdir, scope = void 0, fallback) {
1330
+ return loadSkillDetail(ctx, registry, name, workdir, scope, fallback);
1331
+ }
1332
+ /** Read one skill-bundle file's text content on demand. */
1333
+ async function catalogReadSkillFile(registry, name, filePath, workdir, scope = void 0, fallback) {
1334
+ return readSkillFileContent(registry, name, filePath, workdir, scope, fallback);
1335
+ }
1336
+ /** Open the host's native directory chooser (the workspace "add" dialog). */
1337
+ async function catalogPickDirectory(ctx, signal) {
1338
+ const cap = (ctx.get?.("directoryPicker"))?.capability?.();
1339
+ if (cap?.kind !== "native" || cap.pick === void 0) return null;
1340
+ try {
1341
+ return await cap.pick(signal ?? new AbortController().signal);
1342
+ } catch {
1343
+ return null;
1344
+ }
1345
+ }
1346
+ /** Delete a catalog-owned file skill (rejects built-in / plugin-provided). */
1347
+ async function catalogDeleteSkill(registry, name, workdir, scope = void 0) {
1348
+ const base = workdir === void 0 ? {} : { cwd: workdir };
1349
+ const lookup = scope === void 0 ? base : {
1350
+ ...base,
1351
+ scope
1352
+ };
1353
+ const def = await registry.get(name, lookup);
1354
+ if (def === void 0) return {
1355
+ ok: false,
1356
+ error: "skill not found"
1357
+ };
1358
+ return deleteSkillDir(def);
1359
+ }
1360
+ /** Set one declared credential value (never returned on the wire). */
1361
+ async function catalogSetCredential(ctx, request) {
1362
+ const { credentials } = resolveServices(ctx);
1363
+ if (credentials === void 0) return false;
1364
+ try {
1365
+ if (!CREDENTIAL_REF_NAME.test(request.key)) return false;
1366
+ await credentials.set(request.key, request.value);
1367
+ return true;
1368
+ } catch {
1369
+ return false;
1370
+ }
1371
+ }
1372
+ /** Add a skill from a raw SKILL.md text payload into the managed root. */
1373
+ async function catalogAddSkill(ctx, request, dshHome) {
1374
+ const { registry } = resolveServices(ctx);
1375
+ if (registry === void 0) return {
1376
+ ok: false,
1377
+ error: "skills service absent in this composition"
1378
+ };
1379
+ if (request.channel === "zip") return addSkillFromPayload(request, dshHome);
1380
+ if (request.channel === "command") return commandInstall(request, dshHome);
1381
+ return {
1382
+ ok: false,
1383
+ error: "github clone not wired in v1"
1384
+ };
1385
+ }
1386
+ /** List the skills inside a local container dir (for the add-skill chooser). */
1387
+ async function catalogListDirSkills(dirPath) {
1388
+ return listDirSkills(dirPath);
1389
+ }
1390
+ //#endregion
1391
+ //#region lib/types/preset-scope.js
1392
+ /**
1393
+ * Resolving WHICH scope the catalog reads — and the one place where a listing
1394
+ * and a fingerprint part company.
1395
+ *
1396
+ * The catalog reads the skill and tool registries at an agent preset's
1397
+ * standing scope, because that is the view the model actually has. Resolving
1398
+ * it can fail: the composition may mount no roster, the named preset may not
1399
+ * exist, or its composition may refuse to mount.
1400
+ *
1401
+ * The roster face depends on the host line: 0.1.5 exposes a lease-free
1402
+ * `standingKeyFor(id)`, while rc.1 replaced it with a LEASED
1403
+ * `acquireScope(id)` — the resolved lease carries the key and a release on
1404
+ * `Symbol.asyncDispose`, and a lease never released leaks a generation user,
1405
+ * so the preset's scope is never collected. Both faces resolve through
1406
+ * {@link acquireStandingScope}; callers that hold the key across reads own
1407
+ * the release (see {@link ResolvedPresetScope.dispose}).
1408
+ *
1409
+ * A LISTING degrades on all three. The settings card must not go blank
1410
+ * because a preset has a bad row, and the global layer is a useful, honestly
1411
+ * unlabelled answer there.
1412
+ *
1413
+ * A FINGERPRINT must not. Falling back to the global layer and stamping the
1414
+ * requested preset's name on the result hands back a hash that is not that
1415
+ * preset's face — which is precisely the "claim with nothing behind it" the
1416
+ * hash exists to eliminate. It was not hypothetical: on a real sub-dsh whose
1417
+ * preset carried one invalid row, two scopes rostering two different presets
1418
+ * produced one identical hash, silently, and the only way to notice was to
1419
+ * already know the answer.
1420
+ * @module @khorsheed/dsh-capability-catalog/preset-scope
1421
+ */
1422
+ /**
1423
+ * Acquire one preset's standing scope key from whichever face the roster
1424
+ * offers — 0.1.5's lease-free `standingKeyFor` first, rc.1's leased
1425
+ * `acquireScope` when that is absent. Errors propagate unchanged, so the
1426
+ * caller owns the strict/degrade policy; the caller also owns the release.
1427
+ * @param roster - the agent-preset roster (already probed present).
1428
+ * @param id - the preset to read, or undefined for the deployment default.
1429
+ * @returns the key and the lease release, if the face leases.
1430
+ */
1431
+ async function acquireStandingScope(roster, id) {
1432
+ if (roster.standingKeyFor !== void 0) return { key: await roster.standingKeyFor(id) };
1433
+ if (roster.acquireScope === void 0) return { key: void 0 };
1434
+ const lease = await roster.acquireScope(id);
1435
+ const dispose = leaseRelease(lease);
1436
+ return {
1437
+ key: leaseKey(lease),
1438
+ ...dispose === void 0 ? {} : { dispose }
1439
+ };
1440
+ }
1441
+ /**
1442
+ * Resolve the catalog's reading scope.
1443
+ *
1444
+ * Resolving a preset MOUNTS it (the roster's single-flight standing mount),
1445
+ * so this is not a free read: asking for the capability face of a preset
1446
+ * nothing has composed yet composes it.
1447
+ * @param roster - the optional agent-preset roster (`ctx.get('agentPresets')`).
1448
+ * @param presetId - the preset to read, or undefined for the deployment default.
1449
+ * @param strict - fingerprint mode: throw rather than degrade to the global layer.
1450
+ * @returns the scope to read at and the label it may carry.
1451
+ * @throws in strict mode when the roster is absent for a named preset, or
1452
+ * cannot resolve a standing scope.
1453
+ */
1454
+ async function resolvePresetScope(roster, presetId, strict) {
1455
+ if (roster?.standingKeyFor === void 0 && roster?.acquireScope === void 0) {
1456
+ if (strict && presetId !== void 0) throw new Error(`capability-catalog: cannot fingerprint preset ${JSON.stringify(presetId)} — this composition mounts no agent-preset roster`);
1457
+ return {
1458
+ scope: void 0,
1459
+ preset: void 0
1460
+ };
1461
+ }
1462
+ const wanted = presetId ?? roster?.defaultId;
1463
+ let read;
1464
+ try {
1465
+ read = await acquireStandingScope(roster, wanted);
1466
+ } catch (error) {
1467
+ if (strict) throw new Error(`capability-catalog: cannot fingerprint preset ${JSON.stringify(wanted)}: ${error instanceof Error ? error.message : String(error)}`);
1468
+ return {
1469
+ scope: void 0,
1470
+ preset: void 0
1471
+ };
1472
+ }
1473
+ if (read.key === void 0) {
1474
+ await read.dispose?.();
1475
+ if (strict) throw new Error(`capability-catalog: the roster resolved no standing scope for preset ${JSON.stringify(wanted)}`);
1476
+ return {
1477
+ scope: void 0,
1478
+ preset: void 0
1479
+ };
1480
+ }
1481
+ return {
1482
+ scope: read.key,
1483
+ preset: typeof wanted === "string" && wanted !== "" ? wanted : void 0,
1484
+ ...read.dispose === void 0 ? {} : { dispose: read.dispose }
1485
+ };
1486
+ }
1487
+ /** The lease's key, read structurally (`key` is opaque to the catalog). */
1488
+ function leaseKey(lease) {
1489
+ if (lease === null || typeof lease !== "object" && typeof lease !== "function") return void 0;
1490
+ return lease.key;
1491
+ }
1492
+ /**
1493
+ * The lease's release, hung on `Symbol.asyncDispose` by the roster. The
1494
+ * es2024 target does not type that symbol, so it is read through a cast —
1495
+ * any runtime hosting an rc.1 roster defines it, because the roster's own
1496
+ * lease literal is keyed by it. A lease without a callable release degrades
1497
+ * to the 0.1.5 shape (nothing to dispose) rather than failing the read.
1498
+ */
1499
+ function leaseRelease(lease) {
1500
+ if (lease === null || typeof lease !== "object" && typeof lease !== "function") return void 0;
1501
+ const asyncDispose = Symbol.asyncDispose;
1502
+ const release = asyncDispose === void 0 ? void 0 : lease[asyncDispose];
1503
+ if (typeof release !== "function") return void 0;
1504
+ return async () => {
1505
+ await release.call(lease);
1506
+ };
1507
+ }
1508
+ //#endregion
1509
+ //#region lib/types/scoped-delivery.js
1510
+ /**
1511
+ * Preset-scoped skill delivery: a plugin-owned skills root whose entries are
1512
+ * delivered **into one preset's scope layer** instead of into the global layer.
1513
+ *
1514
+ * The skill registry is layered by the scope a registration is made from, and a
1515
+ * read merges the global layer with the viewing agent's scope chain. A provider
1516
+ * registered through a context scoped to a preset's standing key is therefore
1517
+ * consulted only for sessions on that preset — no read-time identity guessing,
1518
+ * and no dependence on the borrowed lookup options.
1519
+ *
1520
+ * The per-skill policy lives in the skill's own frontmatter
1521
+ * (`metadata.presetScope: [<preset-id>, …]`); an absent or empty list means the
1522
+ * entry is not delivered by this module at all. A managed name that ALSO exists
1523
+ * in a default skill root is refused delivery and reported: a leftover copy is
1524
+ * served by the host provider in every preset, so silently delivering a second
1525
+ * candidate would make the scope a lie.
1526
+ *
1527
+ * Scope of enforcement: this is a discovery/delivery policy for one Harness
1528
+ * instance, not an authorization boundary. `ScopeKey` values are ordinary
1529
+ * objects and the roster hands real ones to any caller, so a same-process plugin
1530
+ * can claim membership. In-process agents and children joining the parent's
1531
+ * composition are covered; native codex/claude-code and ACP backends start their
1532
+ * own runtime and never consult this registry at all.
1533
+ * @module @khorsheed/dsh-capability-catalog/scoped-delivery
1534
+ */
1535
+ let presetRegistryProbe;
1536
+ /**
1537
+ * Resolve the preset-registry module by name. The 0.1.7-rc.1 host renamed
1538
+ * `@deepseek-ai/dsh-agent-presets` to `@deepseek-ai/dsh-agent-preset-registry`
1539
+ * (the livePresetMounts API carried over); 0.1.5/0.1.6 hosts install only the
1540
+ * old name, and a host without preset composition installs neither — `null`,
1541
+ * and {@link ScopedSkillDelivery.liveKeys} then reports nothing live, the same
1542
+ * degrade the call's own catch encodes. A STATIC import of either name is a
1543
+ * module-resolution failure at load on hosts installing only the other —
1544
+ * pack-dist strips `dependencies`, so the specifier resolves against the
1545
+ * host's install tree. Both specifiers stay static string literals so tsdown
1546
+ * externalizes them and tsc type-checks them.
1547
+ * @returns the module namespace, or null when neither name resolves.
1548
+ */
1549
+ function loadPresetRegistry() {
1550
+ presetRegistryProbe ??= import("@deepseek-ai/dsh-agent-preset-registry").catch(() => import("@deepseek-ai/dsh-agent-presets")).catch(() => null);
1551
+ return presetRegistryProbe;
1552
+ }
1553
+ /** Provider label for managed entries; also the candidate's `provider` field. */
1554
+ const SCOPED_PROVIDER_NAME = "capability-catalog";
1555
+ /** Candidate rank, beside the filesystem provider's `custom` roots. */
1556
+ const MANAGED_SKILL_RANK = 300;
1557
+ /** Directory under the harness home that holds preset-scoped skills. */
1558
+ const SCOPED_SKILLS_DIR = join("capability-catalog", "skills");
1559
+ /** Debounce for filesystem-triggered rescans. */
1560
+ 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
+ /** The managed root for one harness home. */
1566
+ function scopedSkillsRoot(dshHome) {
1567
+ return join(dshHome, SCOPED_SKILLS_DIR);
1568
+ }
1569
+ /**
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
+ * Parse one managed `SKILL.md` into its delivery record.
1641
+ * @param content - the file text.
1642
+ * @param dir - the skill's directory (its resource base).
1643
+ * @param path - the absolute `SKILL.md` path.
1644
+ * @returns the record, or undefined when the frontmatter is unusable.
1645
+ */
1646
+ 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);
1654
+ return {
1655
+ name,
1656
+ 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,
1661
+ dir,
1662
+ path,
1663
+ content
1664
+ };
1665
+ }
1666
+ /** Whether this skill asks to be delivered to a specific preset set. */
1667
+ function isScoped(skill) {
1668
+ return skill.presetScope !== void 0 && skill.presetScope.length > 0;
1669
+ }
1670
+ /** Project one managed skill onto a provider candidate for one preset. */
1671
+ function candidateFor(skill) {
1672
+ return {
1673
+ name: skill.name,
1674
+ description: skill.description,
1675
+ ...skill.whenToUse === void 0 ? {} : { whenToUse: skill.whenToUse },
1676
+ invocation: {
1677
+ modelInvocable: skill.modelInvocable,
1678
+ userInvocable: skill.userInvocable
1679
+ },
1680
+ source: "custom",
1681
+ provider: SCOPED_PROVIDER_NAME,
1682
+ rank: 300,
1683
+ locator: skill.path,
1684
+ path: skill.path,
1685
+ metadata: { presetScope: [...skill.presetScope ?? []] },
1686
+ resourceBase: {
1687
+ kind: "directory",
1688
+ path: skill.dir
1689
+ }
1690
+ };
1691
+ }
1692
+ /** Project one managed skill onto the complete definition the loader returns. */
1693
+ function definitionFor(skill) {
1694
+ return {
1695
+ ...candidateFor(skill),
1696
+ content: skill.content
1697
+ };
1698
+ }
1699
+ /** The default roots a duplicate copy could live in. */
1700
+ function defaultSkillRoots(dshHome, workdir) {
1701
+ const roots = [
1702
+ join(dshHome, "skills"),
1703
+ join(dshHome, ".agents", "skills"),
1704
+ join(process.env.DSH_AGENTS_HOME ?? join(homedir(), ".agents"), "skills")
1705
+ ];
1706
+ if (workdir !== void 0 && workdir !== "") roots.push(join(workdir, ".dsh", "skills"), join(workdir, ".agents", "skills"));
1707
+ return roots;
1708
+ }
1709
+ /**
1710
+ * Find managed names that a default root also supplies.
1711
+ *
1712
+ * A duplicate is what makes a scope claim false: the host provider delivers the
1713
+ * default root's copy into every preset regardless of this module's decision.
1714
+ * @param skills - the managed entries under consideration.
1715
+ * @param dshHome - the harness home.
1716
+ * @param workdir - the session workspace, when known.
1717
+ * @returns skill name → the default root that already supplies it.
1718
+ */
1719
+ function detectConflicts(skills, dshHome, workdir) {
1720
+ const conflicts = /* @__PURE__ */ new Map();
1721
+ for (const root of defaultSkillRoots(dshHome, workdir)) for (const skill of skills) {
1722
+ if (conflicts.has(skill.name)) continue;
1723
+ if (existsSync(join(root, skill.name, "SKILL.md")) || existsSync(join(root, `${skill.name}.md`))) conflicts.set(skill.name, root);
1724
+ }
1725
+ return conflicts;
1726
+ }
1727
+ /**
1728
+ * Scan the managed root for skill directories.
1729
+ * @param root - the managed skills root.
1730
+ * @returns every readable, usable entry, sorted by name.
1731
+ */
1732
+ async function scanManagedSkills(root) {
1733
+ let children;
1734
+ try {
1735
+ children = await readdir(root, { withFileTypes: true });
1736
+ } catch {
1737
+ return [];
1738
+ }
1739
+ const found = [];
1740
+ for (const child of children) {
1741
+ if (!child.isDirectory() || !KEBAB_ID.test(child.name)) continue;
1742
+ const dir = join(root, child.name);
1743
+ const path = join(dir, "SKILL.md");
1744
+ try {
1745
+ const [content, info] = await Promise.all([readFile(path, "utf8"), stat(path)]);
1746
+ if (!info.isFile()) continue;
1747
+ const skill = managedSkillFrom(content, dir, path);
1748
+ if (skill !== void 0) found.push(skill);
1749
+ } catch {}
1750
+ }
1751
+ return found.sort((left, right) => left.name.localeCompare(right.name));
1752
+ }
1753
+ /** The sentinel preset id of the delivery that serves every preset. */
1754
+ const GLOBAL_PRESET = "*";
1755
+ /**
1756
+ * Delivers managed skills into the scope layers of the presets their
1757
+ * frontmatter names.
1758
+ *
1759
+ * Lifecycle: {@link start} once (watcher + first reconcile), {@link reconcile}
1760
+ * whenever policy or files may have changed, {@link dispose} when the owning
1761
+ * plugin unloads. Reconciliation never fails loudly: every problem becomes a
1762
+ * status field, because a catalog must not fail the boot of the instance it is
1763
+ * describing.
1764
+ */
1765
+ var ScopedSkillDelivery = class {
1766
+ ctx;
1767
+ deps;
1768
+ entries = [];
1769
+ managed = [];
1770
+ conflicts = /* @__PURE__ */ new Map();
1771
+ presetErrors = /* @__PURE__ */ new Map();
1772
+ watcher;
1773
+ timer;
1774
+ watching = false;
1775
+ failure;
1776
+ started = false;
1777
+ disposed = false;
1778
+ /** The probed registry's mount list (undefined until start()'s probe resolves). */
1779
+ presetMounts;
1780
+ /**
1781
+ * @param ctx - the host context the delivery scopes are minted under.
1782
+ * @param deps - borrowed services and paths, all optional at runtime.
1783
+ */
1784
+ constructor(ctx, deps) {
1785
+ this.ctx = ctx;
1786
+ this.deps = deps;
1787
+ }
1788
+ /** Start watching the managed root and deliver what it currently holds. */
1789
+ async start() {
1790
+ if (this.started || this.disposed) return;
1791
+ this.started = true;
1792
+ this.presetMounts = (await loadPresetRegistry())?.livePresetMounts;
1793
+ await this.ensureRoot();
1794
+ this.watchRoot();
1795
+ this.ctx.on("agent-preset/selected", () => {
1796
+ this.reconcile();
1797
+ });
1798
+ await this.reconcile();
1799
+ }
1800
+ /** Re-read policy and files, then converge the delivery registrations. */
1801
+ async reconcile() {
1802
+ if (this.disposed) return;
1803
+ const registry = this.deps.registry();
1804
+ const roster = this.deps.roster();
1805
+ if (registry?.registerProvider === void 0) {
1806
+ this.failure = "the skill registry exposes no provider registration";
1807
+ this.disposeEntries();
1808
+ this.managed = [];
1809
+ return;
1810
+ }
1811
+ if (roster?.standingKeyFor === void 0 && roster?.acquireScope === void 0) {
1812
+ this.failure = "no agent-preset roster is composed";
1813
+ this.disposeEntries();
1814
+ this.managed = [];
1815
+ return;
1816
+ }
1817
+ const root = this.root();
1818
+ this.managed = await scanManagedSkills(root);
1819
+ this.conflicts = detectConflicts(this.managed, this.deps.dshHome(), this.deps.workdir?.());
1820
+ const byPreset = /* @__PURE__ */ new Map();
1821
+ for (const skill of this.managed) {
1822
+ if (!isScoped(skill) || this.conflicts.has(skill.name)) continue;
1823
+ for (const presetId of skill.presetScope ?? []) {
1824
+ const list = byPreset.get(presetId) ?? [];
1825
+ list.push(skill);
1826
+ byPreset.set(presetId, list);
1827
+ }
1828
+ }
1829
+ const unscoped = this.managed.filter((skill) => !this.conflicts.has(skill.name) && !isScoped(skill));
1830
+ this.presetErrors = /* @__PURE__ */ new Map();
1831
+ const live = this.liveKeys();
1832
+ const resolved = /* @__PURE__ */ new Map();
1833
+ for (const presetId of byPreset.keys()) resolved.set(presetId, await this.resolveKey(roster, presetId));
1834
+ for (const entry of [...this.entries]) {
1835
+ if (entry.presetId === GLOBAL_PRESET) continue;
1836
+ if (!byPreset.has(entry.presetId)) {
1837
+ await this.dropEntry(entry);
1838
+ continue;
1839
+ }
1840
+ const wantedKey = resolved.get(entry.presetId);
1841
+ if (wantedKey === entry.key) continue;
1842
+ if (wantedKey !== void 0 && entry.key !== void 0 && live.has(entry.key)) continue;
1843
+ await this.dropEntry(entry);
1844
+ }
1845
+ for (const presetId of byPreset.keys()) {
1846
+ const key = resolved.get(presetId);
1847
+ if (key === void 0) continue;
1848
+ if (this.entries.some((entry) => entry.presetId === presetId && entry.key === key)) continue;
1849
+ await this.register(presetId, key);
1850
+ }
1851
+ const globalEntry = this.entries.find((entry) => entry.presetId === GLOBAL_PRESET);
1852
+ if (unscoped.length > 0) {
1853
+ if (globalEntry === void 0) await this.registerGlobal();
1854
+ } else if (globalEntry !== void 0) await this.dropEntry(globalEntry);
1855
+ this.failure = void 0;
1856
+ this.invalidateAll();
1857
+ }
1858
+ /** The current status, for the settings surface and the model-facing tool. */
1859
+ status() {
1860
+ const delivered = /* @__PURE__ */ new Set();
1861
+ for (const entry of this.entries) for (const skill of this.skillsFor(entry.presetId)) delivered.add(skill.name);
1862
+ const skills = this.managed.map((skill) => {
1863
+ const conflict = this.conflicts.get(skill.name);
1864
+ return {
1865
+ name: skill.name,
1866
+ description: skill.description,
1867
+ modelInvocable: skill.modelInvocable,
1868
+ userInvocable: skill.userInvocable,
1869
+ path: skill.path,
1870
+ presets: [...skill.presetScope ?? []],
1871
+ delivered: delivered.has(skill.name),
1872
+ ...conflict === void 0 ? {} : { conflict: join(conflict, skill.name) }
1873
+ };
1874
+ });
1875
+ const presets = [...new Set(this.entries.map((entry) => entry.presetId))].sort((left, right) => left.localeCompare(right)).map((presetId) => {
1876
+ const skills = this.managed.filter((skill) => !this.conflicts.has(skill.name) && (skill.presetScope ?? []).includes(presetId)).map((skill) => skill.name);
1877
+ const error = this.presetErrors.get(presetId);
1878
+ return {
1879
+ presetId,
1880
+ skills,
1881
+ ...error === void 0 ? {} : { error }
1882
+ };
1883
+ });
1884
+ for (const [presetId, error] of this.presetErrors) if (!presets.some((row) => row.presetId === presetId)) presets.push({
1885
+ presetId,
1886
+ skills: [],
1887
+ error
1888
+ });
1889
+ return {
1890
+ enabled: this.failure === void 0 && this.entries.length > 0,
1891
+ ...this.failure === void 0 ? {} : { reason: this.failure },
1892
+ root: this.root(),
1893
+ watching: this.watching,
1894
+ skills,
1895
+ presets,
1896
+ customRootsUnverifiable: true
1897
+ };
1898
+ }
1899
+ /** Stop watching and dispose every delivery registration. */
1900
+ async dispose() {
1901
+ if (this.disposed) return;
1902
+ this.disposed = true;
1903
+ if (this.timer !== void 0) clearTimeout(this.timer);
1904
+ this.timer = void 0;
1905
+ this.watcher?.close();
1906
+ this.watcher = void 0;
1907
+ this.watching = false;
1908
+ await this.disposeEntries();
1909
+ this.managed = [];
1910
+ }
1911
+ /** The managed root under the current harness home. */
1912
+ root() {
1913
+ return scopedSkillsRoot(this.deps.dshHome());
1914
+ }
1915
+ /** Create the managed root if it is still absent. */
1916
+ async ensureRoot() {
1917
+ try {
1918
+ await mkdir(this.root(), { recursive: true });
1919
+ } catch (error) {
1920
+ this.failure = `the managed root could not be created: ${describeError(error)}`;
1921
+ }
1922
+ }
1923
+ /** Watch the managed root, degrading to on-demand rescans when it cannot. */
1924
+ watchRoot() {
1925
+ try {
1926
+ this.watcher = watch(this.root(), { recursive: true }, () => this.scheduleRescan());
1927
+ this.watching = true;
1928
+ } catch (error) {
1929
+ this.watching = false;
1930
+ this.deps.log(`capability-catalog: managed root is not watched (${describeError(error)}); scope changes apply on the next reconcile`);
1931
+ }
1932
+ }
1933
+ /** Coalesce a burst of filesystem events into one reconcile. */
1934
+ scheduleRescan() {
1935
+ if (this.timer !== void 0) clearTimeout(this.timer);
1936
+ this.timer = setTimeout(() => {
1937
+ this.timer = void 0;
1938
+ this.reconcile().catch((error) => {
1939
+ this.deps.log(`capability-catalog: scoped skill reconcile failed: ${describeError(error)}`);
1940
+ });
1941
+ }, RESCAN_DEBOUNCE_MS);
1942
+ }
1943
+ /** The standing keys that still have a live mount in this process. */
1944
+ liveKeys() {
1945
+ const keys = /* @__PURE__ */ new Set();
1946
+ const livePresetMounts = this.presetMounts;
1947
+ if (livePresetMounts === void 0) return keys;
1948
+ try {
1949
+ for (const mount of livePresetMounts()) if (mount.key !== void 0) keys.add(mount.key);
1950
+ } catch {}
1951
+ return keys;
1952
+ }
1953
+ /** Resolve one preset's standing key, remembering why it failed. */
1954
+ async resolveKey(roster, presetId) {
1955
+ try {
1956
+ const read = await acquireStandingScope(roster, presetId);
1957
+ try {
1958
+ if (read.key === void 0) {
1959
+ this.presetErrors.set(presetId, "the roster resolved no standing scope");
1960
+ return;
1961
+ }
1962
+ return read.key;
1963
+ } finally {
1964
+ await read.dispose?.().catch((error) => {
1965
+ this.deps.log(`capability-catalog: scope lease release failed: ${describeError(error)}`);
1966
+ });
1967
+ }
1968
+ } catch (error) {
1969
+ this.presetErrors.set(presetId, describeError(error));
1970
+ return;
1971
+ }
1972
+ }
1973
+ /** Register one delivery provider into a preset's layer. */
1974
+ async register(presetId, key) {
1975
+ const scope = createScope(this.ctx, key);
1976
+ try {
1977
+ const injected = await injectableScope(scope.ctx);
1978
+ if (injected === void 0) {
1979
+ await scope.dispose();
1980
+ this.presetErrors.set(presetId, `the ${SKILLS_SERVICE} service was not injectable in the delivery scope`);
1981
+ return;
1982
+ }
1983
+ let invalidate;
1984
+ injected.skills.registerProvider((control) => {
1985
+ invalidate = control.invalidate;
1986
+ return this.providerFor(presetId, control);
1987
+ });
1988
+ this.entries.push({
1989
+ presetId,
1990
+ key,
1991
+ dispose: () => scope.dispose(),
1992
+ invalidate: () => invalidate?.()
1993
+ });
1994
+ } catch (error) {
1995
+ await scope.dispose().catch(() => {});
1996
+ this.presetErrors.set(presetId, `delivery scope could not be created: ${describeError(error)}`);
1997
+ }
1998
+ }
1999
+ /** Register the provider that serves every preset (the unscoped skills). */
2000
+ async registerGlobal() {
2001
+ try {
2002
+ const injected = await injectableScope(this.ctx);
2003
+ if (injected === void 0) {
2004
+ this.failure = `the ${SKILLS_SERVICE} service was not injectable for the global delivery`;
2005
+ return;
2006
+ }
2007
+ let invalidate;
2008
+ const dispose = injected.skills.registerProvider((control) => {
2009
+ invalidate = control.invalidate;
2010
+ return this.providerFor(GLOBAL_PRESET, control);
2011
+ });
2012
+ this.entries.push({
2013
+ presetId: GLOBAL_PRESET,
2014
+ dispose: () => {
2015
+ dispose();
2016
+ },
2017
+ invalidate: () => invalidate?.()
2018
+ });
2019
+ } catch (error) {
2020
+ this.failure = `the global delivery scope could not be created: ${describeError(error)}`;
2021
+ }
2022
+ }
2023
+ /**
2024
+ * The provider serving one preset. It reads only its own preset's assignment
2025
+ * and re-reads the files, so a policy or content edit needs no re-registration
2026
+ * beyond the invalidation reconcile already performs.
2027
+ */
2028
+ providerFor(presetId, _control) {
2029
+ return {
2030
+ name: SCOPED_PROVIDER_NAME,
2031
+ list: async () => this.skillsFor(presetId).map((skill) => candidateFor(skill)),
2032
+ get: async (candidate) => {
2033
+ const skill = this.skillsFor(presetId).find((entry) => entry.name === candidate.name);
2034
+ return skill === void 0 ? void 0 : definitionFor(skill);
2035
+ }
2036
+ };
2037
+ }
2038
+ /** The managed skills one delivery currently serves. */
2039
+ skillsFor(presetId) {
2040
+ return this.managed.filter((skill) => !this.conflicts.has(skill.name) && (presetId === GLOBAL_PRESET ? !isScoped(skill) : (skill.presetScope ?? []).includes(presetId)));
2041
+ }
2042
+ /** Invalidate every registration's completed catalogs. */
2043
+ invalidateAll() {
2044
+ for (const entry of this.entries) entry.invalidate();
2045
+ }
2046
+ /** Dispose every registration. */
2047
+ async disposeEntries() {
2048
+ const entries = this.entries.splice(0, this.entries.length);
2049
+ for (const entry of entries) try {
2050
+ await entry.dispose();
2051
+ } catch (error) {
2052
+ this.deps.log(`capability-catalog: delivery scope disposal failed: ${describeError(error)}`);
2053
+ }
2054
+ }
2055
+ /** Dispose one registration and forget it. */
2056
+ async dropEntry(entry) {
2057
+ const index = this.entries.indexOf(entry);
2058
+ if (index >= 0) this.entries.splice(index, 1);
2059
+ try {
2060
+ await entry.dispose();
2061
+ } catch (error) {
2062
+ this.deps.log(`capability-catalog: delivery scope disposal failed: ${describeError(error)}`);
2063
+ }
2064
+ }
2065
+ };
2066
+ /** Render an unknown failure for a status field or a log line. */
2067
+ function describeError(error) {
2068
+ return error instanceof Error ? error.message : String(error);
2069
+ }
2070
+ /** The registry every delivery registration is made through. */
2071
+ const SKILLS_SERVICE = "skills";
2072
+ /** How long an injection may take before the registration is abandoned. */
2073
+ const INJECT_TIMEOUT_MS = 500;
2074
+ /**
2075
+ * A context within `scope` that may access the skill registry.
2076
+ *
2077
+ * A scope-minted context carries no dependency access by itself, and Cordis
2078
+ * resolves an `inject` callback after the current task — hence the bounded
2079
+ * await, which gives `reconcile()` a settled answer instead of a registration
2080
+ * that appears at some later tick.
2081
+ * @param scopeCtx - the context minted for one preset's standing key.
2082
+ * @returns the injected context, or undefined when it never became available.
2083
+ */
2084
+ function injectableScope(scopeCtx) {
2085
+ return new Promise((resolve) => {
2086
+ let settled = false;
2087
+ const finish = (value) => {
2088
+ if (settled) return;
2089
+ settled = true;
2090
+ clearTimeout(timer);
2091
+ resolve(value);
2092
+ };
2093
+ const timer = setTimeout(() => finish(void 0), INJECT_TIMEOUT_MS);
2094
+ scopeCtx.inject([SKILLS_SERVICE], (injected) => finish(injected));
2095
+ });
2096
+ }
2097
+ //#endregion
2098
+ //#region lib/types/scoped-edits.js
2099
+ /**
2100
+ * Writing side of preset-scoped delivery: rewrite one managed skill's
2101
+ * `metadata.presetScope`, adopt an installed skill into the managed root, and
2102
+ * release one back out.
2103
+ *
2104
+ * Every write here is deliberately narrow. The preset scope is edited in place
2105
+ * in the skill's OWN frontmatter (the same store the delivery reads), adoption
2106
+ * copies before it removes anything, and release refuses to overwrite an
2107
+ * existing default-root skill. Nothing deletes a managed skill: releasing is the
2108
+ * documented recovery direction, and a broken plugin must not take the user's
2109
+ * files with it.
2110
+ * @module @khorsheed/dsh-capability-catalog/scoped-edits
2111
+ */
2112
+ /** Shared guard: every write path addresses one skill directory by name. */
2113
+ function invalidName(name) {
2114
+ return isKebabId(name) ? void 0 : {
2115
+ ok: false,
2116
+ error: `"${name}" is not a valid skill name`
2117
+ };
2118
+ }
2119
+ /**
2120
+ * Rewrite a SKILL.md's `metadata.presetScope` list.
2121
+ *
2122
+ * The frontmatter is edited line by line rather than re-serialized, so every
2123
+ * other key, comment, and quote in the file survives untouched. The canonical
2124
+ * inline form is written: `presetScope: []` for "every preset", otherwise
2125
+ * `presetScope: [a, b]`.
2126
+ * @param content - the current SKILL.md text.
2127
+ * @param presets - the preset ids to declare (deduplicated, order preserved).
2128
+ * @returns the rewritten file text.
2129
+ * @throws when the file has no frontmatter block to edit.
2130
+ */
2131
+ function withPresetScope(content, presets) {
2132
+ const split = splitFrontmatter(content);
2133
+ if (split === void 0) throw new Error("the skill has no frontmatter block to edit");
2134
+ const ids = [...new Set(presets)];
2135
+ const lines = split.front.split("\n");
2136
+ const kept = [];
2137
+ for (let index = 0; index < lines.length; index += 1) {
2138
+ const line = lines[index] ?? "";
2139
+ const match = /^(\s*)presetScope\s*:/.exec(line);
2140
+ if (match === null) {
2141
+ kept.push(line);
2142
+ continue;
2143
+ }
2144
+ const indent = (match[1] ?? "").length;
2145
+ while (index + 1 < lines.length) {
2146
+ const next = lines[index + 1] ?? "";
2147
+ const entry = /^(\s*)-\s*/.exec(next);
2148
+ if (entry === null || (entry[1] ?? "").length <= indent) break;
2149
+ index += 1;
2150
+ }
2151
+ }
2152
+ const rendered = `presetScope: [${ids.join(", ")}]`;
2153
+ const metadataAt = kept.findIndex((line) => /^\s*metadata\s*:\s*$/.test(line));
2154
+ if (metadataAt >= 0) {
2155
+ const indent = (/^(\s*)/.exec(kept[metadataAt] ?? "")?.[1] ?? "").length + 2;
2156
+ kept.splice(metadataAt + 1, 0, `${" ".repeat(indent)}${rendered}`);
2157
+ } else if (kept.some((line) => line.trim() !== "")) kept.push("metadata:", ` ${rendered}`);
2158
+ else kept.push("metadata:", ` ${rendered}`);
2159
+ return `---\n${kept.join("\n")}\n---\n${split.body}`;
2160
+ }
2161
+ /** Find the managed entry for one skill name. */
2162
+ async function managedLocation(dshHome, name) {
2163
+ const skill = (await scanManagedSkills(scopedSkillsRoot(dshHome))).find((entry) => entry.name === name);
2164
+ if (skill === void 0) return void 0;
2165
+ return {
2166
+ name: skill.name,
2167
+ path: skill.path,
2168
+ dir: skill.dir
2169
+ };
2170
+ }
2171
+ /** Write a file through a sibling temp file and one atomic rename. */
2172
+ async function writeAtomic(path, content) {
2173
+ const temp = join(dirname(path), `.${Date.now()}-${process.pid}.tmp`);
2174
+ await writeFile(temp, content, "utf8");
2175
+ await rename(temp, path);
2176
+ }
2177
+ /**
2178
+ * Set one managed skill's preset scope.
2179
+ * @param dshHome - the harness home owning the managed root.
2180
+ * @param name - the skill name (its directory under the managed root).
2181
+ * @param presets - the preset ids to declare.
2182
+ * @returns the outcome, with a reason on failure.
2183
+ */
2184
+ async function setManagedPresetScope(dshHome, name, presets) {
2185
+ const badName = invalidName(name);
2186
+ if (badName !== void 0) return badName;
2187
+ const location = await managedLocation(dshHome, name);
2188
+ if (location === void 0) return {
2189
+ ok: false,
2190
+ error: `"${name}" is not a managed skill`
2191
+ };
2192
+ try {
2193
+ const content = await readFile(location.path, "utf8");
2194
+ const updated = withPresetScope(content, presets);
2195
+ if (updated !== content) await writeAtomic(location.path, updated);
2196
+ return { ok: true };
2197
+ } catch (error) {
2198
+ return {
2199
+ ok: false,
2200
+ error: messageOf(error)
2201
+ };
2202
+ }
2203
+ }
2204
+ /**
2205
+ * Locate one skill in the default roots.
2206
+ * @param dshHome - the harness home.
2207
+ * @param workdir - the session workspace, when known.
2208
+ * @param name - the skill name.
2209
+ * @returns the source found, or undefined.
2210
+ */
2211
+ function findSkillSource(dshHome, workdir, name) {
2212
+ for (const root of defaultSkillRoots(dshHome, workdir)) {
2213
+ const dir = join(root, name);
2214
+ if (existsSync(join(dir, "SKILL.md"))) return {
2215
+ root,
2216
+ dir
2217
+ };
2218
+ const file = join(root, `${name}.md`);
2219
+ if (existsSync(file)) return {
2220
+ root,
2221
+ file
2222
+ };
2223
+ }
2224
+ }
2225
+ /**
2226
+ * Move an installed skill into the managed root and declare its preset scope.
2227
+ *
2228
+ * Order matters: the source is only removed after the copy has been committed
2229
+ * and the scope written, so a failure at any step leaves one complete copy
2230
+ * behind. The copy lands as a temp sibling and is committed with one rename, so
2231
+ * the managed root never holds a half-written skill.
2232
+ * @param dshHome - the harness home.
2233
+ * @param name - the skill name.
2234
+ * @param presets - the preset ids to declare.
2235
+ * @param workdir - the session workspace, when known.
2236
+ * @returns the outcome, with a reason on failure.
2237
+ */
2238
+ async function adoptManagedSkill(dshHome, name, presets, workdir) {
2239
+ const badName = invalidName(name);
2240
+ if (badName !== void 0) return badName;
2241
+ const root = scopedSkillsRoot(dshHome);
2242
+ const target = join(root, name);
2243
+ if (existsSync(target)) return {
2244
+ ok: false,
2245
+ error: `the managed root already holds "${name}"`
2246
+ };
2247
+ const source = findSkillSource(dshHome, workdir, name);
2248
+ if (source === void 0) return {
2249
+ ok: false,
2250
+ error: `"${name}" was not found in a default skill root`
2251
+ };
2252
+ const staging = join(root, `.${name}-${process.pid}`);
2253
+ try {
2254
+ await mkdir(root, { recursive: true });
2255
+ await rm(staging, {
2256
+ recursive: true,
2257
+ force: true
2258
+ });
2259
+ if (source.dir !== void 0) await cp(source.dir, staging, { recursive: true });
2260
+ else {
2261
+ await mkdir(staging, { recursive: true });
2262
+ await writeFile(join(staging, "SKILL.md"), await readFile(source.file ?? "", "utf8"), "utf8");
2263
+ }
2264
+ const staged = join(staging, "SKILL.md");
2265
+ const content = await readFile(staged, "utf8");
2266
+ await writeFile(staged, withPresetScope(content, presets), "utf8");
2267
+ await rename(staging, target);
2268
+ } catch (error) {
2269
+ await rm(staging, {
2270
+ recursive: true,
2271
+ force: true
2272
+ }).catch(() => {});
2273
+ return {
2274
+ ok: false,
2275
+ error: messageOf(error)
2276
+ };
2277
+ }
2278
+ try {
2279
+ if (source.dir !== void 0) await rm(source.dir, {
2280
+ recursive: true,
2281
+ force: true
2282
+ });
2283
+ else if (source.file !== void 0) await rm(source.file, { force: true });
2284
+ } catch (error) {
2285
+ return {
2286
+ ok: false,
2287
+ error: `adopted "${name}", but the default-root copy could not be removed: ${messageOf(error)}`
2288
+ };
2289
+ }
2290
+ return { ok: true };
2291
+ }
2292
+ /**
2293
+ * Move a managed skill back to the user skill root — the recovery direction.
2294
+ *
2295
+ * A managed skill is delivered by this plugin, so releasing it restores plain
2296
+ * filesystem behavior that survives the plugin being removed.
2297
+ * @param dshHome - the harness home.
2298
+ * @param name - the skill name.
2299
+ * @returns the outcome, with a reason on failure.
2300
+ */
2301
+ async function releaseManagedSkill(dshHome, name) {
2302
+ const badName = invalidName(name);
2303
+ if (badName !== void 0) return badName;
2304
+ const location = await managedLocation(dshHome, name);
2305
+ if (location === void 0) return {
2306
+ ok: false,
2307
+ error: `"${name}" is not a managed skill`
2308
+ };
2309
+ const target = join(dshHome, "skills", name);
2310
+ if (existsSync(target)) return {
2311
+ ok: false,
2312
+ error: `"${name}" already exists in ${join(dshHome, "skills")}`
2313
+ };
2314
+ try {
2315
+ await mkdir(dirname(target), { recursive: true });
2316
+ await cp(location.dir, target, { recursive: true });
2317
+ await rm(location.dir, {
2318
+ recursive: true,
2319
+ force: true
2320
+ });
2321
+ return { ok: true };
2322
+ } catch (error) {
2323
+ return {
2324
+ ok: false,
2325
+ error: messageOf(error)
2326
+ };
2327
+ }
2328
+ }
2329
+ /** Whether a path exists and is a directory. */
2330
+ async function isDirectory(path) {
2331
+ try {
2332
+ return (await stat(path)).isDirectory();
2333
+ } catch {
2334
+ return false;
2335
+ }
2336
+ }
2337
+ /** Render an unknown failure. */
2338
+ function messageOf(error) {
2339
+ return error instanceof Error ? error.message : String(error);
2340
+ }
2341
+ //#endregion
2342
+ //#region lib/types/shellEnv.js
2343
+ /**
2344
+ * Skill env injection via the official `ctx.shellEnv` seam (service-agnostic).
2345
+ *
2346
+ * The catalog lets a user configure a credential for a skill's declared env var
2347
+ * (e.g. `$WEREED_API_KEY`). That value is NOT in `process.env` — the launch env
2348
+ * is a read-only snapshot and never injects credentials. This module registers
2349
+ * a `ctx.shellEnv` contributor that exposes each configured credential as a
2350
+ * trusted, per-execution `DSH_<KEY>` variable. The model references it by shell
2351
+ * expansion (`--key="$DSH_WEREED_API_KEY"`) so the raw value never enters the
2352
+ * model's context; it only becomes visible if the model (or a command) actively
2353
+ * prints it — a default-hide, not a hard secret boundary.
2354
+ *
2355
+ * Constraint handling (shell-env/src/index.ts):
2356
+ * - keys must start `DSH_` and match `/^[A-Z][A-Z0-9_]*$/`; one owner per key;
2357
+ * reserved built-ins (`DSH_HOME`/`DSH_SHELL`/`DSH_SESSION_ID`) are not ownable.
2358
+ * - `resolve()` is synchronous; credential reads are async, so values live in a
2359
+ * cache (`envCache`) refreshed on apply + on `credentials/*-updated`.
2360
+ *
2361
+ * Robustness (addressed for the review):
2362
+ * - read the ref namespace: `credentials.resolve(credentialRef(key))` (value from
2363
+ * the .credentials store that `setCredential` writes) — never `readRecord`.
2364
+ * - filter env keys that map onto reserved (`HOME`/`SHELL`/`SESSION_ID`) or are
2365
+ * already owned by another contributor (e.g. `DSH_SESSION_JSONL`) so a single
2366
+ * bad/conflicting key cannot break the whole registration; skip + warn.
2367
+ * - use a *declared superset*: register the full valid key set once and keep it;
2368
+ * deleting a credential just makes `resolve` return empty (no delete-then-
2369
+ * re-register window). Re-register only when the key SET changes.
2370
+ * - serialise refresh (single in-flight + queued) and gate commits on a
2371
+ * generation counter + `disposed` so a stale run cannot overwrite or
2372
+ * re-register after dispose.
2373
+ * @module @khorsheed/dsh-capability-catalog/shellEnv
2374
+ */
2375
+ const DSH_PREFIX$1 = "DSH_";
2376
+ const ENV_KEY_SUFFIX = /^[A-Z][A-Z0-9_]*$/;
2377
+ /** Reserved `DSH_*` names the host's shell-env registry owns as built-in facts
2378
+ * (the instance's real HOME / SHELL / SESSION_ID). A credential must never be
2379
+ * allowed to take one of these over — a mapped value shadowing the built-in
2380
+ * `DSH_HOME` would make the agent read the configured credential as the
2381
+ * instance home. Keyed on the MAPPED name (not the raw declaration), so it
2382
+ * matches the upstream registry's own reserved set (dsh-home-paths/dsh-shell)
2383
+ * and survives prefix/schema drift. The catalog's own `DSH_`-prefixed keys are
2384
+ * unaffected: declaring e.g. `DSH_HOME` maps to `DSH_DSH_HOME`, which is not a
2385
+ * reserved name and never collides. */
2386
+ const RESERVED_ENV_KEYS = /* @__PURE__ */ new Set([
2387
+ "DSH_HOME",
2388
+ "DSH_SHELL",
2389
+ "DSH_SESSION_ID"
2390
+ ]);
2391
+ /**
2392
+ * Install the skill env injection contributor. Returns a disposer; the `ctx.on`
2393
+ * and `shellEnv.register` disposers are also effect-scoped to the calling fiber.
2394
+ * Degrades silently (a no-op) when `shellEnv`, `credentials`, or `skills` is
2395
+ * absent in the composition.
2396
+ *
2397
+ * Boot-timing robustness: the contributor's `register()` runs `ctx.effect(...)`
2398
+ * internally, so it must be called while its backing fiber is still `active`
2399
+ * (alpha's reordered app-boot can leave it in a transient inactive window where
2400
+ * a captured reference would throw `INACTIVE_EFFECT`). This install never caches
2401
+ * a service reference across an `await`; it re-reads `ctx.get` (strict — returns
2402
+ * the impl only while its fiber is active) immediately before calling `register`,
2403
+ * and self-retries on `INACTIVE_EFFECT` so it self-heals once boot stabilizes.
2404
+ */
2405
+ function installSkillEnvInjection(ctx, getScope) {
2406
+ let disposed = false;
2407
+ let envCache = /* @__PURE__ */ new Map();
2408
+ let disposeEnv;
2409
+ let refreshKey;
2410
+ let refreshGen = 0;
2411
+ let inFlight;
2412
+ let queued = false;
2413
+ let retryTimer;
2414
+ let retries = 0;
2415
+ const MAX_RETRIES = 30;
2416
+ const RETRY_DELAY = 300;
2417
+ const cancelRetry = () => {
2418
+ if (retryTimer !== void 0) {
2419
+ clearTimeout(retryTimer);
2420
+ retryTimer = void 0;
2421
+ }
2422
+ };
2423
+ const scheduleRetry = () => {
2424
+ if (disposed || retries >= MAX_RETRIES) return;
2425
+ cancelRetry();
2426
+ retries++;
2427
+ retryTimer = setTimeout(() => {
2428
+ retryTimer = void 0;
2429
+ if (!disposed) refresh();
2430
+ }, RETRY_DELAY);
2431
+ };
2432
+ /** True for the `INACTIVE_EFFECT` Cordis error thrown by `ctx.effect` when the
2433
+ * fiber is inactive — a transient boot-time race, not a real owner collision. */
2434
+ const isInactiveEffect = (err) => {
2435
+ return !!err && typeof err === "object" && "code" in err && err.code === "INACTIVE_EFFECT";
2436
+ };
2437
+ /** One refresh pass; commits only if still the latest generation and not disposed. */
2438
+ const refresh = async () => {
2439
+ if (disposed) return;
2440
+ if (inFlight !== void 0) {
2441
+ refreshGen++;
2442
+ queued = true;
2443
+ return;
2444
+ }
2445
+ const shellEnv = ctx.get?.("shellEnv");
2446
+ const credentials = ctx.get?.("credentials");
2447
+ const skills = ctx.get?.("skills");
2448
+ if (shellEnv?.register === void 0 || credentials === void 0 || skills === void 0) {
2449
+ scheduleRetry();
2450
+ return;
2451
+ }
2452
+ const gen = ++refreshGen;
2453
+ inFlight = (async () => {
2454
+ const scope = await getScope().catch(() => void 0);
2455
+ const snapshotOptions = scope === void 0 ? {} : { scope };
2456
+ try {
2457
+ const snapshot = await skills.snapshot(snapshotOptions);
2458
+ const occupied = new Set((shellEnv.list?.() ?? []).filter((e) => e.contributor !== "capability-catalog").map((e) => e.key));
2459
+ const keys = /* @__PURE__ */ new Set();
2460
+ 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;
2470
+ const decls = mergeCredentialDecls(decodeCredentialDecls(def.metadata), decodeEnvDecls(def.content ?? ""));
2471
+ for (const decl of decls) {
2472
+ if (!ENV_KEY_SUFFIX.test(decl.key)) continue;
2473
+ const dshKey = `${DSH_PREFIX$1}${decl.key}`;
2474
+ if (RESERVED_ENV_KEYS.has(dshKey) || occupied.has(dshKey)) continue;
2475
+ keys.add(decl.key);
2476
+ const resolved = await credentials.resolve(decl.key).catch(() => void 0);
2477
+ if (resolved !== void 0 && resolved.value.length > 0) values.set(dshKey, resolved.value);
2478
+ }
2479
+ }
2480
+ if (disposed || gen !== refreshGen) return;
2481
+ envCache = values;
2482
+ const keySet = [...keys].sort().join("\n");
2483
+ if (keySet !== refreshKey) {
2484
+ disposeEnv?.();
2485
+ disposeEnv = void 0;
2486
+ refreshKey = void 0;
2487
+ const variables = {};
2488
+ for (const k of keys) variables[`${DSH_PREFIX$1}${k}`] = { description: `Configured via the capability catalog for skill env \`${k}\`` };
2489
+ const shellEnvNow = ctx.get?.("shellEnv");
2490
+ if (shellEnvNow?.register === void 0) {
2491
+ scheduleRetry();
2492
+ return;
2493
+ }
2494
+ try {
2495
+ disposeEnv = shellEnvNow.register({
2496
+ name: "capability-catalog",
2497
+ variables,
2498
+ resolve: () => {
2499
+ const out = {};
2500
+ for (const [k, v] of envCache) if (Object.hasOwn(variables, k)) out[k] = v;
2501
+ return out;
2502
+ }
2503
+ });
2504
+ refreshKey = keySet;
2505
+ retries = 0;
2506
+ } catch (err) {
2507
+ process.stderr.write(`capability-catalog: skill env contributor register failed (${String(err)})`);
2508
+ if (isInactiveEffect(err)) scheduleRetry();
2509
+ }
2510
+ }
2511
+ } catch (err) {
2512
+ process.stderr.write(`capability-catalog: skill env injection refresh failed (${String(err)})`);
2513
+ } finally {
2514
+ inFlight = void 0;
2515
+ if (queued && !disposed) {
2516
+ queued = false;
2517
+ refresh();
2518
+ }
2519
+ }
2520
+ })();
2521
+ await inFlight;
2522
+ };
2523
+ refresh();
2524
+ const offRecord = ctx.on("credentials/record-updated", () => {
2525
+ refresh();
2526
+ });
2527
+ const offReference = ctx.on("credentials/reference-updated", () => {
2528
+ refresh();
2529
+ });
2530
+ return () => {
2531
+ disposed = true;
2532
+ cancelRetry();
2533
+ disposeEnv?.();
2534
+ offRecord();
2535
+ offReference();
2536
+ };
2537
+ }
2538
+ //#endregion
2539
+ //#region lib/types/mcps.js
2540
+ /** Find every `secretRef:<token>` marker in a config value (scheme-prefixed or bare). */
2541
+ const SECRET_REF_RE = /secretRef:([A-Za-z0-9_.-]+)/g;
2542
+ /** Flag a config field secret in the read-only display: `secretRef:x → ·secretRef·`,
2543
+ * preserving any scheme prefix (e.g. `Bearer ·secretRef·`). */
2544
+ function maskSecret(value) {
2545
+ return value.replace(SECRET_REF_RE, "·secretRef·");
2546
+ }
2547
+ /** Mask a server config for the wire (the Remote DTO): replace every `secretRef:`
2548
+ * marker (bare or scheme-prefixed) with `·secretRef·`, so neither the secret key
2549
+ * name nor the marker is exposed to the browser. */
2550
+ function maskConfig(config) {
2551
+ const env = (config.env ?? []).map(([k, v]) => [k, maskSecret(v)]);
2552
+ const headers = (config.headers ?? []).map(([k, v]) => [k, maskSecret(v)]);
2553
+ const url = config.url !== void 0 ? maskSecret(config.url) : void 0;
2554
+ return {
2555
+ ...config,
2556
+ ...env.length > 0 ? { env } : {},
2557
+ ...headers.length > 0 ? { headers } : {},
2558
+ ...url !== void 0 ? { url } : {}
2559
+ };
2560
+ }
2561
+ /** Resolve `secretRef:` markers in a config into plaintext before connecting
2562
+ * (the catalog resolves via the stored credential values, then connects).
2563
+ * Replaces embedded markers too, so a scheme-prefixed value (`Bearer secretRef:a`)
2564
+ * resolves to `Bearer <value>`. */
2565
+ async function resolveConfigSecrets(config, resolve) {
2566
+ const resolveValue = async (value) => {
2567
+ let out = value;
2568
+ for (const m of value.matchAll(SECRET_REF_RE)) {
2569
+ const ref = m[1];
2570
+ const resolvedValue = await resolve(ref) ?? "";
2571
+ out = out.replace(`secretRef:${ref}`, resolvedValue);
2572
+ }
2573
+ return out;
2574
+ };
2575
+ const env = [];
2576
+ for (const [k, v] of config.env ?? []) env.push([k, await resolveValue(v)]);
2577
+ const headers = [];
2578
+ for (const [k, v] of config.headers ?? []) headers.push([k, await resolveValue(v)]);
2579
+ const url = config.url !== void 0 ? await resolveValue(config.url) : void 0;
2580
+ return {
2581
+ ...config,
2582
+ ...env.length > 0 ? { env } : {},
2583
+ ...headers.length > 0 ? { headers } : {},
2584
+ ...url !== void 0 ? { url } : {}
2585
+ };
2586
+ }
2587
+ //#endregion
2588
+ //#region lib/types/mcpStore.js
2589
+ /**
2590
+ * Host-side MCP server store: keeps the user's configured MCP servers, their
2591
+ * discovered tools, credential state, and enable flags. Persistence is a pluggable
2592
+ * seam: the host hands the store an `onPersist` callback (backed by the settings
2593
+ * namespace) and seeds it with `loadFrom` at boot, so servers survive a restart.
2594
+ * Tool discovery connects via the SDK connector and caches the tool list.
2595
+ */
2596
+ /** The catalog's own MCP management store (one per process). */
2597
+ var McpStore = class {
2598
+ servers = /* @__PURE__ */ new Map();
2599
+ /** Persistence hook, set by the host after the settings scope is available. */
2600
+ onPersist = void 0;
2601
+ /** Seed the store from the persisted state (boot or external config edit). */
2602
+ loadFrom(state) {
2603
+ if (state === void 0) return;
2604
+ for (const server of state.servers ?? []) {
2605
+ const existing = this.servers.get(server.config.serverName);
2606
+ const known = new Map((existing?.tools ?? []).map((t) => [t.name, t]));
2607
+ const tools = (server.tools ?? []).map((t) => {
2608
+ const prev = known.get(t.name);
2609
+ return prev !== void 0 ? {
2610
+ ...prev,
2611
+ enabled: t.enabled
2612
+ } : {
2613
+ name: t.name,
2614
+ description: "",
2615
+ enabled: t.enabled
2616
+ };
2617
+ });
2618
+ this.servers.set(server.config.serverName, {
2619
+ config: server.config,
2620
+ credentials: existing?.credentials ?? /* @__PURE__ */ new Map(),
2621
+ tools,
2622
+ error: void 0
2623
+ });
2624
+ }
2625
+ for (const [ref, value] of Object.entries(state.credentials ?? {})) {
2626
+ const s = this.servers.get(refServer(ref));
2627
+ if (s !== void 0) s.credentials.set(ref, value);
2628
+ }
2629
+ }
2630
+ /** Serialize the current state for persistence. */
2631
+ toPersisted() {
2632
+ const servers = [...this.servers.values()].sort((a, b) => a.config.serverName.localeCompare(b.config.serverName)).map((s) => ({
2633
+ config: s.config,
2634
+ tools: s.tools.map((t) => ({
2635
+ name: t.name,
2636
+ enabled: t.enabled
2637
+ }))
2638
+ }));
2639
+ const credentials = {};
2640
+ for (const s of this.servers.values()) for (const [ref, value] of s.credentials) credentials[ref] = value;
2641
+ return {
2642
+ servers,
2643
+ credentials
2644
+ };
2645
+ }
2646
+ persist() {
2647
+ this.onPersist?.();
2648
+ }
2649
+ list() {
2650
+ return [...this.servers.values()].map((s) => ({
2651
+ ...s.config,
2652
+ enabled: s.config.enabled
2653
+ })).sort((a, b) => a.serverName.localeCompare(b.serverName));
2654
+ }
2655
+ /** Discovered tool list per server (name → tools). */
2656
+ toolsByServer() {
2657
+ const out = {};
2658
+ for (const [name, s] of this.servers) out[name] = s.tools;
2659
+ return out;
2660
+ }
2661
+ /** Credential declarations for all servers (whether configured, never the value). */
2662
+ credentials() {
2663
+ const out = [];
2664
+ for (const [name, s] of this.servers) for (const ref of this.credentialRefsOf(s.config)) out.push({
2665
+ ref,
2666
+ label: refLabel(ref),
2667
+ configured: s.credentials.has(ref)
2668
+ });
2669
+ return out;
2670
+ }
2671
+ /** Add / replace a server config (keeps its credentials if already present). */
2672
+ add(config) {
2673
+ const existing = this.servers.get(config.serverName);
2674
+ this.servers.set(config.serverName, {
2675
+ config,
2676
+ credentials: existing?.credentials ?? /* @__PURE__ */ new Map(),
2677
+ tools: existing?.tools ?? [],
2678
+ error: existing?.error
2679
+ });
2680
+ this.persist();
2681
+ }
2682
+ remove(serverName) {
2683
+ if (this.servers.delete(serverName)) this.persist();
2684
+ }
2685
+ /** Set server-level enable flag (discovery/registration is the caller's job). */
2686
+ setEnabled(serverName, enabled) {
2687
+ const s = this.servers.get(serverName);
2688
+ if (s === void 0) return;
2689
+ s.config = {
2690
+ ...s.config,
2691
+ enabled
2692
+ };
2693
+ this.persist();
2694
+ }
2695
+ /** Set one credential value (empty clears it). */
2696
+ setCredential(ref, value) {
2697
+ const serverName = refServer(ref);
2698
+ const s = this.servers.get(serverName);
2699
+ if (s === void 0) return;
2700
+ if (value === "") s.credentials.delete(ref);
2701
+ else s.credentials.set(ref, value);
2702
+ this.persist();
2703
+ }
2704
+ /** Configure a tool's enable flag. */
2705
+ setToolEnabled(serverName, toolName, enabled) {
2706
+ const s = this.servers.get(serverName);
2707
+ if (s === void 0) return;
2708
+ s.tools = s.tools.map((t) => t.name === toolName ? {
2709
+ ...t,
2710
+ enabled
2711
+ } : t);
2712
+ this.persist();
2713
+ }
2714
+ /** Connect + discover tools for a server; caches the list (connect errors surface). */
2715
+ async discover(serverName) {
2716
+ const s = this.servers.get(serverName);
2717
+ if (s === void 0) return [];
2718
+ try {
2719
+ 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
+ }));
2731
+ const prevEnabled = new Map(s.tools.map((t) => [t.name, t.enabled]));
2732
+ s.tools = connection.tools.map((t) => ({
2733
+ ...t,
2734
+ enabled: prevEnabled.get(t.name) ?? true
2735
+ }));
2736
+ s.error = void 0;
2737
+ await connection.close();
2738
+ this.persist();
2739
+ return s.tools;
2740
+ } catch (error) {
2741
+ s.error = String(error);
2742
+ this.persist();
2743
+ return s.tools;
2744
+ }
2745
+ }
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
+ /** Forward one tool call to a server (connect → call → close). Resolves secrets. */
2761
+ async callTool(serverName, rawName, args) {
2762
+ const s = this.servers.get(serverName);
2763
+ if (s === void 0) throw new Error(`unknown MCP server: ${serverName}`);
2764
+ const resolved = await resolveConfigSecrets(s.config, async (ref) => {
2765
+ const candidates = [
2766
+ `mcp.${serverName}.env.${ref}`,
2767
+ `mcp.${serverName}.header.${ref}`,
2768
+ `mcp.${serverName}.query.${ref}`
2769
+ ];
2770
+ for (const candidate of candidates) {
2771
+ const value = s.credentials.get(candidate);
2772
+ if (value !== void 0) return value;
2773
+ }
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
+ }
2782
+ }
2783
+ /** Extract credential reference paths from a config (env/header/url-query).
2784
+ * Namespaced `mcp.<serverName>.env.<KEY>` so multiple servers don't collide. */
2785
+ credentialRefsOf(config) {
2786
+ const refs = [];
2787
+ for (const [k, v] of config.env ?? []) if (isSecretRef(v)) refs.push(`mcp.${config.serverName}.env.${k}`);
2788
+ for (const [k, v] of config.headers ?? []) if (isSecretRef(v)) refs.push(`mcp.${config.serverName}.header.${k}`);
2789
+ if (config.url !== void 0) for (const m of config.url.matchAll(/secretRef:([A-Za-z0-9_.-]+)/g)) refs.push(`mcp.${config.serverName}.query.${m[1]}`);
2790
+ return refs;
2791
+ }
2792
+ };
2793
+ /** Whether a config value contains an unresolved secret marker (bare or scheme-prefixed). */
2794
+ function isSecretRef(value) {
2795
+ return value.includes("secretRef:");
2796
+ }
2797
+ /** Human label for a namespaced ref (strip the `mcp.<server>.` prefix). */
2798
+ function refLabel(ref) {
2799
+ const idx = ref.indexOf(".");
2800
+ return ref.slice(idx + 1);
2801
+ }
2802
+ /** The server name a namespaced credential ref belongs to (`mcp.<server>…`). */
2803
+ function refServer(ref) {
2804
+ return /^mcp\.([A-Za-z0-9_-]+)\./.exec(ref)?.[1] ?? "";
2805
+ }
2806
+ //#endregion
2807
+ //#region lib/types/mcpTools.js
2808
+ /**
2809
+ * MCP tool bridge: turns catalog-discovered MCP tools into harness `ToolDefinition`s
2810
+ * registered on `ctx.tools` so the model can call them. A bounded subset of the
2811
+ * official mcp-client bridge — the common text-result case (no image/attachment
2812
+ * projection, no task execution). Model-facing names follow the stable
2813
+ * `mcp__<serverName>__<rawName>` contract, normalized to the DeepSeek
2814
+ * function-name constraints; the raw name is only ever sent on the wire.
2815
+ *
2816
+ * Registration must always invoke `ctx.tools.register(definition)` as a MEMBER
2817
+ * call on the traceable service proxy (never extract `register` to a standalone
2818
+ * variable) — the proxy resolves `this.ctx` (effect/scope ownership) from the
2819
+ * calling context, which a bare function reference loses.
2820
+ * @module @khorsheed/dsh-capability-catalog/mcpTools
2821
+ */
2822
+ /** DeepSeek function-name contract: at most 64 characters. */
2823
+ const MAX_PUBLIC_NAME_LENGTH = 64;
2824
+ /** DeepSeek function-name contract: only `[A-Za-z0-9_-]` is allowed. */
2825
+ const INVALID_NAME_CHARS = /[^A-Za-z0-9_-]/g;
2826
+ /** Hex chars of the SHA-256 identity hash appended on lossy normalization. */
2827
+ const HASH_LENGTH = 12;
2828
+ /** Deterministic model-facing name for `(serverName, rawName)`. */
2829
+ function publicToolName(serverName, rawName) {
2830
+ const joined = `mcp__${serverName}__${rawName}`;
2831
+ const normalized = joined.replace(INVALID_NAME_CHARS, "_");
2832
+ if (normalized === joined && normalized.length <= MAX_PUBLIC_NAME_LENGTH) return normalized;
2833
+ const hash = createHash("sha256").update(joined).digest("hex").slice(0, HASH_LENGTH);
2834
+ return `${normalized.slice(0, 51)}_${hash}`;
2835
+ }
2836
+ /** Coerce a raw MCP inputSchema into a JSON Schema object the registry accepts. */
2837
+ function sanitizeParameters(parameters) {
2838
+ if (parameters !== null && typeof parameters === "object" && !Array.isArray(parameters)) return parameters;
2839
+ return {
2840
+ type: "object",
2841
+ properties: {}
2842
+ };
2843
+ }
2844
+ /** Map the SDK `callTool` result to the canonical `{ content, structuredContent }`. */
2845
+ function normalizeResult(result) {
2846
+ const r = result ?? {};
2847
+ if (r.isError === true) throw new Error(r.error ?? "MCP tool reported an error");
2848
+ return {
2849
+ content: Array.isArray(r.content) ? r.content : [],
2850
+ ...r.structuredContent !== void 0 ? { structuredContent: r.structuredContent } : {}
2851
+ };
2852
+ }
2853
+ /** Extract human-readable text from MCP content blocks (text blocks joined). */
2854
+ function extractText(content) {
2855
+ const blocks = Array.isArray(content) ? content : [];
2856
+ const texts = blocks.filter((b) => typeof b === "object" && b !== null && b.type === "text" && typeof b.text === "string").map((b) => b.text);
2857
+ return texts.length > 0 ? texts.join("\n") : JSON.stringify(blocks);
2858
+ }
2859
+ /** Build a harness `ToolDefinition` for one discovered MCP tool. */
2860
+ function mcpToolDefinition(opts) {
2861
+ const { publicName, rawName, serverName, description, parameters, callTool } = opts;
2862
+ return {
2863
+ name: publicName,
2864
+ description,
2865
+ parameters: sanitizeParameters(parameters),
2866
+ output: {
2867
+ schema: {
2868
+ type: "object",
2869
+ properties: { content: {
2870
+ type: "array",
2871
+ items: {}
2872
+ } },
2873
+ required: ["content"],
2874
+ additionalProperties: true
2875
+ },
2876
+ render: (_args, value) => {
2877
+ return [{
2878
+ type: "text",
2879
+ text: extractText(value.content)
2880
+ }];
2881
+ }
2882
+ },
2883
+ execute: async (args) => {
2884
+ return normalizeResult(await callTool(serverName, rawName, args ?? {}));
2885
+ }
2886
+ };
2887
+ }
2888
+ /**
2889
+ * Reconcile registered MCP tools: dispose every previously-registered tool, then
2890
+ * register the desired set. Registration failures are contained per tool.
2891
+ */
2892
+ function reconcileRegisteredMcpTools(desired, registry, current, callTool) {
2893
+ for (const dispose of [...current.values()]) dispose();
2894
+ current.clear();
2895
+ for (const d of desired) try {
2896
+ const dispose = registry.register(mcpToolDefinition({
2897
+ publicName: d.publicName,
2898
+ serverName: d.serverName,
2899
+ rawName: d.rawName,
2900
+ description: d.description,
2901
+ parameters: d.parameters,
2902
+ callTool
2903
+ }));
2904
+ current.set(d.publicName, dispose);
2905
+ } catch (error) {
2906
+ registry.onRegisterError?.(error);
2907
+ }
2908
+ }
2909
+ /** Compute the desired set from enabled servers + their enabled tools. */
2910
+ function desiredMcpTools(servers, toolsByServer) {
2911
+ const desired = [];
2912
+ for (const server of servers) {
2913
+ if (!server.enabled) continue;
2914
+ for (const tool of toolsByServer[server.serverName] ?? []) {
2915
+ if (!tool.enabled) continue;
2916
+ desired.push({
2917
+ publicName: publicToolName(server.serverName, tool.name),
2918
+ serverName: server.serverName,
2919
+ rawName: tool.name,
2920
+ description: tool.description,
2921
+ parameters: tool.parameters
2922
+ });
2923
+ }
2924
+ }
2925
+ return desired;
2926
+ }
2927
+ //#endregion
2928
+ //#region lib/types/envHint.js
2929
+ /**
2930
+ * Skill env hint via `tools/post-execute` — a runtime companion context that
2931
+ * tells the model about any skill's configured credential env mappings.
2932
+ *
2933
+ * The catalog injects credentials as trusted per-execution `DSH_<KEY>` env
2934
+ * vars (see shellEnv.ts), but a skill's own text references the original
2935
+ * `$KEY` name and the model does not derive the alias on its own. This module
2936
+ * observes `tools/post-execute` when the `skill` tool loads a skill and appends
2937
+ * a generic, per-skill note listing each configured mapping, so the model can
2938
+ * use the value without us modifying the user's SKILL.md.
2939
+ *
2940
+ * Generic: it derives the mappings from the loaded skill's own env-decl keys
2941
+ * (decodeEnvDecls) + the credential store, so any future key / skill is
2942
+ * covered. It never touches the skill file or the provider's returned content.
2943
+ * @module @khorsheed/dsh-capability-catalog/envHint
2944
+ */
2945
+ const DSH_PREFIX = "DSH_";
2946
+ /** Subscribe to `tools/post-execute`; appends an env-mapping hint for the
2947
+ * loaded skill. Returns a disposer. Degrades silently when tools/registry/
2948
+ * credentials are absent or the tool is not the skills loader. */
2949
+ function installSkillEnvHint(ctx, getScope) {
2950
+ ctx.inject(["tools"], () => {
2951
+ ctx.on("tools/post-execute", (async (exec, _result, next) => {
2952
+ if (exec?.name !== "skill") return next();
2953
+ const skillName = exec.arguments?.name;
2954
+ if (typeof skillName !== "string" || skillName.length === 0) return next();
2955
+ const scope = await getScope().catch(() => void 0);
2956
+ const { registry, credentials } = resolveServices(ctx);
2957
+ if (registry === void 0 || credentials === void 0) return next();
2958
+ const lookup = scope === void 0 ? {} : { scope };
2959
+ let def;
2960
+ try {
2961
+ def = await registry.get(skillName, lookup);
2962
+ } catch {
2963
+ return next();
2964
+ }
2965
+ 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");
2975
+ return {
2976
+ kind: "accept",
2977
+ additionalContexts: [{
2978
+ id: `capability-catalog-env-hint-${skillName}`,
2979
+ role: "user",
2980
+ content: [{
2981
+ type: "text",
2982
+ text: hint
2983
+ }],
2984
+ source: {
2985
+ kind: "capability-catalog",
2986
+ plugin: "capability-catalog",
2987
+ form: "env-hint"
2988
+ }
2989
+ }]
2990
+ };
2991
+ }));
2992
+ });
2993
+ }
2994
+ //#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
+ //#region lib/types/namespace.js
3027
+ /**
3028
+ * 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.
3032
+ */
3033
+ /** Settings namespace owned by the capability-catalog plugin. */
3034
+ const CAPABILITY_CATALOG_NS = "capability-catalog";
3035
+ const mcpField = z.any().default({});
3036
+ /** Section shape: the MCP management state block (permissive) + nothing else. */
3037
+ const CapabilityCatalogSettingsSchema = z.object({ ["mcp"]: typeof mcpField.volatile === "function" ? mcpField.volatile() : mcpField });
3038
+ //#endregion
3039
+ //#region lib/types/modes.js
3040
+ /**
3041
+ * MODES: reading the catalog through each agent preset.
3042
+ *
3043
+ * A dsh instance composes every session from an agent preset — 「模式」 in the
3044
+ * settings copy, `preset` in the code: the same deployment can run a full
3045
+ * coding agent, a minimal single-tool one, or a user-authored preset, and each
3046
+ * of those sees a DIFFERENT set of skills and tools. The catalog's default read
3047
+ * answers for one of them (the deployment default). "Which of these does this
3048
+ * mode actually load, and which modes load it?" is a different question, and
3049
+ * this module is the whole answer to it.
3050
+ *
3051
+ * Two shapes travel:
3052
+ *
3053
+ * - a mode ROSTER ({@link modeOptions}) — the presets a viewer offers, with the
3054
+ * deployment default marked so a picker can label one 「标准模式(默认)」;
3055
+ * - a mode FACE ({@link readModeFaces}) — what one mode's standing scope
3056
+ * actually registers, read through the same `resolvePresetScope` +
3057
+ * `catalogSnapshot` path the default read uses, so a mode view and the
3058
+ * default view cannot drift apart.
3059
+ *
3060
+ * Reading a mode is NOT free: resolving a preset's standing scope MOUNTS its
3061
+ * composition (the roster's single-flight standing mount), so a face read for a
3062
+ * mode nothing has composed yet composes it. That is the price of the only
3063
+ * honest answer — a composition file says which plugins a mode NAMES, not which
3064
+ * tools and skills those plugins register once they run — and it is why the
3065
+ * settings surface asks for faces only when a human chooses to compare modes.
3066
+ *
3067
+ * Both functions are pure over their inputs (the caller supplies the roster rows
3068
+ * and the face reader), so their unit tests need no Cordis context.
3069
+ * @module @khorsheed/dsh-capability-catalog/modes
3070
+ */
3071
+ /**
3072
+ * The mode roster a viewer offers: the agent-preset rows, path-free, with the
3073
+ * deployment default marked.
3074
+ *
3075
+ * Roster order is preserved — the roster already orders shipped presets by the
3076
+ * `order` they publish — because a picker that re-sorts hands the human a
3077
+ * different order every time the roster grows.
3078
+ * @param rows - the roster rows (`agentPresets.list()`).
3079
+ * @param defaultId - the preset a session naming none composes, when known.
3080
+ * @returns the options a mode picker renders, in roster order.
3081
+ */
3082
+ function modeOptions(rows, defaultId) {
3083
+ return rows.map((row) => ({
3084
+ id: row.id,
3085
+ ...row.name === void 0 ? {} : { name: row.name },
3086
+ ...row.description === void 0 ? {} : { description: row.description },
3087
+ ...row.broken === void 0 ? {} : { broken: row.broken },
3088
+ ...row.id === defaultId ? { isDefault: true } : {}
3089
+ }));
3090
+ }
3091
+ /**
3092
+ * Read one face per mode, in roster order.
3093
+ *
3094
+ * A BROKEN preset is not read at all: discovery already refused it, and asking
3095
+ * the roster to mount it would spend a mount attempt on a composition known to
3096
+ * be unusable. It keeps its row with `unavailable` set, so a comparison can say
3097
+ * which mode is missing instead of silently showing an empty face — "this mode
3098
+ * has no tools" and "this mode could not be read" are different claims, and a
3099
+ * viewer that conflates them is worse than one that omits the mode.
3100
+ *
3101
+ * A reader answering `undefined` means the same thing (its scope did not
3102
+ * resolve, so the answer would be the global layer wearing a mode's name).
3103
+ * Faces are read SEQUENTIALLY: each read may start a mount, and walking the
3104
+ * roster's single-flight mount in order is cheaper than racing it.
3105
+ * @param options - the mode roster (from {@link modeOptions}).
3106
+ * @param readFace - read one mode's face; undefined when its scope refuses.
3107
+ * @returns one face per option, roster order preserved.
3108
+ */
3109
+ async function readModeFaces(options, readFace) {
3110
+ const faces = [];
3111
+ for (const option of options) {
3112
+ const identity = {
3113
+ preset: option.id,
3114
+ ...option.name === void 0 ? {} : { name: option.name },
3115
+ ...option.description === void 0 ? {} : { description: option.description },
3116
+ isDefault: option.isDefault === true
3117
+ };
3118
+ if (option.broken !== void 0) {
3119
+ faces.push({
3120
+ ...identity,
3121
+ skills: [],
3122
+ tools: [],
3123
+ unavailable: option.broken
3124
+ });
3125
+ continue;
3126
+ }
3127
+ let face;
3128
+ try {
3129
+ face = await readFace(option.id);
3130
+ } catch {
3131
+ face = void 0;
3132
+ }
3133
+ faces.push(face === void 0 ? {
3134
+ ...identity,
3135
+ skills: [],
3136
+ tools: [],
3137
+ unavailable: "the preset resolved no standing scope"
3138
+ } : {
3139
+ ...identity,
3140
+ skills: face.skills,
3141
+ tools: face.tools
3142
+ });
3143
+ }
3144
+ return faces;
3145
+ }
3146
+ //#endregion
3147
+ //#region lib/types/index.js
3148
+ /**
3149
+ * Capability catalog host service: a Typert Remote over the running skill and
3150
+ * tool registries. It projects `ctx.skills.snapshot()` into catalog rows,
3151
+ * attributes `ctx.tools.schemas()` to a channel (mcp prefix / official
3152
+ * whitelist / baseline-diff), loads per-skill detail on demand, writes
3153
+ * declared credentials, and adds skills into the managed root. The browser
3154
+ * half binds through the generated `capabilityCatalog` Remote namespace.
3155
+ *
3156
+ * Zero host edits: everything rides existing official services (`ctx.skills`,
3157
+ * `ctx.tools`, `ctx.credentials`) through `ctx.get`, degrading silently when a
3158
+ * capability is absent.
3159
+ * @module @khorsheed/dsh-capability-catalog
3160
+ */
3161
+ var __runInitializers = function(thisArg, initializers, value) {
3162
+ var useValue = arguments.length > 2;
3163
+ for (var i = 0; i < initializers.length; i++) value = useValue ? initializers[i].call(thisArg, value) : initializers[i].call(thisArg);
3164
+ return useValue ? value : void 0;
3165
+ };
3166
+ var __esDecorate = function(ctor, descriptorIn, decorators, contextIn, initializers, extraInitializers) {
3167
+ function accept(f) {
3168
+ if (f !== void 0 && typeof f !== "function") throw new TypeError("Function expected");
3169
+ return f;
3170
+ }
3171
+ var kind = contextIn.kind, key = kind === "getter" ? "get" : kind === "setter" ? "set" : "value";
3172
+ var target = !descriptorIn && ctor ? contextIn["static"] ? ctor : ctor.prototype : null;
3173
+ var descriptor = descriptorIn || (target ? Object.getOwnPropertyDescriptor(target, contextIn.name) : {});
3174
+ var _, done = false;
3175
+ for (var i = decorators.length - 1; i >= 0; i--) {
3176
+ var context = {};
3177
+ for (var p in contextIn) context[p] = p === "access" ? {} : contextIn[p];
3178
+ for (var p in contextIn.access) context.access[p] = contextIn.access[p];
3179
+ context.addInitializer = function(f) {
3180
+ if (done) throw new TypeError("Cannot add initializers after decoration has completed");
3181
+ extraInitializers.push(accept(f || null));
3182
+ };
3183
+ var result = (0, decorators[i])(kind === "accessor" ? {
3184
+ get: descriptor.get,
3185
+ set: descriptor.set
3186
+ } : descriptor[key], context);
3187
+ if (kind === "accessor") {
3188
+ if (result === void 0) continue;
3189
+ if (result === null || typeof result !== "object") throw new TypeError("Object expected");
3190
+ if (_ = accept(result.get)) descriptor.get = _;
3191
+ if (_ = accept(result.set)) descriptor.set = _;
3192
+ if (_ = accept(result.init)) initializers.unshift(_);
3193
+ } else if (_ = accept(result)) {
3194
+ if (kind === "field") initializers.unshift(_);
3195
+ else descriptor[key] = _;
3196
+ }
3197
+ }
3198
+ if (target) Object.defineProperty(target, contextIn.name, descriptor);
3199
+ done = true;
3200
+ };
3201
+ /** The remote namespace name (matches the generated remote-client type). */
3202
+ const REMOTE_NS = "capabilityCatalog";
3203
+ /** Custom inject: subscribe no extra service beyond the two we probe. */
3204
+ function resolveServicesHelper(ctx) {
3205
+ return resolveServices(ctx);
3206
+ }
3207
+ /**
3208
+ * Capability catalog Remote service.
3209
+ * @module @khorsheed/dsh-capability-catalog
3210
+ */
3211
+ let CapabilityCatalogService = (() => {
3212
+ let _classSuper = TypertRemoteService;
3213
+ let _instanceExtraInitializers = [];
3214
+ let _presetScopeStatus_decorators;
3215
+ let _presetScopeRoster_decorators;
3216
+ let _modeFaces_decorators;
3217
+ let _presetScopeSet_decorators;
3218
+ let _presetScopeAdopt_decorators;
3219
+ let _presetScopeRelease_decorators;
3220
+ let _snapshot_decorators;
3221
+ let _snapshotAt_decorators;
3222
+ let _snapshotFor_decorators;
3223
+ let _detail_decorators;
3224
+ let _readSkillFile_decorators;
3225
+ let _listDirSkills_decorators;
3226
+ let _setCredential_decorators;
3227
+ let _addSkill_decorators;
3228
+ let _deleteSkill_decorators;
3229
+ let _pickDirectory_decorators;
3230
+ let _mcpList_decorators;
3231
+ let _mcpAdd_decorators;
3232
+ let _mcpRemove_decorators;
3233
+ let _mcpSetEnabled_decorators;
3234
+ let _mcpSetCredential_decorators;
3235
+ let _mcpSetToolEnabled_decorators;
3236
+ let _mcpDiscover_decorators;
3237
+ let _mcpSnapshot_decorators;
3238
+ return class CapabilityCatalogService extends _classSuper {
3239
+ static {
3240
+ const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(_classSuper[Symbol.metadata] ?? null) : void 0;
3241
+ _presetScopeStatus_decorators = [Remote("presetScopeStatus")];
3242
+ _presetScopeRoster_decorators = [Remote("presetScopeRoster")];
3243
+ _modeFaces_decorators = [Remote("modeFaces")];
3244
+ _presetScopeSet_decorators = [Remote("presetScopeSet")];
3245
+ _presetScopeAdopt_decorators = [Remote("presetScopeAdopt")];
3246
+ _presetScopeRelease_decorators = [Remote("presetScopeRelease")];
3247
+ _snapshot_decorators = [Remote("snapshot")];
3248
+ _snapshotAt_decorators = [Remote("snapshotAt")];
3249
+ _snapshotFor_decorators = [Remote("snapshotFor")];
3250
+ _detail_decorators = [Remote("detail")];
3251
+ _readSkillFile_decorators = [Remote("readSkillFile")];
3252
+ _listDirSkills_decorators = [Remote("listDirSkills")];
3253
+ _setCredential_decorators = [Remote("setCredential")];
3254
+ _addSkill_decorators = [Remote("addSkill")];
3255
+ _deleteSkill_decorators = [Remote("deleteSkill")];
3256
+ _pickDirectory_decorators = [Remote("pickDirectory")];
3257
+ _mcpList_decorators = [Remote("mcpList")];
3258
+ _mcpAdd_decorators = [Remote("mcpAdd")];
3259
+ _mcpRemove_decorators = [Remote("mcpRemove")];
3260
+ _mcpSetEnabled_decorators = [Remote("mcpSetEnabled")];
3261
+ _mcpSetCredential_decorators = [Remote("mcpSetCredential")];
3262
+ _mcpSetToolEnabled_decorators = [Remote("mcpSetToolEnabled")];
3263
+ _mcpDiscover_decorators = [Remote("mcpDiscover")];
3264
+ _mcpSnapshot_decorators = [Remote("mcpSnapshot")];
3265
+ __esDecorate(this, null, _presetScopeStatus_decorators, {
3266
+ kind: "method",
3267
+ name: "presetScopeStatus",
3268
+ static: false,
3269
+ private: false,
3270
+ access: {
3271
+ has: (obj) => "presetScopeStatus" in obj,
3272
+ get: (obj) => obj.presetScopeStatus
3273
+ },
3274
+ metadata: _metadata
3275
+ }, null, _instanceExtraInitializers);
3276
+ __esDecorate(this, null, _presetScopeRoster_decorators, {
3277
+ kind: "method",
3278
+ name: "presetScopeRoster",
3279
+ static: false,
3280
+ private: false,
3281
+ access: {
3282
+ has: (obj) => "presetScopeRoster" in obj,
3283
+ get: (obj) => obj.presetScopeRoster
3284
+ },
3285
+ metadata: _metadata
3286
+ }, null, _instanceExtraInitializers);
3287
+ __esDecorate(this, null, _modeFaces_decorators, {
3288
+ kind: "method",
3289
+ name: "modeFaces",
3290
+ static: false,
3291
+ private: false,
3292
+ access: {
3293
+ has: (obj) => "modeFaces" in obj,
3294
+ get: (obj) => obj.modeFaces
3295
+ },
3296
+ metadata: _metadata
3297
+ }, null, _instanceExtraInitializers);
3298
+ __esDecorate(this, null, _presetScopeSet_decorators, {
3299
+ kind: "method",
3300
+ name: "presetScopeSet",
3301
+ static: false,
3302
+ private: false,
3303
+ access: {
3304
+ has: (obj) => "presetScopeSet" in obj,
3305
+ get: (obj) => obj.presetScopeSet
3306
+ },
3307
+ metadata: _metadata
3308
+ }, null, _instanceExtraInitializers);
3309
+ __esDecorate(this, null, _presetScopeAdopt_decorators, {
3310
+ kind: "method",
3311
+ name: "presetScopeAdopt",
3312
+ static: false,
3313
+ private: false,
3314
+ access: {
3315
+ has: (obj) => "presetScopeAdopt" in obj,
3316
+ get: (obj) => obj.presetScopeAdopt
3317
+ },
3318
+ metadata: _metadata
3319
+ }, null, _instanceExtraInitializers);
3320
+ __esDecorate(this, null, _presetScopeRelease_decorators, {
3321
+ kind: "method",
3322
+ name: "presetScopeRelease",
3323
+ static: false,
3324
+ private: false,
3325
+ access: {
3326
+ has: (obj) => "presetScopeRelease" in obj,
3327
+ get: (obj) => obj.presetScopeRelease
3328
+ },
3329
+ metadata: _metadata
3330
+ }, null, _instanceExtraInitializers);
3331
+ __esDecorate(this, null, _snapshot_decorators, {
3332
+ kind: "method",
3333
+ name: "snapshot",
3334
+ static: false,
3335
+ private: false,
3336
+ access: {
3337
+ has: (obj) => "snapshot" in obj,
3338
+ get: (obj) => obj.snapshot
3339
+ },
3340
+ metadata: _metadata
3341
+ }, null, _instanceExtraInitializers);
3342
+ __esDecorate(this, null, _snapshotAt_decorators, {
3343
+ kind: "method",
3344
+ name: "snapshotAt",
3345
+ static: false,
3346
+ private: false,
3347
+ access: {
3348
+ has: (obj) => "snapshotAt" in obj,
3349
+ get: (obj) => obj.snapshotAt
3350
+ },
3351
+ metadata: _metadata
3352
+ }, null, _instanceExtraInitializers);
3353
+ __esDecorate(this, null, _snapshotFor_decorators, {
3354
+ kind: "method",
3355
+ name: "snapshotFor",
3356
+ static: false,
3357
+ private: false,
3358
+ access: {
3359
+ has: (obj) => "snapshotFor" in obj,
3360
+ get: (obj) => obj.snapshotFor
3361
+ },
3362
+ metadata: _metadata
3363
+ }, null, _instanceExtraInitializers);
3364
+ __esDecorate(this, null, _detail_decorators, {
3365
+ kind: "method",
3366
+ name: "detail",
3367
+ static: false,
3368
+ private: false,
3369
+ access: {
3370
+ has: (obj) => "detail" in obj,
3371
+ get: (obj) => obj.detail
3372
+ },
3373
+ metadata: _metadata
3374
+ }, null, _instanceExtraInitializers);
3375
+ __esDecorate(this, null, _readSkillFile_decorators, {
3376
+ kind: "method",
3377
+ name: "readSkillFile",
3378
+ static: false,
3379
+ private: false,
3380
+ access: {
3381
+ has: (obj) => "readSkillFile" in obj,
3382
+ get: (obj) => obj.readSkillFile
3383
+ },
3384
+ metadata: _metadata
3385
+ }, null, _instanceExtraInitializers);
3386
+ __esDecorate(this, null, _listDirSkills_decorators, {
3387
+ kind: "method",
3388
+ name: "listDirSkills",
3389
+ static: false,
3390
+ private: false,
3391
+ access: {
3392
+ has: (obj) => "listDirSkills" in obj,
3393
+ get: (obj) => obj.listDirSkills
3394
+ },
3395
+ metadata: _metadata
3396
+ }, null, _instanceExtraInitializers);
3397
+ __esDecorate(this, null, _setCredential_decorators, {
3398
+ kind: "method",
3399
+ name: "setCredential",
3400
+ static: false,
3401
+ private: false,
3402
+ access: {
3403
+ has: (obj) => "setCredential" in obj,
3404
+ get: (obj) => obj.setCredential
3405
+ },
3406
+ metadata: _metadata
3407
+ }, null, _instanceExtraInitializers);
3408
+ __esDecorate(this, null, _addSkill_decorators, {
3409
+ kind: "method",
3410
+ name: "addSkill",
3411
+ static: false,
3412
+ private: false,
3413
+ access: {
3414
+ has: (obj) => "addSkill" in obj,
3415
+ get: (obj) => obj.addSkill
3416
+ },
3417
+ metadata: _metadata
3418
+ }, null, _instanceExtraInitializers);
3419
+ __esDecorate(this, null, _deleteSkill_decorators, {
3420
+ kind: "method",
3421
+ name: "deleteSkill",
3422
+ static: false,
3423
+ private: false,
3424
+ access: {
3425
+ has: (obj) => "deleteSkill" in obj,
3426
+ get: (obj) => obj.deleteSkill
3427
+ },
3428
+ metadata: _metadata
3429
+ }, null, _instanceExtraInitializers);
3430
+ __esDecorate(this, null, _pickDirectory_decorators, {
3431
+ kind: "method",
3432
+ name: "pickDirectory",
3433
+ static: false,
3434
+ private: false,
3435
+ access: {
3436
+ has: (obj) => "pickDirectory" in obj,
3437
+ get: (obj) => obj.pickDirectory
3438
+ },
3439
+ metadata: _metadata
3440
+ }, 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
+ __esDecorate(this, null, _mcpAdd_decorators, {
3453
+ kind: "method",
3454
+ name: "mcpAdd",
3455
+ static: false,
3456
+ private: false,
3457
+ access: {
3458
+ has: (obj) => "mcpAdd" in obj,
3459
+ get: (obj) => obj.mcpAdd
3460
+ },
3461
+ metadata: _metadata
3462
+ }, null, _instanceExtraInitializers);
3463
+ __esDecorate(this, null, _mcpRemove_decorators, {
3464
+ kind: "method",
3465
+ name: "mcpRemove",
3466
+ static: false,
3467
+ private: false,
3468
+ access: {
3469
+ has: (obj) => "mcpRemove" in obj,
3470
+ get: (obj) => obj.mcpRemove
3471
+ },
3472
+ metadata: _metadata
3473
+ }, null, _instanceExtraInitializers);
3474
+ __esDecorate(this, null, _mcpSetEnabled_decorators, {
3475
+ kind: "method",
3476
+ name: "mcpSetEnabled",
3477
+ static: false,
3478
+ private: false,
3479
+ access: {
3480
+ has: (obj) => "mcpSetEnabled" in obj,
3481
+ get: (obj) => obj.mcpSetEnabled
3482
+ },
3483
+ metadata: _metadata
3484
+ }, null, _instanceExtraInitializers);
3485
+ __esDecorate(this, null, _mcpSetCredential_decorators, {
3486
+ kind: "method",
3487
+ name: "mcpSetCredential",
3488
+ static: false,
3489
+ private: false,
3490
+ access: {
3491
+ has: (obj) => "mcpSetCredential" in obj,
3492
+ get: (obj) => obj.mcpSetCredential
3493
+ },
3494
+ metadata: _metadata
3495
+ }, null, _instanceExtraInitializers);
3496
+ __esDecorate(this, null, _mcpSetToolEnabled_decorators, {
3497
+ kind: "method",
3498
+ name: "mcpSetToolEnabled",
3499
+ static: false,
3500
+ private: false,
3501
+ access: {
3502
+ has: (obj) => "mcpSetToolEnabled" in obj,
3503
+ get: (obj) => obj.mcpSetToolEnabled
3504
+ },
3505
+ metadata: _metadata
3506
+ }, null, _instanceExtraInitializers);
3507
+ __esDecorate(this, null, _mcpDiscover_decorators, {
3508
+ kind: "method",
3509
+ name: "mcpDiscover",
3510
+ static: false,
3511
+ private: false,
3512
+ access: {
3513
+ has: (obj) => "mcpDiscover" in obj,
3514
+ get: (obj) => obj.mcpDiscover
3515
+ },
3516
+ metadata: _metadata
3517
+ }, null, _instanceExtraInitializers);
3518
+ __esDecorate(this, null, _mcpSnapshot_decorators, {
3519
+ kind: "method",
3520
+ name: "mcpSnapshot",
3521
+ static: false,
3522
+ private: false,
3523
+ access: {
3524
+ has: (obj) => "mcpSnapshot" in obj,
3525
+ get: (obj) => obj.mcpSnapshot
3526
+ },
3527
+ metadata: _metadata
3528
+ }, null, _instanceExtraInitializers);
3529
+ if (_metadata) Object.defineProperty(this, Symbol.metadata, {
3530
+ enumerable: true,
3531
+ configurable: true,
3532
+ writable: true,
3533
+ value: _metadata
3534
+ });
3535
+ }
3536
+ config = __runInitializers(this, _instanceExtraInitializers);
3537
+ static inject = [];
3538
+ static Config = CapabilityCatalogSettingsSchema;
3539
+ baseline;
3540
+ appearedAfterApply;
3541
+ mcp;
3542
+ /** The live `ctx.tools` (traceable proxy) captured from the tools inject. */
3543
+ mcpToolsRegistry;
3544
+ /** Registered MCP tool disposers, keyed by model-facing public name. */
3545
+ mcpToolDisposers = /* @__PURE__ */ new Map();
3546
+ /** Preset-scoped delivery of the plugin's own managed skill root. */
3547
+ scoped;
3548
+ /** The workspace the catalog was last asked about, for project-root conflict checks. */
3549
+ observedWorkdir;
3550
+ constructor(ctx, config) {
3551
+ super(ctx, "capabilityCatalog");
3552
+ this.config = config;
3553
+ this.mcp = new McpStore();
3554
+ this.baseline = /* @__PURE__ */ new Set();
3555
+ this.appearedAfterApply = /* @__PURE__ */ new Set();
3556
+ ctx.inject(["settings"], (settingsCtx) => {
3557
+ const svc = settingsCtx.settings;
3558
+ if (typeof svc.register === "function") {
3559
+ const scope = svc.register(CAPABILITY_CATALOG_NS, CapabilityCatalogSettingsSchema);
3560
+ const persisted = scope.get()?.mcp;
3561
+ if (persisted !== void 0) this.mcp.loadFrom(persisted);
3562
+ this.mcp.onPersist = () => {
3563
+ scope.update({ mcp: this.mcp.toPersisted() }).catch((error) => this.ctx.logger.error(error));
3564
+ };
3565
+ scope.watch((next) => {
3566
+ this.mcp.loadFrom(next?.mcp);
3567
+ this.afterMcpMutation();
3568
+ });
3569
+ } else if (typeof svc.update === "function") {
3570
+ svc.configure?.({ auto: false }, ctx.fiber);
3571
+ const readVolatile = () => {
3572
+ const field = this.config.mcp;
3573
+ if (field !== null && typeof field === "object" && typeof field.get === "function") return field.get();
3574
+ return field;
3575
+ };
3576
+ const persisted = readVolatile();
3577
+ if (persisted !== void 0) this.mcp.loadFrom(persisted);
3578
+ this.mcp.onPersist = () => {
3579
+ (typeof svc.replace === "function" ? svc.replace(CAPABILITY_CATALOG_NS, { mcp: this.mcp.toPersisted() }) : svc.update(CAPABILITY_CATALOG_NS, { mcp: this.mcp.toPersisted() })).catch((error) => this.ctx.logger.error(error));
3580
+ };
3581
+ settingsCtx.on("settings/document-updated", (ns) => {
3582
+ if (ns !== "capability-catalog") return;
3583
+ this.mcp.loadFrom(readVolatile());
3584
+ this.afterMcpMutation();
3585
+ });
3586
+ }
3587
+ this.syncRegisteredMcpTools();
3588
+ this.reconnectEnabledMcp();
3589
+ });
3590
+ ctx.inject(["tools"], (toolCtx) => {
3591
+ this.mcpToolsRegistry = toolCtx.tools;
3592
+ this.syncRegisteredMcpTools();
3593
+ this.registerListTool(toolCtx.tools);
3594
+ this.baseline = new Set(toolCtx.tools.schemas().map((t) => t.name) ?? []);
3595
+ this.appearedAfterApply.clear();
3596
+ ctx.on("tools/change", () => this.markNewTools());
3597
+ });
3598
+ ctx.inject([
3599
+ "shellEnv",
3600
+ "credentials",
3601
+ "skills"
3602
+ ], () => {
3603
+ installSkillEnvInjection(ctx, () => this.catalogScope());
3604
+ });
3605
+ installSkillEnvHint(ctx, () => this.catalogScope());
3606
+ ctx.inject(["skills"], (skillsCtx) => {
3607
+ const delivery = new ScopedSkillDelivery(skillsCtx, {
3608
+ dshHome: () => this.dshHome(),
3609
+ roster: () => this.agentPresets(),
3610
+ registry: () => this.ctx.get?.("skills"),
3611
+ workdir: () => this.observedWorkdir,
3612
+ log: (message) => this.ctx.logger.warn(message)
3613
+ });
3614
+ this.scoped = delivery;
3615
+ skillsCtx.effect(() => {
3616
+ delivery.start().catch((error) => {
3617
+ this.ctx.logger.warn(`capability-catalog: preset-scoped delivery failed to start: ${error instanceof Error ? error.message : String(error)}`);
3618
+ });
3619
+ return () => {
3620
+ delivery.dispose();
3621
+ };
3622
+ }, "capability-catalog: preset-scoped skills");
3623
+ });
3624
+ }
3625
+ /** The preset-scoped delivery's current state, for the settings surface. */
3626
+ async presetScopeStatus() {
3627
+ const status = this.scoped?.status();
3628
+ return status === void 0 ? {
3629
+ enabled: false,
3630
+ reason: "preset-scoped delivery is not composed",
3631
+ root: scopedSkillsRoot(this.dshHome()),
3632
+ watching: false,
3633
+ skills: [],
3634
+ presets: [],
3635
+ customRootsUnverifiable: true
3636
+ } : status;
3637
+ }
3638
+ /** Every preset the roster supplies, for the scope picker. */
3639
+ async presetScopeRoster() {
3640
+ return modeOptions(await this.rosterRows(), this.agentPresets()?.defaultId);
3641
+ }
3642
+ /**
3643
+ * Every mode's capability face in ONE call, for the cross-mode comparison
3644
+ * view ("which modes load this tool/skill?").
3645
+ *
3646
+ * This is the expensive verb of the pair: a mode whose standing scope has not
3647
+ * been composed yet is MOUNTED by its read (see modes.ts — there is no cheap
3648
+ * honest source, because a composition file names plugins, not the tools and
3649
+ * skills those plugins register). The browser therefore calls it only when a
3650
+ * human chooses to compare modes, never on opening the section.
3651
+ *
3652
+ * A mode that cannot be read keeps its row with `unavailable` set rather than
3653
+ * an empty face, so the comparison never claims that a broken mode loads
3654
+ * nothing. `preset` on a face is always the id that was asked for: a read
3655
+ * that silently degraded to the global layer is reported as unavailable, not
3656
+ * as that mode's face.
3657
+ * @param workdir - optional cwd for project-scoped skill roots.
3658
+ */
3659
+ async modeFaces(workdir) {
3660
+ return readModeFaces(modeOptions(await this.rosterRows(), this.agentPresets()?.defaultId), async (id) => {
3661
+ try {
3662
+ const face = await this.collect(id, workdir, false);
3663
+ if (face.preset !== id) {
3664
+ this.ctx.logger.warn(`capability-catalog: preset "${id}" resolved no standing scope — its mode face reads as unavailable`);
3665
+ return;
3666
+ }
3667
+ return face;
3668
+ } catch (error) {
3669
+ this.ctx.logger.warn(`capability-catalog: mode face read for preset "${id}" failed: ${error instanceof Error ? error.message : String(error)}`);
3670
+ return;
3671
+ }
3672
+ });
3673
+ }
3674
+ /** The roster rows, or [] when this composition mounts no agent-preset service. */
3675
+ async rosterRows() {
3676
+ const roster = this.agentPresets();
3677
+ if (roster?.list === void 0) return [];
3678
+ try {
3679
+ return await roster.list();
3680
+ } catch (error) {
3681
+ this.ctx.logger.warn(`capability-catalog: the preset roster could not be listed: ${error instanceof Error ? error.message : String(error)}`);
3682
+ return [];
3683
+ }
3684
+ }
3685
+ /** Declare which presets one managed skill is delivered to. */
3686
+ async presetScopeSet(request) {
3687
+ const result = await setManagedPresetScope(this.dshHome(), request.name, request.presets);
3688
+ await this.reconcileScoped();
3689
+ return result;
3690
+ }
3691
+ /** Move an installed skill into the managed root with a preset scope. */
3692
+ async presetScopeAdopt(request) {
3693
+ const result = await adoptManagedSkill(this.dshHome(), request.name, request.presets, request.workdir ?? this.observedWorkdir);
3694
+ await this.reconcileScoped();
3695
+ return result;
3696
+ }
3697
+ /** Move a managed skill back to the user skill root (the recovery direction). */
3698
+ async presetScopeRelease(name) {
3699
+ const result = await releaseManagedSkill(this.dshHome(), name);
3700
+ await this.reconcileScoped();
3701
+ return result;
3702
+ }
3703
+ /** Re-deliver after a write; a delivery that is absent is not an error. */
3704
+ async reconcileScoped() {
3705
+ try {
3706
+ await this.scoped?.reconcile();
3707
+ } catch (error) {
3708
+ this.ctx.logger.warn(`capability-catalog: scoped skill reconcile failed: ${error instanceof Error ? error.message : String(error)}`);
3709
+ }
3710
+ }
3711
+ /** Record tools that appeared after the apply-time baseline (reads the live
3712
+ * registry each time, so a change event never sees a stale/undefined ref). */
3713
+ markNewTools() {
3714
+ const tools = this.ctx.get?.("tools");
3715
+ const current = new Set(tools?.schemas().map((t) => t.name) ?? []);
3716
+ for (const name of current) if (!this.baseline.has(name)) this.appearedAfterApply.add(name);
3717
+ }
3718
+ /** The managed skills root under `$DSH_HOME`. */
3719
+ dshHome() {
3720
+ return (this.ctx.get?.("settings"))?.home ?? process.env.DSH_HOME ?? join(homedir(), ".dsh-official");
3721
+ }
3722
+ /**
3723
+ * The standing skill view scope of ONE preset. In the web bundle the
3724
+ * host-global skill-filesystem provider is mounted under each agent
3725
+ * preset's standing scope, so a host-global snapshot sees only runtime
3726
+ * skills; reading at the preset's scope surfaces the official/plugin/user
3727
+ * skills the model actually sees. When the presets service is absent the
3728
+ * catalog degrades to the global layer alone.
3729
+ *
3730
+ * `presetId` names WHICH preset — the deployment default when omitted, the
3731
+ * way every reader before `snapshotFor` behaved. The roster's scope face
3732
+ * (0.1.5's `standingKeyFor`, rc.1's leased `acquireScope`) has always taken
3733
+ * an id; the catalog simply never passed one, so "the capability face of
3734
+ * preset X" was unaskable and a condition declaring `preset: X` could only
3735
+ * be believed.
3736
+ *
3737
+ * `strict` is the listing/fingerprint split — see
3738
+ * {@link resolvePresetScope}, which owns the policy.
3739
+ * @param presetId - the preset to read, or undefined for the default.
3740
+ * @param strict - throw instead of degrading when the roster cannot answer.
3741
+ */
3742
+ async catalogScope(presetId, strict = false) {
3743
+ const { scope, dispose } = await resolvePresetScope(this.agentPresets(), presetId, strict);
3744
+ await this.releaseScope(dispose);
3745
+ return scope;
3746
+ }
3747
+ /** Release a scope lease, logging rather than failing the read it followed. */
3748
+ async releaseScope(dispose) {
3749
+ if (dispose === void 0) return;
3750
+ try {
3751
+ await dispose();
3752
+ } catch (error) {
3753
+ this.ctx.logger.warn(`capability-catalog: scope lease release failed: ${error instanceof Error ? error.message : String(error)}`);
3754
+ }
3755
+ }
3756
+ /** The optional agent-preset roster (absent in a rosterless composition). */
3757
+ agentPresets() {
3758
+ return this.ctx.get?.("agentPresets");
3759
+ }
3760
+ /** Register the model-facing `list_capabilities` tool (optional; called from the
3761
+ * tools inject so the registry is guaranteed present). */
3762
+ registerListTool(tools) {
3763
+ if (tools.register === void 0) return;
3764
+ tools.register(defineTool({
3765
+ name: "list_capabilities",
3766
+ description: "List the capabilities (skills and tools) registered in this instance and their source channels. Call to discover what is available.",
3767
+ parameters: { kind: {
3768
+ type: "string",
3769
+ enum: ["skill", "tool"],
3770
+ description: "Optional: filter to skills or tools."
3771
+ } },
3772
+ output: {
3773
+ schema: { type: "string" },
3774
+ render: (_args, value) => [{
3775
+ type: "text",
3776
+ text: String(value)
3777
+ }]
3778
+ },
3779
+ execute: async (args, exec) => {
3780
+ const { registry } = resolveServicesHelper(this.ctx);
3781
+ if (registry === void 0) return "skills service absent in this composition";
3782
+ const scope = exec?.agent ?? await this.catalogScope();
3783
+ const snapshot = await catalogSnapshot(void 0, registry, this.toolSchemasIn(scope), this.mcpServerNamesIn(scope), this.appearedAfterApply, [scope], this.toolOriginsMap(scope), { fingerprint: true });
3784
+ const filter = args?.kind;
3785
+ const skills = filter === void 0 || filter === "skill" ? snapshot.skills : [];
3786
+ const toolsRows = filter === void 0 || filter === "tool" ? snapshot.tools : [];
3787
+ return JSON.stringify({
3788
+ capabilities: capsTag(snapshot.sha),
3789
+ skills,
3790
+ tools: toolsRows
3791
+ }, null, 2);
3792
+ }
3793
+ }));
3794
+ }
3795
+ async snapshot(workdir) {
3796
+ return this.collect(void 0, workdir, false);
3797
+ }
3798
+ /**
3799
+ * The capability face of ONE mode as a LISTING — {@link snapshot} scoped to a
3800
+ * named preset, for the settings surface's mode picker.
3801
+ *
3802
+ * The LISTING/FINGERPRINT split matters here the way it does everywhere else:
3803
+ * a viewer wants rows as fast as the registries can produce them, not every
3804
+ * skill body loaded and digested. Asking for a preset's IDENTITY is
3805
+ * {@link snapshotFor}. Like every scope resolution, this MOUNTS a preset
3806
+ * nothing has composed yet.
3807
+ *
3808
+ * Degrades like {@link snapshot}: a preset whose standing scope refuses
3809
+ * answers with the global layer and NO `preset` stamp, which is how a caller
3810
+ * tells "this mode" from "the fallback" and says so.
3811
+ * @param presetId - the mode to read, or undefined for the deployment default.
3812
+ * @param workdir - optional cwd for project-scoped skill roots.
3813
+ */
3814
+ async snapshotAt(presetId, workdir) {
3815
+ return this.collect(presetId, workdir, false);
3816
+ }
3817
+ /**
3818
+ * The capability face of ONE preset, with its {@link hashOf} digest.
3819
+ *
3820
+ * This is the fingerprint verb: every skill body is loaded (so the rows
3821
+ * carry `bodySha`) and `sha` is stamped, which is what makes the answer an
3822
+ * identity rather than a listing. `presetId` omitted reads the deployment
3823
+ * default — the same face `snapshot` reads, now hashable.
3824
+ *
3825
+ * Degrades like every other verb: without a skills registry the answer is
3826
+ * an empty face with the hash of an empty face, not a throw. Note that a
3827
+ * preset nobody has composed yet is MOUNTED by the read (the roster's
3828
+ * single-flight standing mount), so asking is not free.
3829
+ * @param presetId - the preset to fingerprint, or undefined for the default.
3830
+ * @param workdir - optional cwd for project-scoped skill roots.
3831
+ */
3832
+ async snapshotFor(presetId, workdir) {
3833
+ return this.collect(presetId, workdir, true);
3834
+ }
3835
+ /**
3836
+ * Shared body of {@link snapshot} and {@link snapshotFor}. The scope is
3837
+ * resolved ONCE — three resolutions of a mounting preset would race the
3838
+ * roster's single-flight for no reason — and `fingerprint` makes the
3839
+ * resolution strict.
3840
+ */
3841
+ async collect(presetId, workdir, fingerprint) {
3842
+ const { registry } = resolveServicesHelper(this.ctx);
3843
+ if (workdir !== void 0 && workdir !== "") this.observedWorkdir = workdir;
3844
+ const { scope, preset, dispose } = await resolvePresetScope(this.agentPresets(), presetId, fingerprint);
3845
+ try {
3846
+ if (registry === void 0) {
3847
+ const empty = {
3848
+ skills: [],
3849
+ tools: [],
3850
+ mcpServers: [],
3851
+ channels: [],
3852
+ ...preset !== void 0 ? { preset } : {}
3853
+ };
3854
+ return fingerprint ? {
3855
+ ...empty,
3856
+ sha: hashOf(empty)
3857
+ } : empty;
3858
+ }
3859
+ return await catalogSnapshot(workdir, registry, this.toolSchemasIn(scope), this.mcpServerNamesIn(scope), this.appearedAfterApply, [scope], this.toolOriginsMap(scope), {
3860
+ fingerprint,
3861
+ ...preset !== void 0 ? { preset } : {}
3862
+ });
3863
+ } finally {
3864
+ await this.releaseScope(dispose);
3865
+ }
3866
+ }
3867
+ async detail(name, workdir, presetId) {
3868
+ const { registry } = resolveServicesHelper(this.ctx);
3869
+ if (registry === void 0) return void 0;
3870
+ return catalogDetail(this.ctx, registry, name, workdir, await this.catalogScope(presetId), await this.managedDefinition(name));
3871
+ }
3872
+ async readSkillFile(name, filePath, workdir, presetId) {
3873
+ const { registry } = resolveServicesHelper(this.ctx);
3874
+ if (registry === void 0) return void 0;
3875
+ return catalogReadSkillFile(registry, name, filePath, workdir, await this.catalogScope(presetId), await this.managedDefinition(name));
3876
+ }
3877
+ /** One managed skill's definition, for management reads outside its scope. */
3878
+ async managedDefinition(name) {
3879
+ const skill = (await scanManagedSkills(scopedSkillsRoot(this.dshHome()))).find((entry) => entry.name === name);
3880
+ return skill === void 0 ? void 0 : definitionFor(skill);
3881
+ }
3882
+ async listDirSkills(dirPath) {
3883
+ return catalogListDirSkills(dirPath);
3884
+ }
3885
+ async setCredential(request) {
3886
+ return catalogSetCredential(this.ctx, request);
3887
+ }
3888
+ async addSkill(request) {
3889
+ return catalogAddSkill(this.ctx, request, this.dshHome());
3890
+ }
3891
+ async deleteSkill(name, workdir, presetId) {
3892
+ const { registry } = resolveServicesHelper(this.ctx);
3893
+ if (registry === void 0) return {
3894
+ ok: false,
3895
+ error: "skills service absent in this composition"
3896
+ };
3897
+ return catalogDeleteSkill(registry, name, workdir, await this.catalogScope(presetId));
3898
+ }
3899
+ async pickDirectory() {
3900
+ return catalogPickDirectory(this.ctx);
3901
+ }
3902
+ async mcpList() {
3903
+ return this.mcp.list().map(maskConfig);
3904
+ }
3905
+ async mcpAdd(config) {
3906
+ if (config.serverName.trim() === "") return false;
3907
+ this.mcp.add(config);
3908
+ this.afterMcpMutation();
3909
+ return true;
3910
+ }
3911
+ async mcpRemove(serverName) {
3912
+ this.mcp.remove(serverName);
3913
+ this.afterMcpMutation();
3914
+ return true;
3915
+ }
3916
+ async mcpSetEnabled(serverName, enabled) {
3917
+ this.mcp.setEnabled(serverName, enabled);
3918
+ this.afterMcpMutation();
3919
+ }
3920
+ async mcpSetCredential(ref, value) {
3921
+ this.mcp.setCredential(ref, value);
3922
+ return true;
3923
+ }
3924
+ async mcpSetToolEnabled(serverName, tool, enabled) {
3925
+ this.mcp.setToolEnabled(serverName, tool, enabled);
3926
+ this.afterMcpMutation();
3927
+ }
3928
+ async mcpDiscover(serverName) {
3929
+ const tools = await this.mcp.discover(serverName);
3930
+ this.afterMcpMutation();
3931
+ return tools;
3932
+ }
3933
+ async mcpSnapshot() {
3934
+ return {
3935
+ servers: this.mcp.list().map(maskConfig),
3936
+ tools: this.mcp.toolsByServer(),
3937
+ credentials: this.mcp.credentials()
3938
+ };
3939
+ }
3940
+ /** The visible tool schemas in the standing scope (the set the model sees),
3941
+ * or the global layer when no preset standing key resolves. Skills already
3942
+ * enumerate through the same scope; tools must use it too so the
3943
+ * settings reader sees the official/plugin/MCP tools the model actually has. */
3944
+ 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);
3948
+ }
3949
+ /** tool name → author-declared origin, read via `ctx.tools.get(name)` so the
3950
+ * `Symbol.for('dsh.tool.origin')` tag on the retained definition survives (the
3951
+ * system-prompt assembly strips it, but `get()` returns the full definition). */
3952
+ 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;
3973
+ }
3974
+ /** MCP server names from live `mcp__`-prefixed tools (prefix-derived baseline). */
3975
+ 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;
3985
+ }
3986
+ /**
3987
+ * Reconcile which discovered MCP tools are registered on `ctx.tools` so the
3988
+ * model can call them. `mcpToolsRegistry` is the traceable `ctx.tools` proxy,
3989
+ * so `register` is always invoked as a MEMBER call (`reconcile` calls
3990
+ * `registry.register(...)`) — extracting it to a standalone variable loses the
3991
+ * `this.ctx` binding and breaks the disposal effect.
3992
+ */
3993
+ syncRegisteredMcpTools() {
3994
+ const registry = this.mcpToolsRegistry;
3995
+ if (registry === void 0) return;
3996
+ reconcileRegisteredMcpTools(desiredMcpTools(this.mcp.list(), this.mcp.toolsByServer()), {
3997
+ register: (definition) => registry.register(definition),
3998
+ onRegisterError: (error) => this.ctx.logger.error(`capability-catalog: MCP tool registration failed: ${String(error)}`)
3999
+ }, this.mcpToolDisposers, (serverName, rawName, args) => this.mcp.callTool(serverName, rawName, args));
4000
+ }
4001
+ /** Re-sync registered MCP tools after any MCP store mutation. */
4002
+ afterMcpMutation() {
4003
+ this.syncRegisteredMcpTools();
4004
+ }
4005
+ /** Refill the persisted MCP tools' descriptions/parameters by re-connecting the
4006
+ * enabled servers at boot (the persisted `mcp` block stores only name+enabled to
4007
+ * keep settings.yaml small). Async: boot is not blocked; each server's errors are
4008
+ * contained, and registration is re-synced once the descriptions are back. */
4009
+ async reconnectEnabledMcp() {
4010
+ const enabled = this.mcp.list().filter((s) => s.enabled);
4011
+ if (enabled.length === 0) return;
4012
+ for (const server of enabled) try {
4013
+ await this.mcp.discover(server.serverName);
4014
+ } catch (error) {
4015
+ this.ctx.logger.error(`capability-catalog: reconnect ${server.serverName} failed: ${String(error)}`);
4016
+ }
4017
+ this.syncRegisteredMcpTools();
4018
+ }
4019
+ };
4020
+ })();
4021
+ //#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 };