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.
Files changed (86) hide show
  1. package/README.md +29 -3
  2. package/README.zh.md +29 -3
  3. package/client/client.js +1388 -204
  4. package/client/client.js.map +1 -1
  5. package/lib/application/catalog.js +537 -71
  6. package/lib/application/details.js +39 -12
  7. package/lib/catalog/archive.js +341 -0
  8. package/lib/catalog/git.js +85 -12
  9. package/lib/catalog/legacy-root-migration.js +51 -0
  10. package/lib/catalog/lsp-spec.js +87 -0
  11. package/lib/catalog/manifests.js +84 -32
  12. package/lib/catalog/paths.js +65 -7
  13. package/lib/catalog/scan-pipeline.js +50 -0
  14. package/lib/catalog/scan-resolvers.js +402 -0
  15. package/lib/catalog/source-catalog.js +22 -8
  16. package/lib/catalog/suite-scanner.js +5 -176
  17. package/lib/catalog/surfaces.js +80 -30
  18. package/lib/contracts/lsp-status.js +2 -0
  19. package/lib/contracts/market.js +8 -1
  20. package/lib/index.js +118 -13
  21. package/lib/model/state.js +14 -1
  22. package/lib/model/types.js +21 -2
  23. package/lib/routes.js +88 -6
  24. package/lib/runtime/commands-mounts.js +6 -2
  25. package/lib/runtime/hooks-mounts.js +9 -6
  26. package/lib/runtime/lsp-direct-config.js +44 -0
  27. package/lib/runtime/lsp-mounts.js +236 -0
  28. package/lib/runtime/lsp-status.js +96 -0
  29. package/lib/runtime/mcp-backend.js +60 -0
  30. package/lib/runtime/mcp-client/bridge.js +126 -0
  31. package/lib/runtime/mcp-client/config.js +42 -0
  32. package/lib/runtime/mcp-client/connection.js +380 -0
  33. package/lib/runtime/mcp-client/host-seams.js +88 -0
  34. package/lib/runtime/mcp-client/json-schema-subset.js +181 -0
  35. package/lib/runtime/mcp-client/oauth.js +379 -0
  36. package/lib/runtime/mcp-client/tools.js +432 -0
  37. package/lib/runtime/mcp-client/transport.js +103 -0
  38. package/lib/runtime/mcp-config.js +196 -54
  39. package/lib/runtime/mcp-credentials.js +20 -0
  40. package/lib/runtime/mcp-mounts.js +237 -27
  41. package/lib/runtime/mcp-overrides.js +64 -6
  42. package/lib/runtime/mcp-redaction.js +92 -0
  43. package/lib/runtime/mcp-status.js +76 -32
  44. package/lib/runtime/reconciler.js +35 -1
  45. package/lib/runtime/regions.js +56 -0
  46. package/lib/types/application/catalog.d.ts +191 -14
  47. package/lib/types/application/details.d.ts +1 -6
  48. package/lib/types/application/queries.d.ts +28 -1
  49. package/lib/types/catalog/archive.d.ts +48 -0
  50. package/lib/types/catalog/git.d.ts +27 -3
  51. package/lib/types/catalog/legacy-root-migration.d.ts +14 -0
  52. package/lib/types/catalog/lsp-spec.d.ts +14 -0
  53. package/lib/types/catalog/manifests.d.ts +16 -1
  54. package/lib/types/catalog/paths.d.ts +23 -2
  55. package/lib/types/catalog/scan-pipeline.d.ts +76 -0
  56. package/lib/types/catalog/scan-resolvers.d.ts +81 -0
  57. package/lib/types/catalog/source-catalog.d.ts +5 -0
  58. package/lib/types/catalog/suite-scanner.d.ts +14 -7
  59. package/lib/types/catalog/surfaces.d.ts +3 -3
  60. package/lib/types/contracts/lsp-status.d.ts +53 -0
  61. package/lib/types/contracts/market.d.ts +55 -1
  62. package/lib/types/contracts/mcp-status.d.ts +14 -2
  63. package/lib/types/index.d.ts +24 -8
  64. package/lib/types/model/types.d.ts +52 -3
  65. package/lib/types/runtime/lsp-direct-config.d.ts +12 -0
  66. package/lib/types/runtime/lsp-mounts.d.ts +89 -0
  67. package/lib/types/runtime/lsp-status.d.ts +30 -0
  68. package/lib/types/runtime/mcp-backend.d.ts +49 -0
  69. package/lib/types/runtime/mcp-client/bridge.d.ts +37 -0
  70. package/lib/types/runtime/mcp-client/config.d.ts +123 -0
  71. package/lib/types/runtime/mcp-client/connection.d.ts +82 -0
  72. package/lib/types/runtime/mcp-client/host-seams.d.ts +89 -0
  73. package/lib/types/runtime/mcp-client/json-schema-subset.d.ts +58 -0
  74. package/lib/types/runtime/mcp-client/oauth.d.ts +121 -0
  75. package/lib/types/runtime/mcp-client/tools.d.ts +136 -0
  76. package/lib/types/runtime/mcp-client/transport.d.ts +36 -0
  77. package/lib/types/runtime/mcp-config.d.ts +29 -12
  78. package/lib/types/runtime/mcp-credentials.d.ts +9 -0
  79. package/lib/types/runtime/mcp-mounts.d.ts +56 -6
  80. package/lib/types/runtime/mcp-overrides.d.ts +23 -4
  81. package/lib/types/runtime/mcp-redaction.d.ts +7 -0
  82. package/lib/types/runtime/mcp-status.d.ts +4 -2
  83. package/lib/types/runtime/reconciler.d.ts +22 -4
  84. package/lib/types/runtime/regions.d.ts +37 -0
  85. package/package.json +15 -11
  86. package/schemas/1.0.0/mcp.schema.json +22 -0
