dsh-agent-plugins-market 0.7.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/README.md +2 -2
  2. package/README.zh.md +2 -2
  3. package/client/client.js +28048 -1033
  4. package/client/client.js.map +1 -1
  5. package/lib/application/catalog-context.js +8 -1
  6. package/lib/application/catalog.js +13 -0
  7. package/lib/application/details.js +13 -2
  8. package/lib/application/mcp-service.js +17 -0
  9. package/lib/application/panel-resources.js +16 -4
  10. package/lib/application/project-agent-roles.js +7 -1
  11. package/lib/catalog/archive.js +1 -1
  12. package/lib/catalog/paths.js +8 -0
  13. package/lib/catalog/plugin-variables.js +38 -0
  14. package/lib/catalog/project-hooks.js +14 -1
  15. package/lib/catalog/skills-parse.js +47 -0
  16. package/lib/catalog/source-catalog.js +8 -3
  17. package/lib/catalog/suite-components.js +2 -2
  18. package/lib/contracts/market.js +1 -0
  19. package/lib/contracts/settings.js +63 -0
  20. package/lib/index.js +26 -11
  21. package/lib/model/layouts.js +10 -1
  22. package/lib/routes.js +12 -0
  23. package/lib/runtime/agent-role-router.js +28 -6
  24. package/lib/runtime/commands-mounts.js +50 -9
  25. package/lib/runtime/deadline.js +32 -6
  26. package/lib/runtime/dynamic-context.js +140 -0
  27. package/lib/runtime/feedback-tool.js +7 -3
  28. package/lib/runtime/host-locale.js +39 -3
  29. package/lib/runtime/lsp-mounts.js +23 -2
  30. package/lib/runtime/mcp-backend.js +14 -15
  31. package/lib/runtime/mcp-client/config.js +8 -0
  32. package/lib/runtime/mcp-client/connection.js +6 -6
  33. package/lib/runtime/mcp-client/host-seams.js +8 -88
  34. package/lib/runtime/mcp-config.js +7 -4
  35. package/lib/runtime/project-runtime.js +23 -9
  36. package/lib/runtime/reconcile-scheduler.js +6 -8
  37. package/lib/runtime/reconciler.js +2 -1
  38. package/lib/runtime/regions.js +0 -19
  39. package/lib/runtime/settings-namespace.js +21 -13
  40. package/lib/runtime/skills-provider.js +33 -11
  41. package/lib/runtime/source-auto-update.js +10 -14
  42. package/lib/runtime/timer-seat.js +80 -0
  43. package/lib/runtime/tool-registry-observer.js +20 -20
  44. package/lib/runtime/user-commands.js +5 -1
  45. package/lib/runtime/user-panels.js +110 -41
  46. package/lib/runtime/user-store.js +114 -58
  47. package/lib/types/application/catalog-context.d.ts +6 -0
  48. package/lib/types/application/catalog.d.ts +9 -0
  49. package/lib/types/application/mcp-service.d.ts +6 -0
  50. package/lib/types/application/ports.d.ts +1 -1
  51. package/lib/types/application/queries.d.ts +2 -0
  52. package/lib/types/catalog/paths.d.ts +6 -0
  53. package/lib/types/catalog/plugin-variables.d.ts +16 -0
  54. package/lib/types/catalog/skills-parse.d.ts +13 -0
  55. package/lib/types/catalog/source-catalog.d.ts +7 -2
  56. package/lib/types/contracts/market.d.ts +10 -0
  57. package/lib/types/contracts/settings.d.ts +59 -0
  58. package/lib/types/model/layouts.d.ts +10 -0
  59. package/lib/types/runtime/agent-role-router.d.ts +6 -2
  60. package/lib/types/runtime/commands-mounts.d.ts +15 -7
  61. package/lib/types/runtime/deadline.d.ts +10 -11
  62. package/lib/types/runtime/dynamic-context.d.ts +81 -0
  63. package/lib/types/runtime/host-locale.d.ts +19 -1
  64. package/lib/types/runtime/lsp-mounts.d.ts +7 -0
  65. package/lib/types/runtime/mcp-backend.d.ts +8 -15
  66. package/lib/types/runtime/mcp-client/config.d.ts +8 -0
  67. package/lib/types/runtime/mcp-client/host-seams.d.ts +20 -64
  68. package/lib/types/runtime/project-runtime.d.ts +10 -3
  69. package/lib/types/runtime/reconcile-scheduler.d.ts +1 -1
  70. package/lib/types/runtime/regions.d.ts +1 -4
  71. package/lib/types/runtime/settings-namespace.d.ts +10 -10
  72. package/lib/types/runtime/skills-provider.d.ts +11 -2
  73. package/lib/types/runtime/source-auto-update.d.ts +3 -4
  74. package/lib/types/runtime/timer-seat.d.ts +51 -0
  75. package/lib/types/runtime/tool-registry-observer.d.ts +12 -6
  76. package/lib/types/runtime/user-panels.d.ts +65 -12
  77. package/lib/types/runtime/user-store.d.ts +65 -16
  78. package/package.json +21 -4
