@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.
- package/LICENSE +21 -0
- package/README.i18n.yaml +6 -0
- package/README.md +286 -0
- package/README.zh.md +138 -0
- package/cordis.patch.yml +8 -0
- package/lib/client.js +10100 -0
- package/lib/index.js +4022 -0
- package/lib/invariant.js +25 -0
- package/lib/mcpConnector-C2TbkPBY.js +69 -0
- package/lib/typert.host.d.ts +3 -0
- package/lib/typert.host.js +1417 -0
- package/lib/typert.remote-client.d.ts +68 -0
- package/lib/typert.remote-client.js +1136 -0
- package/lib/types/capabilities.d.ts +103 -0
- package/lib/types/capabilities.js +116 -0
- package/lib/types/channels.d.ts +41 -0
- package/lib/types/channels.js +75 -0
- package/lib/types/client/AddMcpDialog.d.ts +13 -0
- package/lib/types/client/AddMcpDialog.js +79 -0
- package/lib/types/client/AddSkillModal.d.ts +17 -0
- package/lib/types/client/AddSkillModal.js +138 -0
- package/lib/types/client/BundleFileTree.d.ts +7 -0
- package/lib/types/client/BundleFileTree.js +74 -0
- package/lib/types/client/CapabilityCatalogCard.d.ts +57 -0
- package/lib/types/client/CapabilityCatalogCard.js +479 -0
- package/lib/types/client/CredentialField.d.ts +14 -0
- package/lib/types/client/CredentialField.js +9 -0
- package/lib/types/client/McpCards.d.ts +13 -0
- package/lib/types/client/McpCards.js +12 -0
- package/lib/types/client/McpServerManageModal.d.ts +18 -0
- package/lib/types/client/McpServerManageModal.js +63 -0
- package/lib/types/client/MetadataRow.d.ts +10 -0
- package/lib/types/client/MetadataRow.js +6 -0
- package/lib/types/client/ModalShell.d.ts +16 -0
- package/lib/types/client/ModalShell.js +27 -0
- package/lib/types/client/ModeChips.d.ts +38 -0
- package/lib/types/client/ModeChips.js +42 -0
- package/lib/types/client/SchemaView.d.ts +11 -0
- package/lib/types/client/SchemaView.js +143 -0
- package/lib/types/client/SkillCards.d.ts +28 -0
- package/lib/types/client/SkillCards.js +43 -0
- package/lib/types/client/SkillDetailModal.d.ts +69 -0
- package/lib/types/client/SkillDetailModal.js +167 -0
- package/lib/types/client/ToolCards.d.ts +28 -0
- package/lib/types/client/ToolCards.js +52 -0
- package/lib/types/client/ToolDetailModal.d.ts +12 -0
- package/lib/types/client/ToolDetailModal.js +29 -0
- package/lib/types/client/icons.d.ts +26 -0
- package/lib/types/client/icons.js +10 -0
- package/lib/types/client/index.d.ts +21 -0
- package/lib/types/client/index.js +196 -0
- package/lib/types/client/locales.d.ts +392 -0
- package/lib/types/client/locales.js +395 -0
- package/lib/types/client/mcp-model.d.ts +24 -0
- package/lib/types/client/mcp-model.js +87 -0
- package/lib/types/client/mode-model.d.ts +108 -0
- package/lib/types/client/mode-model.js +140 -0
- package/lib/types/client/preset-display.d.ts +21 -0
- package/lib/types/client/preset-display.js +20 -0
- package/lib/types/client/settle.d.ts +40 -0
- package/lib/types/client/settle.js +57 -0
- package/lib/types/client/slots.d.ts +86 -0
- package/lib/types/client/slots.js +1 -0
- package/lib/types/envHint.d.ts +22 -0
- package/lib/types/envHint.js +76 -0
- package/lib/types/import.d.ts +106 -0
- package/lib/types/import.js +635 -0
- package/lib/types/index.d.ts +216 -0
- package/lib/types/index.js +739 -0
- package/lib/types/invariant.d.ts +16 -0
- package/lib/types/invariant.js +23 -0
- package/lib/types/mcpConnector.d.ts +24 -0
- package/lib/types/mcpConnector.js +73 -0
- package/lib/types/mcpStore.d.ts +68 -0
- package/lib/types/mcpStore.js +235 -0
- package/lib/types/mcpTools.d.ts +45 -0
- package/lib/types/mcpTools.js +125 -0
- package/lib/types/mcps.d.ts +64 -0
- package/lib/types/mcps.js +157 -0
- package/lib/types/modes.d.ts +65 -0
- package/lib/types/modes.js +97 -0
- package/lib/types/namespace.d.ts +9 -0
- package/lib/types/namespace.js +8 -0
- package/lib/types/official-tools.d.ts +3 -0
- package/lib/types/official-tools.js +44 -0
- package/lib/types/preset-scope.d.ts +105 -0
- package/lib/types/preset-scope.js +117 -0
- package/lib/types/remote.d.ts +48 -0
- package/lib/types/remote.js +120 -0
- package/lib/types/scoped-delivery.d.ts +243 -0
- package/lib/types/scoped-delivery.js +686 -0
- package/lib/types/scoped-edits.d.ts +90 -0
- package/lib/types/scoped-edits.js +234 -0
- package/lib/types/settings.d.ts +28 -0
- package/lib/types/settings.js +28 -0
- package/lib/types/shellEnv.d.ts +65 -0
- package/lib/types/shellEnv.js +232 -0
- package/lib/types/skills.d.ts +103 -0
- package/lib/types/skills.js +303 -0
- package/lib/types/tool-origin.d.ts +29 -0
- package/lib/types/tool-origin.js +30 -0
- package/lib/types/types.d.ts +315 -0
- package/lib/types/types.js +8 -0
- package/lib/types/zip.d.ts +24 -0
- package/lib/types/zip.js +80 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +119 -0
- 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 };
|