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