dsh-agent-plugins-market 0.4.3
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.md +171 -0
- package/README.zh.md +171 -0
- package/client/client.js +954 -0
- package/client/client.js.map +1 -0
- package/cordis.patch.yml +4 -0
- package/lib/commands-mounts.js +169 -0
- package/lib/context.js +80 -0
- package/lib/discovery/manifests.js +214 -0
- package/lib/discovery/surfaces.js +232 -0
- package/lib/discovery.js +234 -0
- package/lib/git.js +52 -0
- package/lib/hooks-mounts.js +95 -0
- package/lib/index.js +73 -0
- package/lib/manager.js +490 -0
- package/lib/mcp-config.js +100 -0
- package/lib/mcp-mounts.js +88 -0
- package/lib/paths.js +85 -0
- package/lib/routes.js +208 -0
- package/lib/skills-parse.js +114 -0
- package/lib/skills-provider.js +170 -0
- package/lib/state.js +64 -0
- package/lib/types/commands-mounts.d.ts +29 -0
- package/lib/types/context.d.ts +13 -0
- package/lib/types/discovery/manifests.d.ts +55 -0
- package/lib/types/discovery/surfaces.d.ts +15 -0
- package/lib/types/discovery.d.ts +16 -0
- package/lib/types/git.d.ts +8 -0
- package/lib/types/hooks-mounts.d.ts +19 -0
- package/lib/types/index.d.ts +27 -0
- package/lib/types/manager.d.ts +87 -0
- package/lib/types/mcp-config.d.ts +28 -0
- package/lib/types/mcp-mounts.d.ts +31 -0
- package/lib/types/paths.d.ts +29 -0
- package/lib/types/routes.d.ts +11 -0
- package/lib/types/skills-parse.d.ts +22 -0
- package/lib/types/skills-provider.d.ts +16 -0
- package/lib/types/state.d.ts +6 -0
- package/lib/types/types.d.ts +165 -0
- package/lib/types/validate.d.ts +34 -0
- package/lib/types.js +10 -0
- package/lib/validate.js +199 -0
- package/package.json +114 -0
- package/schemas/1.0.0/mcp.schema.json +120 -0
- package/schemas/1.0.0/plugin.schema.json +65 -0
- package/schemas/README.md +14 -0
package/lib/manager.js
ADDED
|
@@ -0,0 +1,490 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SuiteManager: the host-authoritative orchestrator behind the market page.
|
|
3
|
+
*
|
|
4
|
+
* Owns the persisted state (sources + install entries), discovery over the
|
|
5
|
+
* source checkouts, and every mutating action (source add/remove/refresh,
|
|
6
|
+
* install/uninstall, enable/disable). Mutations serialize through a
|
|
7
|
+
* single-flight queue so concurrent HTTP actions cannot interleave state
|
|
8
|
+
* writes or git operations. After every mutation it calls `onChanged()`,
|
|
9
|
+
* which is where the runtime consumers (skill registry invalidation, MCP
|
|
10
|
+
* mount reconciliation) pick the new enabled set up.
|
|
11
|
+
*/
|
|
12
|
+
import { mkdir, readFile } from 'node:fs/promises';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
import { discoverLspEntries, discoverSourceList, discoverSuitesInSource, listMdFiles, repoName } from './discovery.js';
|
|
15
|
+
import { gitClone, gitHead, gitPull, gitRemove } from './git.js';
|
|
16
|
+
import { deriveSourceId, expandHome, isDirectory, resolveProjectRoot, sanitizeId, sourceCheckoutDir, sourcesDir, STATE_FILE_NAME } from './paths.js';
|
|
17
|
+
import { loadState, saveState, EMPTY_STATE } from './state.js';
|
|
18
|
+
export class SuiteManager {
|
|
19
|
+
options;
|
|
20
|
+
state = EMPTY_STATE;
|
|
21
|
+
mutationQueue = Promise.resolve();
|
|
22
|
+
statePath;
|
|
23
|
+
constructor(options) {
|
|
24
|
+
this.options = options;
|
|
25
|
+
this.statePath = join(options.userRoot, STATE_FILE_NAME);
|
|
26
|
+
}
|
|
27
|
+
/** Load persisted state once at plugin activation. */
|
|
28
|
+
async load() {
|
|
29
|
+
this.state = await loadState(this.statePath);
|
|
30
|
+
}
|
|
31
|
+
get sources() {
|
|
32
|
+
return this.state.sources;
|
|
33
|
+
}
|
|
34
|
+
/** The user-dimension suite root this manager operates. */
|
|
35
|
+
get userRoot() {
|
|
36
|
+
return this.options.userRoot;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* One suite's full detail for the market detail modal: manifest fields,
|
|
40
|
+
* skill metadata, validated mcp.json servers, and per-surface file lists.
|
|
41
|
+
*/
|
|
42
|
+
async suiteDetail(sourceId, suiteId) {
|
|
43
|
+
const suites = await this.discoverDimension('user', this.options.userRoot);
|
|
44
|
+
const suite = suites.find(entry => entry.sourceId === sourceId && entry.id === suiteId);
|
|
45
|
+
if (suite === undefined)
|
|
46
|
+
throw new Error(`suite "${suiteId}" not found in source "${sourceId}"`);
|
|
47
|
+
const installed = this.state.installed[installKey(sourceId, suiteId)];
|
|
48
|
+
const remoteUrl = suite.remote?.url;
|
|
49
|
+
return {
|
|
50
|
+
sourceId,
|
|
51
|
+
suiteId: suite.id,
|
|
52
|
+
name: suite.manifest.name,
|
|
53
|
+
version: suite.manifest.version ?? null,
|
|
54
|
+
description: suite.manifest.description ?? null,
|
|
55
|
+
author: suite.manifest.author ?? null,
|
|
56
|
+
keywords: suite.manifest.keywords ?? [],
|
|
57
|
+
layout: suite.manifest.layout,
|
|
58
|
+
dimension: suite.dimension,
|
|
59
|
+
root: remoteUrl ?? suite.root,
|
|
60
|
+
remoteUrl: remoteUrl ?? null,
|
|
61
|
+
installed: installed !== undefined,
|
|
62
|
+
enabled: installed?.enabled === true,
|
|
63
|
+
skills: suite.skills.map(skill => ({
|
|
64
|
+
name: skill.name,
|
|
65
|
+
description: skill.description,
|
|
66
|
+
...skill.whenToUse === undefined ? {} : { whenToUse: skill.whenToUse },
|
|
67
|
+
path: skill.file,
|
|
68
|
+
})),
|
|
69
|
+
mcpServers: suite.mcp === undefined ? [] : Object.entries(suite.mcp.servers).map(([key, server]) => ({ key, ...server })),
|
|
70
|
+
hooks: remoteUrl === undefined ? await this.hooksPreviews(suite.root) : { count: 0, entries: [] },
|
|
71
|
+
commands: remoteUrl === undefined ? await this.commandPreviews(`${suite.root}/commands`) : [],
|
|
72
|
+
agents: remoteUrl === undefined ? await this.agentPreviews(`${suite.root}/agents`) : [],
|
|
73
|
+
lsp: remoteUrl === undefined ? await this.lspPreviews(suite.root) : [],
|
|
74
|
+
errors: suite.errors,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
async readPreview(path, capBytes = 64 * 1024) {
|
|
78
|
+
const text = await readFile(path, 'utf8');
|
|
79
|
+
return text.length > capBytes ? `${text.slice(0, capBytes)}\n… (truncated)` : text;
|
|
80
|
+
}
|
|
81
|
+
/** One skill's full SKILL.md text for the market detail modal. */
|
|
82
|
+
async skillContent(sourceId, suiteId, skillName) {
|
|
83
|
+
const suites = await this.discoverDimension('user', this.options.userRoot);
|
|
84
|
+
const suite = suites.find(entry => entry.sourceId === sourceId && entry.id === suiteId);
|
|
85
|
+
if (suite === undefined)
|
|
86
|
+
throw new Error(`suite "${suiteId}" not found in source "${sourceId}"`);
|
|
87
|
+
const skill = suite.skills.find(entry => entry.name === skillName);
|
|
88
|
+
if (skill === undefined)
|
|
89
|
+
throw new Error(`skill "${skillName}" not found in suite "${suiteId}"`);
|
|
90
|
+
let content;
|
|
91
|
+
try {
|
|
92
|
+
content = await readFile(skill.file, 'utf8');
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
throw new Error(`skill file unreadable: ${error instanceof Error ? error.message : String(error)}`);
|
|
96
|
+
}
|
|
97
|
+
return { name: skill.name, description: skill.description, content, path: skill.file };
|
|
98
|
+
}
|
|
99
|
+
/** The full market overview: fresh discovery merged with install entries. */
|
|
100
|
+
async overview() {
|
|
101
|
+
const suites = await this.discoverDimension('user', this.options.userRoot);
|
|
102
|
+
const sourceRows = [];
|
|
103
|
+
for (const source of this.state.sources) {
|
|
104
|
+
const checkout = this.sourceCheckoutPath(source);
|
|
105
|
+
let cloned = false;
|
|
106
|
+
let lockCommit;
|
|
107
|
+
let error;
|
|
108
|
+
if (source.local === true) {
|
|
109
|
+
cloned = await isDirectory(checkout);
|
|
110
|
+
if (!cloned)
|
|
111
|
+
error = `local source directory ${checkout} is missing`;
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
try {
|
|
115
|
+
lockCommit = await gitHead(checkout);
|
|
116
|
+
cloned = true;
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
// not cloned yet (or a broken checkout); the clone attempt records its own error on refresh
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
const sourceSuites = suites.filter(suite => suite.sourceId === source.id);
|
|
123
|
+
sourceRows.push({
|
|
124
|
+
id: source.id,
|
|
125
|
+
url: source.url,
|
|
126
|
+
...source.branch === undefined ? {} : { branch: source.branch },
|
|
127
|
+
...source.local === true ? { local: true } : {},
|
|
128
|
+
cloned,
|
|
129
|
+
...lockCommit === undefined ? {} : { lockCommit },
|
|
130
|
+
...error === undefined ? {} : { error },
|
|
131
|
+
suiteIds: sourceSuites.map(suite => suite.id),
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
const installed = new Set(Object.keys(this.state.installed));
|
|
135
|
+
const cards = suites.map(suite => ({
|
|
136
|
+
sourceId: suite.sourceId,
|
|
137
|
+
suiteId: suite.id,
|
|
138
|
+
name: suite.manifest.name,
|
|
139
|
+
version: suite.manifest.version,
|
|
140
|
+
description: suite.manifest.description,
|
|
141
|
+
keywords: suite.manifest.keywords ?? [],
|
|
142
|
+
surfaces: suite.surfaces,
|
|
143
|
+
enabled: suite.enabled,
|
|
144
|
+
installed: installed.has(installKey(suite.sourceId, suite.id)),
|
|
145
|
+
dimension: suite.dimension,
|
|
146
|
+
layout: suite.manifest.layout,
|
|
147
|
+
errors: suite.errors,
|
|
148
|
+
}));
|
|
149
|
+
return {
|
|
150
|
+
sources: sourceRows,
|
|
151
|
+
suites: cards,
|
|
152
|
+
totals: {
|
|
153
|
+
all: cards.length,
|
|
154
|
+
installed: cards.filter(card => card.installed).length,
|
|
155
|
+
enabled: cards.filter(card => card.enabled).length,
|
|
156
|
+
},
|
|
157
|
+
roots: { user: this.options.userRoot, data: this.options.dataRoot },
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
/** Enabled user-dimension suites (the MCP mount input). */
|
|
161
|
+
async enabledUserSuites() {
|
|
162
|
+
const suites = await this.discoverDimension('user', this.options.userRoot);
|
|
163
|
+
return suites.filter(suite => suite.enabled);
|
|
164
|
+
}
|
|
165
|
+
/** Enabled user- and project-dimension suites for a workspace cwd. */
|
|
166
|
+
async enabledSuitesForCwd(cwd) {
|
|
167
|
+
const user = await this.enabledUserSuites();
|
|
168
|
+
const project = (await this.discoverDimension('project', await resolveProjectRoot(cwd))).filter(suite => suite.enabled);
|
|
169
|
+
return { user, project };
|
|
170
|
+
}
|
|
171
|
+
/** All suites (any enabled state) of one dimension. */
|
|
172
|
+
async suitesForDimension(dimension, dimensionRoot) {
|
|
173
|
+
return this.discoverDimension(dimension, dimensionRoot);
|
|
174
|
+
}
|
|
175
|
+
// ---- mutations ----
|
|
176
|
+
/**
|
|
177
|
+
* Add a source and clone it immediately. The id is derived from the suite
|
|
178
|
+
* repository's own JSON (marketplace plugin entry name first, then the
|
|
179
|
+
* root manifest name, then the repo basename), sanitized; collisions get a
|
|
180
|
+
* numeric suffix.
|
|
181
|
+
*/
|
|
182
|
+
async addSource(input) {
|
|
183
|
+
return this.enqueue(async () => {
|
|
184
|
+
const baseId = this.uniqueSourceId(deriveSourceId(input.url));
|
|
185
|
+
const source = {
|
|
186
|
+
id: baseId,
|
|
187
|
+
url: input.url,
|
|
188
|
+
...input.branch === undefined ? {} : { branch: input.branch },
|
|
189
|
+
...input.local === true ? { local: true } : {},
|
|
190
|
+
};
|
|
191
|
+
const checkout = this.sourceCheckoutPath(source);
|
|
192
|
+
if (input.local === true) {
|
|
193
|
+
if (!await isDirectory(checkout))
|
|
194
|
+
throw new Error(`local source directory ${checkout} is missing`);
|
|
195
|
+
}
|
|
196
|
+
else {
|
|
197
|
+
await this.ensureClone(source);
|
|
198
|
+
}
|
|
199
|
+
source.id = this.uniqueSourceId(sanitizeId(await repoName(checkout)));
|
|
200
|
+
this.state = { ...this.state, sources: [...this.state.sources, source] };
|
|
201
|
+
await saveState(this.statePath, this.state);
|
|
202
|
+
this.options.onChanged();
|
|
203
|
+
return source;
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
uniqueSourceId(derived) {
|
|
207
|
+
if (!this.state.sources.some(source => source.id === derived))
|
|
208
|
+
return derived;
|
|
209
|
+
for (let suffix = 2;; suffix++) {
|
|
210
|
+
const candidate = `${derived}-${suffix}`;
|
|
211
|
+
if (!this.state.sources.some(source => source.id === candidate))
|
|
212
|
+
return candidate;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Update one source's url / branch / local flag. A git source whose URL
|
|
217
|
+
* changes drops its stale checkout (the next refresh clones the new URL);
|
|
218
|
+
* a local source's directory is never touched.
|
|
219
|
+
*/
|
|
220
|
+
async updateSource(sourceId, patch) {
|
|
221
|
+
return this.enqueue(async () => {
|
|
222
|
+
const index = this.state.sources.findIndex(source => source.id === sourceId);
|
|
223
|
+
if (index === -1)
|
|
224
|
+
throw new Error(`unknown source "${sourceId}"`);
|
|
225
|
+
const current = this.state.sources[index];
|
|
226
|
+
const next = {
|
|
227
|
+
id: sourceId,
|
|
228
|
+
url: patch.url ?? current.url,
|
|
229
|
+
...(patch.branch !== undefined ? { branch: patch.branch } : current.branch === undefined ? {} : { branch: current.branch }),
|
|
230
|
+
...(patch.local !== undefined ? { local: patch.local } : current.local === undefined ? {} : { local: current.local }),
|
|
231
|
+
};
|
|
232
|
+
if (current.local !== true && patch.url !== undefined && patch.url !== current.url) {
|
|
233
|
+
await gitRemove(sourceCheckoutDir(this.options.userRoot, sourceId));
|
|
234
|
+
}
|
|
235
|
+
this.state = { ...this.state, sources: this.state.sources.map((source, i) => i === index ? next : source) };
|
|
236
|
+
await saveState(this.statePath, this.state);
|
|
237
|
+
this.options.onChanged();
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
/** Remove a source: delete its checkout, forget its suites and install entries. */
|
|
241
|
+
async removeSource(sourceId) {
|
|
242
|
+
return this.enqueue(async () => {
|
|
243
|
+
this.state = {
|
|
244
|
+
...this.state,
|
|
245
|
+
sources: this.state.sources.filter(source => source.id !== sourceId),
|
|
246
|
+
installed: Object.fromEntries(Object.entries(this.state.installed).filter(([key]) => !key.startsWith(`${sourceId}/`))),
|
|
247
|
+
};
|
|
248
|
+
await saveState(this.statePath, this.state);
|
|
249
|
+
const source = this.state.sources.find(entry => entry.id === sourceId);
|
|
250
|
+
if (source === undefined || source.local !== true) {
|
|
251
|
+
await gitRemove(sourceCheckoutDir(this.options.userRoot, sourceId));
|
|
252
|
+
}
|
|
253
|
+
this.options.onChanged();
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
/** Refresh one source checkout (pull), or every source when `sourceId` is omitted. */
|
|
257
|
+
async refreshSource(sourceId) {
|
|
258
|
+
return this.enqueue(async () => {
|
|
259
|
+
const targets = sourceId === undefined ? this.state.sources : this.state.sources.filter(source => source.id === sourceId);
|
|
260
|
+
for (const source of targets) {
|
|
261
|
+
if (source.local === true) {
|
|
262
|
+
if (!await isDirectory(expandHome(source.url))) {
|
|
263
|
+
throw new Error(`local source directory ${expandHome(source.url)} is missing`);
|
|
264
|
+
}
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
const checkout = sourceCheckoutDir(this.options.userRoot, source.id);
|
|
268
|
+
try {
|
|
269
|
+
await gitHead(checkout);
|
|
270
|
+
}
|
|
271
|
+
catch {
|
|
272
|
+
await this.ensureClone(source);
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
await gitPull(checkout);
|
|
276
|
+
}
|
|
277
|
+
this.options.onChanged();
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
/** Install a suite: the source must be cloned; the suite dir becomes an enabled install entry. */
|
|
281
|
+
async install(sourceId, suiteId) {
|
|
282
|
+
return this.enqueue(async () => {
|
|
283
|
+
const source = this.state.sources.find(entry => entry.id === sourceId);
|
|
284
|
+
if (source === undefined)
|
|
285
|
+
throw new Error(`unknown source "${sourceId}"`);
|
|
286
|
+
const checkout = this.sourceCheckoutPath(source);
|
|
287
|
+
if (!await isDirectory(checkout))
|
|
288
|
+
await this.ensureClone(source);
|
|
289
|
+
const suites = await discoverSuitesInSource(checkout, sourceId, 'user');
|
|
290
|
+
const suite = suites.find(entry => entry.id === suiteId);
|
|
291
|
+
if (suite === undefined)
|
|
292
|
+
throw new Error(`suite "${suiteId}" not found in source "${sourceId}"`);
|
|
293
|
+
if (suite.remote !== undefined)
|
|
294
|
+
throw new Error(`suite "${suiteId}" is a remote reference (${suite.remote.url}); add its repository as a source before installing`);
|
|
295
|
+
await this.setInstalled(sourceId, suiteId, { enabled: true, installedAt: new Date().toISOString(), lockCommit: await tryHead(checkout) });
|
|
296
|
+
this.options.onChanged();
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
/** Uninstall a suite: drop the install entry (the source clone stays for browsing). */
|
|
300
|
+
async uninstall(sourceId, suiteId) {
|
|
301
|
+
return this.enqueue(async () => {
|
|
302
|
+
const key = installKey(sourceId, suiteId);
|
|
303
|
+
if (this.state.installed[key] === undefined)
|
|
304
|
+
throw new Error(`suite "${suiteId}" is not installed`);
|
|
305
|
+
const { [key]: _removed, ...rest } = this.state.installed;
|
|
306
|
+
this.state = { ...this.state, installed: rest };
|
|
307
|
+
await saveState(this.statePath, this.state);
|
|
308
|
+
this.options.onChanged();
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
/** Enable or disable an installed suite. */
|
|
312
|
+
async setEnabled(sourceId, suiteId, enabled) {
|
|
313
|
+
return this.enqueue(async () => {
|
|
314
|
+
const key = installKey(sourceId, suiteId);
|
|
315
|
+
const entry = this.state.installed[key];
|
|
316
|
+
if (entry === undefined)
|
|
317
|
+
throw new Error(`suite "${suiteId}" is not installed`);
|
|
318
|
+
await this.setInstalled(sourceId, suiteId, { ...entry, enabled });
|
|
319
|
+
this.options.onChanged();
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
/** Append config-seeded sources missing from state and persist; clones lazily on first refresh/install. */
|
|
323
|
+
async mergeSources(sources) {
|
|
324
|
+
const existing = new Set(this.state.sources.map(source => source.id));
|
|
325
|
+
const additions = sources.filter(source => !existing.has(source.id));
|
|
326
|
+
if (additions.length === 0)
|
|
327
|
+
return;
|
|
328
|
+
this.state = { ...this.state, sources: [...this.state.sources, ...additions] };
|
|
329
|
+
await saveState(this.statePath, this.state);
|
|
330
|
+
}
|
|
331
|
+
async setInstalled(sourceId, suiteId, entry) {
|
|
332
|
+
this.state = { ...this.state, installed: { ...this.state.installed, [installKey(sourceId, suiteId)]: entry } };
|
|
333
|
+
await saveState(this.statePath, this.state);
|
|
334
|
+
}
|
|
335
|
+
async ensureClone(source) {
|
|
336
|
+
if (source.local === true) {
|
|
337
|
+
if (!await isDirectory(expandHome(source.url))) {
|
|
338
|
+
throw new Error(`local source directory ${expandHome(source.url)} is missing`);
|
|
339
|
+
}
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
const checkout = sourceCheckoutDir(this.options.userRoot, source.id);
|
|
343
|
+
await mkdir(sourcesDir(this.options.userRoot), { recursive: true });
|
|
344
|
+
await gitClone(source.url, source.branch, checkout);
|
|
345
|
+
}
|
|
346
|
+
/** The filesystem location one source is read from. */
|
|
347
|
+
sourceCheckoutPath(source) {
|
|
348
|
+
return source.local === true ? expandHome(source.url) : sourceCheckoutDir(this.options.userRoot, source.id);
|
|
349
|
+
}
|
|
350
|
+
async discoverDimension(dimension, dimensionRoot) {
|
|
351
|
+
const discovered = await discoverSourceList(this.state.sources, dimension, dimensionRoot);
|
|
352
|
+
const suites = [];
|
|
353
|
+
for (const suite of discovered) {
|
|
354
|
+
const installed = this.state.installed[installKey(suite.sourceId, suite.id)];
|
|
355
|
+
suites.push({
|
|
356
|
+
...suite,
|
|
357
|
+
enabled: dimension === 'user' && installed?.enabled === true,
|
|
358
|
+
...installed?.lockCommit === undefined ? {} : { lockCommit: installed.lockCommit },
|
|
359
|
+
...installed?.installedAt === undefined ? {} : { installedAt: installed.installedAt },
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
return suites;
|
|
363
|
+
}
|
|
364
|
+
async commandPreviews(dir) {
|
|
365
|
+
const names = await listMdFiles(dir);
|
|
366
|
+
const previews = [];
|
|
367
|
+
for (const name of names) {
|
|
368
|
+
const file = `${dir}/${name}`;
|
|
369
|
+
try {
|
|
370
|
+
const content = await this.readPreview(file);
|
|
371
|
+
const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(content);
|
|
372
|
+
let description;
|
|
373
|
+
if (match !== null) {
|
|
374
|
+
const yaml = (await import('yaml')).parse(match[1]);
|
|
375
|
+
if (typeof yaml === 'object' && yaml !== null) {
|
|
376
|
+
const desc = yaml['description'];
|
|
377
|
+
if (typeof desc === 'string')
|
|
378
|
+
description = desc;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
previews.push({ name: name.slice(0, -3), ...description === undefined ? {} : { description }, content });
|
|
382
|
+
}
|
|
383
|
+
catch {
|
|
384
|
+
// unreadable command files are skipped in previews
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
return previews;
|
|
388
|
+
}
|
|
389
|
+
async agentPreviews(dir) {
|
|
390
|
+
const names = await listMdFiles(dir);
|
|
391
|
+
const previews = [];
|
|
392
|
+
for (const name of names) {
|
|
393
|
+
const file = `${dir}/${name}`;
|
|
394
|
+
try {
|
|
395
|
+
const content = await this.readPreview(file);
|
|
396
|
+
const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(content);
|
|
397
|
+
let description;
|
|
398
|
+
if (match !== null) {
|
|
399
|
+
const yaml = (await import('yaml')).parse(match[1]);
|
|
400
|
+
if (typeof yaml === 'object' && yaml !== null) {
|
|
401
|
+
const desc = yaml['description'];
|
|
402
|
+
if (typeof desc === 'string')
|
|
403
|
+
description = desc;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
previews.push({ name: name.slice(0, -3), ...description === undefined ? {} : { description }, content });
|
|
407
|
+
}
|
|
408
|
+
catch {
|
|
409
|
+
// unreadable agent files are skipped in previews
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
return previews;
|
|
413
|
+
}
|
|
414
|
+
/** Parse CC hooks.json into flat {event, matcher, command} preview entries. */
|
|
415
|
+
async hooksPreviews(root) {
|
|
416
|
+
for (const relative of ['hooks/hooks.json', 'hooks.json']) {
|
|
417
|
+
let text;
|
|
418
|
+
try {
|
|
419
|
+
text = await readFile(`${root}/${relative}`, 'utf8');
|
|
420
|
+
}
|
|
421
|
+
catch {
|
|
422
|
+
continue;
|
|
423
|
+
}
|
|
424
|
+
try {
|
|
425
|
+
const parsed = JSON.parse(text);
|
|
426
|
+
if (typeof parsed !== 'object' || parsed === null)
|
|
427
|
+
continue;
|
|
428
|
+
const hooks = parsed['hooks'];
|
|
429
|
+
if (typeof hooks !== 'object' || hooks === null)
|
|
430
|
+
continue;
|
|
431
|
+
const entries = [];
|
|
432
|
+
for (const [event, groups] of Object.entries(hooks)) {
|
|
433
|
+
if (!Array.isArray(groups))
|
|
434
|
+
continue;
|
|
435
|
+
for (const group of groups) {
|
|
436
|
+
if (typeof group !== 'object' || group === null)
|
|
437
|
+
continue;
|
|
438
|
+
const record = group;
|
|
439
|
+
const matcher = typeof record['matcher'] === 'string' ? record['matcher'] : undefined;
|
|
440
|
+
const hooksList = record['hooks'];
|
|
441
|
+
if (Array.isArray(hooksList)) {
|
|
442
|
+
for (const hook of hooksList) {
|
|
443
|
+
if (typeof hook !== 'object' || hook === null)
|
|
444
|
+
continue;
|
|
445
|
+
const hookRecord = hook;
|
|
446
|
+
if (typeof hookRecord['command'] === 'string') {
|
|
447
|
+
entries.push({ event, ...matcher === undefined ? {} : { matcher }, command: hookRecord['command'] });
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
return { count: entries.length, entries };
|
|
454
|
+
}
|
|
455
|
+
catch {
|
|
456
|
+
// unparsable hook files yield zero entries
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
return { count: 0, entries: [] };
|
|
460
|
+
}
|
|
461
|
+
async lspPreviews(root) {
|
|
462
|
+
const previews = [];
|
|
463
|
+
for (const entry of await discoverLspEntries(root)) {
|
|
464
|
+
try {
|
|
465
|
+
previews.push({ name: entry.name, content: await this.readPreview(entry.path) });
|
|
466
|
+
}
|
|
467
|
+
catch {
|
|
468
|
+
// unreadable LSP files are skipped in previews
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
return previews;
|
|
472
|
+
}
|
|
473
|
+
enqueue(operation) {
|
|
474
|
+
const result = this.mutationQueue.then(operation, operation);
|
|
475
|
+
this.mutationQueue = result.catch(() => { });
|
|
476
|
+
return result;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
function installKey(sourceId, suiteId) {
|
|
480
|
+
return `${sourceId}/${suiteId}`;
|
|
481
|
+
}
|
|
482
|
+
/** Read a checkout's HEAD when it is a git repository; other checkouts have no lock commit. */
|
|
483
|
+
async function tryHead(dir) {
|
|
484
|
+
try {
|
|
485
|
+
return await gitHead(dir);
|
|
486
|
+
}
|
|
487
|
+
catch {
|
|
488
|
+
return undefined;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Maps one validated suite `mcp.json` onto `dsh-mcp-client` config rows.
|
|
3
|
+
*
|
|
4
|
+
* The portable format is translated, not executed directly: stdio commands
|
|
5
|
+
* resolve against the suite root (spec §7.2.1), `${PLUGIN_ROOT}` /
|
|
6
|
+
* `${PLUGIN_DATA}` expand against the suite root and its data directory, and
|
|
7
|
+
* `${NAME}` expands from the process environment (documented extension over
|
|
8
|
+
* the portable format, matching how hook commands already consume ambient
|
|
9
|
+
* env). Legacy HTTP+SSE servers have no transport in the dsh MCP client and
|
|
10
|
+
* are skipped with a per-server reason.
|
|
11
|
+
*/
|
|
12
|
+
import { createHash } from 'node:crypto';
|
|
13
|
+
import { expandPlaceholders, resolveCwd } from './validate.js';
|
|
14
|
+
/** The max length `dsh-mcp-client` accepts for a serverName. */
|
|
15
|
+
const SERVER_NAME_MAX = 32;
|
|
16
|
+
/**
|
|
17
|
+
* Build one mount request per supported mcp.json server.
|
|
18
|
+
* @returns mount requests plus per-server failures (unsupported transport,
|
|
19
|
+
* invalid server key, or derived serverName collision candidates are
|
|
20
|
+
* checked by the mount registry, not here).
|
|
21
|
+
*/
|
|
22
|
+
export function toMcpMounts(suite, pluginDataRoot) {
|
|
23
|
+
if (suite.mcp === undefined)
|
|
24
|
+
return { mounts: [], failures: [] };
|
|
25
|
+
const mounts = [];
|
|
26
|
+
const failures = [];
|
|
27
|
+
for (const [serverKey, server] of Object.entries(suite.mcp.servers)) {
|
|
28
|
+
const request = toMount(suite, serverKey, server, pluginDataRoot);
|
|
29
|
+
if (request === undefined) {
|
|
30
|
+
failures.push({ serverKey, reason: transportReason(server) });
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
mounts.push(request);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return { mounts, failures };
|
|
37
|
+
}
|
|
38
|
+
function toMount(suite, serverKey, server, pluginDataRoot) {
|
|
39
|
+
const serverName = deriveServerName(suite.id, serverKey);
|
|
40
|
+
if (server.type === 'stdio')
|
|
41
|
+
return { suiteId: suite.id, serverKey, config: toStdio(serverName, suite, server, pluginDataRoot) };
|
|
42
|
+
if (server.type === 'streamable-http')
|
|
43
|
+
return { suiteId: suite.id, serverKey, config: toHttp(serverName, suite, server, pluginDataRoot) };
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
function toStdio(serverName, suite, server, pluginDataRoot) {
|
|
47
|
+
const command = server.command.startsWith('./') ? joinInside(suite.root, server.command.slice(2)) : server.command;
|
|
48
|
+
const pluginData = joinInside(pluginDataRoot, suite.id);
|
|
49
|
+
const args = (server.args ?? []).map(arg => expandPlaceholders(arg, suite.root, pluginData));
|
|
50
|
+
const env = Object.fromEntries(Object.entries(server.env ?? {}).map(([key, value]) => [key, expandPlaceholders(value, suite.root, pluginData)]));
|
|
51
|
+
const cwd = server.cwd === undefined ? suite.root : resolveCwd(expandPlaceholders(server.cwd, suite.root, pluginData), suite.root, pluginData);
|
|
52
|
+
return {
|
|
53
|
+
transport: 'stdio',
|
|
54
|
+
serverName,
|
|
55
|
+
command,
|
|
56
|
+
args,
|
|
57
|
+
env,
|
|
58
|
+
cwd,
|
|
59
|
+
toolCallTimeoutMs: 60_000,
|
|
60
|
+
failOnStartupError: false,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
function toHttp(serverName, suite, server, pluginDataRoot) {
|
|
64
|
+
const pluginData = joinInside(pluginDataRoot, suite.id);
|
|
65
|
+
const headers = Object.fromEntries(Object.entries(server.headers ?? {}).map(([key, value]) => [key, expandPlaceholders(value, suite.root, pluginData)]));
|
|
66
|
+
return {
|
|
67
|
+
transport: 'streamable-http',
|
|
68
|
+
serverName,
|
|
69
|
+
url: server.url,
|
|
70
|
+
headers,
|
|
71
|
+
toolCallTimeoutMs: 60_000,
|
|
72
|
+
failOnStartupError: false,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
function transportReason(server) {
|
|
76
|
+
if (server.type === 'sse')
|
|
77
|
+
return 'legacy HTTP+SSE transport is not supported by the dsh MCP client';
|
|
78
|
+
return 'unsupported mcp.json server shape';
|
|
79
|
+
}
|
|
80
|
+
function joinInside(root, segment) {
|
|
81
|
+
return `${root.replace(/[\\/]$/, '')}/${segment}`;
|
|
82
|
+
}
|
|
83
|
+
/** Sanitize one token into `[A-Za-z0-9_-]`. */
|
|
84
|
+
function sanitizeToken(raw) {
|
|
85
|
+
const cleaned = raw.replace(/[^A-Za-z0-9_-]+/g, '_').replace(/_+/g, '_').replace(/^_+|_+$/g, '');
|
|
86
|
+
return cleaned === '' ? 'server' : cleaned;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Derive a stable, unique-ish `dsh-mcp-client` serverName from the suite and
|
|
90
|
+
* server ids: `${suiteId}__${serverKey}` sanitized, truncated to 32 chars
|
|
91
|
+
* with a deterministic 12-hex suffix when the join exceeds the budget (the
|
|
92
|
+
* same deterministic-hash policy the MCP client uses for long tool names).
|
|
93
|
+
*/
|
|
94
|
+
export function deriveServerName(suiteId, serverKey) {
|
|
95
|
+
const candidate = `${sanitizeToken(suiteId)}__${sanitizeToken(serverKey)}`;
|
|
96
|
+
if (candidate.length <= SERVER_NAME_MAX)
|
|
97
|
+
return candidate;
|
|
98
|
+
const hash = createHash('sha256').update(`${suiteId}\u0000${serverKey}`).digest('hex').slice(0, 12);
|
|
99
|
+
return `${candidate.slice(0, SERVER_NAME_MAX - 13)}-${hash}`;
|
|
100
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { toMcpMounts } from './mcp-config.js';
|
|
2
|
+
export class McpMountRegistry {
|
|
3
|
+
ctx;
|
|
4
|
+
pluginDataRoot;
|
|
5
|
+
live = new Map();
|
|
6
|
+
names = new Map();
|
|
7
|
+
constructor(ctx, pluginDataRoot) {
|
|
8
|
+
this.ctx = ctx;
|
|
9
|
+
this.pluginDataRoot = pluginDataRoot;
|
|
10
|
+
}
|
|
11
|
+
/** Mount/unmount MCP servers to match the enabled suites exactly. */
|
|
12
|
+
async reconcile(enabledSuites) {
|
|
13
|
+
const wanted = new Map();
|
|
14
|
+
const diagnostics = [];
|
|
15
|
+
for (const suite of enabledSuites) {
|
|
16
|
+
const { mounts, failures } = toMcpMounts(suite, this.pluginDataRoot);
|
|
17
|
+
for (const failure of failures) {
|
|
18
|
+
diagnostics.push({ suiteId: suite.id, serverKey: failure.serverKey, reason: failure.reason });
|
|
19
|
+
}
|
|
20
|
+
for (const mount of mounts) {
|
|
21
|
+
wanted.set(mountKey(mount.suiteId, mount.serverKey), { suite, serverKey: mount.serverKey });
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
for (const [key, live] of [...this.live]) {
|
|
25
|
+
if (!wanted.has(key)) {
|
|
26
|
+
await this.unmount(key, live);
|
|
27
|
+
diagnostics.push({ suiteId: live.suiteId, serverKey: live.serverKey, reason: 'unmounted' });
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
for (const [key, entry] of wanted) {
|
|
31
|
+
if (this.live.has(key))
|
|
32
|
+
continue;
|
|
33
|
+
const reason = await this.mount(entry.suite, entry.serverKey);
|
|
34
|
+
if (reason !== undefined)
|
|
35
|
+
diagnostics.push({ suiteId: entry.suite.id, serverKey: entry.serverKey, reason });
|
|
36
|
+
}
|
|
37
|
+
return diagnostics.filter(diagnostic => diagnostic.reason !== 'unmounted');
|
|
38
|
+
}
|
|
39
|
+
/** Dispose every live mount; used at plugin teardown. */
|
|
40
|
+
async disposeAll() {
|
|
41
|
+
for (const [key, live] of [...this.live]) {
|
|
42
|
+
await this.unmount(key, live);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
async mount(suite, serverKey) {
|
|
46
|
+
const { mounts } = toMcpMounts(suite, this.pluginDataRoot);
|
|
47
|
+
const request = mounts.find(mount => mount.serverKey === serverKey);
|
|
48
|
+
if (request === undefined)
|
|
49
|
+
return 'server no longer present in mcp.json';
|
|
50
|
+
const owner = this.names.get(request.config.serverName);
|
|
51
|
+
if (owner !== undefined)
|
|
52
|
+
return `derived serverName "${request.config.serverName}" already mounted by ${owner}`;
|
|
53
|
+
let mcpClient;
|
|
54
|
+
try {
|
|
55
|
+
mcpClient = await import('@deepseek-ai/dsh-mcp-client');
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return 'the @deepseek-ai/dsh-mcp-client package is not installed in this profile';
|
|
59
|
+
}
|
|
60
|
+
const mountCtx = this.ctx;
|
|
61
|
+
if (typeof mountCtx.plugin !== 'function')
|
|
62
|
+
return 'the host context does not support dynamic plugin mounting';
|
|
63
|
+
try {
|
|
64
|
+
const handle = mountCtx.plugin(mcpClient, request.config);
|
|
65
|
+
await handle.await();
|
|
66
|
+
const key = mountKey(suite.id, serverKey);
|
|
67
|
+
this.live.set(key, { suiteId: suite.id, serverKey, serverName: request.config.serverName, disposer: () => handle.dispose() });
|
|
68
|
+
this.names.set(request.config.serverName, `${suite.id}/${serverKey}`);
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
return `mount failed: ${error instanceof Error ? error.message : String(error)}`;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
async unmount(key, live) {
|
|
76
|
+
this.live.delete(key);
|
|
77
|
+
this.names.delete(live.serverName);
|
|
78
|
+
try {
|
|
79
|
+
await live.disposer();
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
this.ctx.logger?.warn(`[dsh-agent-plugins-market] unmount ${live.suiteId}/${live.serverKey} failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
function mountKey(suiteId, serverKey) {
|
|
87
|
+
return `${suiteId}\u0000${serverKey}`;
|
|
88
|
+
}
|