dsh-agent-plugins-market 0.5.2 → 0.6.0
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 +29 -3
- package/README.zh.md +29 -3
- package/client/client.js +1388 -204
- package/client/client.js.map +1 -1
- package/lib/application/catalog.js +537 -71
- package/lib/application/details.js +39 -12
- package/lib/catalog/archive.js +341 -0
- package/lib/catalog/git.js +85 -12
- package/lib/catalog/legacy-root-migration.js +51 -0
- package/lib/catalog/lsp-spec.js +87 -0
- package/lib/catalog/manifests.js +84 -32
- package/lib/catalog/paths.js +65 -7
- package/lib/catalog/scan-pipeline.js +50 -0
- package/lib/catalog/scan-resolvers.js +402 -0
- package/lib/catalog/source-catalog.js +22 -8
- package/lib/catalog/suite-scanner.js +5 -176
- package/lib/catalog/surfaces.js +80 -30
- package/lib/contracts/lsp-status.js +2 -0
- package/lib/contracts/market.js +8 -1
- package/lib/index.js +118 -13
- package/lib/model/state.js +14 -1
- package/lib/model/types.js +21 -2
- package/lib/routes.js +88 -6
- package/lib/runtime/commands-mounts.js +6 -2
- package/lib/runtime/hooks-mounts.js +9 -6
- package/lib/runtime/lsp-direct-config.js +44 -0
- package/lib/runtime/lsp-mounts.js +236 -0
- package/lib/runtime/lsp-status.js +96 -0
- package/lib/runtime/mcp-backend.js +60 -0
- package/lib/runtime/mcp-client/bridge.js +126 -0
- package/lib/runtime/mcp-client/config.js +42 -0
- package/lib/runtime/mcp-client/connection.js +380 -0
- package/lib/runtime/mcp-client/host-seams.js +88 -0
- package/lib/runtime/mcp-client/json-schema-subset.js +181 -0
- package/lib/runtime/mcp-client/oauth.js +379 -0
- package/lib/runtime/mcp-client/tools.js +432 -0
- package/lib/runtime/mcp-client/transport.js +103 -0
- package/lib/runtime/mcp-config.js +196 -54
- package/lib/runtime/mcp-credentials.js +20 -0
- package/lib/runtime/mcp-mounts.js +237 -27
- package/lib/runtime/mcp-overrides.js +64 -6
- package/lib/runtime/mcp-redaction.js +92 -0
- package/lib/runtime/mcp-status.js +76 -32
- package/lib/runtime/reconciler.js +35 -1
- package/lib/runtime/regions.js +56 -0
- package/lib/types/application/catalog.d.ts +191 -14
- package/lib/types/application/details.d.ts +1 -6
- package/lib/types/application/queries.d.ts +28 -1
- package/lib/types/catalog/archive.d.ts +48 -0
- package/lib/types/catalog/git.d.ts +27 -3
- package/lib/types/catalog/legacy-root-migration.d.ts +14 -0
- package/lib/types/catalog/lsp-spec.d.ts +14 -0
- package/lib/types/catalog/manifests.d.ts +16 -1
- package/lib/types/catalog/paths.d.ts +23 -2
- package/lib/types/catalog/scan-pipeline.d.ts +76 -0
- package/lib/types/catalog/scan-resolvers.d.ts +81 -0
- package/lib/types/catalog/source-catalog.d.ts +5 -0
- package/lib/types/catalog/suite-scanner.d.ts +14 -7
- package/lib/types/catalog/surfaces.d.ts +3 -3
- package/lib/types/contracts/lsp-status.d.ts +53 -0
- package/lib/types/contracts/market.d.ts +55 -1
- package/lib/types/contracts/mcp-status.d.ts +14 -2
- package/lib/types/index.d.ts +24 -8
- package/lib/types/model/types.d.ts +52 -3
- package/lib/types/runtime/lsp-direct-config.d.ts +12 -0
- package/lib/types/runtime/lsp-mounts.d.ts +89 -0
- package/lib/types/runtime/lsp-status.d.ts +30 -0
- package/lib/types/runtime/mcp-backend.d.ts +49 -0
- package/lib/types/runtime/mcp-client/bridge.d.ts +37 -0
- package/lib/types/runtime/mcp-client/config.d.ts +123 -0
- package/lib/types/runtime/mcp-client/connection.d.ts +82 -0
- package/lib/types/runtime/mcp-client/host-seams.d.ts +89 -0
- package/lib/types/runtime/mcp-client/json-schema-subset.d.ts +58 -0
- package/lib/types/runtime/mcp-client/oauth.d.ts +121 -0
- package/lib/types/runtime/mcp-client/tools.d.ts +136 -0
- package/lib/types/runtime/mcp-client/transport.d.ts +36 -0
- package/lib/types/runtime/mcp-config.d.ts +29 -12
- package/lib/types/runtime/mcp-credentials.d.ts +9 -0
- package/lib/types/runtime/mcp-mounts.d.ts +56 -6
- package/lib/types/runtime/mcp-overrides.d.ts +23 -4
- package/lib/types/runtime/mcp-redaction.d.ts +7 -0
- package/lib/types/runtime/mcp-status.d.ts +4 -2
- package/lib/types/runtime/reconciler.d.ts +22 -4
- package/lib/types/runtime/regions.d.ts +37 -0
- package/package.json +15 -11
- package/schemas/1.0.0/mcp.schema.json +22 -0
package/lib/catalog/surfaces.js
CHANGED
|
@@ -9,29 +9,47 @@
|
|
|
9
9
|
* fail-closed: broken files produce a diagnostic and are skipped, never a
|
|
10
10
|
* thrown discovery.
|
|
11
11
|
*/
|
|
12
|
-
import { readFile, readdir, stat } from 'node:fs/promises';
|
|
13
|
-
import { join } from 'node:path';
|
|
12
|
+
import { readFile, readdir, realpath, stat } from 'node:fs/promises';
|
|
13
|
+
import { join, resolve } from 'node:path';
|
|
14
14
|
import { parseSkillFrontmatter } from './skills-parse.js';
|
|
15
15
|
import { isDirectory } from './paths.js';
|
|
16
16
|
import { validateMcpJson } from './validate.js';
|
|
17
17
|
import { declaredMcpServers } from './manifests.js';
|
|
18
18
|
const DOT_DIRS = new Set(['.git', '.github', '.claude', '.cursor', '.kimi', '.plugin', '.sources', 'node_modules']);
|
|
19
|
-
/**
|
|
20
|
-
|
|
19
|
+
/**
|
|
20
|
+
* Resolve a manifest-declared skills path into absolute directories (string or
|
|
21
|
+
* array form). Containment is checked on the *realpath* of both sides: the
|
|
22
|
+
* declared path may be (or pass through) a symlink whose target leaves the
|
|
23
|
+
* suite root, which lexical resolution cannot see.
|
|
24
|
+
*/
|
|
25
|
+
async function declaredSkillDirs(root, declared) {
|
|
21
26
|
const values = Array.isArray(declared) ? declared : [declared];
|
|
22
27
|
const dirs = [];
|
|
28
|
+
const realRoot = await realpath(root).catch(() => root);
|
|
23
29
|
for (const value of values) {
|
|
24
30
|
if (typeof value !== 'string' || value === '')
|
|
25
31
|
continue;
|
|
26
32
|
const cleaned = value.replace(/^\.\//, '');
|
|
27
|
-
const path =
|
|
28
|
-
|
|
33
|
+
const path = resolve(root, cleaned);
|
|
34
|
+
// A missing path is not an escape; the discovery walk reports it absent.
|
|
35
|
+
const realPath = await realpath(path).catch(() => undefined);
|
|
36
|
+
if (realPath === undefined)
|
|
37
|
+
continue;
|
|
38
|
+
if (isWithin(realRoot, realPath))
|
|
29
39
|
dirs.push(path);
|
|
30
40
|
}
|
|
31
41
|
return dirs;
|
|
32
42
|
}
|
|
33
|
-
|
|
34
|
-
|
|
43
|
+
/**
|
|
44
|
+
* Whether `candidate` stays inside `root` once both are resolved. Callers pass
|
|
45
|
+
* resolved paths: a sibling directory (`<root>-evil`) and any `../` escape
|
|
46
|
+
* must be rejected — a bare string-prefix test admits both.
|
|
47
|
+
*/
|
|
48
|
+
function isWithin(root, candidate) {
|
|
49
|
+
if (candidate === root)
|
|
50
|
+
return true;
|
|
51
|
+
const separator = candidate.includes('\\') && !candidate.includes('/') ? '\\' : '/';
|
|
52
|
+
return candidate.startsWith(`${root}${separator}`);
|
|
35
53
|
}
|
|
36
54
|
/** Discover SKILL.md files under the suite's skills directory, up to 3 levels deep. */
|
|
37
55
|
export async function discoverSkills(root, errors, declared) {
|
|
@@ -41,7 +59,7 @@ export async function discoverSkills(root, errors, declared) {
|
|
|
41
59
|
const rootParsed = await parseOneSkill(rootSkill, root, rootName, errors);
|
|
42
60
|
if (rootParsed !== undefined)
|
|
43
61
|
skills.push(rootParsed);
|
|
44
|
-
const skillsDirs = declaredSkillDirs(root, declared);
|
|
62
|
+
const skillsDirs = await declaredSkillDirs(root, declared);
|
|
45
63
|
if (skillsDirs.length === 0) {
|
|
46
64
|
const fallback = join(root, 'skills');
|
|
47
65
|
if (await isDirectory(fallback))
|
|
@@ -76,30 +94,62 @@ export async function discoverSkills(root, errors, declared) {
|
|
|
76
94
|
}
|
|
77
95
|
return skills;
|
|
78
96
|
}
|
|
97
|
+
/**
|
|
98
|
+
* Parse cache for SKILL.md files, keyed by path and stamped by mtime+size —
|
|
99
|
+
* a rescan of an unchanged tree re-stats files but skips re-reading and
|
|
100
|
+
* re-parsing thousands of frontmatters (CPU dominates large marketplaces).
|
|
101
|
+
* Results are immutable parse verdicts; rejections are re-reported to each
|
|
102
|
+
* scan's `errors` on hit. Bounded: over the cap the cache resets wholesale
|
|
103
|
+
* (a marketplace-scale tree holds thousands of entries, not millions).
|
|
104
|
+
*/
|
|
105
|
+
const SKILL_PARSE_CACHE_CAP = 20_000;
|
|
106
|
+
const skillParseCache = new Map();
|
|
79
107
|
async function parseOneSkill(file, directory, fallbackName, errors) {
|
|
80
|
-
|
|
81
|
-
return undefined;
|
|
82
|
-
let text;
|
|
108
|
+
let info;
|
|
83
109
|
try {
|
|
84
|
-
|
|
110
|
+
info = await stat(file);
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return undefined;
|
|
85
114
|
}
|
|
86
|
-
|
|
87
|
-
errors.push(`skill "${fallbackName}": unreadable SKILL.md (${error instanceof Error ? error.message : String(error)})`);
|
|
115
|
+
if (!info.isFile())
|
|
88
116
|
return undefined;
|
|
117
|
+
const stamp = { mtimeMs: info.mtimeMs, size: info.size };
|
|
118
|
+
const cached = skillParseCache.get(file);
|
|
119
|
+
let verdict;
|
|
120
|
+
if (cached !== undefined && cached.mtimeMs === stamp.mtimeMs && cached.size === stamp.size) {
|
|
121
|
+
verdict = cached.verdict;
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
let text;
|
|
125
|
+
try {
|
|
126
|
+
text = await readFile(file, 'utf8');
|
|
127
|
+
}
|
|
128
|
+
catch (error) {
|
|
129
|
+
errors.push(`skill "${fallbackName}": unreadable SKILL.md (${error instanceof Error ? error.message : String(error)})`);
|
|
130
|
+
return undefined;
|
|
131
|
+
}
|
|
132
|
+
const parsed = parseSkillFrontmatter(text, undefined);
|
|
133
|
+
verdict =
|
|
134
|
+
typeof parsed === 'string'
|
|
135
|
+
? parsed
|
|
136
|
+
: {
|
|
137
|
+
name: parsed.name,
|
|
138
|
+
directory,
|
|
139
|
+
file,
|
|
140
|
+
description: parsed.description,
|
|
141
|
+
...(parsed.whenToUse === undefined ? {} : { whenToUse: parsed.whenToUse }),
|
|
142
|
+
invocation: parsed.invocation
|
|
143
|
+
};
|
|
144
|
+
if (skillParseCache.size >= SKILL_PARSE_CACHE_CAP)
|
|
145
|
+
skillParseCache.clear();
|
|
146
|
+
skillParseCache.set(file, { ...stamp, verdict });
|
|
89
147
|
}
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
errors.push(`skill "${fallbackName}": ${parsed}`);
|
|
148
|
+
if (typeof verdict === 'string') {
|
|
149
|
+
errors.push(`skill "${fallbackName}": ${verdict}`);
|
|
93
150
|
return undefined;
|
|
94
151
|
}
|
|
95
|
-
return {
|
|
96
|
-
name: parsed.name,
|
|
97
|
-
directory,
|
|
98
|
-
file,
|
|
99
|
-
description: parsed.description,
|
|
100
|
-
...(parsed.whenToUse === undefined ? {} : { whenToUse: parsed.whenToUse }),
|
|
101
|
-
invocation: parsed.invocation
|
|
102
|
-
};
|
|
152
|
+
return { ...verdict, directory };
|
|
103
153
|
}
|
|
104
154
|
/** Read the suite's MCP config: `mcp.json` or `.mcp.json`, else the winning manifest's inline `mcpServers`. */
|
|
105
155
|
export async function discoverMcp(root, errors) {
|
|
@@ -126,22 +176,22 @@ export async function discoverMcp(root, errors) {
|
|
|
126
176
|
errors.push(...result.errors);
|
|
127
177
|
return result.config;
|
|
128
178
|
}
|
|
129
|
-
/** Count surfaces for a suite; mcp counts only validated servers. */
|
|
130
|
-
export async function countSurfaces(root, skills, mcp) {
|
|
179
|
+
/** Count surfaces for a suite; mcp counts only validated servers, lsp counts inline servers plus directory entries. */
|
|
180
|
+
export async function countSurfaces(root, skills, mcp, lsp) {
|
|
131
181
|
let hooks = 0;
|
|
132
182
|
for (const relative of [join('hooks', 'hooks.json'), 'hooks.json']) {
|
|
133
183
|
hooks += await countHookEntries(join(root, relative));
|
|
134
184
|
}
|
|
135
185
|
const commands = (await listMdFiles(join(root, 'commands'))).length;
|
|
136
186
|
const agents = (await listMdFiles(join(root, 'agents'))).length;
|
|
137
|
-
const
|
|
187
|
+
const lspCount = Object.keys(lsp?.servers ?? {}).length + (await discoverLspEntries(root)).length;
|
|
138
188
|
return {
|
|
139
189
|
skills: skills.length,
|
|
140
190
|
mcp: mcp === undefined ? 0 : Object.keys(mcp.servers).length,
|
|
141
191
|
hooks,
|
|
142
192
|
commands,
|
|
143
193
|
agents,
|
|
144
|
-
lsp
|
|
194
|
+
lsp: lspCount
|
|
145
195
|
};
|
|
146
196
|
}
|
|
147
197
|
/** LSP definitions: `.claude-plugin/lsp/*.json` plus reverse-domain `lsp/` dirs. */
|
package/lib/contracts/market.js
CHANGED
|
@@ -5,12 +5,15 @@ export const MARKET_API_PREFIX = '/api/agent-plugins/';
|
|
|
5
5
|
export const MARKET_ROUTES = {
|
|
6
6
|
overview: `${MARKET_API_PREFIX}overview`,
|
|
7
7
|
mcpStatus: `${MARKET_API_PREFIX}mcp-status`,
|
|
8
|
+
lspStatus: `${MARKET_API_PREFIX}lsp-status`,
|
|
9
|
+
lspServers: `${MARKET_API_PREFIX}lsp-servers`,
|
|
8
10
|
progress: `${MARKET_API_PREFIX}progress`,
|
|
9
11
|
config: `${MARKET_API_PREFIX}config`,
|
|
10
12
|
suite: `${MARKET_API_PREFIX}suite`,
|
|
11
13
|
skill: `${MARKET_API_PREFIX}skill`,
|
|
12
14
|
addSource: `${MARKET_API_PREFIX}sources/add`,
|
|
13
15
|
updateSource: `${MARKET_API_PREFIX}sources/update`,
|
|
16
|
+
adoptSource: `${MARKET_API_PREFIX}sources/adopt`,
|
|
14
17
|
removeSource: `${MARKET_API_PREFIX}sources/remove`,
|
|
15
18
|
refreshSource: `${MARKET_API_PREFIX}sources/refresh`,
|
|
16
19
|
install: `${MARKET_API_PREFIX}install`,
|
|
@@ -18,7 +21,11 @@ export const MARKET_ROUTES = {
|
|
|
18
21
|
setEnabled: `${MARKET_API_PREFIX}set-enabled`,
|
|
19
22
|
setSurface: `${MARKET_API_PREFIX}set-surface`,
|
|
20
23
|
mcpOverrides: `${MARKET_API_PREFIX}mcp-overrides`,
|
|
21
|
-
setMcpOverride: `${MARKET_API_PREFIX}set-mcp-override
|
|
24
|
+
setMcpOverride: `${MARKET_API_PREFIX}set-mcp-override`,
|
|
25
|
+
mcpRetry: `${MARKET_API_PREFIX}mcp-retry`,
|
|
26
|
+
mcpReauthorize: `${MARKET_API_PREFIX}mcp-reauthorize`,
|
|
27
|
+
mcpBackend: `${MARKET_API_PREFIX}mcp-backend`,
|
|
28
|
+
setMcpBackend: `${MARKET_API_PREFIX}set-mcp-backend`
|
|
22
29
|
};
|
|
23
30
|
/** Build a suite-detail URL without duplicating route or query encoding logic. */
|
|
24
31
|
export function suiteRoute(sourceId, suiteId) {
|
package/lib/index.js
CHANGED
|
@@ -1,15 +1,24 @@
|
|
|
1
1
|
import { Catalog } from './application/catalog.js';
|
|
2
2
|
import { RuntimeReconciler } from './runtime/reconciler.js';
|
|
3
3
|
import { inspectToolRegistry } from './runtime/tool-registry-observer.js';
|
|
4
|
-
import {
|
|
4
|
+
import { migrateLegacyDataRoot } from './catalog/legacy-root-migration.js';
|
|
5
|
+
import { resolveDataRoot, resolveDshHome, resolveUserRoot } from './catalog/paths.js';
|
|
6
|
+
import { join } from 'node:path';
|
|
5
7
|
import { mountSuiteRoutes } from './routes.js';
|
|
8
|
+
import { MCP_SETTINGS_NAMESPACE, MarketSettingsSchema, readMcpBackend } from './runtime/mcp-backend.js';
|
|
9
|
+
import { narrowDownloadRegion } from './runtime/regions.js';
|
|
6
10
|
import { SuiteSkillProvider } from './runtime/skills-provider.js';
|
|
11
|
+
import { loadLspServers } from './runtime/lsp-direct-config.js';
|
|
7
12
|
import { bindHostLocale, loadHostLocale } from './runtime/host-locale.js';
|
|
8
13
|
export const name = 'dsh-agent-plugins-market';
|
|
9
14
|
export const inject = ['skills', 'commands'];
|
|
10
15
|
export function apply(ctx, config = {}) {
|
|
11
16
|
const userRoot = resolveUserRoot(config.userRoot);
|
|
12
|
-
const dataRoot = resolveDataRoot(config.dataRoot);
|
|
17
|
+
const dataRoot = resolveDataRoot(config.dataRoot, userRoot);
|
|
18
|
+
// The pre-0.5.4 layout kept a sibling `agent-plugins-data` root; fold it in.
|
|
19
|
+
void migrateLegacyDataRoot(join(resolveDshHome(), 'agent-plugins-data'), dataRoot).catch(error => {
|
|
20
|
+
ctx.logger?.warn?.(`[dsh-agent-plugins-market] legacy data-root migration failed: ${String(error)}`);
|
|
21
|
+
});
|
|
13
22
|
let providerControl;
|
|
14
23
|
// Host runtime copy resolves from the harness `locale.preference` setting;
|
|
15
24
|
// the async settings read lands before the first session starts in practice.
|
|
@@ -20,14 +29,17 @@ export function apply(ctx, config = {}) {
|
|
|
20
29
|
});
|
|
21
30
|
const hostLocale = { t };
|
|
22
31
|
const runtime = new RuntimeReconciler(ctx, dataRoot, key => hostLocale.t(key));
|
|
23
|
-
const reconcileMounts = () => {
|
|
24
|
-
|
|
32
|
+
const reconcileMounts = async () => {
|
|
33
|
+
try {
|
|
25
34
|
const snapshot = await catalog.readUserCatalog();
|
|
26
35
|
const diagnostics = await runtime.reconcile(snapshot.enabledSuites);
|
|
27
36
|
catalog.mcpDiagnostics = diagnostics.mcp;
|
|
28
37
|
for (const diagnostic of diagnostics.mcp) {
|
|
29
38
|
ctx.logger?.warn(`[dsh-agent-plugins-market] suite "${diagnostic.suiteId}" mcp server "${diagnostic.serverKey}": ${diagnostic.reason}`);
|
|
30
39
|
}
|
|
40
|
+
for (const diagnostic of diagnostics.lsp) {
|
|
41
|
+
ctx.logger?.warn(`[dsh-agent-plugins-market] suite "${diagnostic.suiteId}" lsp server "${diagnostic.serverKey}": ${diagnostic.reason}`);
|
|
42
|
+
}
|
|
31
43
|
for (const diagnostic of diagnostics.commands) {
|
|
32
44
|
if (diagnostic.reason !== '')
|
|
33
45
|
ctx.logger?.warn(`[dsh-agent-plugins-market] suite "${diagnostic.suiteId}" command "${diagnostic.command}": ${diagnostic.reason}`);
|
|
@@ -38,23 +50,111 @@ export function apply(ctx, config = {}) {
|
|
|
38
50
|
for (const error of diagnostics.errors) {
|
|
39
51
|
ctx.logger?.warn(`[dsh-agent-plugins-market] ${error.surface} reconcile failed: ${error.reason}`);
|
|
40
52
|
}
|
|
41
|
-
}
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
42
55
|
ctx.logger?.warn(`[dsh-agent-plugins-market] runtime reconcile failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
43
|
-
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
44
58
|
};
|
|
45
|
-
|
|
59
|
+
/**
|
|
60
|
+
* Coalesce credential updates.
|
|
61
|
+
*
|
|
62
|
+
* `runtime.usesCredential(ref)` reflects the last completed pass, so an event
|
|
63
|
+
* arriving while the first reconcile is still running would be filtered out
|
|
64
|
+
* and the remount lost. Pending refs are therefore kept until the next pass
|
|
65
|
+
* observes them, and bursts are collapsed into one reconcile.
|
|
66
|
+
*/
|
|
67
|
+
const pendingCredentialRefs = new Set();
|
|
68
|
+
let credentialFlush;
|
|
69
|
+
const flushCredentialUpdates = () => {
|
|
70
|
+
credentialFlush = undefined;
|
|
71
|
+
const refs = [...pendingCredentialRefs];
|
|
72
|
+
pendingCredentialRefs.clear();
|
|
73
|
+
// Re-read the catalog first: an unknown ref means the snapshot predates
|
|
74
|
+
// this change, so reconcile anyway to refresh the known reference set.
|
|
75
|
+
void reconcileMounts().catch(() => { });
|
|
76
|
+
if (refs.length > 0) {
|
|
77
|
+
ctx.logger?.info?.(`[dsh-agent-plugins-market] reconciling after credential update: ${refs.join(', ')}`);
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
const onChanged = async () => {
|
|
46
81
|
providerControl?.invalidate();
|
|
47
|
-
reconcileMounts();
|
|
82
|
+
await reconcileMounts();
|
|
48
83
|
};
|
|
49
|
-
|
|
84
|
+
// The credential store powers the MCP re-authorize action (dropping a grant
|
|
85
|
+
// record forces the next mount through a fresh browser authorization).
|
|
86
|
+
// Resolved lazily at call time: this plugin's apply may run before the
|
|
87
|
+
// credentials plugin provisions, and a snapshot taken here would be
|
|
88
|
+
// permanently undefined even after the service is live.
|
|
89
|
+
const catalog = new Catalog({ userRoot, dataRoot, onChanged, ...(config.git === undefined ? {} : { git: config.git }) });
|
|
90
|
+
// Mirror oauth.ts's `credentialIdFor`: the record is stored under the folded
|
|
91
|
+
// serverName, so the delete must fold the same way or it misses the record.
|
|
92
|
+
const credentialIdFor = (serverName) => serverName
|
|
93
|
+
.toLowerCase()
|
|
94
|
+
.replace(/[^a-z0-9-]+/g, '-')
|
|
95
|
+
.replace(/^-+|-+$/g, '') || 'server';
|
|
96
|
+
catalog.setCredentialsStore({
|
|
97
|
+
deleteGrantRecord: async (serverName) => {
|
|
98
|
+
const store = ctx.get?.('credentials');
|
|
99
|
+
if (store?.deleteRecord === undefined)
|
|
100
|
+
throw new Error('credentials service is not mounted');
|
|
101
|
+
await store.deleteRecord(`mcp-auth/${credentialIdFor(serverName)}`);
|
|
102
|
+
}
|
|
103
|
+
});
|
|
50
104
|
runtime.setMcpOverridesProvider(() => catalog.allMcpOverrides());
|
|
105
|
+
// Re-authorize must rebuild the live bridge, not just drop the grant: the
|
|
106
|
+
// registry flags the mount and the following reconcile tears it down and
|
|
107
|
+
// remounts, so the server's 401 restarts the browser authorization.
|
|
108
|
+
catalog.setMcpRemountHook((suiteId, serverKey) => runtime.forceMcpRemount(suiteId, serverKey), serverName => runtime.mcpServerOwner(serverName));
|
|
109
|
+
catalog.setLspStatusSource(runtime.lsp);
|
|
110
|
+
runtime.lsp.setDirectProvider(async () => (await loadLspServers(dataRoot)).servers);
|
|
111
|
+
// The MCP backend choice is a host settings namespace (the registration is
|
|
112
|
+
// also what makes the plugin-config tab serve our card). The node half owns
|
|
113
|
+
// it: the provider derives the mount backend from the scope, the writer
|
|
114
|
+
// flips the switch, and the watcher remounts servers when it flips.
|
|
115
|
+
ctx.inject(['settings'], settingsCtx => {
|
|
116
|
+
const settings = settingsCtx.settings;
|
|
117
|
+
const scope = settings.register(MCP_SETTINGS_NAMESPACE, MarketSettingsSchema, {
|
|
118
|
+
base: { mcpEnhanced: true, downloadRegion: 'auto' }
|
|
119
|
+
});
|
|
120
|
+
catalog.setMcpBackendProvider(async () => (scope.get().mcpEnhanced === false ? 'host' : 'builtin'));
|
|
121
|
+
catalog.setMcpBackendWriter(async (backend) => {
|
|
122
|
+
await scope.update({ mcpEnhanced: backend !== 'host' });
|
|
123
|
+
});
|
|
124
|
+
catalog.setDownloadRegionProvider(async () => narrowDownloadRegion(scope.get().downloadRegion));
|
|
125
|
+
// One-time migration from the earlier data-root settings.json choice.
|
|
126
|
+
void readMcpBackend(dataRoot).then(backend => {
|
|
127
|
+
if (backend === 'host')
|
|
128
|
+
void scope.update({ mcpEnhanced: false }).catch(() => { });
|
|
129
|
+
});
|
|
130
|
+
scope.watch(() => {
|
|
131
|
+
void reconcileMounts().catch(() => { });
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
runtime.setMcpBackendProvider(() => catalog.mcpBackend());
|
|
135
|
+
const eventHost = ctx;
|
|
136
|
+
const disposeCredentialUpdates = eventHost.on?.('credentials/reference-updated', ref => {
|
|
137
|
+
pendingCredentialRefs.add(ref);
|
|
138
|
+
if (credentialFlush !== undefined)
|
|
139
|
+
clearTimeout(credentialFlush);
|
|
140
|
+
credentialFlush = setTimeout(flushCredentialUpdates, 150);
|
|
141
|
+
credentialFlush.unref?.();
|
|
142
|
+
}) ?? (() => { });
|
|
51
143
|
ctx.inject(['tools'], toolsCtx => {
|
|
52
|
-
|
|
144
|
+
const registry = toolsCtx.tools;
|
|
145
|
+
catalog.setMcpToolSnapshotProvider(() => inspectToolRegistry(registry));
|
|
146
|
+
// Foreign-namespace guard: a native host MCP client (or another plugin)
|
|
147
|
+
// owning `mcp__<serverName>__` names makes the mount registry skip its
|
|
148
|
+
// own server with a clear diagnostic instead of failing mid-registration.
|
|
149
|
+
runtime.setMcpToolNamesProvider(() => inspectToolRegistry(registry).map(tool => tool.name));
|
|
53
150
|
});
|
|
54
|
-
void catalog
|
|
151
|
+
void catalog
|
|
152
|
+
.load()
|
|
153
|
+
.then(async () => {
|
|
55
154
|
await catalog.mergeSources(config.sources ?? []);
|
|
56
|
-
reconcileMounts();
|
|
57
|
-
})
|
|
155
|
+
await reconcileMounts();
|
|
156
|
+
})
|
|
157
|
+
.catch(() => { });
|
|
58
158
|
ctx.skills.registerProvider(control => {
|
|
59
159
|
providerControl = control;
|
|
60
160
|
return new SuiteSkillProvider(catalog, key => hostLocale.t(key));
|
|
@@ -63,6 +163,11 @@ export function apply(ctx, config = {}) {
|
|
|
63
163
|
hostCtx.effect(() => mountSuiteRoutes(hostCtx, catalog), 'dsh-agent-plugins-market: http routes');
|
|
64
164
|
});
|
|
65
165
|
ctx.effect(() => () => {
|
|
166
|
+
disposeCredentialUpdates();
|
|
167
|
+
if (credentialFlush !== undefined)
|
|
168
|
+
clearTimeout(credentialFlush);
|
|
169
|
+
credentialFlush = undefined;
|
|
170
|
+
pendingCredentialRefs.clear();
|
|
66
171
|
void runtime.dispose();
|
|
67
172
|
}, 'dsh-agent-plugins-market: lifecycle');
|
|
68
173
|
}
|
package/lib/model/state.js
CHANGED
|
@@ -38,7 +38,20 @@ function normalizeState(record) {
|
|
|
38
38
|
return [];
|
|
39
39
|
const branch = typeof source['branch'] === 'string' ? source['branch'] : undefined;
|
|
40
40
|
const local = source['local'] === true;
|
|
41
|
-
|
|
41
|
+
const kind = source['kind'] === 'git' || source['kind'] === 'local' || source['kind'] === 'archive' ? source['kind'] : undefined;
|
|
42
|
+
const sha256 = typeof source['sha256'] === 'string' && source['sha256'] !== '' ? source['sha256'] : undefined;
|
|
43
|
+
const adopted = source['adopted'] === true;
|
|
44
|
+
return [
|
|
45
|
+
{
|
|
46
|
+
id,
|
|
47
|
+
url,
|
|
48
|
+
...(branch === undefined ? {} : { branch }),
|
|
49
|
+
...(local ? { local: true } : {}),
|
|
50
|
+
...(kind === undefined ? {} : { kind }),
|
|
51
|
+
...(sha256 === undefined ? {} : { sha256 }),
|
|
52
|
+
...(adopted ? { adopted: true } : {})
|
|
53
|
+
}
|
|
54
|
+
];
|
|
42
55
|
})
|
|
43
56
|
: [];
|
|
44
57
|
const installed = {};
|
package/lib/model/types.js
CHANGED
|
@@ -7,8 +7,26 @@
|
|
|
7
7
|
* Codex `.codex-plugin/plugin.json`). Discovery maps every layout onto this
|
|
8
8
|
* internal shape; runtime injection consumes only the internal shape.
|
|
9
9
|
*/
|
|
10
|
+
/** Archive URL extensions the archive acquisition path understands. */
|
|
11
|
+
const ARCHIVE_URL_PATTERN = /\.(zip|tgz|tar\.gz|tar)$/i;
|
|
12
|
+
/** Whether a URL points at a downloadable archive. */
|
|
13
|
+
export function isArchiveUrl(url) {
|
|
14
|
+
return ARCHIVE_URL_PATTERN.test(url.trim());
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Effective acquisition kind of a source: the explicit `kind` wins, the
|
|
18
|
+
* legacy `local` flag maps to `'local'`, archive-shaped URLs infer
|
|
19
|
+
* `'archive'`, and everything else stays `'git'`.
|
|
20
|
+
*/
|
|
21
|
+
export function resolveSourceKind(source) {
|
|
22
|
+
if (source.local === true || source.kind === 'local')
|
|
23
|
+
return 'local';
|
|
24
|
+
if (source.kind === 'git' || source.kind === 'archive')
|
|
25
|
+
return source.kind;
|
|
26
|
+
return isArchiveUrl(source.url) ? 'archive' : 'git';
|
|
27
|
+
}
|
|
10
28
|
/** The full set of toggleable surfaces, in display order. */
|
|
11
|
-
export const SUITE_SURFACE_KEYS = ['skills', 'mcp', 'hooks', 'commands', 'agents'];
|
|
29
|
+
export const SUITE_SURFACE_KEYS = ['skills', 'mcp', 'hooks', 'commands', 'agents', 'lsp'];
|
|
12
30
|
/** Merge user overrides over the enabled default into the effective surface set. */
|
|
13
31
|
export function effectiveSurfaces(overrides) {
|
|
14
32
|
return {
|
|
@@ -16,6 +34,7 @@ export function effectiveSurfaces(overrides) {
|
|
|
16
34
|
mcp: overrides?.mcp !== false,
|
|
17
35
|
hooks: overrides?.hooks !== false,
|
|
18
36
|
commands: overrides?.commands !== false,
|
|
19
|
-
agents: overrides?.agents !== false
|
|
37
|
+
agents: overrides?.agents !== false,
|
|
38
|
+
lsp: overrides?.lsp !== false
|
|
20
39
|
};
|
|
21
40
|
}
|
package/lib/routes.js
CHANGED
|
@@ -50,6 +50,12 @@ export function mountSuiteRoutes(hostCtx, manager) {
|
|
|
50
50
|
get(MARKET_ROUTES.mcpStatus, async (_request, response) => {
|
|
51
51
|
sendJson(response, 200, await manager.mcpStatus());
|
|
52
52
|
});
|
|
53
|
+
get(MARKET_ROUTES.lspStatus, async (_request, response) => {
|
|
54
|
+
sendJson(response, 200, await manager.lspStatus());
|
|
55
|
+
});
|
|
56
|
+
get(MARKET_ROUTES.lspServers, async (_request, response) => {
|
|
57
|
+
sendJson(response, 200, { lspServers: await manager.lspServers() });
|
|
58
|
+
});
|
|
53
59
|
get(MARKET_ROUTES.progress, async (_request, response) => {
|
|
54
60
|
sendJson(response, 200, manager.sourceProgress());
|
|
55
61
|
});
|
|
@@ -97,11 +103,20 @@ export function mountSuiteRoutes(hostCtx, manager) {
|
|
|
97
103
|
if (!url.startsWith('~/') && url !== '~' && !isAbsolute(expanded))
|
|
98
104
|
throw new Error('local source url must be an absolute path or start with ~/');
|
|
99
105
|
}
|
|
106
|
+
const kind = parseSourceKind(body['kind']);
|
|
107
|
+
if (kind === 'local' && !local) {
|
|
108
|
+
const expanded = expandHome(url);
|
|
109
|
+
if (!url.startsWith('~/') && url !== '~' && !isAbsolute(expanded))
|
|
110
|
+
throw new Error('local source url must be an absolute path or start with ~/');
|
|
111
|
+
}
|
|
100
112
|
const branch = body['branch'];
|
|
113
|
+
const sha256 = parseSha256(body['sha256']);
|
|
101
114
|
const source = await manager.addSource({
|
|
102
|
-
url: local ? expandHome(url) : url,
|
|
115
|
+
url: local || kind === 'local' ? expandHome(url) : url,
|
|
103
116
|
...(typeof branch === 'string' && branch.trim() !== '' ? { branch: branch.trim() } : {}),
|
|
104
|
-
...(local ? { local: true } : {})
|
|
117
|
+
...(local ? { local: true } : {}),
|
|
118
|
+
...(kind === undefined ? {} : { kind }),
|
|
119
|
+
...(sha256 === undefined ? {} : { sha256 })
|
|
105
120
|
});
|
|
106
121
|
return { source };
|
|
107
122
|
});
|
|
@@ -120,9 +135,24 @@ export function mountSuiteRoutes(hostCtx, manager) {
|
|
|
120
135
|
patch.branch = String(body['branch']).trim();
|
|
121
136
|
if (body['local'] !== undefined)
|
|
122
137
|
patch.local = body['local'] === true;
|
|
138
|
+
const kind = parseSourceKind(body['kind']);
|
|
139
|
+
if (kind !== undefined)
|
|
140
|
+
patch.kind = kind;
|
|
141
|
+
const sha256 = parseSha256(body['sha256']);
|
|
142
|
+
if (sha256 !== undefined)
|
|
143
|
+
patch.sha256 = sha256;
|
|
123
144
|
await manager.updateSource(id, patch);
|
|
124
145
|
return {};
|
|
125
146
|
});
|
|
147
|
+
// Register one unmanaged `.sources/` checkout in place — the manual-clone
|
|
148
|
+
// repair path. Nothing is cloned, moved, or deleted.
|
|
149
|
+
post(MARKET_ROUTES.adoptSource, async (body) => {
|
|
150
|
+
const id = body['id'];
|
|
151
|
+
if (typeof id !== 'string' || id === '')
|
|
152
|
+
throw new Error('missing checkout id');
|
|
153
|
+
const source = await manager.adoptSource(id);
|
|
154
|
+
return { source };
|
|
155
|
+
});
|
|
126
156
|
post(MARKET_ROUTES.removeSource, async (body) => {
|
|
127
157
|
const id = body['id'];
|
|
128
158
|
if (typeof id !== 'string' || id === '')
|
|
@@ -165,13 +195,15 @@ export function mountSuiteRoutes(hostCtx, manager) {
|
|
|
165
195
|
return {};
|
|
166
196
|
});
|
|
167
197
|
get(MARKET_ROUTES.mcpOverrides, async (request, response) => {
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
|
|
198
|
+
const query = queryOf(request);
|
|
199
|
+
const sourceId = query.get('sourceId');
|
|
200
|
+
const suiteId = query.get('suiteId');
|
|
201
|
+
if (sourceId === null || suiteId === null) {
|
|
202
|
+
sendJson(response, 400, { ok: false, error: 'missing sourceId or suiteId' });
|
|
171
203
|
return;
|
|
172
204
|
}
|
|
173
205
|
try {
|
|
174
|
-
sendJson(response, 200, { ok: true, overrides: await manager.mcpOverrides(suiteId) });
|
|
206
|
+
sendJson(response, 200, { ok: true, overrides: await manager.mcpOverrides(sourceId, suiteId) });
|
|
175
207
|
}
|
|
176
208
|
catch (error) {
|
|
177
209
|
sendJson(response, 404, { ok: false, error: error instanceof Error ? error.message : String(error) });
|
|
@@ -189,6 +221,39 @@ export function mountSuiteRoutes(hostCtx, manager) {
|
|
|
189
221
|
await manager.setMcpOverride(sourceId, suiteId, serverKey, sanitized ?? null);
|
|
190
222
|
return {};
|
|
191
223
|
});
|
|
224
|
+
// Manual MCP reconcile: retries failed mounts and clears residual tools
|
|
225
|
+
// without touching any catalog state.
|
|
226
|
+
post(MARKET_ROUTES.mcpRetry, async () => {
|
|
227
|
+
await manager.retryMounts();
|
|
228
|
+
return {};
|
|
229
|
+
});
|
|
230
|
+
// Drop one server's OAuth grant record: the next mount re-runs the browser
|
|
231
|
+
// authorization, which is how a user widens a too-narrow scope.
|
|
232
|
+
post(MARKET_ROUTES.mcpReauthorize, async (body) => {
|
|
233
|
+
const serverName = body['serverName'];
|
|
234
|
+
if (typeof serverName !== 'string' || serverName === '')
|
|
235
|
+
throw new Error('serverName is required');
|
|
236
|
+
await manager.reauthorizeMcpServer(serverName);
|
|
237
|
+
return {};
|
|
238
|
+
});
|
|
239
|
+
// The MCP backend block: which client mounts suite servers, and whether the
|
|
240
|
+
// host's dsh-mcp-client is resolvable as the compatibility option.
|
|
241
|
+
get(MARKET_ROUTES.mcpBackend, async (_request, response) => {
|
|
242
|
+
sendJson(response, 200, await manager.mcpBackendInfo());
|
|
243
|
+
});
|
|
244
|
+
post(MARKET_ROUTES.setMcpBackend, async (body) => {
|
|
245
|
+
const backend = body['backend'];
|
|
246
|
+
if (backend !== 'builtin' && backend !== 'host')
|
|
247
|
+
throw new Error('backend must be "builtin" or "host"');
|
|
248
|
+
await manager.setMcpBackend(backend);
|
|
249
|
+
return await manager.mcpBackendInfo();
|
|
250
|
+
});
|
|
251
|
+
// Validate and persist the user's direct LSP server table; the reconcile
|
|
252
|
+
// pass picks it up and mounts it alongside the suite declarations.
|
|
253
|
+
post(MARKET_ROUTES.lspServers, async (body) => {
|
|
254
|
+
const servers = await manager.setLspServers(body['lspServers']);
|
|
255
|
+
return { lspServers: servers };
|
|
256
|
+
});
|
|
192
257
|
return () => {
|
|
193
258
|
for (const dispose of disposers)
|
|
194
259
|
dispose();
|
|
@@ -203,6 +268,23 @@ function parseTarget(body) {
|
|
|
203
268
|
throw new Error('missing suiteId');
|
|
204
269
|
return { sourceId, suiteId };
|
|
205
270
|
}
|
|
271
|
+
/** Parse an optional acquisition-kind field; rejects unknown values. */
|
|
272
|
+
function parseSourceKind(raw) {
|
|
273
|
+
if (raw === undefined || raw === null || raw === '')
|
|
274
|
+
return undefined;
|
|
275
|
+
if (raw === 'git' || raw === 'local' || raw === 'archive')
|
|
276
|
+
return raw;
|
|
277
|
+
throw new Error(`invalid source kind "${String(raw)}"`);
|
|
278
|
+
}
|
|
279
|
+
/** Parse an optional SHA-256 hex digest; rejects malformed values. */
|
|
280
|
+
function parseSha256(raw) {
|
|
281
|
+
if (raw === undefined || raw === null || raw === '')
|
|
282
|
+
return undefined;
|
|
283
|
+
const value = String(raw).trim().toLowerCase();
|
|
284
|
+
if (!/^[0-9a-f]{64}$/.test(value))
|
|
285
|
+
throw new Error('sha256 must be a 64-character hex digest');
|
|
286
|
+
return value;
|
|
287
|
+
}
|
|
206
288
|
function sameOrigin(request) {
|
|
207
289
|
const origin = request.headers['origin'];
|
|
208
290
|
if (origin === undefined)
|
|
@@ -14,6 +14,7 @@ import { readFile, readdir } from 'node:fs/promises';
|
|
|
14
14
|
import { join } from 'node:path';
|
|
15
15
|
import { parse as parseYaml } from 'yaml';
|
|
16
16
|
import { parseSkillFrontmatter, stripFrontmatter } from '../catalog/skills-parse.js';
|
|
17
|
+
import { qualifiedSuiteId } from '../catalog/paths.js';
|
|
17
18
|
import { bindHostLocale } from './host-locale.js';
|
|
18
19
|
const COMMAND_NAME = /^[a-z][a-z0-9_-]*$/;
|
|
19
20
|
export class CommandMountRegistry {
|
|
@@ -36,8 +37,11 @@ export class CommandMountRegistry {
|
|
|
36
37
|
...(suite.activeSurfaces?.agents === false ? [] : await readAgents(suite.root, this.t))
|
|
37
38
|
];
|
|
38
39
|
for (const spec of specs) {
|
|
39
|
-
|
|
40
|
-
|
|
40
|
+
// The registry key is source-qualified: bare suite ids are unique per
|
|
41
|
+
// source only, so two sources' same-named suites would collide.
|
|
42
|
+
const suiteKey = qualifiedSuiteId(suite.sourceId, suite.id);
|
|
43
|
+
const key = `${suiteKey}/${spec.name}`;
|
|
44
|
+
wanted.set(key, { ...spec, suiteId: suiteKey, suiteName: suite.manifest.name });
|
|
41
45
|
}
|
|
42
46
|
}
|
|
43
47
|
for (const [key, disposer] of [...this.live]) {
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import { stat } from 'node:fs/promises';
|
|
14
14
|
import { join } from 'node:path';
|
|
15
|
+
import { qualifiedSuiteId } from '../catalog/paths.js';
|
|
15
16
|
export class HooksMountRegistry {
|
|
16
17
|
ctx;
|
|
17
18
|
live = new Map();
|
|
@@ -22,18 +23,20 @@ export class HooksMountRegistry {
|
|
|
22
23
|
async reconcile(enabledSuites) {
|
|
23
24
|
const diagnostics = [];
|
|
24
25
|
const active = enabledSuites.filter(suite => suite.activeSurfaces?.hooks !== false);
|
|
25
|
-
|
|
26
|
+
// Keys are the qualified suite id: bare ids are unique per source only.
|
|
27
|
+
const wanted = new Set(active.map(suite => qualifiedSuiteId(suite.sourceId, suite.id)));
|
|
26
28
|
for (const [suiteId, handle] of [...this.live]) {
|
|
27
29
|
if (!wanted.has(suiteId)) {
|
|
28
30
|
await this.unmount(suiteId, handle);
|
|
29
31
|
}
|
|
30
32
|
}
|
|
31
33
|
for (const suite of active) {
|
|
32
|
-
|
|
34
|
+
const key = qualifiedSuiteId(suite.sourceId, suite.id);
|
|
35
|
+
if (this.live.has(key))
|
|
33
36
|
continue;
|
|
34
|
-
const reason = await this.mount(suite);
|
|
37
|
+
const reason = await this.mount(key, suite);
|
|
35
38
|
if (reason !== undefined)
|
|
36
|
-
diagnostics.push({ suiteId:
|
|
39
|
+
diagnostics.push({ suiteId: key, reason });
|
|
37
40
|
}
|
|
38
41
|
return diagnostics;
|
|
39
42
|
}
|
|
@@ -43,7 +46,7 @@ export class HooksMountRegistry {
|
|
|
43
46
|
await this.unmount(suiteId, handle);
|
|
44
47
|
}
|
|
45
48
|
}
|
|
46
|
-
async mount(suite) {
|
|
49
|
+
async mount(key, suite) {
|
|
47
50
|
const configPath = await hookConfigPath(suite.root);
|
|
48
51
|
if (configPath === undefined)
|
|
49
52
|
return undefined;
|
|
@@ -63,7 +66,7 @@ export class HooksMountRegistry {
|
|
|
63
66
|
pluginRoot: suite.root
|
|
64
67
|
});
|
|
65
68
|
await handle.await();
|
|
66
|
-
this.live.set(
|
|
69
|
+
this.live.set(key, handle);
|
|
67
70
|
return undefined;
|
|
68
71
|
}
|
|
69
72
|
catch (error) {
|