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,464 +1,146 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Catalog application module: the
|
|
3
|
-
*
|
|
2
|
+
* Catalog application module: the use-case surface the HTTP routes, the host
|
|
3
|
+
* entry, and the runtime mount adapters call.
|
|
4
4
|
*
|
|
5
|
-
* The
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* The class is a thin facade. Persisted state, the serialized mutation queue,
|
|
6
|
+
* the revision and scan generations, and the change pipeline live in
|
|
7
|
+
* {@link CatalogContext}; discovery caching in {@link SnapshotCache}; and each
|
|
8
|
+
* use-case family in its own collaborator (source acquisition, install state,
|
|
9
|
+
* MCP, LSP). The facade composes them and forwards, so a caller never has to
|
|
10
|
+
* know which collaborator owns a given invariant.
|
|
9
11
|
*/
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import { discoverSourceListWithNotes } from '../catalog/source-catalog.js';
|
|
15
|
-
import { discoverSuitesInSource } from '../catalog/suite-scanner.js';
|
|
16
|
-
import { archiveInstall } from '../catalog/archive.js';
|
|
17
|
-
import { gitClone, gitCurrentBranch, gitHead, gitRemoteUrl, gitRemove, gitSetRemoteUrl, gitSync } from '../catalog/git.js';
|
|
18
|
-
import { buildMcpStatus } from '../runtime/mcp-status.js';
|
|
19
|
-
import { addUserMcpServer, loadUserMcpSuite, USER_MCP_SOURCE, USER_MCP_SUITE } from '../runtime/mcp-direct-config.js';
|
|
20
|
-
import { buildLspStatus } from '../runtime/lsp-status.js';
|
|
21
|
-
import { loadLspServers, saveLspServers } from '../runtime/lsp-direct-config.js';
|
|
22
|
-
import { loadDisabledLspServers, saveDisabledLspServers } from '../runtime/lsp-server-state.js';
|
|
23
|
-
import { applyOverride, loadSuiteOverrides, mergeOverridePatch, saveSuiteOverrides } from '../runtime/mcp-overrides.js';
|
|
24
|
-
import { applyLspOverrides, lspConfig, restoreRedactedConfig, saveLspOverride, validateServerLsp, validateServerMcp } from '../runtime/server-config.js';
|
|
25
|
-
import { probeHostMcpClient } from '../runtime/mcp-backend.js';
|
|
26
|
-
import { githubCloneUrl, resolveRegion } from '../runtime/regions.js';
|
|
27
|
-
import { readLocalePreference } from '../runtime/host-locale.js';
|
|
28
|
-
import { redactMcpConfig, redactMcpOverrides } from '../runtime/mcp-redaction.js';
|
|
12
|
+
import { qualifiedSuiteId } from '../catalog/paths.js';
|
|
13
|
+
import { loadUserMcpSuite } from '../runtime/mcp-direct-config.js';
|
|
14
|
+
import { loadSuiteOverrides } from '../runtime/mcp-overrides.js';
|
|
15
|
+
import { applyLspOverrides } from '../runtime/server-config.js';
|
|
29
16
|
import { buildSuiteDetail, readSkillContent } from './details.js';
|
|
30
|
-
import {
|
|
31
|
-
import {
|
|
32
|
-
import {
|
|
17
|
+
import { CatalogContext } from './catalog-context.js';
|
|
18
|
+
import { InstallStore } from './install-store.js';
|
|
19
|
+
import { LspService } from './lsp-service.js';
|
|
20
|
+
import { McpService } from './mcp-service.js';
|
|
21
|
+
import { resolveCatalogPorts } from './ports.js';
|
|
22
|
+
import { SourceStore, codeloadTarballUrl } from './source-store.js';
|
|
33
23
|
export class Catalog {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
userSnapshot;
|
|
42
|
-
userSnapshotExpiresAt = 0;
|
|
43
|
-
userSnapshotPromise;
|
|
44
|
-
/**
|
|
45
|
-
* Discovery scan cache: per-sources-fingerprint scan results with a TTL.
|
|
46
|
-
* Install / enable / surface toggles re-derive the snapshot from cached
|
|
47
|
-
* discovery (cheap mapping) instead of re-walking every checkout — a full
|
|
48
|
-
* rescan of a 2,500-suite catalog costs ~1s and UI mutations happen in
|
|
49
|
-
* bursts. Content-changing mutations (add / update / remove / adopt /
|
|
50
|
-
* refresh / acquire) clear every cached discovery generation; the TTL
|
|
51
|
-
* bounds staleness for in-place working-tree
|
|
52
|
-
* edits of local sources.
|
|
53
|
-
*/
|
|
54
|
-
scanCache = new Map();
|
|
55
|
-
scanGeneration = 0;
|
|
56
|
-
scanPromises = new Map();
|
|
57
|
-
static SCAN_CACHE_TTL_MS = 30_000;
|
|
58
|
-
static SCAN_CACHE_MAX_ENTRIES = 8;
|
|
59
|
-
/**
|
|
60
|
-
* User-dimension snapshot TTL: bounded staleness for edits made outside the
|
|
61
|
-
* plugin (a new skill dropped into a local source's working tree, a hand
|
|
62
|
-
* edit in a project layout) becomes visible on the next read past the TTL
|
|
63
|
-
* instead of living until the next mutation. Configurable; a 0 value
|
|
64
|
-
* disables snapshot caching entirely.
|
|
65
|
-
*/
|
|
66
|
-
userSnapshotTtlMs;
|
|
67
|
-
/** Project-dimension snapshots keyed by project root, with TTL freshness. */
|
|
68
|
-
projectSnapshots = new Map();
|
|
69
|
-
projectSnapshotPromises = new Map();
|
|
70
|
-
projectSnapshotTtlMs;
|
|
71
|
-
/** Latest MCP mount diagnostics (suiteId -> reasons), fed by host reconcile. */
|
|
72
|
-
mcpDiagnostics = [];
|
|
73
|
-
/** Latest LSP mount diagnostics source, wired by the host entry. */
|
|
74
|
-
lspStatusSource;
|
|
75
|
-
/** Credential-store seam for dropping a grant record (MCP re-authorize). */
|
|
76
|
-
credentialsStore;
|
|
77
|
-
toolSnapshotProvider = () => [];
|
|
78
|
-
/** Mount-registry seam: flags a mount key for an explicit rebuild (re-authorize). */
|
|
79
|
-
mcpRemount = () => { };
|
|
80
|
-
/** Resolves the mount key owning one derived serverName; absent when the server is not mounted. */
|
|
81
|
-
mcpServerOwner = () => undefined;
|
|
82
|
-
/** Backend source (the host settings namespace scope); default is the built-in client. */
|
|
83
|
-
mcpBackendProvider = async () => 'builtin';
|
|
84
|
-
/** Backend writer (a scope update); absent until the settings service resolves. */
|
|
85
|
-
mcpBackendWriter = async () => {
|
|
86
|
-
throw new Error('the settings service is not mounted');
|
|
87
|
-
};
|
|
88
|
-
/** Download-region source (the host settings namespace scope). */
|
|
89
|
-
downloadRegionProvider = async () => 'auto';
|
|
90
|
-
scanProjectLayouts = true;
|
|
91
|
-
/** Apply the host's project-layout switch and invalidate every project snapshot. */
|
|
92
|
-
async setScanProjectLayouts(enabled) {
|
|
93
|
-
if (this.scanProjectLayouts === enabled)
|
|
94
|
-
return;
|
|
95
|
-
this.scanProjectLayouts = enabled;
|
|
96
|
-
await this.notifyChanged();
|
|
97
|
-
}
|
|
24
|
+
context;
|
|
25
|
+
ports;
|
|
26
|
+
snapshots;
|
|
27
|
+
sourceStore;
|
|
28
|
+
installs;
|
|
29
|
+
mcp;
|
|
30
|
+
lsp;
|
|
98
31
|
constructor(options) {
|
|
99
|
-
this.
|
|
100
|
-
this.
|
|
101
|
-
this.
|
|
102
|
-
this.
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
this.toolSnapshotProvider = provider;
|
|
107
|
-
}
|
|
108
|
-
/** Install the credential-store seam backing the MCP re-authorize action. */
|
|
109
|
-
setCredentialsStore(store) {
|
|
110
|
-
this.credentialsStore = store;
|
|
111
|
-
}
|
|
112
|
-
/**
|
|
113
|
-
* Drop one MCP server's OAuth grant record so the next mount re-runs the
|
|
114
|
-
* browser authorization — the path for "I picked too narrow a scope".
|
|
115
|
-
* @param serverName - the derived serverName whose folded form keys the record.
|
|
116
|
-
* @throws when the credentials service is not mounted.
|
|
117
|
-
*/
|
|
118
|
-
async reauthorizeMcpServer(serverName) {
|
|
119
|
-
await this.enqueue(async () => {
|
|
120
|
-
await this.credentialsStore?.deleteGrantRecord(serverName);
|
|
121
|
-
// Dropping a grant changes no resolved config field, so a fingerprint
|
|
122
|
-
// reconcile would keep the live bridge — with its in-memory tokens —
|
|
123
|
-
// untouched. The serverName maps back to exactly one mount key (the
|
|
124
|
-
// registry reserved the name), so flag it for an explicit rebuild.
|
|
125
|
-
const key = this.mcpServerOwner(serverName);
|
|
126
|
-
if (key !== undefined)
|
|
127
|
-
this.mcpRemount?.(key.suiteId, key.serverKey);
|
|
128
|
-
await this.notifyChanged(true);
|
|
129
|
-
});
|
|
130
|
-
}
|
|
131
|
-
/**
|
|
132
|
-
* Install the MCP remount seam used by re-authorize: the registry flags the
|
|
133
|
-
* mount so the next reconcile tears the live bridge down and rebuilds it.
|
|
134
|
-
*/
|
|
135
|
-
setMcpRemountHook(hook, serverOwner) {
|
|
136
|
-
this.mcpRemount = hook;
|
|
137
|
-
this.mcpServerOwner = serverOwner;
|
|
138
|
-
}
|
|
139
|
-
/** Whether the re-authorize action can run in this composition. */
|
|
140
|
-
mcpReauthorizeAvailable() {
|
|
141
|
-
return this.credentialsStore !== undefined;
|
|
142
|
-
}
|
|
143
|
-
/** Install the LSP mount status source used by the LSP status surface. */
|
|
144
|
-
setLspStatusSource(source) {
|
|
145
|
-
this.lspStatusSource = source;
|
|
32
|
+
this.context = new CatalogContext(options);
|
|
33
|
+
this.ports = resolveCatalogPorts(options.ports);
|
|
34
|
+
this.snapshots = this.context.snapshots;
|
|
35
|
+
this.sourceStore = new SourceStore(this.context, this.ports);
|
|
36
|
+
this.installs = new InstallStore(this.context, this.sourceStore);
|
|
37
|
+
this.mcp = new McpService(this.context, this.ports, this.sourceStore);
|
|
38
|
+
this.lsp = new LspService(this.context, this.ports);
|
|
146
39
|
}
|
|
147
|
-
/**
|
|
148
|
-
|
|
149
|
-
this.
|
|
150
|
-
return () => this.listeners.delete(listener);
|
|
151
|
-
}
|
|
152
|
-
/** Build the flat MCP service inventory for the status surface. */
|
|
153
|
-
async mcpStatus() {
|
|
154
|
-
const snapshot = await this.readUserCatalog();
|
|
155
|
-
const suites = [...snapshot.suites, await loadUserMcpSuite(this.options.dataRoot)];
|
|
156
|
-
const payload = buildMcpStatus(suites, this.mcpDiagnostics, this.toolSnapshotProvider(), await this.allMcpOverrides(suites));
|
|
157
|
-
for (const entry of payload.entries)
|
|
158
|
-
if (entry.suiteId === `${USER_MCP_SOURCE}/${USER_MCP_SUITE}`) {
|
|
159
|
-
entry.kind = 'direct';
|
|
160
|
-
entry.managed = true;
|
|
161
|
-
}
|
|
162
|
-
const oauthBackend = (await this.mcpBackend()) === 'builtin' && this.mcpReauthorizeAvailable();
|
|
163
|
-
for (const entry of payload.entries) {
|
|
164
|
-
const auth = entry.config?.auth;
|
|
165
|
-
entry.canReauthorize =
|
|
166
|
-
oauthBackend &&
|
|
167
|
-
(entry.kind === 'plugin' || entry.managed === true) &&
|
|
168
|
-
(entry.transport === 'sse' || entry.transport === 'streamable-http') &&
|
|
169
|
-
auth?.enabled !== false &&
|
|
170
|
-
!['disabled', 'foreign', 'orphaned'].includes(entry.state);
|
|
171
|
-
}
|
|
172
|
-
return payload;
|
|
173
|
-
}
|
|
174
|
-
/** Persist a user-owned MCP service and reconcile its bridge mount. */
|
|
175
|
-
async addMcpServer(name, server) {
|
|
176
|
-
return this.enqueue(async () => {
|
|
177
|
-
await addUserMcpServer(this.options.dataRoot, name, server);
|
|
178
|
-
await this.notifyChanged(true);
|
|
179
|
-
});
|
|
180
|
-
}
|
|
181
|
-
/** Read effective service configuration without exposing credential literals. */
|
|
182
|
-
async serverConfig(kind, id) {
|
|
183
|
-
const config = await this.resolveServerConfig(kind, id);
|
|
184
|
-
return { kind, id, editable: true, config: redactMcpConfig(config.value) };
|
|
185
|
-
}
|
|
186
|
-
async resolveServerConfig(kind, id) {
|
|
187
|
-
if (kind === 'lsp' && id.startsWith('direct/')) {
|
|
188
|
-
const key = id.slice(7);
|
|
189
|
-
const server = (await loadLspServers(this.options.dataRoot)).servers[key];
|
|
190
|
-
if (server === undefined)
|
|
191
|
-
throw new Error('LSP server not found');
|
|
192
|
-
return { key, suiteKey: 'direct', root: this.options.dataRoot, value: lspConfig(server) };
|
|
193
|
-
}
|
|
194
|
-
const suites = [...(await this.readUserCatalog()).suites, await loadUserMcpSuite(this.options.dataRoot)];
|
|
195
|
-
for (const suite of await applyLspOverrides(this.options.dataRoot, suites)) {
|
|
196
|
-
const suiteKey = qualifiedSuiteId(suite.sourceId, suite.id);
|
|
197
|
-
if (kind === 'mcp') {
|
|
198
|
-
for (const [key, server] of Object.entries(suite.mcp?.servers ?? {})) {
|
|
199
|
-
if (id !== `plugin:${suiteKey}/${key}`)
|
|
200
|
-
continue;
|
|
201
|
-
const overrides = await loadSuiteOverrides(this.options.dataRoot, suiteKey);
|
|
202
|
-
return { key, suiteKey, root: suite.root, value: { ...applyOverride(server, overrides[key]) } };
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
else {
|
|
206
|
-
for (const [key, server] of Object.entries(suite.lsp?.servers ?? {})) {
|
|
207
|
-
if (id === `${suiteKey}/${key}`)
|
|
208
|
-
return { key, suiteKey, root: suite.root, value: lspConfig(server) };
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
throw new Error('service configuration is not managed by this plugin');
|
|
213
|
-
}
|
|
214
|
-
/** Validate a complete replacement before writing; plugin checkouts remain untouched. */
|
|
215
|
-
async saveServerConfig(kind, id, config) {
|
|
216
|
-
return this.enqueue(async () => {
|
|
217
|
-
const current = await this.resolveServerConfig(kind, id);
|
|
218
|
-
const value = restoreRedactedConfig(config, current.value);
|
|
219
|
-
if (kind === 'mcp') {
|
|
220
|
-
const server = await validateServerMcp(current.root, current.key, value);
|
|
221
|
-
const overrides = await loadSuiteOverrides(this.options.dataRoot, current.suiteKey);
|
|
222
|
-
const enabled = overrides[current.key]?.enabled;
|
|
223
|
-
overrides[current.key] = { config: server, ...(enabled === undefined ? {} : { enabled }) };
|
|
224
|
-
await saveSuiteOverrides(this.options.dataRoot, current.suiteKey, overrides);
|
|
225
|
-
}
|
|
226
|
-
else {
|
|
227
|
-
const server = validateServerLsp(current.key, value);
|
|
228
|
-
if (current.suiteKey === 'direct') {
|
|
229
|
-
const direct = await loadLspServers(this.options.dataRoot);
|
|
230
|
-
if (direct.errors.length > 0)
|
|
231
|
-
throw new Error(direct.errors.join('; '));
|
|
232
|
-
direct.servers[current.key] = server;
|
|
233
|
-
await saveLspServers(this.options.dataRoot, { lspServers: Object.fromEntries(Object.entries(direct.servers).map(([key, spec]) => [key, lspConfig(spec)])) });
|
|
234
|
-
}
|
|
235
|
-
else
|
|
236
|
-
await saveLspOverride(this.options.dataRoot, id, server);
|
|
237
|
-
}
|
|
238
|
-
await this.notifyChanged(true);
|
|
239
|
-
});
|
|
240
|
-
}
|
|
241
|
-
/** The LSP status surface: declared servers merged with mount diagnostics. */
|
|
242
|
-
async lspStatus() {
|
|
243
|
-
const snapshot = await this.readUserCatalog();
|
|
244
|
-
const direct = await loadLspServers(this.options.dataRoot);
|
|
245
|
-
return buildLspStatus(await applyLspOverrides(this.options.dataRoot, snapshot.suites), this.lspStatusSource ?? { diagnosticsSnapshot: () => new Map(), hasLiveMounts: () => false }, direct);
|
|
40
|
+
/** Latest MCP mount diagnostics (suiteId -> reasons), fed by host reconcile. */
|
|
41
|
+
get mcpDiagnostics() {
|
|
42
|
+
return this.mcp.diagnostics;
|
|
246
43
|
}
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
return (await loadLspServers(this.options.dataRoot)).servers;
|
|
44
|
+
set mcpDiagnostics(value) {
|
|
45
|
+
this.mcp.diagnostics = value;
|
|
250
46
|
}
|
|
251
|
-
/**
|
|
252
|
-
|
|
253
|
-
return this.
|
|
254
|
-
if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/.test(name))
|
|
255
|
-
throw new Error('invalid LSP server name');
|
|
256
|
-
const direct = await loadLspServers(this.options.dataRoot);
|
|
257
|
-
if (direct.errors.length > 0)
|
|
258
|
-
throw new Error(direct.errors.join('; '));
|
|
259
|
-
if (Object.hasOwn(direct.servers, name))
|
|
260
|
-
throw new Error('LSP server already exists');
|
|
261
|
-
const spec = validateServerLsp(name, config);
|
|
262
|
-
await saveLspServers(this.options.dataRoot, {
|
|
263
|
-
lspServers: { ...Object.fromEntries(Object.entries(direct.servers).map(([key, value]) => [key, lspConfig(value)])), [name]: lspConfig(spec) }
|
|
264
|
-
});
|
|
265
|
-
await this.notifyChanged(true);
|
|
266
|
-
});
|
|
47
|
+
/** The user-dimension suite root this catalog operates. */
|
|
48
|
+
get userRoot() {
|
|
49
|
+
return this.context.userRoot;
|
|
267
50
|
}
|
|
268
|
-
/**
|
|
269
|
-
|
|
270
|
-
return this.
|
|
271
|
-
const { servers } = await saveLspServers(this.options.dataRoot, raw);
|
|
272
|
-
await this.notifyChanged(true);
|
|
273
|
-
return servers;
|
|
274
|
-
});
|
|
51
|
+
/** The plugin storage root holding per-suite `${PLUGIN_DATA}` directories and overrides. */
|
|
52
|
+
get dataRoot() {
|
|
53
|
+
return this.context.dataRoot;
|
|
275
54
|
}
|
|
276
|
-
|
|
277
|
-
return this.
|
|
278
|
-
const disabled = await loadDisabledLspServers(this.options.dataRoot);
|
|
279
|
-
if (enabled)
|
|
280
|
-
disabled.delete(id);
|
|
281
|
-
else
|
|
282
|
-
disabled.add(id);
|
|
283
|
-
await saveDisabledLspServers(this.options.dataRoot, disabled);
|
|
284
|
-
await this.notifyChanged(true);
|
|
285
|
-
});
|
|
55
|
+
get sources() {
|
|
56
|
+
return this.context.state.sources;
|
|
286
57
|
}
|
|
287
|
-
/**
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
const snapshot = await this.readUserCatalog();
|
|
291
|
-
if (!snapshot.suites.some(suite => suite.sourceId === sourceId && suite.id === suiteId))
|
|
292
|
-
throw new Error(`suite "${suiteKey}" not found`);
|
|
293
|
-
return redactMcpOverrides(await loadSuiteOverrides(this.options.dataRoot, suiteKey));
|
|
58
|
+
/** Whether one suite currently carries an install entry. */
|
|
59
|
+
isInstalled(sourceId, suiteId) {
|
|
60
|
+
return this.context.installed(sourceId, suiteId) !== undefined;
|
|
294
61
|
}
|
|
295
|
-
/**
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
* in the suite's parsed mcp.json.
|
|
299
|
-
*/
|
|
300
|
-
async setMcpOverride(sourceId, suiteId, serverKey, override) {
|
|
301
|
-
return this.enqueue(async () => {
|
|
302
|
-
const suiteKey = qualifiedSuiteId(sourceId, suiteId);
|
|
303
|
-
const source = this.state.sources.find(entry => entry.id === sourceId);
|
|
304
|
-
let suites;
|
|
305
|
-
if (sourceId === USER_MCP_SOURCE && suiteId === USER_MCP_SUITE) {
|
|
306
|
-
suites = [await loadUserMcpSuite(this.options.dataRoot)];
|
|
307
|
-
}
|
|
308
|
-
else {
|
|
309
|
-
if (source === undefined)
|
|
310
|
-
throw new Error(`unknown source "${sourceId}"`);
|
|
311
|
-
const checkout = this.sourceCheckoutPath(source);
|
|
312
|
-
if (!(await isDirectory(checkout)))
|
|
313
|
-
await this.acquire(source);
|
|
314
|
-
suites = await discoverSuitesInSource(checkout, sourceId, 'user', source.url);
|
|
315
|
-
}
|
|
316
|
-
const suite = suites.find(entry => entry.id === suiteId);
|
|
317
|
-
if (suite === undefined)
|
|
318
|
-
throw new Error(`suite "${suiteId}" not found in source "${sourceId}"`);
|
|
319
|
-
if (suite.mcp?.servers[serverKey] === undefined)
|
|
320
|
-
throw new Error(`server "${serverKey}" is not defined by suite "${suiteId}"`);
|
|
321
|
-
// Override files are keyed by the qualified id so two sources' same-named
|
|
322
|
-
// suites never share one override record.
|
|
323
|
-
const overrides = await loadSuiteOverrides(this.options.dataRoot, suiteKey);
|
|
324
|
-
if (override === null) {
|
|
325
|
-
delete overrides[serverKey];
|
|
326
|
-
}
|
|
327
|
-
else {
|
|
328
|
-
// Merge onto the stored record: the UI never receives literal secret
|
|
329
|
-
// values, so a verbatim write would drop keys it simply redacted.
|
|
330
|
-
overrides[serverKey] = mergeOverridePatch(overrides[serverKey] ?? {}, override);
|
|
331
|
-
}
|
|
332
|
-
await saveSuiteOverrides(this.options.dataRoot, suiteKey, overrides);
|
|
333
|
-
await this.notifyChanged(true);
|
|
334
|
-
});
|
|
62
|
+
/** Apply the host's project-layout switch and invalidate every project snapshot. */
|
|
63
|
+
async setScanProjectLayouts(enabled) {
|
|
64
|
+
await this.context.setScanProjectLayouts(enabled);
|
|
335
65
|
}
|
|
336
|
-
/**
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
*/
|
|
340
|
-
async retryMounts() {
|
|
341
|
-
await this.notifyChanged(true);
|
|
66
|
+
/** Load persisted user state once at plugin activation. */
|
|
67
|
+
async load() {
|
|
68
|
+
await this.context.load();
|
|
342
69
|
}
|
|
343
|
-
/**
|
|
344
|
-
async
|
|
345
|
-
|
|
70
|
+
/** Append config-seeded sources missing from user state and persist them. */
|
|
71
|
+
async mergeSources(sources) {
|
|
72
|
+
await this.installs.mergeSources(sources);
|
|
346
73
|
}
|
|
347
74
|
/**
|
|
348
|
-
*
|
|
349
|
-
*
|
|
350
|
-
*
|
|
75
|
+
* Panel changed hook: the HTTP layer notifies after a user-panel mutation
|
|
76
|
+
* (skills / commands / agent personas). Catalog state is untouched — this
|
|
77
|
+
* only runs the change pipeline so the runtime remounts commands and the
|
|
78
|
+
* skill providers re-read their catalogs.
|
|
351
79
|
*/
|
|
352
|
-
async
|
|
353
|
-
|
|
354
|
-
return { backend, hostClient, downloadRegion: { setting: regionSetting, effective: resolveRegion(regionSetting, locale) } };
|
|
80
|
+
async notifyPanelsChanged() {
|
|
81
|
+
await this.context.notifyChanged();
|
|
355
82
|
}
|
|
356
83
|
/**
|
|
357
|
-
*
|
|
358
|
-
*
|
|
359
|
-
* unresolvable or unsupported transports surface as per-server diagnostics.
|
|
84
|
+
* Wait for the derived refresh triggered by the latest change, bounded so a
|
|
85
|
+
* stuck mount reports back instead of holding an explicit retry open.
|
|
360
86
|
*/
|
|
361
|
-
async
|
|
362
|
-
return this.
|
|
363
|
-
await this.mcpBackendWriter(backend);
|
|
364
|
-
await this.notifyChanged(true);
|
|
365
|
-
});
|
|
366
|
-
}
|
|
367
|
-
/** Install the backend source (the host settings namespace's scope). */
|
|
368
|
-
setMcpBackendProvider(provider) {
|
|
369
|
-
this.mcpBackendProvider = provider;
|
|
370
|
-
}
|
|
371
|
-
/** Install the backend writer (a scope update on the same namespace). */
|
|
372
|
-
setMcpBackendWriter(writer) {
|
|
373
|
-
this.mcpBackendWriter = writer;
|
|
374
|
-
}
|
|
375
|
-
/** Install the download-region source (the host settings namespace scope). */
|
|
376
|
-
setDownloadRegionProvider(provider) {
|
|
377
|
-
this.downloadRegionProvider = provider;
|
|
378
|
-
}
|
|
379
|
-
/** MCP overrides for a supplied runtime selection, or the full market catalog when omitted. */
|
|
380
|
-
async allMcpOverrides(suites) {
|
|
381
|
-
const map = new Map();
|
|
382
|
-
for (const suite of suites ?? (await this.readUserCatalog()).suites) {
|
|
383
|
-
const suiteKey = qualifiedSuiteId(suite.sourceId, suite.id);
|
|
384
|
-
const overrides = await loadSuiteOverrides(this.options.dataRoot, suiteKey);
|
|
385
|
-
if (Object.keys(overrides).length > 0)
|
|
386
|
-
map.set(suiteKey, overrides);
|
|
387
|
-
}
|
|
388
|
-
return map;
|
|
389
|
-
}
|
|
390
|
-
/** Load persisted user state once at plugin activation. */
|
|
391
|
-
async load() {
|
|
392
|
-
this.state = await loadState(this.statePath);
|
|
393
|
-
this.invalidateScans();
|
|
394
|
-
this.invalidateSnapshot(false);
|
|
395
|
-
}
|
|
396
|
-
get sources() {
|
|
397
|
-
return this.state.sources;
|
|
398
|
-
}
|
|
399
|
-
/** The user-dimension suite root this catalog operates. */
|
|
400
|
-
get userRoot() {
|
|
401
|
-
return this.options.userRoot;
|
|
87
|
+
async refreshSettled(deadlineMs) {
|
|
88
|
+
return this.context.refreshSettled(deadlineMs);
|
|
402
89
|
}
|
|
403
90
|
/** Read one coherent user-dimension snapshot, reusing in-flight discovery. */
|
|
404
91
|
async readUserCatalog() {
|
|
405
|
-
|
|
406
|
-
// mirroring the project dimension's caching-disabled semantics.
|
|
407
|
-
if (this.userSnapshotTtlMs <= 0)
|
|
408
|
-
return this.buildSnapshot(this.state, 'user', this.options.userRoot);
|
|
409
|
-
if (this.userSnapshot !== undefined && Date.now() < this.userSnapshotExpiresAt)
|
|
410
|
-
return this.userSnapshot;
|
|
411
|
-
if (this.userSnapshotPromise !== undefined)
|
|
412
|
-
return this.userSnapshotPromise;
|
|
413
|
-
const revision = this.revision;
|
|
414
|
-
const generation = this.scanGeneration;
|
|
415
|
-
const promise = this.buildSnapshot(this.state, 'user', this.options.userRoot)
|
|
416
|
-
.then(snapshot => {
|
|
417
|
-
if (revision === this.revision && generation === this.scanGeneration) {
|
|
418
|
-
this.userSnapshot = snapshot;
|
|
419
|
-
this.userSnapshotExpiresAt = Date.now() + this.userSnapshotTtlMs;
|
|
420
|
-
}
|
|
421
|
-
return snapshot;
|
|
422
|
-
})
|
|
423
|
-
.finally(() => {
|
|
424
|
-
if (this.userSnapshotPromise === promise)
|
|
425
|
-
this.userSnapshotPromise = undefined;
|
|
426
|
-
});
|
|
427
|
-
this.userSnapshotPromise = promise;
|
|
428
|
-
return promise;
|
|
92
|
+
return this.snapshots.readUserCatalog();
|
|
429
93
|
}
|
|
430
94
|
/** Read one coherent project-dimension snapshot for a workspace cwd. */
|
|
431
95
|
async readProjectCatalog(cwd) {
|
|
432
|
-
|
|
433
|
-
if (this.projectSnapshotTtlMs <= 0)
|
|
434
|
-
return this.buildProjectSnapshot(projectRoot);
|
|
435
|
-
const cached = this.projectSnapshots.get(projectRoot);
|
|
436
|
-
if (cached !== undefined && cached.expiresAt > Date.now())
|
|
437
|
-
return cached.snapshot;
|
|
438
|
-
const inFlight = this.projectSnapshotPromises.get(projectRoot);
|
|
439
|
-
if (inFlight !== undefined)
|
|
440
|
-
return inFlight;
|
|
441
|
-
const revision = this.revision;
|
|
442
|
-
const generation = this.scanGeneration;
|
|
443
|
-
const promise = this.buildProjectSnapshot(projectRoot)
|
|
444
|
-
.then(snapshot => {
|
|
445
|
-
if (revision === this.revision && generation === this.scanGeneration) {
|
|
446
|
-
this.projectSnapshots.set(projectRoot, { snapshot, expiresAt: Date.now() + this.projectSnapshotTtlMs });
|
|
447
|
-
}
|
|
448
|
-
return snapshot;
|
|
449
|
-
})
|
|
450
|
-
.finally(() => {
|
|
451
|
-
if (this.projectSnapshotPromises.get(projectRoot) === promise)
|
|
452
|
-
this.projectSnapshotPromises.delete(projectRoot);
|
|
453
|
-
});
|
|
454
|
-
this.projectSnapshotPromises.set(projectRoot, promise);
|
|
455
|
-
return promise;
|
|
96
|
+
return this.snapshots.readProjectCatalog(cwd);
|
|
456
97
|
}
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
98
|
+
/** Runtime discovery scans only sources containing an enabled install. No acquisition or network access. */
|
|
99
|
+
async enabledUserSuites() {
|
|
100
|
+
const state = this.context.state;
|
|
101
|
+
const enabledSources = new Set(Object.entries(state.installed)
|
|
102
|
+
.filter(([, entry]) => entry.enabled)
|
|
103
|
+
.map(([key]) => key.slice(0, key.indexOf('/'))));
|
|
104
|
+
const sources = state.sources.filter(source => enabledSources.has(source.id));
|
|
105
|
+
const suites = sources.length === 0 ? [] : (await this.snapshots.build({ ...state, sources }, 'user', this.context.userRoot)).enabledSuites;
|
|
106
|
+
const direct = await loadUserMcpSuite(this.context.agentsRoot);
|
|
107
|
+
return applyLspOverrides(this.context.dataRoot, Object.keys(direct.mcp.servers).length === 0 ? suites : [...suites, direct]);
|
|
108
|
+
}
|
|
109
|
+
/** The full market overview from one user snapshot. */
|
|
110
|
+
async overview() {
|
|
111
|
+
const snapshot = await this.readUserCatalog();
|
|
112
|
+
const sourceRows = await this.sourceStore.overviewRows(snapshot);
|
|
113
|
+
const cards = snapshot.suites.map(suite => {
|
|
114
|
+
const isInstalled = this.isInstalled(suite.sourceId, suite.id);
|
|
115
|
+
return {
|
|
116
|
+
sourceId: suite.sourceId,
|
|
117
|
+
suiteId: suite.id,
|
|
118
|
+
name: suite.manifest.name,
|
|
119
|
+
version: suite.manifest.version,
|
|
120
|
+
description: suite.manifest.description,
|
|
121
|
+
keywords: suite.manifest.keywords ?? [],
|
|
122
|
+
surfaces: suite.surfaces,
|
|
123
|
+
enabled: suite.enabled,
|
|
124
|
+
installed: isInstalled,
|
|
125
|
+
...(isInstalled ? { surfaceToggles: suite.activeSurfaces } : {}),
|
|
126
|
+
...(suite.remote === undefined ? {} : { remoteUrl: suite.remote.url }),
|
|
127
|
+
dimension: suite.dimension,
|
|
128
|
+
layout: suite.manifest.layout,
|
|
129
|
+
errors: suite.errors,
|
|
130
|
+
mcpErrors: this.mcp.diagnostics.filter(diagnostic => diagnostic.suiteId === suite.id).map(diagnostic => `${diagnostic.serverKey}: ${diagnostic.reason}`)
|
|
131
|
+
};
|
|
132
|
+
});
|
|
133
|
+
return {
|
|
134
|
+
sources: sourceRows,
|
|
135
|
+
suites: cards,
|
|
136
|
+
totals: {
|
|
137
|
+
all: cards.length,
|
|
138
|
+
installed: cards.filter(card => card.installed).length,
|
|
139
|
+
enabled: cards.filter(card => card.enabled).length
|
|
140
|
+
},
|
|
141
|
+
roots: { user: this.context.userRoot, data: this.context.dataRoot },
|
|
142
|
+
unmanaged: await this.sourceStore.unmanaged()
|
|
143
|
+
};
|
|
462
144
|
}
|
|
463
145
|
/** One suite's full detail for the market detail modal. */
|
|
464
146
|
async suiteDetail(sourceId, suiteId) {
|
|
@@ -467,7 +149,7 @@ export class Catalog {
|
|
|
467
149
|
if (suite === undefined)
|
|
468
150
|
throw new Error(`suite "${suiteId}" not found in source "${sourceId}"`);
|
|
469
151
|
const suiteKey = qualifiedSuiteId(sourceId, suiteId);
|
|
470
|
-
return buildSuiteDetail(suite, this.
|
|
152
|
+
return buildSuiteDetail(suite, this.context.installed(sourceId, suiteId), this.mcp.diagnostics, await loadSuiteOverrides(this.context.dataRoot, suiteKey));
|
|
471
153
|
}
|
|
472
154
|
/** One skill's full SKILL.md text for the market detail modal. */
|
|
473
155
|
async skillContent(sourceId, suiteId, skillName) {
|
|
@@ -477,723 +159,161 @@ export class Catalog {
|
|
|
477
159
|
throw new Error(`suite "${suiteId}" not found in source "${sourceId}"`);
|
|
478
160
|
return readSkillContent(suite, skillName);
|
|
479
161
|
}
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
const snapshot = await this.readUserCatalog();
|
|
483
|
-
const sourceRows = [];
|
|
484
|
-
for (const source of snapshot.sources) {
|
|
485
|
-
const inFlight = this.currentSourceState?.sourceId === source.id ? this.currentSourceState : undefined;
|
|
486
|
-
const checkout = this.sourceCheckoutPath(source);
|
|
487
|
-
const kind = resolveSourceKind(source);
|
|
488
|
-
let cloned = false;
|
|
489
|
-
let lockCommit;
|
|
490
|
-
let error;
|
|
491
|
-
if (kind === 'local') {
|
|
492
|
-
cloned = await isDirectory(checkout);
|
|
493
|
-
if (!cloned)
|
|
494
|
-
error = `local source directory ${checkout} is missing`;
|
|
495
|
-
}
|
|
496
|
-
else if (kind === 'archive') {
|
|
497
|
-
cloned = await isDirectory(checkout);
|
|
498
|
-
lockCommit = this.headCache.get(source.id);
|
|
499
|
-
}
|
|
500
|
-
else if (inFlight !== undefined) {
|
|
501
|
-
// A mutation owns this source right now: do not race git against its checkout.
|
|
502
|
-
cloned = inFlight.cloned || (await isDirectory(checkout));
|
|
503
|
-
lockCommit = inFlight.head;
|
|
504
|
-
}
|
|
505
|
-
else {
|
|
506
|
-
const cachedHead = this.headCache.get(source.id);
|
|
507
|
-
if (cachedHead !== undefined && (await isDirectory(checkout))) {
|
|
508
|
-
cloned = true;
|
|
509
|
-
lockCommit = cachedHead;
|
|
510
|
-
}
|
|
511
|
-
else {
|
|
512
|
-
try {
|
|
513
|
-
lockCommit = await gitHead(checkout);
|
|
514
|
-
cloned = true;
|
|
515
|
-
this.headCache.set(source.id, lockCommit);
|
|
516
|
-
}
|
|
517
|
-
catch {
|
|
518
|
-
// Not cloned yet or a broken checkout; refresh reports the actionable error.
|
|
519
|
-
}
|
|
520
|
-
}
|
|
521
|
-
}
|
|
522
|
-
const sourceSuites = snapshot.suites.filter(suite => suite.sourceId === source.id);
|
|
523
|
-
const scanNotes = snapshot.scanNotes?.[source.id];
|
|
524
|
-
sourceRows.push({
|
|
525
|
-
id: source.id,
|
|
526
|
-
url: source.url,
|
|
527
|
-
...(source.branch === undefined ? {} : { branch: source.branch }),
|
|
528
|
-
...(source.local === true ? { local: true } : {}),
|
|
529
|
-
kind,
|
|
530
|
-
...(source.adopted === true ? { adopted: true } : {}),
|
|
531
|
-
cloned,
|
|
532
|
-
...(lockCommit === undefined ? {} : { lockCommit }),
|
|
533
|
-
...(error === undefined ? {} : { error }),
|
|
534
|
-
...(scanNotes === undefined ? {} : { scanNotes }),
|
|
535
|
-
suiteIds: sourceSuites.map(suite => suite.id)
|
|
536
|
-
});
|
|
537
|
-
}
|
|
538
|
-
const installed = new Set(Object.keys(this.state.installed));
|
|
539
|
-
const cards = snapshot.suites.map(suite => ({
|
|
540
|
-
sourceId: suite.sourceId,
|
|
541
|
-
suiteId: suite.id,
|
|
542
|
-
name: suite.manifest.name,
|
|
543
|
-
version: suite.manifest.version,
|
|
544
|
-
description: suite.manifest.description,
|
|
545
|
-
keywords: suite.manifest.keywords ?? [],
|
|
546
|
-
surfaces: suite.surfaces,
|
|
547
|
-
enabled: suite.enabled,
|
|
548
|
-
installed: installed.has(installKey(suite.sourceId, suite.id)),
|
|
549
|
-
...(installed.has(installKey(suite.sourceId, suite.id)) ? { surfaceToggles: suite.activeSurfaces } : {}),
|
|
550
|
-
...(suite.remote === undefined ? {} : { remoteUrl: suite.remote.url }),
|
|
551
|
-
dimension: suite.dimension,
|
|
552
|
-
layout: suite.manifest.layout,
|
|
553
|
-
errors: suite.errors,
|
|
554
|
-
mcpErrors: this.mcpDiagnostics.filter(diagnostic => diagnostic.suiteId === suite.id).map(diagnostic => `${diagnostic.serverKey}: ${diagnostic.reason}`)
|
|
555
|
-
}));
|
|
556
|
-
return {
|
|
557
|
-
sources: sourceRows,
|
|
558
|
-
suites: cards,
|
|
559
|
-
totals: {
|
|
560
|
-
all: cards.length,
|
|
561
|
-
installed: cards.filter(card => card.installed).length,
|
|
562
|
-
enabled: cards.filter(card => card.enabled).length
|
|
563
|
-
},
|
|
564
|
-
roots: { user: this.options.userRoot, data: this.options.dataRoot },
|
|
565
|
-
unmanaged: await this.unmanagedSources()
|
|
566
|
-
};
|
|
567
|
-
}
|
|
568
|
-
/** Runtime discovery scans only sources containing an enabled install. No acquisition or network access. */
|
|
569
|
-
async enabledUserSuites() {
|
|
570
|
-
const enabledSources = new Set(Object.entries(this.state.installed)
|
|
571
|
-
.filter(([, entry]) => entry.enabled)
|
|
572
|
-
.map(([key]) => key.slice(0, key.indexOf('/'))));
|
|
573
|
-
const sources = this.state.sources.filter(source => enabledSources.has(source.id));
|
|
574
|
-
const suites = sources.length === 0 ? [] : (await this.buildSnapshot({ ...this.state, sources }, 'user', this.options.userRoot)).enabledSuites;
|
|
575
|
-
const direct = await loadUserMcpSuite(this.options.dataRoot);
|
|
576
|
-
return applyLspOverrides(this.options.dataRoot, Object.keys(direct.mcp.servers).length === 0 ? suites : [...suites, direct]);
|
|
577
|
-
}
|
|
578
|
-
/** Enabled user- and project-dimension suites for a workspace cwd. */
|
|
579
|
-
async enabledSuitesForCwd(cwd) {
|
|
580
|
-
const [user, project] = await Promise.all([this.enabledUserSuites(), this.readProjectCatalog(cwd)]);
|
|
581
|
-
return { user, project: project.enabledSuites };
|
|
582
|
-
}
|
|
583
|
-
/** All suites of one dimension. */
|
|
584
|
-
async suitesForDimension(dimension, dimensionRoot) {
|
|
585
|
-
const state = dimension === 'user' && dimensionRoot === this.options.userRoot ? this.state : await loadState(join(dimensionRoot, STATE_FILE_NAME));
|
|
586
|
-
return (await this.buildSnapshot(state, dimension, dimensionRoot)).suites;
|
|
587
|
-
}
|
|
588
|
-
/**
|
|
589
|
-
* Add a source and acquire it immediately (clone, download, or in-place).
|
|
590
|
-
*
|
|
591
|
-
* Git sources with an already-present checkout whose `origin` matches the
|
|
592
|
-
* input URL are adopted in place — the manual-clone repair path — so no
|
|
593
|
-
* second clone is made and no `-2` suffixed id is invented. Adopted
|
|
594
|
-
* checkouts are never deleted on source removal.
|
|
595
|
-
*/
|
|
162
|
+
// ---- Source acquisition and CRUD ----
|
|
163
|
+
/** Add a source and acquire it immediately (clone, download, or in-place). */
|
|
596
164
|
async addSource(input) {
|
|
597
|
-
return this.
|
|
598
|
-
const kind = input.local === true ? 'local' : resolveSourceKind({ url: input.url, kind: input.kind });
|
|
599
|
-
if (kind === 'git') {
|
|
600
|
-
// Adoption first: a pre-existing checkout with a matching origin URL
|
|
601
|
-
// is registered as-is, skipping the clone entirely.
|
|
602
|
-
const adoptable = await this.findAdoptableCheckout(input.url, deriveSourceIdCandidates(input.url));
|
|
603
|
-
if (adoptable !== undefined) {
|
|
604
|
-
const source = {
|
|
605
|
-
id: adoptable,
|
|
606
|
-
url: input.url,
|
|
607
|
-
...(input.branch === undefined ? {} : { branch: input.branch }),
|
|
608
|
-
kind: 'git',
|
|
609
|
-
adopted: true
|
|
610
|
-
};
|
|
611
|
-
const head = await tryHead(sourceCheckoutDir(this.options.userRoot, adoptable));
|
|
612
|
-
if (head !== undefined)
|
|
613
|
-
this.headCache.set(source.id, head);
|
|
614
|
-
this.state = { ...this.state, sources: [...this.state.sources, source] };
|
|
615
|
-
await saveState(this.statePath, this.state);
|
|
616
|
-
await this.notifyChanged();
|
|
617
|
-
return source;
|
|
618
|
-
}
|
|
619
|
-
}
|
|
620
|
-
const baseId = await this.pickSourceId(deriveSourceIdCandidates(input.url), kind === 'local');
|
|
621
|
-
const source = {
|
|
622
|
-
id: baseId,
|
|
623
|
-
url: input.url,
|
|
624
|
-
...(input.branch === undefined ? {} : { branch: input.branch }),
|
|
625
|
-
...(kind === 'local' ? { local: true } : { kind }),
|
|
626
|
-
...(input.kind === 'archive' && input.sha256 !== undefined && input.sha256 !== '' ? { sha256: input.sha256 } : {})
|
|
627
|
-
};
|
|
628
|
-
let checkout = this.sourceCheckoutPath(source);
|
|
629
|
-
if (kind === 'local') {
|
|
630
|
-
if (!(await isDirectory(checkout)))
|
|
631
|
-
throw new Error(`local source directory ${checkout} is missing`);
|
|
632
|
-
}
|
|
633
|
-
else {
|
|
634
|
-
this.beginSourceState(baseId, kind === 'archive' ? 'downloading' : 'cloning', false);
|
|
635
|
-
try {
|
|
636
|
-
const lock = await this.acquire(source);
|
|
637
|
-
if (lock !== undefined)
|
|
638
|
-
this.headCache.set(source.id, lock);
|
|
639
|
-
}
|
|
640
|
-
catch (error) {
|
|
641
|
-
this.endSourceState();
|
|
642
|
-
throw error;
|
|
643
|
-
}
|
|
644
|
-
this.updateSourceStep('reading');
|
|
645
|
-
}
|
|
646
|
-
// The repo's own manifest names the source; move the checkout along so
|
|
647
|
-
// the registered id and its `.sources/` directory stay coherent. URL
|
|
648
|
-
// derived candidates stay as readable fallbacks before numeric suffixes.
|
|
649
|
-
const named = [...new Set([sanitizeId(await repoName(checkout)), ...deriveSourceIdCandidates(input.url)])];
|
|
650
|
-
const finalId = await this.pickSourceId(named, kind === 'local', kind === 'local' ? undefined : checkout);
|
|
651
|
-
if (kind !== 'local' && finalId !== baseId) {
|
|
652
|
-
const targetDir = sourceCheckoutDir(this.options.userRoot, finalId);
|
|
653
|
-
await rename(checkout, targetDir);
|
|
654
|
-
checkout = targetDir;
|
|
655
|
-
}
|
|
656
|
-
source.id = finalId;
|
|
657
|
-
if (resolveSourceKind(source) === 'git') {
|
|
658
|
-
const head = await tryHead(checkout);
|
|
659
|
-
if (head !== undefined)
|
|
660
|
-
this.headCache.set(source.id, head);
|
|
661
|
-
}
|
|
662
|
-
this.endSourceState();
|
|
663
|
-
this.state = { ...this.state, sources: [...this.state.sources, source] };
|
|
664
|
-
await saveState(this.statePath, this.state);
|
|
665
|
-
await this.notifyChanged();
|
|
666
|
-
return source;
|
|
667
|
-
});
|
|
668
|
-
}
|
|
669
|
-
/**
|
|
670
|
-
* First candidate id whose `.sources/` checkout already exists and whose
|
|
671
|
-
* `origin` remote matches the input URL (canonical-form equality) — the
|
|
672
|
-
* signature of a manual clone of the very same repository. Registered ids
|
|
673
|
-
* and checkouts of differently-origined directories never adopt.
|
|
674
|
-
*/
|
|
675
|
-
async findAdoptableCheckout(url, candidates) {
|
|
676
|
-
const registered = new Set(this.state.sources.map(source => source.id));
|
|
677
|
-
for (const candidate of candidates) {
|
|
678
|
-
if (registered.has(candidate))
|
|
679
|
-
continue;
|
|
680
|
-
const dir = sourceCheckoutDir(this.options.userRoot, candidate);
|
|
681
|
-
if (!(await isDirectory(dir)))
|
|
682
|
-
continue;
|
|
683
|
-
let origin;
|
|
684
|
-
try {
|
|
685
|
-
origin = await gitRemoteUrl(dir);
|
|
686
|
-
}
|
|
687
|
-
catch {
|
|
688
|
-
continue;
|
|
689
|
-
}
|
|
690
|
-
if (origin !== '' && canonicalGitUrl(origin) === canonicalGitUrl(url))
|
|
691
|
-
return candidate;
|
|
692
|
-
}
|
|
693
|
-
return undefined;
|
|
165
|
+
return this.sourceStore.add(input);
|
|
694
166
|
}
|
|
695
167
|
/** Register one unmanaged `.sources/` checkout as a source without touching its files. */
|
|
696
168
|
async adoptSource(id) {
|
|
697
|
-
return this.
|
|
698
|
-
if (this.state.sources.some(source => source.id === id))
|
|
699
|
-
throw new Error(`source "${id}" is already registered`);
|
|
700
|
-
// The id becomes one path segment under `.sources/`: only flat safe
|
|
701
|
-
// names qualify. Anything else (separators, `..`, leading dots) could
|
|
702
|
-
// traverse out of the checkouts root and register an outside directory.
|
|
703
|
-
if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(id) || id.includes('..')) {
|
|
704
|
-
throw new Error(`invalid checkout id "${id}" — use letters, digits, dots, dashes, or underscores`);
|
|
705
|
-
}
|
|
706
|
-
const dir = sourceCheckoutDir(this.options.userRoot, id);
|
|
707
|
-
if (!(await isDirectory(dir)))
|
|
708
|
-
throw new Error(`no checkout directory at ${dir}`);
|
|
709
|
-
let source;
|
|
710
|
-
try {
|
|
711
|
-
const origin = await gitRemoteUrl(dir);
|
|
712
|
-
source = { id, url: origin, kind: 'git', adopted: true };
|
|
713
|
-
const head = await tryHead(dir);
|
|
714
|
-
if (head !== undefined)
|
|
715
|
-
this.headCache.set(id, head);
|
|
716
|
-
}
|
|
717
|
-
catch {
|
|
718
|
-
// Not a git checkout: read it in place, like a local source.
|
|
719
|
-
source = { id, url: dir, local: true, adopted: true };
|
|
720
|
-
}
|
|
721
|
-
this.state = { ...this.state, sources: [...this.state.sources, source] };
|
|
722
|
-
await saveState(this.statePath, this.state);
|
|
723
|
-
await this.notifyChanged();
|
|
724
|
-
return source;
|
|
725
|
-
});
|
|
169
|
+
return this.sourceStore.adopt(id);
|
|
726
170
|
}
|
|
727
171
|
/** Unmanaged `.sources/` checkouts: present on disk, absent from state. */
|
|
728
172
|
async unmanagedSources() {
|
|
729
|
-
|
|
730
|
-
let entries;
|
|
731
|
-
try {
|
|
732
|
-
entries = await readdir(checkoutRoot, { withFileTypes: true });
|
|
733
|
-
}
|
|
734
|
-
catch {
|
|
735
|
-
return [];
|
|
736
|
-
}
|
|
737
|
-
const registered = new Set(this.state.sources.map(source => source.id));
|
|
738
|
-
const unmanaged = [];
|
|
739
|
-
for (const entry of entries) {
|
|
740
|
-
if (!entry.isDirectory() || entry.name.startsWith('.') || registered.has(entry.name))
|
|
741
|
-
continue;
|
|
742
|
-
const dir = join(checkoutRoot, entry.name);
|
|
743
|
-
try {
|
|
744
|
-
unmanaged.push({ id: entry.name, url: await gitRemoteUrl(dir) });
|
|
745
|
-
}
|
|
746
|
-
catch {
|
|
747
|
-
unmanaged.push({ id: entry.name });
|
|
748
|
-
}
|
|
749
|
-
}
|
|
750
|
-
return unmanaged;
|
|
173
|
+
return this.sourceStore.unmanaged();
|
|
751
174
|
}
|
|
752
|
-
/**
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
beginSourceState(sourceId, step, cloned) {
|
|
756
|
-
this.currentSourceState = { sourceId, step, cloned };
|
|
175
|
+
/** Update one source's URL, branch, kind, or archive digest. */
|
|
176
|
+
async updateSource(sourceId, patch) {
|
|
177
|
+
await this.sourceStore.update(sourceId, patch);
|
|
757
178
|
}
|
|
758
|
-
/**
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
this.currentSourceState = { ...this.currentSourceState, step };
|
|
179
|
+
/** Remove a source and its install entries; `deleteCheckout` also deletes the managed checkout. */
|
|
180
|
+
async removeSource(sourceId, deleteCheckout = false) {
|
|
181
|
+
await this.sourceStore.remove(sourceId, deleteCheckout);
|
|
762
182
|
}
|
|
763
|
-
/**
|
|
764
|
-
|
|
765
|
-
this.
|
|
183
|
+
/** Refresh one source checkout, or every source when sourceId is omitted. */
|
|
184
|
+
async refreshSource(sourceId) {
|
|
185
|
+
await this.sourceStore.refresh(sourceId);
|
|
766
186
|
}
|
|
767
187
|
/** Progress snapshot for the progress route. */
|
|
768
188
|
sourceProgress() {
|
|
769
|
-
|
|
770
|
-
return state === undefined ? { active: false, sourceId: '', step: '' } : { active: true, sourceId: state.sourceId, step: state.step };
|
|
771
|
-
}
|
|
772
|
-
/**
|
|
773
|
-
* First candidate id not claimed by a registered source; for git candidates
|
|
774
|
-
* the checkout directory must also be free, so a stale or foreign directory
|
|
775
|
-
* under `.sources/` is never cloned over (it would fail `git clone` with a
|
|
776
|
-
* confusing "destination exists" error). `ownedCheckout` marks the directory
|
|
777
|
-
* the in-flight mutation already controls, keeping a rename onto it legal.
|
|
778
|
-
* Local sources skip the disk check: they read their own path in place and
|
|
779
|
-
* never occupy `.sources/`.
|
|
780
|
-
*/
|
|
781
|
-
async pickSourceId(candidates, skipDisk, ownedCheckout) {
|
|
782
|
-
const free = async (id) => {
|
|
783
|
-
if (this.state.sources.some(source => source.id === id))
|
|
784
|
-
return false;
|
|
785
|
-
if (skipDisk)
|
|
786
|
-
return true;
|
|
787
|
-
const dir = sourceCheckoutDir(this.options.userRoot, id);
|
|
788
|
-
return dir === ownedCheckout || !(await pathExists(dir));
|
|
789
|
-
};
|
|
790
|
-
for (const candidate of candidates) {
|
|
791
|
-
if (await free(candidate))
|
|
792
|
-
return candidate;
|
|
793
|
-
}
|
|
794
|
-
for (let suffix = 2;; suffix++) {
|
|
795
|
-
const candidate = `${candidates[0]}-${suffix}`;
|
|
796
|
-
if (await free(candidate))
|
|
797
|
-
return candidate;
|
|
798
|
-
}
|
|
189
|
+
return this.sourceStore.progress();
|
|
799
190
|
}
|
|
800
|
-
/**
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
const index = this.state.sources.findIndex(source => source.id === sourceId);
|
|
804
|
-
if (index === -1)
|
|
805
|
-
throw new Error(`unknown source "${sourceId}"`);
|
|
806
|
-
const current = this.state.sources[index];
|
|
807
|
-
const nextLocal = patch.local !== undefined ? patch.local : current.local === true;
|
|
808
|
-
// A local source carries the legacy flag only; `kind` stays unwritten
|
|
809
|
-
// so the persisted shape matches the pre-kind records.
|
|
810
|
-
const nextKind = nextLocal ? undefined : (patch.kind ?? current.kind);
|
|
811
|
-
const next = {
|
|
812
|
-
id: sourceId,
|
|
813
|
-
url: patch.url ?? current.url,
|
|
814
|
-
...(patch.branch !== undefined ? { branch: patch.branch } : current.branch === undefined ? {} : { branch: current.branch }),
|
|
815
|
-
...(nextLocal ? { local: true } : {}),
|
|
816
|
-
...(nextKind === undefined ? {} : { kind: nextKind }),
|
|
817
|
-
...(patch.sha256 !== undefined ? (patch.sha256 === '' ? {} : { sha256: patch.sha256 }) : current.sha256 === undefined ? {} : { sha256: current.sha256 }),
|
|
818
|
-
...(current.adopted === true ? { adopted: true } : {})
|
|
819
|
-
};
|
|
820
|
-
const urlChanged = patch.url !== undefined && patch.url !== current.url;
|
|
821
|
-
// A URL change invalidates the old checkout — except for adopted (user
|
|
822
|
-
// cloned) and local sources, whose directories we never own.
|
|
823
|
-
if (urlChanged && current.local !== true && current.adopted !== true) {
|
|
824
|
-
this.headCache.delete(sourceId);
|
|
825
|
-
await gitRemove(sourceCheckoutDir(this.options.userRoot, sourceId));
|
|
826
|
-
}
|
|
827
|
-
this.state = { ...this.state, sources: this.state.sources.map((source, i) => (i === index ? next : source)) };
|
|
828
|
-
await saveState(this.statePath, this.state);
|
|
829
|
-
await this.notifyChanged();
|
|
830
|
-
});
|
|
191
|
+
/** Begin reporting progress for a source mutation. */
|
|
192
|
+
beginSourceState(sourceId, step, cloned) {
|
|
193
|
+
this.sourceStore.beginSourceState(sourceId, step, cloned);
|
|
831
194
|
}
|
|
832
|
-
/**
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
* `.sources/<id>` checkout — including an adopted one, since a directory
|
|
836
|
-
* under `.sources` is manager-owned storage. A `local` source whose URL
|
|
837
|
-
* points outside `.sources` is registration-only: its directory is never
|
|
838
|
-
* touched. Deleting the checkout is what keeps a removed source from
|
|
839
|
-
* reappearing as an unmanaged entry.
|
|
840
|
-
*/
|
|
841
|
-
async removeSource(sourceId, deleteCheckout = false) {
|
|
842
|
-
return this.enqueue(async () => {
|
|
843
|
-
const source = this.state.sources.find(entry => entry.id === sourceId);
|
|
844
|
-
this.state = {
|
|
845
|
-
...this.state,
|
|
846
|
-
sources: this.state.sources.filter(entry => entry.id !== sourceId),
|
|
847
|
-
installed: Object.fromEntries(Object.entries(this.state.installed).filter(([key]) => !key.startsWith(`${sourceId}/`)))
|
|
848
|
-
};
|
|
849
|
-
await saveState(this.statePath, this.state);
|
|
850
|
-
this.headCache.delete(sourceId);
|
|
851
|
-
// A checkout under `.sources` is manager-owned even when it was first
|
|
852
|
-
// discovered/adopted. Only an explicit local path points outside the
|
|
853
|
-
// manager's storage and must remain untouched.
|
|
854
|
-
const externalLocal = source?.local === true && source.url !== sourceCheckoutDir(this.options.userRoot, sourceId);
|
|
855
|
-
if (!externalLocal && deleteCheckout)
|
|
856
|
-
await gitRemove(sourceCheckoutDir(this.options.userRoot, sourceId));
|
|
857
|
-
await this.notifyChanged();
|
|
858
|
-
});
|
|
195
|
+
/** Advance the in-flight source mutation step. */
|
|
196
|
+
updateSourceStep(step) {
|
|
197
|
+
this.sourceStore.updateSourceStep(step);
|
|
859
198
|
}
|
|
860
|
-
/**
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
const targets = sourceId === undefined ? this.state.sources : this.state.sources.filter(source => source.id === sourceId);
|
|
864
|
-
for (const source of targets) {
|
|
865
|
-
const kind = resolveSourceKind(source);
|
|
866
|
-
if (kind === 'local') {
|
|
867
|
-
if (!(await isDirectory(expandHome(source.url))))
|
|
868
|
-
throw new Error(`local source directory ${expandHome(source.url)} is missing`);
|
|
869
|
-
continue;
|
|
870
|
-
}
|
|
871
|
-
const checkout = sourceCheckoutDir(this.options.userRoot, source.id);
|
|
872
|
-
this.headCache.delete(source.id);
|
|
873
|
-
if (kind === 'archive') {
|
|
874
|
-
// Re-download and swap; the fresh digest becomes the lock value.
|
|
875
|
-
const lock = await this.acquire(source);
|
|
876
|
-
if (lock !== undefined)
|
|
877
|
-
this.headCache.set(source.id, lock);
|
|
878
|
-
continue;
|
|
879
|
-
}
|
|
880
|
-
// Adopted checkouts are user-owned working trees: no `reset --hard`
|
|
881
|
-
// (it would destroy uncommitted work), and no re-acquire either —
|
|
882
|
-
// a broken HEAD means the user repairs the checkout, not that the
|
|
883
|
-
// manager may clone or extract over their directory.
|
|
884
|
-
if (source.adopted === true) {
|
|
885
|
-
try {
|
|
886
|
-
this.headCache.set(source.id, await gitHead(checkout));
|
|
887
|
-
}
|
|
888
|
-
catch {
|
|
889
|
-
// A failed HEAD read means the next overview re-probes.
|
|
890
|
-
}
|
|
891
|
-
continue;
|
|
892
|
-
}
|
|
893
|
-
try {
|
|
894
|
-
await gitHead(checkout);
|
|
895
|
-
}
|
|
896
|
-
catch {
|
|
897
|
-
const lock = await this.acquire(source);
|
|
898
|
-
if (lock !== undefined) {
|
|
899
|
-
this.headCache.set(source.id, lock);
|
|
900
|
-
}
|
|
901
|
-
else {
|
|
902
|
-
try {
|
|
903
|
-
this.headCache.set(source.id, await gitHead(checkout));
|
|
904
|
-
}
|
|
905
|
-
catch {
|
|
906
|
-
// A failed HEAD read means the next overview re-probes.
|
|
907
|
-
}
|
|
908
|
-
}
|
|
909
|
-
continue;
|
|
910
|
-
}
|
|
911
|
-
// Shallow-friendly sync: fetch depth 1 into FETCH_HEAD, hard reset.
|
|
912
|
-
const branch = source.branch ?? (await gitCurrentBranch(checkout).catch(() => undefined));
|
|
913
|
-
// Keep origin on the region's route: a clone made before a region
|
|
914
|
-
// switch (or an explicit flip) re-points origin so the fetch follows.
|
|
915
|
-
// Only origins this plugin itself wrote (the direct or proxied
|
|
916
|
-
// source.url) are re-pointed — a foreign remote is left untouched.
|
|
917
|
-
if (source.kind === 'git' || (source.kind === undefined && source.local !== true)) {
|
|
918
|
-
try {
|
|
919
|
-
const region = resolveRegion(await this.downloadRegionProvider(), await readLocalePreference());
|
|
920
|
-
const routed = githubCloneUrl(region, source.url);
|
|
921
|
-
const origin = await gitRemoteUrl(checkout);
|
|
922
|
-
if (origin !== routed && (origin === source.url || origin === githubCloneUrl('china', source.url))) {
|
|
923
|
-
await gitSetRemoteUrl(checkout, routed);
|
|
924
|
-
}
|
|
925
|
-
}
|
|
926
|
-
catch {
|
|
927
|
-
// Origin reconciliation is best effort; plain sync still runs.
|
|
928
|
-
}
|
|
929
|
-
}
|
|
930
|
-
await gitSync(checkout, branch, this.options.git ?? {});
|
|
931
|
-
try {
|
|
932
|
-
this.headCache.set(source.id, await gitHead(checkout));
|
|
933
|
-
}
|
|
934
|
-
catch {
|
|
935
|
-
// A failed HEAD read means the next overview re-probes.
|
|
936
|
-
}
|
|
937
|
-
}
|
|
938
|
-
await this.notifyChanged();
|
|
939
|
-
});
|
|
199
|
+
/** Stop reporting source mutation progress. */
|
|
200
|
+
endSourceState() {
|
|
201
|
+
this.sourceStore.endSourceState();
|
|
940
202
|
}
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
* dialog before this runs; disabling afterwards is always available. */
|
|
203
|
+
// ---- Install state ----
|
|
204
|
+
/** Install a suite from a source and enable it. */
|
|
944
205
|
async install(sourceId, suiteId) {
|
|
945
|
-
|
|
946
|
-
const source = this.state.sources.find(entry => entry.id === sourceId);
|
|
947
|
-
if (source === undefined)
|
|
948
|
-
throw new Error(`unknown source "${sourceId}"`);
|
|
949
|
-
const checkout = this.sourceCheckoutPath(source);
|
|
950
|
-
let freshLock;
|
|
951
|
-
if (!(await isDirectory(checkout)))
|
|
952
|
-
freshLock = await this.acquire(source);
|
|
953
|
-
const suites = await discoverSuitesInSource(checkout, sourceId, 'user', source.url);
|
|
954
|
-
const suite = suites.find(entry => entry.id === suiteId);
|
|
955
|
-
if (suite === undefined)
|
|
956
|
-
throw new Error(`suite "${suiteId}" not found in source "${sourceId}"`);
|
|
957
|
-
if (suite.remote !== undefined)
|
|
958
|
-
throw new Error(`suite "${suiteId}" is a remote reference (${suite.remote.url}); add its repository as a source before installing`);
|
|
959
|
-
// Archive (or tarball-fallback) sources have no git HEAD; their digest
|
|
960
|
-
// is the lock value.
|
|
961
|
-
const lockCommit = (await tryHead(checkout)) ?? freshLock ?? this.headCache.get(sourceId);
|
|
962
|
-
await this.setInstalled(sourceId, suiteId, { enabled: true, installedAt: new Date().toISOString(), lockCommit });
|
|
963
|
-
await this.notifyChanged(true);
|
|
964
|
-
});
|
|
206
|
+
await this.installs.install(sourceId, suiteId);
|
|
965
207
|
}
|
|
966
208
|
/** Uninstall a suite while retaining the source checkout. */
|
|
967
209
|
async uninstall(sourceId, suiteId) {
|
|
968
|
-
|
|
969
|
-
const key = installKey(sourceId, suiteId);
|
|
970
|
-
if (this.state.installed[key] === undefined)
|
|
971
|
-
throw new Error(`suite "${suiteId}" is not installed`);
|
|
972
|
-
const rest = Object.fromEntries(Object.entries(this.state.installed).filter(([entryKey]) => entryKey !== key));
|
|
973
|
-
this.state = { ...this.state, installed: rest };
|
|
974
|
-
await saveState(this.statePath, this.state);
|
|
975
|
-
await this.notifyChanged(true);
|
|
976
|
-
});
|
|
210
|
+
await this.installs.uninstall(sourceId, suiteId);
|
|
977
211
|
}
|
|
978
212
|
/** Enable or disable an installed suite. */
|
|
979
213
|
async setEnabled(sourceId, suiteId, enabled) {
|
|
980
|
-
|
|
981
|
-
const key = installKey(sourceId, suiteId);
|
|
982
|
-
const entry = this.state.installed[key];
|
|
983
|
-
if (entry === undefined)
|
|
984
|
-
throw new Error(`suite "${suiteId}" is not installed`);
|
|
985
|
-
await this.setInstalled(sourceId, suiteId, { ...entry, enabled });
|
|
986
|
-
await this.notifyChanged(true);
|
|
987
|
-
});
|
|
214
|
+
await this.installs.setEnabled(sourceId, suiteId, enabled);
|
|
988
215
|
}
|
|
989
216
|
/** Enable or disable one runtime surface of an installed suite. */
|
|
990
217
|
async setSurface(sourceId, suiteId, surface, enabled) {
|
|
991
|
-
|
|
992
|
-
const key = installKey(sourceId, suiteId);
|
|
993
|
-
const entry = this.state.installed[key];
|
|
994
|
-
if (entry === undefined)
|
|
995
|
-
throw new Error(`suite "${suiteId}" is not installed`);
|
|
996
|
-
if (!SUITE_SURFACE_KEYS.includes(surface))
|
|
997
|
-
throw new Error(`surface "${surface}" is not toggleable`);
|
|
998
|
-
const surfaces = { ...(entry.surfaces ?? {}), [surface]: enabled };
|
|
999
|
-
await this.setInstalled(sourceId, suiteId, { ...entry, surfaces });
|
|
1000
|
-
await this.notifyChanged(true);
|
|
1001
|
-
});
|
|
218
|
+
await this.installs.setSurface(sourceId, suiteId, surface, enabled);
|
|
1002
219
|
}
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
if (additions.length === 0)
|
|
1008
|
-
return;
|
|
1009
|
-
this.state = { ...this.state, sources: [...this.state.sources, ...additions] };
|
|
1010
|
-
await saveState(this.statePath, this.state);
|
|
1011
|
-
// New sources change the fingerprint, but their content arrives through
|
|
1012
|
-
// an acquire, so mark the scan cache dirty conservatively.
|
|
1013
|
-
this.invalidateScans();
|
|
1014
|
-
this.invalidateSnapshot(true);
|
|
220
|
+
// ---- MCP ----
|
|
221
|
+
/** Build the flat MCP service inventory for the status surface. */
|
|
222
|
+
async mcpStatus() {
|
|
223
|
+
return this.mcp.status();
|
|
1015
224
|
}
|
|
1016
|
-
/**
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
* only runs the change pipeline so the runtime remounts commands and the
|
|
1020
|
-
* skill providers re-read their catalogs.
|
|
1021
|
-
*/
|
|
1022
|
-
async notifyPanelsChanged() {
|
|
1023
|
-
await this.notifyChanged();
|
|
225
|
+
/** Persist a user-owned MCP service and reconcile its bridge mount. */
|
|
226
|
+
async addMcpServer(name, server) {
|
|
227
|
+
await this.mcp.addServer(name, server);
|
|
1024
228
|
}
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
229
|
+
/** Read effective service configuration without exposing credential literals. */
|
|
230
|
+
async serverConfig(kind, id) {
|
|
231
|
+
return this.mcp.serverConfig(kind, id);
|
|
232
|
+
}
|
|
233
|
+
/** Validate a complete replacement before writing; plugin checkouts remain untouched. */
|
|
234
|
+
async saveServerConfig(kind, id, config) {
|
|
235
|
+
await this.mcp.saveServerConfig(kind, id, config);
|
|
236
|
+
}
|
|
237
|
+
/** One suite's persisted MCP overrides, addressed by qualified suite id. */
|
|
238
|
+
async mcpOverrides(sourceId, suiteId) {
|
|
239
|
+
return this.mcp.overrides(sourceId, suiteId);
|
|
240
|
+
}
|
|
241
|
+
/** Set or clear one server's MCP override and remount it. */
|
|
242
|
+
async setMcpOverride(sourceId, suiteId, serverKey, override) {
|
|
243
|
+
await this.mcp.setOverride(sourceId, suiteId, serverKey, override);
|
|
1028
244
|
}
|
|
1029
245
|
/**
|
|
1030
|
-
*
|
|
1031
|
-
*
|
|
1032
|
-
*
|
|
1033
|
-
*
|
|
246
|
+
* Enable or disable one declared MCP server, addressed by the same
|
|
247
|
+
* source-qualified suite id its status row carries. Disabling writes an
|
|
248
|
+
* override rather than editing the suite's `mcp.json`, so a refresh cannot
|
|
249
|
+
* clobber the user's choice.
|
|
1034
250
|
*/
|
|
1035
|
-
async
|
|
1036
|
-
|
|
1037
|
-
if (kind === 'local') {
|
|
1038
|
-
if (!(await isDirectory(expandHome(source.url))))
|
|
1039
|
-
throw new Error(`local source directory ${expandHome(source.url)} is missing`);
|
|
1040
|
-
return undefined;
|
|
1041
|
-
}
|
|
1042
|
-
const checkout = sourceCheckoutDir(this.options.userRoot, source.id);
|
|
1043
|
-
await mkdir(sourcesDir(this.options.userRoot), { recursive: true });
|
|
1044
|
-
if (kind === 'archive') {
|
|
1045
|
-
const { sha256 } = await archiveInstall(source.url, checkout, {
|
|
1046
|
-
...(source.sha256 === undefined ? {} : { sha256: source.sha256 }),
|
|
1047
|
-
...(this.options.git?.timeoutMs === undefined ? {} : { timeoutMs: this.options.git.timeoutMs }),
|
|
1048
|
-
...(this.options.git?.allowHttpArchives === true ? { allowHttp: true } : {})
|
|
1049
|
-
});
|
|
1050
|
-
return sha256;
|
|
1051
|
-
}
|
|
1052
|
-
try {
|
|
1053
|
-
// The region routes github.com clones through the China mirror prefix;
|
|
1054
|
-
// the proxied URL becomes `origin`, so refreshes follow the same route.
|
|
1055
|
-
const region = resolveRegion(await this.downloadRegionProvider(), await readLocalePreference());
|
|
1056
|
-
await gitClone(githubCloneUrl(region, source.url), source.branch, checkout, this.options.git ?? {});
|
|
1057
|
-
return undefined;
|
|
1058
|
-
}
|
|
1059
|
-
catch (error) {
|
|
1060
|
-
if (this.options.git?.fallbackTarball !== true)
|
|
1061
|
-
throw error;
|
|
1062
|
-
const tarballUrl = codeloadTarballUrl(source.url, source.branch);
|
|
1063
|
-
if (tarballUrl === undefined)
|
|
1064
|
-
throw error;
|
|
1065
|
-
// The codeload URL ends in a branch path, not `.tar.gz`, so the format
|
|
1066
|
-
// must be stated explicitly — extension detection cannot see it.
|
|
1067
|
-
const { sha256 } = await archiveInstall(tarballUrl, checkout, {
|
|
1068
|
-
...(this.options.git?.timeoutMs === undefined ? {} : { timeoutMs: this.options.git.timeoutMs }),
|
|
1069
|
-
format: 'targz'
|
|
1070
|
-
});
|
|
1071
|
-
return sha256;
|
|
1072
|
-
}
|
|
251
|
+
async setMcpServerEnabled(suiteKey, serverKey, enabled) {
|
|
252
|
+
await this.mcp.setServerEnabled(suiteKey, serverKey, enabled);
|
|
1073
253
|
}
|
|
1074
|
-
/**
|
|
1075
|
-
|
|
1076
|
-
|
|
254
|
+
/** Re-run the MCP reconcile pass without changing any catalog state. */
|
|
255
|
+
async retryMounts() {
|
|
256
|
+
await this.mcp.retryMounts();
|
|
1077
257
|
}
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
// The user snapshot's TTL also bounds scan reuse: a snapshot rebuild that
|
|
1082
|
-
// replays a 30s scan cache would silently outlive its own staleness bound.
|
|
1083
|
-
const scanCacheTtl = dimension === 'user' ? Math.min(this.userSnapshotTtlMs, Catalog.SCAN_CACHE_TTL_MS) : Catalog.SCAN_CACHE_TTL_MS;
|
|
1084
|
-
const cacheFresh = !skipScanCache && cached !== undefined && Date.now() - cached.at < scanCacheTtl;
|
|
1085
|
-
let discovered;
|
|
1086
|
-
let scanNotes;
|
|
1087
|
-
if (cacheFresh && cached !== undefined) {
|
|
1088
|
-
discovered = cached.discovered;
|
|
1089
|
-
scanNotes = cached.scanNotes;
|
|
1090
|
-
}
|
|
1091
|
-
else {
|
|
1092
|
-
const generation = this.scanGeneration;
|
|
1093
|
-
let pending = this.scanPromises.get(fingerprint);
|
|
1094
|
-
if (pending === undefined) {
|
|
1095
|
-
pending = discoverSourceListWithNotes(state.sources, dimension, dimensionRoot, this.scanProjectLayouts);
|
|
1096
|
-
this.scanPromises.set(fingerprint, pending);
|
|
1097
|
-
}
|
|
1098
|
-
let result;
|
|
1099
|
-
try {
|
|
1100
|
-
result = await pending;
|
|
1101
|
-
}
|
|
1102
|
-
finally {
|
|
1103
|
-
if (this.scanPromises.get(fingerprint) === pending)
|
|
1104
|
-
this.scanPromises.delete(fingerprint);
|
|
1105
|
-
}
|
|
1106
|
-
discovered = result.suites;
|
|
1107
|
-
scanNotes = result.scanNotes;
|
|
1108
|
-
// A scan started before a content mutation must not repopulate its cache.
|
|
1109
|
-
if (generation === this.scanGeneration) {
|
|
1110
|
-
if (this.scanCache.size >= Catalog.SCAN_CACHE_MAX_ENTRIES)
|
|
1111
|
-
this.scanCache.clear();
|
|
1112
|
-
this.scanCache.set(fingerprint, { at: Date.now(), discovered, scanNotes });
|
|
1113
|
-
}
|
|
1114
|
-
}
|
|
1115
|
-
const suites = discovered.map(suite => {
|
|
1116
|
-
const installed = state.installed[installKey(suite.sourceId, suite.id)];
|
|
1117
|
-
// Native project layouts (`.claude/`, `.agents/`) are the repository's
|
|
1118
|
-
// own files read in place: they carry no install state and stay enabled.
|
|
1119
|
-
const enabled = suite.manifest.layout === 'project-native' || installed?.enabled === true;
|
|
1120
|
-
return {
|
|
1121
|
-
...suite,
|
|
1122
|
-
enabled,
|
|
1123
|
-
activeSurfaces: {
|
|
1124
|
-
...effectiveSurfaces(installed?.surfaces),
|
|
1125
|
-
...(suite.manifest.layout === 'project-native' ? suite.activeSurfaces : {}),
|
|
1126
|
-
...(dimension === 'project' ? { lsp: false } : {})
|
|
1127
|
-
},
|
|
1128
|
-
...(installed?.lockCommit === undefined ? {} : { lockCommit: installed.lockCommit }),
|
|
1129
|
-
...(installed?.installedAt === undefined ? {} : { installedAt: installed.installedAt })
|
|
1130
|
-
};
|
|
1131
|
-
});
|
|
1132
|
-
return {
|
|
1133
|
-
revision: this.revision,
|
|
1134
|
-
sources: [...state.sources],
|
|
1135
|
-
suites,
|
|
1136
|
-
enabledSuites: suites.filter(suite => suite.enabled),
|
|
1137
|
-
...(Object.keys(scanNotes).length > 0 ? { scanNotes } : {})
|
|
1138
|
-
};
|
|
258
|
+
/** The persisted MCP backend choice without the host-client probe. */
|
|
259
|
+
async mcpBackend() {
|
|
260
|
+
return this.mcp.backend();
|
|
1139
261
|
}
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
this.
|
|
1146
|
-
this.projectSnapshots.clear();
|
|
1147
|
-
this.projectSnapshotPromises.clear();
|
|
262
|
+
/**
|
|
263
|
+
* The active MCP mount backend plus a live probe of the host client, and
|
|
264
|
+
* the download-region setting with its locale-resolved effective route.
|
|
265
|
+
*/
|
|
266
|
+
async mcpBackendInfo() {
|
|
267
|
+
return this.mcp.backendInfo();
|
|
1148
268
|
}
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
this.
|
|
1152
|
-
this.scanPromises.clear();
|
|
269
|
+
/** Switch the MCP mount backend and remount every suite server through it. */
|
|
270
|
+
async setMcpBackend(backend) {
|
|
271
|
+
await this.mcp.setBackend(backend);
|
|
1153
272
|
}
|
|
1154
273
|
/**
|
|
1155
|
-
*
|
|
1156
|
-
*
|
|
1157
|
-
*
|
|
1158
|
-
* re-derives from cached discovery instead of rescanning the filesystem.
|
|
274
|
+
* Drop one MCP server's OAuth grant record so the next mount re-runs the
|
|
275
|
+
* browser authorization.
|
|
276
|
+
* @throws when the credentials service is not mounted.
|
|
1159
277
|
*/
|
|
1160
|
-
async
|
|
1161
|
-
|
|
1162
|
-
this.invalidateScans();
|
|
1163
|
-
this.invalidateSnapshot(true);
|
|
1164
|
-
await this.options.onChanged();
|
|
1165
|
-
for (const listener of this.listeners)
|
|
1166
|
-
listener();
|
|
278
|
+
async reauthorizeMcpServer(serverName) {
|
|
279
|
+
await this.mcp.reauthorize(serverName);
|
|
1167
280
|
}
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
this.
|
|
1171
|
-
return result;
|
|
281
|
+
/** Whether the re-authorize action can run in this composition. */
|
|
282
|
+
mcpReauthorizeAvailable() {
|
|
283
|
+
return this.mcp.reauthorizeAvailable();
|
|
1172
284
|
}
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
}
|
|
1177
|
-
/** Read a checkout's HEAD when it is a Git repository. */
|
|
1178
|
-
async function tryHead(dir) {
|
|
1179
|
-
try {
|
|
1180
|
-
return await gitHead(dir);
|
|
285
|
+
/** MCP overrides for a supplied runtime selection, or the full market catalog when omitted. */
|
|
286
|
+
async allMcpOverrides(suites) {
|
|
287
|
+
return this.mcp.allOverrides(suites);
|
|
1181
288
|
}
|
|
1182
|
-
|
|
1183
|
-
|
|
289
|
+
// ---- LSP ----
|
|
290
|
+
/** The LSP status surface: declared servers merged with mount diagnostics. */
|
|
291
|
+
async lspStatus() {
|
|
292
|
+
return this.lsp.status();
|
|
293
|
+
}
|
|
294
|
+
/** The user's direct LSP server table (normalized specs). */
|
|
295
|
+
async lspServers() {
|
|
296
|
+
return this.lsp.servers();
|
|
297
|
+
}
|
|
298
|
+
/** Create one direct LSP declaration without replacing other user servers. */
|
|
299
|
+
async addLspServer(name, config) {
|
|
300
|
+
await this.lsp.addServer(name, config);
|
|
301
|
+
}
|
|
302
|
+
/** Validate and persist the user's direct LSP server table. */
|
|
303
|
+
async setLspServers(raw) {
|
|
304
|
+
return this.lsp.setServers(raw);
|
|
305
|
+
}
|
|
306
|
+
/** Enable or disable one declared language server by row id. */
|
|
307
|
+
async setLspServerEnabled(id, enabled) {
|
|
308
|
+
await this.lsp.setEnabled(id, enabled);
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Remove one profile's legacy LSP layer — the hand-written
|
|
312
|
+
* `cordis.patch.yml` rows an older release told users to add, which now
|
|
313
|
+
* register a seam this plugin owns.
|
|
314
|
+
*/
|
|
315
|
+
async migrateLegacyLspSeam(profile) {
|
|
316
|
+
return this.lsp.migrateLegacySeam(profile);
|
|
1184
317
|
}
|
|
1185
318
|
}
|
|
1186
|
-
|
|
1187
|
-
* The codeload tarball URL of a GitHub repository, or undefined for any
|
|
1188
|
-
* other host. `https://github.com/<owner>/<repo>` maps to
|
|
1189
|
-
* `https://codeload.github.com/<owner>/<repo>/tar.gz/refs/heads/<branch>`
|
|
1190
|
-
* (or `/tar.gz/HEAD` for the default branch) — the git-protocol fallback
|
|
1191
|
-
* when a clone cannot get through but plain HTTPS file download can.
|
|
1192
|
-
*/
|
|
1193
|
-
export function codeloadTarballUrl(url, branch) {
|
|
1194
|
-
const match = /^https:\/\/github\.com\/([\w.-]+)\/([\w.-]+?)(?:\.git)?\/?$/i.exec(url.trim());
|
|
1195
|
-
if (match === null)
|
|
1196
|
-
return undefined;
|
|
1197
|
-
const [, owner, repo] = match;
|
|
1198
|
-
return `https://codeload.github.com/${owner}/${repo}/tar.gz/${branch === undefined ? 'HEAD' : `refs/heads/${branch}`}`;
|
|
1199
|
-
}
|
|
319
|
+
export { codeloadTarballUrl };
|