@@ -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
- /** Resolve a manifest-declared skills path into absolute directories (string or array form). */
20
- function declaredSkillDirs(root, declared) {
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 = join(root, cleaned);
28
- if (isAbsoluteWithin(root, path))
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
- function isAbsoluteWithin(root, candidate) {
34
- return candidate.startsWith(`${root}${candidate.includes('/') ? '/' : '\\'}`) || candidate.startsWith(root);
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
- if (!(await isFile(file)))
81
- return undefined;
82
- let text;
108
+ let info;
83
109
  try {
84
- text = await readFile(file, 'utf8');
110
+ info = await stat(file);
111
+ }
112
+ catch {
113
+ return undefined;
85
114
  }
86
- catch (error) {
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
- const parsed = parseSkillFrontmatter(text, undefined);
91
- if (typeof parsed === 'string') {
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 lsp = (await discoverLspEntries(root)).length;
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. */
@@ -0,0 +1,2 @@
1
+ /** Browser-safe LSP status records shared by host aggregation and client rendering. */
2
+ export {};
@@ -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 { resolveDataRoot, resolveUserRoot } from './catalog/paths.js';
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
- void (async () => {
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
- })().catch(error => {
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
- const onChanged = () => {
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
- const catalog = new Catalog({ userRoot, dataRoot, onChanged });
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
- catalog.setMcpToolSnapshotProvider(() => inspectToolRegistry(toolsCtx.tools));
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.load().then(async () => {
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
  }
@@ -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
- return [{ id, url, ...(branch === undefined ? {} : { branch }), ...(local ? { local: true } : {}) }];
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 = {};
@@ -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 suiteId = queryOf(request).get('suiteId');
169
- if (suiteId === null) {
170
- sendJson(response, 400, { ok: false, error: 'missing suiteId' });
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
- const key = `${suite.id}/${spec.name}`;
40
- wanted.set(key, { ...spec, suiteId: suite.id, suiteName: suite.manifest.name });
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
- const wanted = new Set(active.map(suite => suite.id));
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
- if (this.live.has(suite.id))
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: suite.id, reason });
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(suite.id, handle);
69
+ this.live.set(key, handle);
67
70
  return undefined;
68
71
  }
69
72
  catch (error) {