@@ -8,6 +8,7 @@
8
8
  * notification path — have a single owner.
9
9
  */
10
10
  import { join } from 'node:path';
11
+ import { MARKET_SETTINGS_DEFAULTS } from '../contracts/settings.js';
11
12
  import { STATE_FILE_NAME } from '../catalog/paths.js';
12
13
  import { settlesWithin } from '../runtime/deadline.js';
13
14
  import { EMPTY_STATE, loadState, saveState } from '../runtime/state-store.js';
@@ -27,7 +28,13 @@ export class CatalogContext {
27
28
  mutationQueue = Promise.resolve();
28
29
  currentRevision = 0;
29
30
  currentScanGeneration = 0;
30
- scanProjectLayoutsEnabled = false;
31
+ /**
32
+ * The project-layout switch in effect. The host settings document owns the
33
+ * value and pushes it here on registration and on every change; the initial
34
+ * value is the contract's declared default, which is what the catalog would
35
+ * read from the document before the settings service resolves.
36
+ */
37
+ scanProjectLayoutsEnabled = MARKET_SETTINGS_DEFAULTS.scanProjectLayouts;
31
38
  /** The derived refresh in flight, shared by every change that lands while it runs. */
32
39
  refreshPass;
33
40
  /** Whether a change landed after the running pass started; it earns one more pass. */
@@ -48,6 +48,10 @@ export class Catalog {
48
48
  get userRoot() {
49
49
  return this.context.userRoot;
50
50
  }
51
+ /** The plugin storage root holding per-suite `${PLUGIN_DATA}` directories and overrides. */
52
+ get dataRoot() {
53
+ return this.context.dataRoot;
54
+ }
51
55
  get sources() {
52
56
  return this.context.state.sources;
53
57
  }
@@ -238,6 +242,15 @@ export class Catalog {
238
242
  async setMcpOverride(sourceId, suiteId, serverKey, override) {
239
243
  await this.mcp.setOverride(sourceId, suiteId, serverKey, override);
240
244
  }
245
+ /**
246
+ * Enable or disable one declared MCP server, addressed by the same
247
+ * source-qualified suite id its status row carries. Disabling writes an
248
+ * override rather than editing the suite's `mcp.json`, so a refresh cannot
249
+ * clobber the user's choice.
250
+ */
251
+ async setMcpServerEnabled(suiteKey, serverKey, enabled) {
252
+ await this.mcp.setServerEnabled(suiteKey, serverKey, enabled);
253
+ }
241
254
  /** Re-run the MCP reconcile pass without changing any catalog state. */
242
255
  async retryMounts() {
243
256
  await this.mcp.retryMounts();
@@ -1,5 +1,5 @@
1
1
  /** Suite detail and preview projections owned by the application layer. */
2
- import { readFile } from 'node:fs/promises';
2
+ import { readFile, stat } from 'node:fs/promises';
3
3
  import { parse as parseYaml } from 'yaml';
4
4
  import { discoverLspEntries } from '../catalog/surfaces.js';
5
5
  import { defaultMarkdownResources, resourceText } from '../catalog/component-files.js';
@@ -7,6 +7,16 @@ import { credentialRefsInServer } from '../runtime/mcp-config.js';
7
7
  import { redactMcpConfig, redactMcpOverrides } from '../runtime/mcp-redaction.js';
8
8
  import { applyOverride } from '../runtime/mcp-overrides.js';
9
9
  import { effectiveSurfaces } from '../model/types.js';
10
+ /** The checkout's last modification, or null when the path cannot be read. */
11
+ async function rootModifiedAt(root) {
12
+ try {
13
+ const stats = await stat(root);
14
+ return new Date(stats.mtimeMs).toISOString();
15
+ }
16
+ catch {
17
+ return null;
18
+ }
19
+ }
10
20
  /** Build the detail response for one normalized suite. */
11
21
  export async function buildSuiteDetail(suite, installed, diagnostics, mcpOverrides = {}) {
12
22
  const remoteUrl = suite.remote?.url;
@@ -18,6 +28,7 @@ export async function buildSuiteDetail(suite, installed, diagnostics, mcpOverrid
18
28
  description: suite.manifest.description ?? null,
19
29
  author: suite.manifest.author ?? null,
20
30
  keywords: suite.manifest.keywords ?? [],
31
+ updatedAt: await rootModifiedAt(suite.root),
21
32
  layout: suite.manifest.layout,
22
33
  dimension: suite.dimension,
23
34
  root: remoteUrl ?? suite.root,
@@ -66,7 +77,7 @@ export async function readSkillContent(suite, skillName) {
66
77
  content = await readFile(skill.file, 'utf8');
67
78
  }
68
79
  catch (error) {
69
- throw new Error(`skill file unreadable: ${error instanceof Error ? error.message : String(error)}`);
80
+ throw new Error(`skill file unreadable: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
70
81
  }
71
82
  return { name: skill.name, description: skill.description, content, path: skill.file };
72
83
  }
@@ -139,8 +139,25 @@ export class McpService {
139
139
  }
140
140
  await saveSuiteOverrides(this.context.dataRoot, suiteKey, overrides);
141
141
  await this.context.notifyChanged(true);
142
+ // The status surface reads the live tool registry, so an immediate read
143
+ // races the reconciler's teardown: a just-disabled server still carries
144
+ // its observed tools and reports the orphaned state with its switch on
145
+ // until the pass finishes. Joining the pass here is bounded — one stuck
146
+ // mount costs the request its deadline, never the plugin.
147
+ await this.context.refreshSettled();
142
148
  });
143
149
  }
150
+ /**
151
+ * Enable or disable one declared server, addressed by the source-qualified
152
+ * suite id the status rows carry. The qualified id is split here rather than
153
+ * by callers so the separator stays a server-side detail.
154
+ */
155
+ async setServerEnabled(suiteKey, serverKey, enabled) {
156
+ const separator = suiteKey.indexOf('/');
157
+ if (separator <= 0)
158
+ throw new Error(`invalid suite id "${suiteKey}"`);
159
+ await this.setOverride(suiteKey.slice(0, separator), suiteKey.slice(separator + 1), serverKey, { enabled });
160
+ }
144
161
  /**
145
162
  * Re-run the MCP reconcile pass: retries failed mounts and clears residual
146
163
  * tools, without changing any catalog state.
@@ -1,8 +1,9 @@
1
1
  /** Installed suite and user resources share one inventory; paths never come from HTTP callers. */
2
- import { realpath, unlink } from 'node:fs/promises';
2
+ import { realpath, stat, unlink } from 'node:fs/promises';
3
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
+ import { pluginRootOf } from '../catalog/plugin-variables.js';
6
+ import { isWithin, suiteDataDir } from '../catalog/paths.js';
6
7
  import { stripFrontmatter } from '../catalog/skills-parse.js';
7
8
  import { parseFrontmatterRecord } from '../runtime/user-store.js';
8
9
  /**
@@ -68,11 +69,22 @@ class PanelResources {
68
69
  rawText,
69
70
  content: stripFrontmatter(rawText),
70
71
  path: file,
71
- description: typeof metadata.description === 'string' ? metadata.description : ''
72
+ description: typeof metadata.description === 'string' ? metadata.description : '',
73
+ ...(pluginRootOf(suite) === undefined ? {} : { suiteRoot: suite.root, suiteData: suiteDataDir(this.catalog.dataRoot, suite.sourceId, suite.id) })
72
74
  });
73
75
  }
74
76
  }
75
- return entries;
77
+ // One pass stamps each entry's own last modification: the panel reads every
78
+ // file's text anyway, and both origins get the same treatment here.
79
+ return await Promise.all(entries.map(async (entry) => {
80
+ try {
81
+ const stats = await stat(entry.path);
82
+ return { ...entry, updatedAt: new Date(stats.mtimeMs).toISOString() };
83
+ }
84
+ catch {
85
+ return entry;
86
+ }
87
+ }));
76
88
  }
77
89
  async get(id) {
78
90
  return (await this.list()).find(entry => (entry.id ?? entry.name) === id);
@@ -1,5 +1,7 @@
1
1
  /** Resolve read-only project roles from the calling session, never from tool-supplied paths. */
2
2
  import { defaultMarkdownResources, resourceText } from '../catalog/component-files.js';
3
+ import { pluginRootOf } from '../catalog/plugin-variables.js';
4
+ import { suiteDataDir } from '../catalog/paths.js';
3
5
  import { parseAgentRole } from '../runtime/agent-role-router.js';
4
6
  export async function projectAgentRoles(catalog, parent) {
5
7
  const cwd = parent?.session?.header?.cwd;
@@ -10,6 +12,8 @@ export async function projectAgentRoles(catalog, parent) {
10
12
  for (const suite of snapshot.enabledSuites) {
11
13
  if (suite.activeSurfaces.agents === false)
12
14
  continue;
15
+ const suiteRoot = pluginRootOf(suite);
16
+ const suiteData = suiteRoot === undefined ? undefined : suiteDataDir(catalog.dataRoot, suite.sourceId, suite.id);
13
17
  for (const resource of suite.resources?.agents ?? (await defaultMarkdownResources(suite.root, 'agents'))) {
14
18
  const path = resource.file;
15
19
  const rawText = await resourceText(resource).catch(error => {
@@ -27,7 +31,9 @@ export async function projectAgentRoles(catalog, parent) {
27
31
  rawText,
28
32
  title: policy.title ?? resource.name.replace(/\.agent$/, ''),
29
33
  description: policy.description ?? `${suite.manifest.name}: ${resource.name}`,
30
- disabled: policy.disabled
34
+ disabled: policy.disabled,
35
+ ...(suiteRoot === undefined ? {} : { suiteRoot }),
36
+ ...(suiteData === undefined ? {} : { suiteData })
31
37
  });
32
38
  }
33
39
  catch {
@@ -269,7 +269,7 @@ async function assertBoundedTree(root) {
269
269
  stack.push(path);
270
270
  continue;
271
271
  }
272
- let size = 0;
272
+ let size;
273
273
  try {
274
274
  size = (await stat(path)).size;
275
275
  }
@@ -93,6 +93,14 @@ export function sourceCheckoutDir(dimensionRoot, sourceId) {
93
93
  export function qualifiedSuiteId(sourceId, suiteId) {
94
94
  return `${sourceId}/${suiteId}`;
95
95
  }
96
+ /**
97
+ * The data directory `${PLUGIN_DATA}` resolves to for one suite: a
98
+ * source-qualified directory under the plugin storage root, shared by every
99
+ * surface of that suite so MCP servers, skills and commands see one location.
100
+ */
101
+ export function suiteDataDir(dataRoot, sourceId, suiteId) {
102
+ return `${dataRoot.replace(/[\\/]$/, '')}/${qualifiedSuiteId(sourceId, suiteId)}`;
103
+ }
96
104
  /**
97
105
  * Whether `candidate` is `root` itself or a path below it, compared on whole
98
106
  * path segments by `flavor`'s rules so a sibling whose name merely starts with
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Path-variable expansion for author-written suite text.
3
+ *
4
+ * A suite authored for Claude Code or its dialects writes `${CLAUDE_PLUGIN_ROOT}`
5
+ * (or the dialect's own spelling) into command bodies, agent personas, hook
6
+ * commands, LSP declarations, startup instructions and skill prose, and uses
7
+ * `${CLAUDE_PLUGIN_DATA}`, `${CLAUDE_PROJECT_DIR}` and `${CLAUDE_SKILL_DIR}` the
8
+ * same way. Its home ecosystem replaces those variables before the text reaches
9
+ * the model or a process; every surface here has to do the same, or the command
10
+ * runs with an empty path. One implementation keeps the surfaces agreeing on
11
+ * what each spelling means.
12
+ *
13
+ * A variable whose value the calling layer does not hold is left exactly as
14
+ * written: an author's `${NAME:-default}`-style intent is never silently
15
+ * emptied, and a surface that cannot supply a session directory does not
16
+ * invent one.
17
+ */
18
+ import { PLUGIN_DATA_VARIABLES, PLUGIN_ROOT_VARIABLES, PROJECT_DIR_VARIABLES, SKILL_DIR_VARIABLE } from '../model/layouts.js';
19
+ /** The suite root `${PLUGIN_ROOT}` and its dialect aliases resolve to. */
20
+ export function pluginRootOf(suite) {
21
+ // Project-native directories are the repository's own files, read in place;
22
+ // no plugin root exists for them, so the variable stays as the author wrote it.
23
+ return suite.manifest.layout === 'project-native' ? undefined : suite.root;
24
+ }
25
+ /** Replace every path variable the context can resolve; other text stays exactly as written. */
26
+ export function expandPluginPaths(text, context) {
27
+ return text.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (match, name) => {
28
+ if (PLUGIN_ROOT_VARIABLES.has(name))
29
+ return context.root ?? match;
30
+ if (PLUGIN_DATA_VARIABLES.has(name))
31
+ return context.data ?? match;
32
+ if (name === SKILL_DIR_VARIABLE)
33
+ return context.skillDir ?? match;
34
+ if (PROJECT_DIR_VARIABLES.has(name))
35
+ return context.projectDir ?? match;
36
+ return match;
37
+ });
38
+ }
@@ -11,6 +11,19 @@ function object(value) {
11
11
  function declarationText(value) {
12
12
  return String(value);
13
13
  }
14
+ /**
15
+ * Present one read document as the settings shape the shared validator reads:
16
+ * a standalone hook file may carry the bare event table instead of a `hooks`
17
+ * key. A recognized top-level event name is the marker — a document with no
18
+ * event name at the top stays verbatim, so an ordinary settings file missing
19
+ * `hooks` is still not accepted as a bare event table.
20
+ */
21
+ function settingsDocument(settings, format) {
22
+ if (format !== 'claude' || settings.hooks !== undefined)
23
+ return settings;
24
+ const isEventTable = Object.keys(settings).some(key => EVENTS.has(key));
25
+ return isEventTable ? { ...settings, hooks: settings } : settings;
26
+ }
14
27
  /** Merge settings layers additively, deduplicating identical event/matcher/command triples. */
15
28
  export async function discoverProjectHooks(projectRoot, files, errors, format = 'claude') {
16
29
  const documents = [];
@@ -19,7 +32,7 @@ export async function discoverProjectHooks(projectRoot, files, errors, format =
19
32
  if (settings === null)
20
33
  return undefined;
21
34
  if (settings !== undefined)
22
- documents.push({ file, settings });
35
+ documents.push({ file, settings: settingsDocument(settings, format) });
23
36
  }
24
37
  return normalizeHookDocuments(projectRoot, documents, errors, format);
25
38
  }
@@ -99,6 +99,53 @@ export function parseSkillFrontmatter(text, expectedName) {
99
99
  }
100
100
  };
101
101
  }
102
+ /** Frontmatter record for the panel's host-parity check, or the reason the YAML is unusable. */
103
+ function strictSkillRecord(text) {
104
+ const frontmatter = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(text)?.[1];
105
+ if (frontmatter === undefined)
106
+ return 'missing YAML frontmatter';
107
+ let raw;
108
+ try {
109
+ raw = parseYaml(frontmatter);
110
+ }
111
+ catch {
112
+ return 'invalid YAML frontmatter';
113
+ }
114
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw))
115
+ return 'frontmatter is not an object';
116
+ return raw;
117
+ }
118
+ /**
119
+ * Rejection for a user skill document the harness's own `dsh-skill-filesystem`
120
+ * reader would drop from `~/.agents/skills`, or `undefined` when that reader
121
+ * accepts it.
122
+ *
123
+ * The two readers share the directory, so the panel publishes only documents
124
+ * both accept: a listed entry never becomes a skill that loads in a
125
+ * market-equipped session alone. The checks mirror the shipped reader rather
126
+ * than the lenient suite scan — strict YAML, no display-name normalization,
127
+ * and a `name` the reader compares only against its kebab grammar.
128
+ * @returns the drop reason, phrased as the reader's own diagnostic.
129
+ */
130
+ export function skillEntryRejection(text) {
131
+ const record = strictSkillRecord(text);
132
+ if (typeof record === 'string')
133
+ return record;
134
+ const name = record['name'];
135
+ const description = record['description'];
136
+ if (typeof name !== 'string' || name === '' || typeof description !== 'string' || description.trim() === '') {
137
+ return 'frontmatter requires name and description';
138
+ }
139
+ if (!isSkillName(name))
140
+ return `invalid skill name "${name}"`;
141
+ const disableModel = parseBoolean(record['disable-model-invocation']);
142
+ if (disableModel === undefined && record['disable-model-invocation'] !== undefined)
143
+ return 'invalid disable-model-invocation value';
144
+ const userInvocable = parseBoolean(record['user-invocable']);
145
+ if (userInvocable === undefined && record['user-invocable'] !== undefined)
146
+ return 'invalid user-invocable value';
147
+ return undefined;
148
+ }
102
149
  /** Lenient frontmatter recovery for prose fields with embedded `: `. */
103
150
  function lenientFrontmatter(body) {
104
151
  const record = {};
@@ -15,10 +15,15 @@ import { scanSource as scanSourceWithNotes } from './suite-scanner.js';
15
15
  import { discoverNativeProjectSuites } from './native-project.js';
16
16
  /** Discover suites from the selected configured or project checkouts. */
17
17
  export async function discoverSourceList(sources, dimension, dimensionRoot) {
18
- return (await discoverSourceListWithNotes(sources, dimension, dimensionRoot)).suites;
18
+ return (await discoverSourceListWithNotes(sources, dimension, dimensionRoot, true)).suites;
19
19
  }
20
- /** Discover suites plus per-source scan diagnostics for one dimension. */
21
- export async function discoverSourceListWithNotes(sources, dimension, dimensionRoot, scanProjectLayouts = false) {
20
+ /**
21
+ * Discover suites plus per-source scan diagnostics for one dimension.
22
+ * @param scanProjectLayouts - whether the project's own native Agent directories
23
+ * join the scan. Required on purpose: the switch has one owner (the host
24
+ * settings document), so a default here would be a second copy of it.
25
+ */
26
+ export async function discoverSourceListWithNotes(sources, dimension, dimensionRoot, scanProjectLayouts) {
22
27
  const checkoutRoot = sourcesDir(dimensionRoot);
23
28
  const listed = new Set(sources.map(source => source.id));
24
29
  const checkouts = sources.map(source => ({
@@ -4,7 +4,7 @@ import { relative } from 'node:path';
4
4
  import { componentDocuments, componentPath, firstComponentFile, isRecord, isStringArray, isUnknownArray } from './component-files.js';
5
5
  import { parseLspServers } from './lsp-spec.js';
6
6
  import { normalizeHookDocuments } from './project-hooks.js';
7
- import { PLUGIN_ROOT_VARIABLES } from '../model/layouts.js';
7
+ import { expandPluginPaths } from './plugin-variables.js';
8
8
  export async function discoverSuiteLsp(root, manifest, errors) {
9
9
  const declaration = manifest.components?.lspServers;
10
10
  const fallback = await firstComponentFile(root, manifest.layout === 'github-copilot' || manifest.layout === 'universal' ? ['lsp.json', '.github/lsp.json', 'lsp-config/servers.json', '.lsp.json'] : ['.lsp.json', 'lsp.json']);
@@ -22,7 +22,7 @@ function quote(argument) {
22
22
  return `'${argument.replaceAll("'", "'\\''")}'`;
23
23
  }
24
24
  function rootVariables(text, root) {
25
- return text.replace(/\$\{([A-Z_]+)\}/g, (match, name) => (PLUGIN_ROOT_VARIABLES.has(name) ? root : match));
25
+ return expandPluginPaths(text, { root });
26
26
  }
27
27
  const EVENT_ALIASES = {
28
28
  sessionStart: 'SessionStart',
@@ -28,6 +28,7 @@ export const MARKET_ROUTES = {
28
28
  setSurface: `${MARKET_API_PREFIX}set-surface`,
29
29
  mcpOverrides: `${MARKET_API_PREFIX}mcp-overrides`,
30
30
  setMcpOverride: `${MARKET_API_PREFIX}set-mcp-override`,
31
+ setMcpServerEnabled: `${MARKET_API_PREFIX}set-mcp-server-enabled`,
31
32
  mcpRetry: `${MARKET_API_PREFIX}mcp-retry`,
32
33
  mcpReauthorize: `${MARKET_API_PREFIX}mcp-reauthorize`,
33
34
  mcpBackend: `${MARKET_API_PREFIX}mcp-backend`,
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Browser-safe settings contract of the market's host settings namespace: the
3
+ * namespace key, the fields with their defaults, and the one reader that turns
4
+ * a stored section into resolved values.
5
+ *
6
+ * The defaults live here and nowhere else. The namespace schema declares them
7
+ * to the host, the node half reads them back through {@link resolveMarketSettings},
8
+ * and the browser half — which cannot import `src/runtime` — reads the same
9
+ * resolved values off the settings snapshot it is handed. Changing a default is
10
+ * therefore a one-line edit in this module.
11
+ *
12
+ * @module contracts/settings
13
+ */
14
+ /** Settings namespace this plugin registers; the key the plugin-config tab pairs our card by. */
15
+ export const MARKET_SETTINGS_NAMESPACE = 'dsh-agent-plugins-market';
16
+ /**
17
+ * The default of every market setting — the single place one is written.
18
+ *
19
+ * These are user-visible behavior: a flip here is a behavior change that ships
20
+ * with its documentation, not a private implementation detail.
21
+ */
22
+ export const MARKET_SETTINGS_DEFAULTS = {
23
+ mcpEnhanced: true,
24
+ scanProjectLayouts: false,
25
+ downloadRegion: 'auto',
26
+ feedbackEnabled: true,
27
+ autoUpdateSources: false
28
+ };
29
+ /**
30
+ * Narrow an untrusted stored region to a setting; anything else reads as the
31
+ * declared default.
32
+ * @param value - the stored `downloadRegion` value.
33
+ * @param fallback - the value an unrecognized input reads as.
34
+ */
35
+ export function narrowDownloadRegion(value, fallback = MARKET_SETTINGS_DEFAULTS.downloadRegion) {
36
+ return value === 'auto' || value === 'global' || value === 'china' ? value : fallback;
37
+ }
38
+ /** One boolean field: a stored boolean, else the field's default. */
39
+ function narrowBoolean(value, fallback) {
40
+ return typeof value === 'boolean' ? value : fallback;
41
+ }
42
+ /**
43
+ * Resolve a stored settings section the way the host resolves it: each field
44
+ * takes its stored value when it is one this field accepts, and its declared
45
+ * default otherwise.
46
+ *
47
+ * The host hands the resolved section back on its own, but not every caller has
48
+ * one — the plugin's own halves read a section they may not have received yet,
49
+ * and a test hands in a bare literal. Funnelling all of them through this
50
+ * function is what keeps a second reading of "what does missing mean" from
51
+ * growing somewhere else.
52
+ * @param section - the stored (or absent) namespace section, untrusted.
53
+ */
54
+ export function resolveMarketSettings(section) {
55
+ const stored = (typeof section === 'object' && section !== null ? section : {});
56
+ return {
57
+ mcpEnhanced: narrowBoolean(stored['mcpEnhanced'], MARKET_SETTINGS_DEFAULTS.mcpEnhanced),
58
+ scanProjectLayouts: narrowBoolean(stored['scanProjectLayouts'], MARKET_SETTINGS_DEFAULTS.scanProjectLayouts),
59
+ downloadRegion: narrowDownloadRegion(stored['downloadRegion']),
60
+ feedbackEnabled: narrowBoolean(stored['feedbackEnabled'], MARKET_SETTINGS_DEFAULTS.feedbackEnabled),
61
+ autoUpdateSources: narrowBoolean(stored['autoUpdateSources'], MARKET_SETTINGS_DEFAULTS.autoUpdateSources)
62
+ };
63
+ }
package/lib/index.js CHANGED
@@ -4,7 +4,7 @@ import { RuntimeReconciler } from './runtime/reconciler.js';
4
4
  import { ReconcileScheduler } from './runtime/reconcile-scheduler.js';
5
5
  import { MarketSettingsNamespace } from './runtime/settings-namespace.js';
6
6
  import { deleteMcpAuthGrant } from './runtime/mcp-auth-record.js';
7
- import { inspectToolRegistry } from './runtime/tool-registry-observer.js';
7
+ import { inspectToolRegistry, toolsServiceOf } from './runtime/tool-registry-observer.js';
8
8
  import { migratePluginStorage } from './runtime/storage-migration.js';
9
9
  import { mountAgentRoleTool } from './runtime/agent-role-router.js';
10
10
  import { projectAgentRoles } from './application/project-agent-roles.js';
@@ -13,9 +13,10 @@ import { createPanelResources } from './application/panel-resources.js';
13
13
  import { resolveAgentsRoot, resolveDataRoot, resolveUserRoot } from './catalog/paths.js';
14
14
  import { mountSuiteRoutes } from './routes.js';
15
15
  import { SuiteSkillProvider } from './runtime/skills-provider.js';
16
+ import { shellSeamOf } from './runtime/dynamic-context.js';
16
17
  import { loadLspServers } from './runtime/lsp-direct-config.js';
17
18
  import { loadDisabledLspServers } from './runtime/lsp-server-state.js';
18
- import { bindHostLocale, loadHostLocale } from './runtime/host-locale.js';
19
+ import { bindHostLocale, loadHostLocale, readHostLocalePreference, setHostLocaleSource } from './runtime/host-locale.js';
19
20
  import { createUserPanelStores } from './runtime/user-panels.js';
20
21
  import { SourceAutoUpdater } from './runtime/source-auto-update.js';
21
22
  import { UserPanelSkillProvider } from './runtime/user-panels.js';
@@ -34,10 +35,20 @@ export async function apply(ctx, config = {}) {
34
35
  // Host runtime copy resolves from the harness `locale.preference` setting;
35
36
  // the async settings read lands before the first session starts in practice.
36
37
  const hostLocale = { t: bindHostLocale(undefined) };
37
- void loadHostLocale().then(locale => {
38
- hostLocale.t = locale.t;
39
- providerControl?.invalidate();
40
- userPanelControl?.invalidate();
38
+ const refreshHostLocale = () => {
39
+ void loadHostLocale().then(locale => {
40
+ hostLocale.t = locale.t;
41
+ providerControl?.invalidate();
42
+ userPanelControl?.invalidate();
43
+ });
44
+ };
45
+ void refreshHostLocale();
46
+ // The locale preference the GUI writes is owned by the host settings service:
47
+ // read it there rather than re-parsing the document, and re-bind once the
48
+ // service lands (the locale namespace may register after this plugin).
49
+ ctx.inject(['settings'], settingsCtx => {
50
+ setHostLocaleSource(() => readHostLocalePreference(settingsCtx));
51
+ refreshHostLocale();
41
52
  });
42
53
  const runtime = new RuntimeReconciler(ctx, dataRoot, key => hostLocale.t(key));
43
54
  // User panel stores (skills / commands / agent personas) and their runtime
@@ -110,9 +121,10 @@ export async function apply(ctx, config = {}) {
110
121
  // port below is read at call time: this plugin's apply may run before the
111
122
  // credentials, tools and settings services provision, and a snapshot taken
112
123
  // here would be permanently undefined even after the service is live.
113
- let toolsRegistry;
124
+ // Read at call time: the tools service provisions after apply() returns.
125
+ let toolsRegistry = toolsServiceOf(ctx);
114
126
  const ports = {
115
- mcpToolSnapshot: () => (toolsRegistry === undefined ? [] : inspectToolRegistry(toolsRegistry)),
127
+ mcpToolSnapshot: () => inspectToolRegistry(toolsRegistry),
116
128
  credentialsStore: {
117
129
  deleteGrantRecord: async (serverName) => {
118
130
  const store = ctx.get?.('credentials');
@@ -155,9 +167,12 @@ export async function apply(ctx, config = {}) {
155
167
  await scheduler.request();
156
168
  })
157
169
  .catch(() => { });
170
+ // The shell seam resolves lazily: the service may land after this plugin, and
171
+ // a profile without one keeps dynamic-context placeholders literal.
172
+ const shellSeam = () => shellSeamOf(ctx);
158
173
  ctx.skills.registerProvider(control => {
159
174
  providerControl = control;
160
- return new SuiteSkillProvider(catalog);
175
+ return new SuiteSkillProvider(catalog, { dataRoot, shell: shellSeam });
161
176
  });
162
177
  // User panel skills ride a second provider so a panel
163
178
  // edit invalidates only its own catalog contribution.
@@ -173,10 +188,10 @@ export async function apply(ctx, config = {}) {
173
188
  });
174
189
  ctx.inject(['agents'], hostCtx => {
175
190
  hostCtx.effect(() => {
176
- const mounted = mountProjectCommands(hostCtx, catalog, key => hostLocale.t(key));
191
+ const mounted = mountProjectCommands(hostCtx, catalog, key => hostLocale.t(key), dataRoot);
177
192
  const mcp = mountProjectMcp(hostCtx, catalog, dataRoot);
178
193
  const hooks = mountProjectHooks(hostCtx, catalog);
179
- const prompts = mountSuiteInstructions(hostCtx, catalog);
194
+ const prompts = mountSuiteInstructions(hostCtx, catalog, dataRoot);
180
195
  projectCommands = mounted;
181
196
  projectMcp = mcp;
182
197
  projectHooks = hooks;
@@ -15,6 +15,10 @@ export const MANIFEST_ALIASES = { kimi: ['kimi.plugin.json'] };
15
15
  /** Equivalent plugin-owned path variables; these never resolve as credentials. */
16
16
  export const PLUGIN_ROOT_VARIABLES = new Set(['PLUGIN_ROOT', 'CLAUDE_PLUGIN_ROOT', 'CODEX_PLUGIN_ROOT', 'ZCODE_PLUGIN_ROOT', 'QODER_PLUGIN_ROOT']);
17
17
  export const PLUGIN_DATA_VARIABLES = new Set(['PLUGIN_DATA', 'CLAUDE_PLUGIN_DATA', 'ZCODE_PLUGIN_DATA', 'QODER_PLUGIN_DATA']);
18
+ /** Equivalent session project-directory variables; only a calling session supplies the directory. */
19
+ export const PROJECT_DIR_VARIABLES = new Set(['CLAUDE_PROJECT_DIR', 'ZCODE_PROJECT_DIR', 'QODER_PROJECT_DIR']);
20
+ /** Claude Code names the directory holding the skill's own file; no dialect alias exists. */
21
+ export const SKILL_DIR_VARIABLE = 'CLAUDE_SKILL_DIR';
18
22
  export const PROJECT_LAYOUTS = [
19
23
  {
20
24
  dirName: '.claude',
@@ -23,7 +27,12 @@ export const PROJECT_LAYOUTS = [
23
27
  mcpFiles: ['.mcp.json'],
24
28
  hookFiles: ['.claude/settings.json', '.claude/settings.local.json']
25
29
  },
26
- { dirName: '.agents', label: 'agents', subdirs: ['skills', 'agents', 'commands'] },
30
+ {
31
+ dirName: '.agents',
32
+ label: 'agents',
33
+ subdirs: ['skills', 'agents', 'commands'],
34
+ hookFiles: ['.agents/hooks/hooks.json', '.agents/hooks.json']
35
+ },
27
36
  { dirName: '.codex', label: 'Codex', subdirs: ['skills'], mcpFiles: ['.codex/config.toml'], mcpFormat: 'codex' },
28
37
  { dirName: '.cursor', label: 'Cursor', subdirs: ['skills', 'agents', 'commands'], mcpFiles: ['.cursor/mcp.json'] },
29
38
  { dirName: '.kimi', label: 'Kimi', subdirs: ['skills'] },
package/lib/routes.js CHANGED
@@ -257,6 +257,18 @@ export function mountSuiteRoutes(hostCtx, manager, panels) {
257
257
  await manager.setMcpOverride(sourceId, suiteId, serverKey, sanitized ?? null);
258
258
  return {};
259
259
  });
260
+ // Enable or disable one declared MCP server. Addressed by the same
261
+ // source-qualified suite id the status rows carry, so the card can toggle a
262
+ // server without holding the source and suite ids apart.
263
+ post(MARKET_ROUTES.setMcpServerEnabled, async (body) => {
264
+ const suiteKey = textField(body['suiteId'] ?? '', 'MCP suite id');
265
+ const serverKey = textField(body['serverKey'] ?? '', 'MCP server key');
266
+ const enabled = body['enabled'];
267
+ if (typeof enabled !== 'boolean')
268
+ throw new Error('missing boolean enabled');
269
+ await manager.setMcpServerEnabled(suiteKey, serverKey, enabled);
270
+ return {};
271
+ });
260
272
  // Manual MCP reconcile: retries failed mounts and clears residual tools
261
273
  // without touching any catalog state.
262
274
  post(MARKET_ROUTES.mcpRetry, async () => {