dsh-agent-plugins-market 0.6.2 → 0.7.1
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/README.md +117 -111
- package/README.zh.md +112 -98
- package/client/client.js +28533 -1511
- package/client/client.js.map +1 -1
- package/lib/application/catalog-context.js +188 -0
- package/lib/application/catalog.js +229 -1109
- package/lib/application/details.js +19 -8
- package/lib/application/install-store.js +95 -0
- package/lib/application/lsp-service.js +97 -0
- package/lib/application/mcp-service.js +263 -0
- package/lib/application/panel-resources.js +43 -14
- package/lib/application/ports.js +21 -0
- package/lib/application/project-agent-roles.js +8 -2
- package/lib/application/snapshot-cache.js +160 -0
- package/lib/application/source-store.js +521 -0
- package/lib/catalog/archive.js +35 -30
- package/lib/catalog/component-files.js +14 -6
- package/lib/catalog/fs-probes.js +43 -0
- package/lib/catalog/manifests.js +53 -92
- package/lib/catalog/native-project.js +2 -2
- package/lib/catalog/paths.js +74 -55
- package/lib/catalog/plugin-variables.js +38 -0
- package/lib/catalog/project-config.js +2 -1
- package/lib/catalog/project-hooks.js +23 -2
- package/lib/catalog/scan-resolvers.js +29 -45
- package/lib/catalog/skills-parse.js +61 -10
- package/lib/catalog/source-catalog.js +10 -4
- package/lib/catalog/suite-components.js +7 -7
- package/lib/catalog/suite-scanner.js +3 -1
- package/lib/catalog/surfaces.js +5 -26
- package/lib/catalog/validate.js +7 -11
- package/lib/contracts/market.js +2 -0
- package/lib/contracts/settings.js +63 -0
- package/lib/index.js +115 -217
- package/lib/model/layouts.js +10 -1
- package/lib/model/types.js +26 -3
- package/lib/routes.js +57 -15
- package/lib/runtime/agent-role-names.js +70 -0
- package/lib/runtime/agent-role-router.js +142 -106
- package/lib/runtime/commands-mounts.js +55 -11
- package/lib/runtime/deadline.js +54 -0
- package/lib/runtime/dynamic-context.js +140 -0
- package/lib/runtime/feedback-tool.js +101 -46
- package/lib/runtime/hooks-mounts.js +9 -11
- package/lib/runtime/host-locale.js +49 -24
- package/lib/runtime/lsp-direct-config.js +19 -19
- package/lib/runtime/lsp-mounts.js +182 -70
- package/lib/runtime/lsp-server-state.js +4 -4
- package/lib/runtime/lsp-status.js +1 -1
- package/lib/runtime/mcp-auth-record.js +42 -0
- package/lib/runtime/mcp-backend.js +14 -14
- package/lib/runtime/mcp-client/config.js +8 -1
- package/lib/runtime/mcp-client/connection.js +38 -43
- package/lib/runtime/mcp-client/host-contract.js +10 -0
- package/lib/runtime/mcp-client/host-seams.js +8 -88
- package/lib/runtime/mcp-client/oauth.js +5 -7
- package/lib/runtime/mcp-client/plugin-identity.js +28 -0
- package/lib/runtime/mcp-client/projection.js +202 -0
- package/lib/runtime/mcp-client/tools.js +4 -189
- package/lib/runtime/mcp-config.js +36 -14
- package/lib/runtime/mcp-direct-config.js +33 -16
- package/lib/runtime/mcp-mounts.js +35 -75
- package/lib/runtime/mcp-overrides.js +5 -5
- package/lib/runtime/mcp-redaction.js +3 -2
- package/lib/runtime/mcp-status.js +7 -8
- package/lib/runtime/mount-lifecycle.js +73 -0
- package/lib/runtime/profile-seam.js +321 -0
- package/lib/runtime/project-runtime.js +34 -17
- package/lib/runtime/reconcile-scheduler.js +97 -0
- package/lib/runtime/reconciler.js +6 -1
- package/lib/runtime/regions.js +0 -19
- package/lib/runtime/server-config.js +6 -4
- package/lib/runtime/settings-namespace.js +144 -0
- package/lib/runtime/skills-provider.js +38 -14
- package/lib/runtime/source-auto-update.js +66 -0
- package/lib/{model/state.js → runtime/state-store.js} +9 -11
- package/lib/runtime/storage-migration.js +35 -18
- package/lib/runtime/subagent-catalog.js +5 -4
- package/lib/runtime/timer-seat.js +80 -0
- package/lib/runtime/tool-registry-observer.js +20 -20
- package/lib/runtime/user-commands.js +7 -3
- package/lib/runtime/user-panels.js +125 -46
- package/lib/runtime/user-store.js +124 -69
- package/lib/types/application/catalog-context.d.ts +124 -0
- package/lib/types/application/catalog.d.ts +108 -304
- package/lib/types/application/install-store.d.ts +21 -0
- package/lib/types/application/lsp-service.d.ts +36 -0
- package/lib/types/application/mcp-service.d.ts +77 -0
- package/lib/types/application/panel-resources.d.ts +8 -0
- package/lib/types/application/ports.d.ts +72 -0
- package/lib/types/application/queries.d.ts +15 -26
- package/lib/types/application/snapshot-cache.d.ts +51 -0
- package/lib/types/application/source-store.d.ts +100 -0
- package/lib/types/catalog/archive.d.ts +3 -11
- package/lib/types/catalog/component-files.d.ts +4 -0
- package/lib/types/catalog/fs-probes.d.ts +10 -0
- package/lib/types/catalog/manifests.d.ts +17 -16
- package/lib/types/catalog/native-project.d.ts +2 -2
- package/lib/types/catalog/paths.d.ts +53 -15
- package/lib/types/catalog/plugin-variables.d.ts +16 -0
- package/lib/types/catalog/scan-pipeline.d.ts +3 -3
- package/lib/types/catalog/scan-resolvers.d.ts +3 -9
- package/lib/types/catalog/skills-parse.d.ts +13 -7
- package/lib/types/catalog/source-catalog.d.ts +10 -5
- package/lib/types/catalog/suite-scanner.d.ts +8 -6
- package/lib/types/catalog/validate.d.ts +0 -2
- package/lib/types/contracts/lsp-status.d.ts +35 -0
- package/lib/types/contracts/market.d.ts +13 -2
- package/lib/types/contracts/mcp-status.d.ts +4 -0
- package/lib/types/contracts/settings.d.ts +59 -0
- package/lib/types/model/layouts.d.ts +10 -0
- package/lib/types/model/types.d.ts +29 -5
- package/lib/types/runtime/agent-role-names.d.ts +6 -0
- package/lib/types/runtime/agent-role-router.d.ts +59 -52
- package/lib/types/runtime/commands-mounts.d.ts +15 -7
- package/lib/types/runtime/deadline.d.ts +17 -0
- package/lib/types/runtime/dynamic-context.d.ts +81 -0
- package/lib/types/runtime/feedback-tool.d.ts +33 -11
- package/lib/types/runtime/hooks-mounts.d.ts +1 -1
- package/lib/types/runtime/host-locale.d.ts +20 -2
- package/lib/types/runtime/lsp-direct-config.d.ts +3 -3
- package/lib/types/runtime/lsp-mounts.d.ts +69 -19
- package/lib/types/runtime/lsp-status.d.ts +2 -1
- package/lib/types/runtime/mcp-auth-record.d.ts +29 -0
- package/lib/types/runtime/mcp-backend.d.ts +10 -14
- package/lib/types/runtime/mcp-client/config.d.ts +8 -2
- package/lib/types/runtime/mcp-client/host-contract.d.ts +78 -0
- package/lib/types/runtime/mcp-client/host-seams.d.ts +20 -64
- package/lib/types/runtime/mcp-client/oauth.d.ts +1 -1
- package/lib/types/runtime/mcp-client/plugin-identity.d.ts +16 -0
- package/lib/types/runtime/mcp-client/projection.d.ts +45 -0
- package/lib/types/runtime/mcp-client/tools.d.ts +5 -72
- package/lib/types/runtime/mcp-config.d.ts +25 -4
- package/lib/types/runtime/mcp-direct-config.d.ts +18 -4
- package/lib/types/runtime/mcp-mounts.d.ts +10 -15
- package/lib/types/runtime/mcp-overrides.d.ts +1 -1
- package/lib/types/runtime/mcp-status.d.ts +1 -1
- package/lib/types/runtime/mount-lifecycle.d.ts +53 -0
- package/lib/types/runtime/profile-seam.d.ts +64 -0
- package/lib/types/runtime/project-runtime.d.ts +10 -3
- package/lib/types/runtime/reconcile-scheduler.d.ts +45 -0
- package/lib/types/runtime/reconciler.d.ts +2 -0
- package/lib/types/runtime/regions.d.ts +1 -4
- package/lib/types/runtime/settings-namespace.d.ts +76 -0
- package/lib/types/runtime/skills-provider.d.ts +11 -2
- package/lib/types/runtime/source-auto-update.d.ts +32 -0
- package/lib/types/{model/state.d.ts → runtime/state-store.d.ts} +2 -2
- package/lib/types/runtime/storage-migration.d.ts +5 -3
- package/lib/types/runtime/subagent-catalog.d.ts +2 -0
- package/lib/types/runtime/timer-seat.d.ts +51 -0
- package/lib/types/runtime/tool-registry-observer.d.ts +12 -6
- package/lib/types/runtime/user-panels.d.ts +69 -19
- package/lib/types/runtime/user-store.d.ts +67 -20
- package/package.json +68 -24
- package/schemas/README.md +1 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** Suite detail and preview projections owned by the application layer. */
|
|
2
|
-
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
3
3
|
import { parse as parseYaml } from 'yaml';
|
|
4
4
|
import { discoverLspEntries } from '../catalog/surfaces.js';
|
|
5
5
|
import { defaultMarkdownResources, resourceText } from '../catalog/component-files.js';
|
|
@@ -7,6 +7,16 @@ import { credentialRefsInServer } from '../runtime/mcp-config.js';
|
|
|
7
7
|
import { redactMcpConfig, redactMcpOverrides } from '../runtime/mcp-redaction.js';
|
|
8
8
|
import { applyOverride } from '../runtime/mcp-overrides.js';
|
|
9
9
|
import { effectiveSurfaces } from '../model/types.js';
|
|
10
|
+
/** The checkout's last modification, or null when the path cannot be read. */
|
|
11
|
+
async function rootModifiedAt(root) {
|
|
12
|
+
try {
|
|
13
|
+
const stats = await stat(root);
|
|
14
|
+
return new Date(stats.mtimeMs).toISOString();
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
10
20
|
/** Build the detail response for one normalized suite. */
|
|
11
21
|
export async function buildSuiteDetail(suite, installed, diagnostics, mcpOverrides = {}) {
|
|
12
22
|
const remoteUrl = suite.remote?.url;
|
|
@@ -18,6 +28,7 @@ export async function buildSuiteDetail(suite, installed, diagnostics, mcpOverrid
|
|
|
18
28
|
description: suite.manifest.description ?? null,
|
|
19
29
|
author: suite.manifest.author ?? null,
|
|
20
30
|
keywords: suite.manifest.keywords ?? [],
|
|
31
|
+
updatedAt: await rootModifiedAt(suite.root),
|
|
21
32
|
layout: suite.manifest.layout,
|
|
22
33
|
dimension: suite.dimension,
|
|
23
34
|
root: remoteUrl ?? suite.root,
|
|
@@ -47,7 +58,7 @@ export async function buildSuiteDetail(suite, installed, diagnostics, mcpOverrid
|
|
|
47
58
|
? suite.resources === undefined
|
|
48
59
|
? await hooksPreviews(suite.root)
|
|
49
60
|
: { count: 0, entries: [] }
|
|
50
|
-
: normalizedHookPreviews(suite)
|
|
61
|
+
: normalizedHookPreviews(suite.hooks)
|
|
51
62
|
: { count: 0, entries: [] },
|
|
52
63
|
commands: remoteUrl === undefined ? await markdownPreviews(suite.resources?.commands ?? (await defaultMarkdownResources(suite.root, 'commands'))) : [],
|
|
53
64
|
agents: remoteUrl === undefined ? await markdownPreviews(suite.resources?.agents ?? (await defaultMarkdownResources(suite.root, 'agents'))) : [],
|
|
@@ -66,7 +77,7 @@ export async function readSkillContent(suite, skillName) {
|
|
|
66
77
|
content = await readFile(skill.file, 'utf8');
|
|
67
78
|
}
|
|
68
79
|
catch (error) {
|
|
69
|
-
throw new Error(`skill file unreadable: ${error instanceof Error ? error.message : String(error)}
|
|
80
|
+
throw new Error(`skill file unreadable: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
70
81
|
}
|
|
71
82
|
return { name: skill.name, description: skill.description, content, path: skill.file };
|
|
72
83
|
}
|
|
@@ -79,10 +90,10 @@ async function markdownPreviews(resources) {
|
|
|
79
90
|
for (const resource of resources) {
|
|
80
91
|
try {
|
|
81
92
|
const content = (await resourceText(resource)).slice(0, 64 * 1024);
|
|
82
|
-
const
|
|
93
|
+
const frontmatter = /^---\r?\n([\s\S]*?)\r?\n---/.exec(content)?.[1];
|
|
83
94
|
let description;
|
|
84
|
-
if (
|
|
85
|
-
const yaml = parseYaml(
|
|
95
|
+
if (frontmatter !== undefined) {
|
|
96
|
+
const yaml = parseYaml(frontmatter);
|
|
86
97
|
if (typeof yaml === 'object' && yaml !== null) {
|
|
87
98
|
const desc = yaml['description'];
|
|
88
99
|
if (typeof desc === 'string')
|
|
@@ -97,8 +108,8 @@ async function markdownPreviews(resources) {
|
|
|
97
108
|
}
|
|
98
109
|
return previews;
|
|
99
110
|
}
|
|
100
|
-
function normalizedHookPreviews(
|
|
101
|
-
const entries = Object.entries(
|
|
111
|
+
function normalizedHookPreviews(hooks) {
|
|
112
|
+
const entries = Object.entries(hooks.events).flatMap(([event, groups]) => groups.flatMap(group => group.hooks.map(hook => ({ event, ...(group.matcher === undefined ? {} : { matcher: group.matcher }), command: hook.command }))));
|
|
102
113
|
return { count: entries.length, entries };
|
|
103
114
|
}
|
|
104
115
|
async function hooksPreviews(root) {
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Install-state mutations over the persisted record: which suites are
|
|
3
|
+
* installed and enabled, which of their surfaces are on, and the config-seeded
|
|
4
|
+
* sources merged in at activation.
|
|
5
|
+
*
|
|
6
|
+
* The checkout a suite is read from stays source-owned: installing writes an
|
|
7
|
+
* entry, never a file inside the source.
|
|
8
|
+
*/
|
|
9
|
+
import { isDirectory } from '../catalog/fs-probes.js';
|
|
10
|
+
import { discoverSuitesInSource } from '../catalog/suite-scanner.js';
|
|
11
|
+
import { SUITE_SURFACE_KEYS } from '../model/types.js';
|
|
12
|
+
import { installKey } from './catalog-context.js';
|
|
13
|
+
import { tryHead } from './source-store.js';
|
|
14
|
+
export class InstallStore {
|
|
15
|
+
context;
|
|
16
|
+
sources;
|
|
17
|
+
constructor(context, sources) {
|
|
18
|
+
this.context = context;
|
|
19
|
+
this.sources = sources;
|
|
20
|
+
}
|
|
21
|
+
/** Install a suite from a source and enable it. The client confirms the
|
|
22
|
+
* injected surfaces (skills / MCP / hooks / commands) in a pre-install
|
|
23
|
+
* dialog before this runs; disabling afterwards is always available. */
|
|
24
|
+
async install(sourceId, suiteId) {
|
|
25
|
+
return this.context.enqueue(async () => {
|
|
26
|
+
const source = this.context.state.sources.find(entry => entry.id === sourceId);
|
|
27
|
+
if (source === undefined)
|
|
28
|
+
throw new Error(`unknown source "${sourceId}"`);
|
|
29
|
+
const checkout = this.sources.checkoutPath(source);
|
|
30
|
+
let freshLock;
|
|
31
|
+
if (!(await isDirectory(checkout)))
|
|
32
|
+
freshLock = await this.sources.acquire(source);
|
|
33
|
+
const suites = await discoverSuitesInSource(checkout, sourceId, 'user', source.url);
|
|
34
|
+
const suite = suites.find(entry => entry.id === suiteId);
|
|
35
|
+
if (suite === undefined)
|
|
36
|
+
throw new Error(`suite "${suiteId}" not found in source "${sourceId}"`);
|
|
37
|
+
if (suite.remote !== undefined)
|
|
38
|
+
throw new Error(`suite "${suiteId}" is a remote reference (${suite.remote.url}); add its repository as a source before installing`);
|
|
39
|
+
// Archive (or tarball-fallback) sources have no git HEAD; their digest
|
|
40
|
+
// is the lock value.
|
|
41
|
+
const lockCommit = (await tryHead(checkout)) ?? freshLock ?? this.sources.knownHead(sourceId);
|
|
42
|
+
await this.setInstalled(sourceId, suiteId, { enabled: true, installedAt: new Date().toISOString(), lockCommit });
|
|
43
|
+
await this.context.notifyChanged(true);
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
/** Uninstall a suite while retaining the source checkout. */
|
|
47
|
+
async uninstall(sourceId, suiteId) {
|
|
48
|
+
return this.context.enqueue(async () => {
|
|
49
|
+
const key = installKey(sourceId, suiteId);
|
|
50
|
+
if (this.context.state.installed[key] === undefined)
|
|
51
|
+
throw new Error(`suite "${suiteId}" is not installed`);
|
|
52
|
+
const rest = Object.fromEntries(Object.entries(this.context.state.installed).filter(([entryKey]) => entryKey !== key));
|
|
53
|
+
await this.context.commit({ ...this.context.state, installed: rest });
|
|
54
|
+
await this.context.notifyChanged(true);
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
/** Enable or disable an installed suite. */
|
|
58
|
+
async setEnabled(sourceId, suiteId, enabled) {
|
|
59
|
+
return this.context.enqueue(async () => {
|
|
60
|
+
const entry = this.context.installed(sourceId, suiteId);
|
|
61
|
+
if (entry === undefined)
|
|
62
|
+
throw new Error(`suite "${suiteId}" is not installed`);
|
|
63
|
+
await this.setInstalled(sourceId, suiteId, { ...entry, enabled });
|
|
64
|
+
await this.context.notifyChanged(true);
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
/** Enable or disable one runtime surface of an installed suite. */
|
|
68
|
+
async setSurface(sourceId, suiteId, surface, enabled) {
|
|
69
|
+
return this.context.enqueue(async () => {
|
|
70
|
+
const entry = this.context.installed(sourceId, suiteId);
|
|
71
|
+
if (entry === undefined)
|
|
72
|
+
throw new Error(`suite "${suiteId}" is not installed`);
|
|
73
|
+
if (!SUITE_SURFACE_KEYS.includes(surface))
|
|
74
|
+
throw new Error(`surface "${surface}" is not toggleable`);
|
|
75
|
+
const surfaces = { ...(entry.surfaces ?? {}), [surface]: enabled };
|
|
76
|
+
await this.setInstalled(sourceId, suiteId, { ...entry, surfaces });
|
|
77
|
+
await this.context.notifyChanged(true);
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
/** Append config-seeded sources missing from user state and persist them. */
|
|
81
|
+
async mergeSources(sources) {
|
|
82
|
+
const existing = new Set(this.context.state.sources.map(source => source.id));
|
|
83
|
+
const additions = sources.filter(source => !existing.has(source.id));
|
|
84
|
+
if (additions.length === 0)
|
|
85
|
+
return;
|
|
86
|
+
await this.context.commit({ ...this.context.state, sources: [...this.context.state.sources, ...additions] });
|
|
87
|
+
// New sources change the fingerprint, but their content arrives through
|
|
88
|
+
// an acquire, so mark the scan cache dirty conservatively.
|
|
89
|
+
this.context.invalidateScans();
|
|
90
|
+
this.context.invalidateSnapshot(true);
|
|
91
|
+
}
|
|
92
|
+
async setInstalled(sourceId, suiteId, entry) {
|
|
93
|
+
await this.context.commit({ ...this.context.state, installed: { ...this.context.state.installed, [installKey(sourceId, suiteId)]: entry } });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { loadLspServers, saveLspServers } from '../runtime/lsp-direct-config.js';
|
|
2
|
+
import { loadDisabledLspServers, saveDisabledLspServers } from '../runtime/lsp-server-state.js';
|
|
3
|
+
import { buildLspStatus } from '../runtime/lsp-status.js';
|
|
4
|
+
import { describeLegacySeam, findLegacyLspSeams, migrateLegacyLspSeam } from '../runtime/profile-seam.js';
|
|
5
|
+
import { applyLspOverrides, lspConfig, validateServerLsp } from '../runtime/server-config.js';
|
|
6
|
+
export class LspService {
|
|
7
|
+
context;
|
|
8
|
+
ports;
|
|
9
|
+
constructor(context, ports) {
|
|
10
|
+
this.context = context;
|
|
11
|
+
this.ports = ports;
|
|
12
|
+
}
|
|
13
|
+
/** The LSP status surface: declared servers merged with mount diagnostics. */
|
|
14
|
+
async status() {
|
|
15
|
+
const snapshot = await this.context.snapshots.readUserCatalog();
|
|
16
|
+
const direct = await loadLspServers(this.context.agentsRoot);
|
|
17
|
+
const payload = buildLspStatus(await applyLspOverrides(this.context.dataRoot, snapshot.suites), this.ports.lspStatusSource, direct);
|
|
18
|
+
// The profile scan is filesystem work, so it runs only while the conflict
|
|
19
|
+
// it explains is actually on screen — the panel polls this while rows start.
|
|
20
|
+
if (payload.entries.some(entry => entry.state === 'conflict'))
|
|
21
|
+
payload.legacySeam = await this.legacySeam();
|
|
22
|
+
return payload;
|
|
23
|
+
}
|
|
24
|
+
/** The profile layer behind a reported seam conflict, when one can be found. */
|
|
25
|
+
async legacySeam() {
|
|
26
|
+
const seams = await findLegacyLspSeams();
|
|
27
|
+
const seam = seams[0];
|
|
28
|
+
if (seam === undefined)
|
|
29
|
+
return undefined;
|
|
30
|
+
return {
|
|
31
|
+
profile: seam.profile,
|
|
32
|
+
patchPath: seam.patchPath,
|
|
33
|
+
rows: seam.rows.map(row => ({ id: row.id, name: row.name })),
|
|
34
|
+
otherProfiles: seams.slice(1).map(other => other.profile),
|
|
35
|
+
restartRequired: seam.patchReload !== 'live',
|
|
36
|
+
summary: describeLegacySeam(seam)
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Remove one profile's legacy LSP layer, then re-reconcile.
|
|
41
|
+
*
|
|
42
|
+
* The removal edits a file the user owns, so the profile is matched against
|
|
43
|
+
* what was actually found on disk instead of trusting the request.
|
|
44
|
+
*/
|
|
45
|
+
async migrateLegacySeam(profile) {
|
|
46
|
+
return this.context.enqueue(async () => {
|
|
47
|
+
const seams = await findLegacyLspSeams();
|
|
48
|
+
const seam = seams.find(candidate => candidate.profile === profile);
|
|
49
|
+
if (seam === undefined)
|
|
50
|
+
throw new Error(`the profile "${profile}" no longer registers an LSP layer`);
|
|
51
|
+
const result = await migrateLegacyLspSeam(seam);
|
|
52
|
+
await this.context.notifyChanged(true);
|
|
53
|
+
return { profile: result.profile, patchPath: result.patchPath, backupPath: result.backupPath, restartRequired: result.restartRequired };
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
/** The user's direct LSP server table (normalized specs). */
|
|
57
|
+
async servers() {
|
|
58
|
+
return (await loadLspServers(this.context.agentsRoot)).servers;
|
|
59
|
+
}
|
|
60
|
+
/** Create one direct LSP declaration without replacing other user servers. */
|
|
61
|
+
async addServer(name, config) {
|
|
62
|
+
return this.context.enqueue(async () => {
|
|
63
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/.test(name))
|
|
64
|
+
throw new Error('invalid LSP server name');
|
|
65
|
+
const direct = await loadLspServers(this.context.agentsRoot);
|
|
66
|
+
if (direct.errors.length > 0)
|
|
67
|
+
throw new Error(direct.errors.join('; '));
|
|
68
|
+
if (Object.hasOwn(direct.servers, name))
|
|
69
|
+
throw new Error('LSP server already exists');
|
|
70
|
+
const spec = validateServerLsp(name, config);
|
|
71
|
+
await saveLspServers(this.context.agentsRoot, {
|
|
72
|
+
lspServers: { ...Object.fromEntries(Object.entries(direct.servers).map(([key, value]) => [key, lspConfig(value)])), [name]: lspConfig(spec) }
|
|
73
|
+
});
|
|
74
|
+
await this.context.notifyChanged(true);
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
/** Validate and persist the user's direct LSP server table. */
|
|
78
|
+
async setServers(raw) {
|
|
79
|
+
return this.context.enqueue(async () => {
|
|
80
|
+
const { servers } = await saveLspServers(this.context.agentsRoot, raw);
|
|
81
|
+
await this.context.notifyChanged(true);
|
|
82
|
+
return servers;
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
/** Enable or disable one declared language server by row id. */
|
|
86
|
+
async setEnabled(id, enabled) {
|
|
87
|
+
return this.context.enqueue(async () => {
|
|
88
|
+
const disabled = await loadDisabledLspServers(this.context.dataRoot);
|
|
89
|
+
if (enabled)
|
|
90
|
+
disabled.delete(id);
|
|
91
|
+
else
|
|
92
|
+
disabled.add(id);
|
|
93
|
+
await saveDisabledLspServers(this.context.dataRoot, disabled);
|
|
94
|
+
await this.context.notifyChanged(true);
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP use cases: the flat service inventory, per-server overrides, the direct
|
|
3
|
+
* user servers, the mount-backend choice, and dropping one server's OAuth
|
|
4
|
+
* grant so the next mount re-runs the browser authorization.
|
|
5
|
+
*
|
|
6
|
+
* Server configuration is edited through saved overrides — a suite's own
|
|
7
|
+
* `mcp.json` stays source-owned and is never rewritten.
|
|
8
|
+
*/
|
|
9
|
+
import { qualifiedSuiteId } from '../catalog/paths.js';
|
|
10
|
+
import { isDirectory } from '../catalog/fs-probes.js';
|
|
11
|
+
import { discoverSuitesInSource } from '../catalog/suite-scanner.js';
|
|
12
|
+
import { readLocalePreference } from '../runtime/host-locale.js';
|
|
13
|
+
import { probeHostMcpClient } from '../runtime/mcp-backend.js';
|
|
14
|
+
import { loadLspServers, saveLspServers } from '../runtime/lsp-direct-config.js';
|
|
15
|
+
import { addUserMcpServer, loadUserMcpSuite, USER_MCP_SOURCE, USER_MCP_SUITE } from '../runtime/mcp-direct-config.js';
|
|
16
|
+
import { applyOverride, loadSuiteOverrides, mergeOverridePatch, saveSuiteOverrides } from '../runtime/mcp-overrides.js';
|
|
17
|
+
import { redactMcpConfig, redactMcpOverrides } from '../runtime/mcp-redaction.js';
|
|
18
|
+
import { buildMcpStatus } from '../runtime/mcp-status.js';
|
|
19
|
+
import { resolveRegion } from '../runtime/regions.js';
|
|
20
|
+
import { applyLspOverrides, lspConfig, restoreRedactedConfig, saveLspOverride, validateServerLsp, validateServerMcp } from '../runtime/server-config.js';
|
|
21
|
+
export class McpService {
|
|
22
|
+
context;
|
|
23
|
+
ports;
|
|
24
|
+
sources;
|
|
25
|
+
/** Latest MCP mount diagnostics (suiteId -> reasons), fed by host reconcile. */
|
|
26
|
+
diagnostics = [];
|
|
27
|
+
constructor(context, ports, sources) {
|
|
28
|
+
this.context = context;
|
|
29
|
+
this.ports = ports;
|
|
30
|
+
this.sources = sources;
|
|
31
|
+
}
|
|
32
|
+
/** Build the flat MCP service inventory for the status surface. */
|
|
33
|
+
async status() {
|
|
34
|
+
const snapshot = await this.context.snapshots.readUserCatalog();
|
|
35
|
+
const suites = [...snapshot.suites, await loadUserMcpSuite(this.context.agentsRoot)];
|
|
36
|
+
const payload = buildMcpStatus(suites, this.diagnostics, this.ports.mcpToolSnapshot(), await this.allOverrides(suites));
|
|
37
|
+
for (const entry of payload.entries)
|
|
38
|
+
if (entry.suiteId === `${USER_MCP_SOURCE}/${USER_MCP_SUITE}`) {
|
|
39
|
+
entry.kind = 'direct';
|
|
40
|
+
entry.managed = true;
|
|
41
|
+
}
|
|
42
|
+
const oauthBackend = (await this.backend()) === 'builtin' && this.reauthorizeAvailable();
|
|
43
|
+
for (const entry of payload.entries) {
|
|
44
|
+
const auth = entry.config?.auth;
|
|
45
|
+
entry.canReauthorize =
|
|
46
|
+
oauthBackend &&
|
|
47
|
+
(entry.kind === 'plugin' || entry.managed === true) &&
|
|
48
|
+
(entry.transport === 'sse' || entry.transport === 'streamable-http') &&
|
|
49
|
+
auth?.enabled !== false &&
|
|
50
|
+
!['disabled', 'foreign', 'orphaned'].includes(entry.state);
|
|
51
|
+
}
|
|
52
|
+
return payload;
|
|
53
|
+
}
|
|
54
|
+
/** Persist a user-owned MCP service and reconcile its bridge mount. */
|
|
55
|
+
async addServer(name, server) {
|
|
56
|
+
return this.context.enqueue(async () => {
|
|
57
|
+
await addUserMcpServer(this.context.agentsRoot, name, server);
|
|
58
|
+
await this.context.notifyChanged(true);
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
/** Read effective service configuration without exposing credential literals. */
|
|
62
|
+
async serverConfig(kind, id) {
|
|
63
|
+
const config = await this.resolveServerConfig(kind, id);
|
|
64
|
+
return { kind, id, editable: true, config: redactMcpConfig(config.value) };
|
|
65
|
+
}
|
|
66
|
+
/** Validate a complete replacement before writing; plugin checkouts remain untouched. */
|
|
67
|
+
async saveServerConfig(kind, id, config) {
|
|
68
|
+
return this.context.enqueue(async () => {
|
|
69
|
+
const current = await this.resolveServerConfig(kind, id);
|
|
70
|
+
const value = restoreRedactedConfig(config, current.value);
|
|
71
|
+
if (kind === 'mcp') {
|
|
72
|
+
const server = await validateServerMcp(current.root, current.key, value);
|
|
73
|
+
const overrides = await loadSuiteOverrides(this.context.dataRoot, current.suiteKey);
|
|
74
|
+
const enabled = overrides[current.key]?.enabled;
|
|
75
|
+
overrides[current.key] = { config: server, ...(enabled === undefined ? {} : { enabled }) };
|
|
76
|
+
await saveSuiteOverrides(this.context.dataRoot, current.suiteKey, overrides);
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
const server = validateServerLsp(current.key, value);
|
|
80
|
+
if (current.suiteKey === 'direct') {
|
|
81
|
+
const direct = await loadLspServers(this.context.agentsRoot);
|
|
82
|
+
if (direct.errors.length > 0)
|
|
83
|
+
throw new Error(direct.errors.join('; '));
|
|
84
|
+
direct.servers[current.key] = server;
|
|
85
|
+
await saveLspServers(this.context.agentsRoot, { lspServers: Object.fromEntries(Object.entries(direct.servers).map(([key, spec]) => [key, lspConfig(spec)])) });
|
|
86
|
+
}
|
|
87
|
+
else
|
|
88
|
+
await saveLspOverride(this.context.dataRoot, id, server);
|
|
89
|
+
}
|
|
90
|
+
await this.context.notifyChanged(true);
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
/** One suite's persisted MCP overrides, addressed by qualified suite id. */
|
|
94
|
+
async overrides(sourceId, suiteId) {
|
|
95
|
+
const suiteKey = qualifiedSuiteId(sourceId, suiteId);
|
|
96
|
+
const snapshot = await this.context.snapshots.readUserCatalog();
|
|
97
|
+
if (!snapshot.suites.some(suite => suite.sourceId === sourceId && suite.id === suiteId))
|
|
98
|
+
throw new Error(`suite "${suiteKey}" not found`);
|
|
99
|
+
return redactMcpOverrides(await loadSuiteOverrides(this.context.dataRoot, suiteKey));
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Set or clear one server's MCP override and remount it. `override === null`
|
|
103
|
+
* clears the override (back to the source config). The serverKey must exist
|
|
104
|
+
* in the suite's parsed mcp.json.
|
|
105
|
+
*/
|
|
106
|
+
async setOverride(sourceId, suiteId, serverKey, override) {
|
|
107
|
+
return this.context.enqueue(async () => {
|
|
108
|
+
const suiteKey = qualifiedSuiteId(sourceId, suiteId);
|
|
109
|
+
const source = this.context.state.sources.find(entry => entry.id === sourceId);
|
|
110
|
+
// Only the suite's declared servers matter here, and one branch reads
|
|
111
|
+
// freshly scanned suites — the scan shape covers both.
|
|
112
|
+
let suites;
|
|
113
|
+
if (sourceId === USER_MCP_SOURCE && suiteId === USER_MCP_SUITE) {
|
|
114
|
+
suites = [await loadUserMcpSuite(this.context.agentsRoot)];
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
if (source === undefined)
|
|
118
|
+
throw new Error(`unknown source "${sourceId}"`);
|
|
119
|
+
const checkout = this.sources.checkoutPath(source);
|
|
120
|
+
if (!(await isDirectory(checkout)))
|
|
121
|
+
await this.sources.acquire(source);
|
|
122
|
+
suites = await discoverSuitesInSource(checkout, sourceId, 'user', source.url);
|
|
123
|
+
}
|
|
124
|
+
const suite = suites.find(entry => entry.id === suiteId);
|
|
125
|
+
if (suite === undefined)
|
|
126
|
+
throw new Error(`suite "${suiteId}" not found in source "${sourceId}"`);
|
|
127
|
+
if (suite.mcp?.servers[serverKey] === undefined)
|
|
128
|
+
throw new Error(`server "${serverKey}" is not defined by suite "${suiteId}"`);
|
|
129
|
+
// Override files are keyed by the qualified id so two sources' same-named
|
|
130
|
+
// suites never share one override record.
|
|
131
|
+
const overrides = await loadSuiteOverrides(this.context.dataRoot, suiteKey);
|
|
132
|
+
if (override === null) {
|
|
133
|
+
delete overrides[serverKey];
|
|
134
|
+
}
|
|
135
|
+
else {
|
|
136
|
+
// Merge onto the stored record: the UI never receives literal secret
|
|
137
|
+
// values, so a verbatim write would drop keys it simply redacted.
|
|
138
|
+
overrides[serverKey] = mergeOverridePatch(overrides[serverKey] ?? {}, override);
|
|
139
|
+
}
|
|
140
|
+
await saveSuiteOverrides(this.context.dataRoot, suiteKey, overrides);
|
|
141
|
+
await this.context.notifyChanged(true);
|
|
142
|
+
// The status surface reads the live tool registry, so an immediate read
|
|
143
|
+
// races the reconciler's teardown: a just-disabled server still carries
|
|
144
|
+
// its observed tools and reports the orphaned state with its switch on
|
|
145
|
+
// until the pass finishes. Joining the pass here is bounded — one stuck
|
|
146
|
+
// mount costs the request its deadline, never the plugin.
|
|
147
|
+
await this.context.refreshSettled();
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Enable or disable one declared server, addressed by the source-qualified
|
|
152
|
+
* suite id the status rows carry. The qualified id is split here rather than
|
|
153
|
+
* by callers so the separator stays a server-side detail.
|
|
154
|
+
*/
|
|
155
|
+
async setServerEnabled(suiteKey, serverKey, enabled) {
|
|
156
|
+
const separator = suiteKey.indexOf('/');
|
|
157
|
+
if (separator <= 0)
|
|
158
|
+
throw new Error(`invalid suite id "${suiteKey}"`);
|
|
159
|
+
await this.setOverride(suiteKey.slice(0, separator), suiteKey.slice(separator + 1), serverKey, { enabled });
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Re-run the MCP reconcile pass: retries failed mounts and clears residual
|
|
163
|
+
* tools, without changing any catalog state.
|
|
164
|
+
*
|
|
165
|
+
* Every live mount is rebuilt rather than fingerprinted: a bridge whose
|
|
166
|
+
* server died underneath it keeps matching its own resolved config, so a
|
|
167
|
+
* retry is the only path that re-verifies a mount that looks connected. The
|
|
168
|
+
* wait is bounded — the pass keeps running, so a slow server reports its
|
|
169
|
+
* outcome to the status surface instead of holding the request open.
|
|
170
|
+
*/
|
|
171
|
+
async retryMounts() {
|
|
172
|
+
this.ports.mcpRemountAll();
|
|
173
|
+
await this.context.notifyChanged(true);
|
|
174
|
+
await this.context.refreshSettled();
|
|
175
|
+
}
|
|
176
|
+
/** The persisted backend choice without the host-client probe (mount-time provider). */
|
|
177
|
+
async backend() {
|
|
178
|
+
return this.ports.mcpBackend();
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* The active MCP mount backend plus a live probe of the host client, and
|
|
182
|
+
* the download-region setting with its locale-resolved effective route —
|
|
183
|
+
* everything the plugin-config card renders.
|
|
184
|
+
*/
|
|
185
|
+
async backendInfo() {
|
|
186
|
+
const [backend, hostClient, regionSetting, locale] = await Promise.all([this.ports.mcpBackend(), probeHostMcpClient(), this.ports.downloadRegion(), readLocalePreference()]);
|
|
187
|
+
return { backend, hostClient, downloadRegion: { setting: regionSetting, effective: resolveRegion(regionSetting, locale) } };
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Switch the MCP mount backend and remount every suite server through it.
|
|
191
|
+
* A host-client switch takes effect only where the host package resolves;
|
|
192
|
+
* unresolvable or unsupported transports surface as per-server diagnostics.
|
|
193
|
+
*/
|
|
194
|
+
async setBackend(backend) {
|
|
195
|
+
return this.context.enqueue(async () => {
|
|
196
|
+
await this.ports.setMcpBackend(backend);
|
|
197
|
+
await this.context.notifyChanged(true);
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Drop one MCP server's OAuth grant record so the next mount re-runs the
|
|
202
|
+
* browser authorization — the path for "I picked too narrow a scope".
|
|
203
|
+
* @param serverName - the derived serverName whose folded form keys the record.
|
|
204
|
+
* @throws when the credentials service is not mounted.
|
|
205
|
+
*/
|
|
206
|
+
async reauthorize(serverName) {
|
|
207
|
+
return this.context.enqueue(async () => {
|
|
208
|
+
await this.ports.credentialsStore?.deleteGrantRecord(serverName);
|
|
209
|
+
// Dropping a grant changes no resolved config field, so a fingerprint
|
|
210
|
+
// reconcile would keep the live bridge — with its in-memory tokens —
|
|
211
|
+
// untouched. The serverName maps back to exactly one mount key (the
|
|
212
|
+
// registry reserved the name), so flag it for an explicit rebuild.
|
|
213
|
+
const key = this.ports.mcpServerOwner(serverName);
|
|
214
|
+
if (key !== undefined)
|
|
215
|
+
this.ports.mcpRemount(key.suiteId, key.serverKey);
|
|
216
|
+
await this.context.notifyChanged(true);
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
/** Whether the re-authorize action can run in this composition. */
|
|
220
|
+
reauthorizeAvailable() {
|
|
221
|
+
return this.ports.credentialsStore !== undefined;
|
|
222
|
+
}
|
|
223
|
+
/** MCP overrides for a supplied runtime selection, or the full market catalog when omitted. */
|
|
224
|
+
async allOverrides(suites) {
|
|
225
|
+
const map = new Map();
|
|
226
|
+
for (const suite of suites ?? (await this.context.snapshots.readUserCatalog()).suites) {
|
|
227
|
+
const suiteKey = qualifiedSuiteId(suite.sourceId, suite.id);
|
|
228
|
+
const overrides = await loadSuiteOverrides(this.context.dataRoot, suiteKey);
|
|
229
|
+
if (Object.keys(overrides).length > 0)
|
|
230
|
+
map.set(suiteKey, overrides);
|
|
231
|
+
}
|
|
232
|
+
return map;
|
|
233
|
+
}
|
|
234
|
+
/** Resolve one editable server: its key, owning suite, root, and effective config. */
|
|
235
|
+
async resolveServerConfig(kind, id) {
|
|
236
|
+
if (kind === 'lsp' && id.startsWith('direct/')) {
|
|
237
|
+
const key = id.slice(7);
|
|
238
|
+
const server = (await loadLspServers(this.context.agentsRoot)).servers[key];
|
|
239
|
+
if (server === undefined)
|
|
240
|
+
throw new Error('LSP server not found');
|
|
241
|
+
return { key, suiteKey: 'direct', root: this.context.agentsRoot, value: lspConfig(server) };
|
|
242
|
+
}
|
|
243
|
+
const suites = [...(await this.context.snapshots.readUserCatalog()).suites, await loadUserMcpSuite(this.context.agentsRoot)];
|
|
244
|
+
for (const suite of await applyLspOverrides(this.context.dataRoot, suites)) {
|
|
245
|
+
const suiteKey = qualifiedSuiteId(suite.sourceId, suite.id);
|
|
246
|
+
if (kind === 'mcp') {
|
|
247
|
+
for (const [key, server] of Object.entries(suite.mcp?.servers ?? {})) {
|
|
248
|
+
if (id !== `plugin:${suiteKey}/${key}`)
|
|
249
|
+
continue;
|
|
250
|
+
const overrides = await loadSuiteOverrides(this.context.dataRoot, suiteKey);
|
|
251
|
+
return { key, suiteKey, root: suite.root, value: { ...applyOverride(server, overrides[key]) } };
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
else {
|
|
255
|
+
for (const [key, server] of Object.entries(suite.lsp?.servers ?? {})) {
|
|
256
|
+
if (id === `${suiteKey}/${key}`)
|
|
257
|
+
return { key, suiteKey, root: suite.root, value: lspConfig(server) };
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
throw new Error('service configuration is not managed by this plugin');
|
|
262
|
+
}
|
|
263
|
+
}
|
|
@@ -1,9 +1,23 @@
|
|
|
1
1
|
/** Installed suite and user resources share one inventory; paths never come from HTTP callers. */
|
|
2
|
-
import { realpath,
|
|
3
|
-
import {
|
|
2
|
+
import { realpath, stat, unlink } from 'node:fs/promises';
|
|
3
|
+
import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write';
|
|
4
4
|
import { defaultMarkdownResources, resourceText } from '../catalog/component-files.js';
|
|
5
|
+
import { pluginRootOf } from '../catalog/plugin-variables.js';
|
|
6
|
+
import { isWithin, suiteDataDir } from '../catalog/paths.js';
|
|
5
7
|
import { stripFrontmatter } from '../catalog/skills-parse.js';
|
|
6
8
|
import { parseFrontmatterRecord } from '../runtime/user-store.js';
|
|
9
|
+
/**
|
|
10
|
+
* Id of one panel entry backed by a file inside an installed suite's checkout.
|
|
11
|
+
* The leading `[` is what distinguishes a plugin entry from a user-authored
|
|
12
|
+
* one; `update`/`remove` use {@link isPluginResourceId} to tell them apart.
|
|
13
|
+
*/
|
|
14
|
+
export function pluginResourceId(sourceId, suiteId, kind, name) {
|
|
15
|
+
return JSON.stringify([sourceId, suiteId, kind, name]);
|
|
16
|
+
}
|
|
17
|
+
/** Whether a panel entry id addresses a file inside an installed suite. */
|
|
18
|
+
export function isPluginResourceId(id) {
|
|
19
|
+
return id.startsWith('[');
|
|
20
|
+
}
|
|
7
21
|
export function createPanelResources(catalog, users) {
|
|
8
22
|
return {
|
|
9
23
|
skills: new PanelResources(catalog, users.skills, 'skills'),
|
|
@@ -23,9 +37,8 @@ class PanelResources {
|
|
|
23
37
|
async list(strict = false) {
|
|
24
38
|
const entries = await this.users.list(strict);
|
|
25
39
|
const snapshot = await this.catalog.readUserCatalog();
|
|
26
|
-
const installed = new Set((await this.catalog.overview()).suites.filter(suite => suite.installed).map(suite => JSON.stringify([suite.sourceId, suite.suiteId])));
|
|
27
40
|
for (const suite of snapshot.suites) {
|
|
28
|
-
if (!
|
|
41
|
+
if (!this.catalog.isInstalled(suite.sourceId, suite.id) || suite.remote !== undefined)
|
|
29
42
|
continue;
|
|
30
43
|
const files = this.kind === 'skills'
|
|
31
44
|
? suite.skills.map(skill => ({ name: skill.name, file: skill.file }))
|
|
@@ -47,20 +60,31 @@ class PanelResources {
|
|
|
47
60
|
metadata = { disabled: true, validationError: String(error) };
|
|
48
61
|
}
|
|
49
62
|
entries.push({
|
|
50
|
-
id:
|
|
63
|
+
id: pluginResourceId(suite.sourceId, suite.id, this.kind, name),
|
|
51
64
|
name,
|
|
52
65
|
origin: 'plugin',
|
|
53
66
|
suiteName: suite.manifest.name,
|
|
54
|
-
disabled: !suite.enabled || suite.activeSurfaces
|
|
67
|
+
disabled: !suite.enabled || suite.activeSurfaces[this.kind] === false || metadata.disabled === true,
|
|
55
68
|
metadata,
|
|
56
69
|
rawText,
|
|
57
70
|
content: stripFrontmatter(rawText),
|
|
58
71
|
path: file,
|
|
59
|
-
description: typeof metadata.description === 'string' ? metadata.description : ''
|
|
72
|
+
description: typeof metadata.description === 'string' ? metadata.description : '',
|
|
73
|
+
...(pluginRootOf(suite) === undefined ? {} : { suiteRoot: suite.root, suiteData: suiteDataDir(this.catalog.dataRoot, suite.sourceId, suite.id) })
|
|
60
74
|
});
|
|
61
75
|
}
|
|
62
76
|
}
|
|
63
|
-
|
|
77
|
+
// One pass stamps each entry's own last modification: the panel reads every
|
|
78
|
+
// file's text anyway, and both origins get the same treatment here.
|
|
79
|
+
return await Promise.all(entries.map(async (entry) => {
|
|
80
|
+
try {
|
|
81
|
+
const stats = await stat(entry.path);
|
|
82
|
+
return { ...entry, updatedAt: new Date(stats.mtimeMs).toISOString() };
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return entry;
|
|
86
|
+
}
|
|
87
|
+
}));
|
|
64
88
|
}
|
|
65
89
|
async get(id) {
|
|
66
90
|
return (await this.list()).find(entry => (entry.id ?? entry.name) === id);
|
|
@@ -68,27 +92,32 @@ class PanelResources {
|
|
|
68
92
|
create(name, text) {
|
|
69
93
|
return this.users.create(name, text);
|
|
70
94
|
}
|
|
95
|
+
/**
|
|
96
|
+
* A plugin resource is writable only when it sits inside the user dimension
|
|
97
|
+
* root; suite checkouts elsewhere on disk stay read-only. The user-panel
|
|
98
|
+
* store lives in the shared Agent layout root instead, so containment is
|
|
99
|
+
* measured against the catalog's root, not the panel directory.
|
|
100
|
+
*/
|
|
71
101
|
async pluginPath(id) {
|
|
72
102
|
const entry = await this.get(id);
|
|
73
103
|
if (entry?.origin !== 'plugin')
|
|
74
104
|
throw new Error('Unknown installed plugin resource');
|
|
75
105
|
if (entry.path.endsWith('.json'))
|
|
76
106
|
throw new Error('Inline manifest resources are read-only; edit their source manifest');
|
|
77
|
-
const root = await realpath(this.
|
|
107
|
+
const root = await realpath(this.catalog.userRoot);
|
|
78
108
|
const path = await realpath(entry.path);
|
|
79
|
-
|
|
80
|
-
if (rel === '..' || rel.startsWith('../') || isAbsolute(rel))
|
|
109
|
+
if (!isWithin(root, path))
|
|
81
110
|
throw new Error('External source files are read-only; create a user resource to customize them');
|
|
82
111
|
return path;
|
|
83
112
|
}
|
|
84
113
|
async update(id, text) {
|
|
85
|
-
if (!id
|
|
114
|
+
if (!isPluginResourceId(id))
|
|
86
115
|
return this.users.update(id, text);
|
|
87
116
|
parseFrontmatterRecord(text);
|
|
88
|
-
await
|
|
117
|
+
await writeFileAtomic(await this.pluginPath(id), text, { mode: 0o644 });
|
|
89
118
|
}
|
|
90
119
|
async remove(id) {
|
|
91
|
-
if (!id
|
|
120
|
+
if (!isPluginResourceId(id))
|
|
92
121
|
return this.users.remove(id);
|
|
93
122
|
await unlink(await this.pluginPath(id));
|
|
94
123
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The seams a catalog runs on when nothing is wired. The MCP backend writer
|
|
3
|
+
* rejects rather than silently accepting a choice nothing can persist.
|
|
4
|
+
*/
|
|
5
|
+
export const defaultCatalogPorts = {
|
|
6
|
+
mcpToolSnapshot: () => [],
|
|
7
|
+
credentialsStore: undefined,
|
|
8
|
+
mcpRemount: () => { },
|
|
9
|
+
mcpRemountAll: () => { },
|
|
10
|
+
mcpServerOwner: () => undefined,
|
|
11
|
+
lspStatusSource: { diagnosticsSnapshot: () => new Map(), hasLiveMounts: () => false },
|
|
12
|
+
mcpBackend: async () => 'builtin',
|
|
13
|
+
setMcpBackend: async () => {
|
|
14
|
+
throw new Error('the settings service is not mounted');
|
|
15
|
+
},
|
|
16
|
+
downloadRegion: async () => 'auto'
|
|
17
|
+
};
|
|
18
|
+
/** Fill in every seam the composition root left unwired. */
|
|
19
|
+
export function resolveCatalogPorts(overrides = {}) {
|
|
20
|
+
return { ...defaultCatalogPorts, ...overrides };
|
|
21
|
+
}
|