dsh-agent-plugins-market 0.5.0 → 0.5.2

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 (41) hide show
  1. package/README.md +4 -2
  2. package/README.zh.md +4 -2
  3. package/client/client.js +310 -17
  4. package/client/client.js.map +1 -1
  5. package/lib/application/catalog.js +101 -4
  6. package/lib/application/details.js +4 -1
  7. package/lib/catalog/native-project.js +73 -0
  8. package/lib/catalog/source-catalog.js +12 -3
  9. package/lib/contracts/market.js +4 -1
  10. package/lib/index.js +12 -5
  11. package/lib/model/state.js +19 -1
  12. package/lib/model/types.js +12 -1
  13. package/lib/routes.js +37 -0
  14. package/lib/runtime/commands-mounts.js +15 -6
  15. package/lib/runtime/hooks-mounts.js +3 -2
  16. package/lib/runtime/host-locale.js +54 -0
  17. package/lib/runtime/mcp-config.js +11 -4
  18. package/lib/runtime/mcp-mounts.js +33 -17
  19. package/lib/runtime/mcp-overrides.js +113 -0
  20. package/lib/runtime/mcp-status.js +8 -4
  21. package/lib/runtime/reconciler.js +7 -2
  22. package/lib/runtime/skills-provider.js +22 -13
  23. package/lib/types/application/catalog.d.ts +30 -2
  24. package/lib/types/application/details.d.ts +2 -2
  25. package/lib/types/application/queries.d.ts +5 -1
  26. package/lib/types/catalog/native-project.d.ts +17 -0
  27. package/lib/types/contracts/market.d.ts +26 -0
  28. package/lib/types/index.d.ts +4 -3
  29. package/lib/types/model/state.d.ts +1 -1
  30. package/lib/types/model/types.d.ts +13 -1
  31. package/lib/types/runtime/commands-mounts.d.ts +4 -2
  32. package/lib/types/runtime/host-locale.d.ts +13 -0
  33. package/lib/types/runtime/mcp-config.d.ts +5 -1
  34. package/lib/types/runtime/mcp-mounts.d.ts +12 -3
  35. package/lib/types/runtime/mcp-overrides.d.ts +37 -0
  36. package/lib/types/runtime/mcp-status.d.ts +2 -1
  37. package/lib/types/runtime/reconciler.d.ts +4 -1
  38. package/lib/types/runtime/skills-provider.d.ts +3 -1
  39. package/package.json +1 -1
  40. package/lib/context.js +0 -78
  41. package/lib/types/context.d.ts +0 -13
@@ -14,9 +14,11 @@ import { discoverSourceList } from '../catalog/source-catalog.js';
14
14
  import { discoverSuitesInSource } from '../catalog/suite-scanner.js';
15
15
  import { gitClone, gitHead, gitPull, gitRemove } from '../catalog/git.js';
16
16
  import { buildMcpStatus } from '../runtime/mcp-status.js';
17
+ import { loadSuiteOverrides, saveSuiteOverrides } from '../runtime/mcp-overrides.js';
17
18
  import { buildSuiteDetail, readSkillContent } from './details.js';
18
19
  import { deriveSourceId, expandHome, isDirectory, resolveProjectRoot, sanitizeId, sourceCheckoutDir, sourcesDir, STATE_FILE_NAME } from '../catalog/paths.js';
19
20
  import { loadState, saveState, EMPTY_STATE } from '../model/state.js';
21
+ import { effectiveSurfaces, SUITE_SURFACE_KEYS } from '../model/types.js';
20
22
  export class Catalog {
21
23
  options;
22
24
  state = EMPTY_STATE;
@@ -27,12 +29,17 @@ export class Catalog {
27
29
  revision = 0;
28
30
  userSnapshot;
29
31
  userSnapshotPromise;
32
+ /** Project-dimension snapshots keyed by project root, with TTL freshness. */
33
+ projectSnapshots = new Map();
34
+ projectSnapshotPromises = new Map();
35
+ projectSnapshotTtlMs;
30
36
  /** Latest MCP mount diagnostics (suiteId -> reasons), fed by host reconcile. */
31
37
  mcpDiagnostics = [];
32
38
  toolSnapshotProvider = () => [];
33
39
  constructor(options) {
34
40
  this.options = options;
35
41
  this.statePath = join(options.userRoot, STATE_FILE_NAME);
42
+ this.projectSnapshotTtlMs = options.projectSnapshotTtlMs ?? 5_000;
36
43
  }
37
44
  /** Install the host tool snapshot provider used by the MCP status surface. */
38
45
  setMcpToolSnapshotProvider(provider) {
@@ -46,7 +53,55 @@ export class Catalog {
46
53
  /** Build the flat MCP service inventory for the status surface. */
47
54
  async mcpStatus() {
48
55
  const snapshot = await this.readUserCatalog();
49
- return buildMcpStatus(snapshot.suites, this.mcpDiagnostics, this.toolSnapshotProvider());
56
+ return buildMcpStatus(snapshot.suites, this.mcpDiagnostics, this.toolSnapshotProvider(), await this.allMcpOverrides());
57
+ }
58
+ /** One suite's persisted MCP overrides. */
59
+ async mcpOverrides(suiteId) {
60
+ const snapshot = await this.readUserCatalog();
61
+ if (!snapshot.suites.some(suite => suite.id === suiteId))
62
+ throw new Error(`suite "${suiteId}" not found`);
63
+ return loadSuiteOverrides(this.options.dataRoot, suiteId);
64
+ }
65
+ /**
66
+ * Set or clear one server's MCP override and remount it. `override === null`
67
+ * clears the override (back to the source config). The serverKey must exist
68
+ * in the suite's parsed mcp.json.
69
+ */
70
+ async setMcpOverride(sourceId, suiteId, serverKey, override) {
71
+ return this.enqueue(async () => {
72
+ const source = this.state.sources.find(entry => entry.id === sourceId);
73
+ if (source === undefined)
74
+ throw new Error(`unknown source "${sourceId}"`);
75
+ const checkout = this.sourceCheckoutPath(source);
76
+ if (!(await isDirectory(checkout)))
77
+ await this.ensureClone(source);
78
+ const suites = await discoverSuitesInSource(checkout, sourceId, 'user');
79
+ const suite = suites.find(entry => entry.id === suiteId);
80
+ if (suite === undefined)
81
+ throw new Error(`suite "${suiteId}" not found in source "${sourceId}"`);
82
+ if (suite.mcp?.servers[serverKey] === undefined)
83
+ throw new Error(`server "${serverKey}" is not defined by suite "${suiteId}"`);
84
+ const overrides = await loadSuiteOverrides(this.options.dataRoot, suiteId);
85
+ if (override === null) {
86
+ delete overrides[serverKey];
87
+ }
88
+ else {
89
+ overrides[serverKey] = override;
90
+ }
91
+ await saveSuiteOverrides(this.options.dataRoot, suiteId, overrides);
92
+ this.notifyChanged();
93
+ });
94
+ }
95
+ /** All persisted MCP overrides keyed by suite id (mount-time provider). */
96
+ async allMcpOverrides() {
97
+ const snapshot = await this.readUserCatalog();
98
+ const map = new Map();
99
+ for (const suite of snapshot.suites) {
100
+ const overrides = await loadSuiteOverrides(this.options.dataRoot, suite.id);
101
+ if (Object.keys(overrides).length > 0)
102
+ map.set(suite.id, overrides);
103
+ }
104
+ return map;
50
105
  }
51
106
  /** Load persisted user state once at plugin activation. */
52
107
  async load() {
@@ -77,6 +132,26 @@ export class Catalog {
77
132
  /** Read one coherent project-dimension snapshot for a workspace cwd. */
78
133
  async readProjectCatalog(cwd) {
79
134
  const projectRoot = await resolveProjectRoot(cwd);
135
+ if (this.projectSnapshotTtlMs <= 0)
136
+ return this.buildProjectSnapshot(projectRoot);
137
+ const cached = this.projectSnapshots.get(projectRoot);
138
+ if (cached !== undefined && cached.expiresAt > Date.now())
139
+ return cached.snapshot;
140
+ const inFlight = this.projectSnapshotPromises.get(projectRoot);
141
+ if (inFlight !== undefined)
142
+ return inFlight;
143
+ const promise = this.buildProjectSnapshot(projectRoot)
144
+ .then(snapshot => {
145
+ this.projectSnapshots.set(projectRoot, { snapshot, expiresAt: Date.now() + this.projectSnapshotTtlMs });
146
+ return snapshot;
147
+ })
148
+ .finally(() => {
149
+ this.projectSnapshotPromises.delete(projectRoot);
150
+ });
151
+ this.projectSnapshotPromises.set(projectRoot, promise);
152
+ return promise;
153
+ }
154
+ async buildProjectSnapshot(projectRoot) {
80
155
  const state = await loadState(join(projectRoot, STATE_FILE_NAME));
81
156
  return this.buildSnapshot(state, 'project', projectRoot);
82
157
  }
@@ -86,7 +161,7 @@ export class Catalog {
86
161
  const suite = snapshot.suites.find(entry => entry.sourceId === sourceId && entry.id === suiteId);
87
162
  if (suite === undefined)
88
163
  throw new Error(`suite "${suiteId}" not found in source "${sourceId}"`);
89
- return buildSuiteDetail(suite, this.state.installed[installKey(sourceId, suiteId)], this.mcpDiagnostics);
164
+ return buildSuiteDetail(suite, this.state.installed[installKey(sourceId, suiteId)], this.mcpDiagnostics, await loadSuiteOverrides(this.options.dataRoot, suiteId));
90
165
  }
91
166
  /** One skill's full SKILL.md text for the market detail modal. */
92
167
  async skillContent(sourceId, suiteId, skillName) {
@@ -156,6 +231,7 @@ export class Catalog {
156
231
  surfaces: suite.surfaces,
157
232
  enabled: suite.enabled,
158
233
  installed: installed.has(installKey(suite.sourceId, suite.id)),
234
+ ...(installed.has(installKey(suite.sourceId, suite.id)) ? { surfaceToggles: suite.activeSurfaces } : {}),
159
235
  ...(suite.remote === undefined ? {} : { remoteUrl: suite.remote.url }),
160
236
  dimension: suite.dimension,
161
237
  layout: suite.manifest.layout,
@@ -327,7 +403,9 @@ export class Catalog {
327
403
  this.notifyChanged();
328
404
  });
329
405
  }
330
- /** Install a suite from a source and enable it. */
406
+ /** Install a suite from a source and enable it. The client confirms the
407
+ * injected surfaces (skills / MCP / hooks / commands) in a pre-install
408
+ * dialog before this runs; disabling afterwards is always available. */
331
409
  async install(sourceId, suiteId) {
332
410
  return this.enqueue(async () => {
333
411
  const source = this.state.sources.find(entry => entry.id === sourceId);
@@ -369,6 +447,20 @@ export class Catalog {
369
447
  this.notifyChanged();
370
448
  });
371
449
  }
450
+ /** Enable or disable one runtime surface of an installed suite. */
451
+ async setSurface(sourceId, suiteId, surface, enabled) {
452
+ return this.enqueue(async () => {
453
+ const key = installKey(sourceId, suiteId);
454
+ const entry = this.state.installed[key];
455
+ if (entry === undefined)
456
+ throw new Error(`suite "${suiteId}" is not installed`);
457
+ if (!SUITE_SURFACE_KEYS.includes(surface))
458
+ throw new Error(`surface "${surface}" is not toggleable`);
459
+ const surfaces = { ...(entry.surfaces ?? {}), [surface]: enabled };
460
+ await this.setInstalled(sourceId, suiteId, { ...entry, surfaces });
461
+ this.notifyChanged();
462
+ });
463
+ }
372
464
  /** Append config-seeded sources missing from user state and persist them. */
373
465
  async mergeSources(sources) {
374
466
  const existing = new Set(this.state.sources.map(source => source.id));
@@ -401,9 +493,13 @@ export class Catalog {
401
493
  const discovered = await discoverSourceList(state.sources, dimension, dimensionRoot);
402
494
  const suites = discovered.map(suite => {
403
495
  const installed = state.installed[installKey(suite.sourceId, suite.id)];
496
+ // Native project layouts (`.claude/`, `.agents/`) are the repository's
497
+ // own files read in place: they carry no install state and stay enabled.
498
+ const enabled = suite.manifest.layout === 'project-native' || installed?.enabled === true;
404
499
  return {
405
500
  ...suite,
406
- enabled: installed?.enabled === true,
501
+ enabled,
502
+ activeSurfaces: effectiveSurfaces(installed?.surfaces),
407
503
  ...(installed?.lockCommit === undefined ? {} : { lockCommit: installed.lockCommit }),
408
504
  ...(installed?.installedAt === undefined ? {} : { installedAt: installed.installedAt })
409
505
  };
@@ -420,6 +516,7 @@ export class Catalog {
420
516
  this.revision++;
421
517
  this.userSnapshot = undefined;
422
518
  this.userSnapshotPromise = undefined;
519
+ this.projectSnapshots.clear();
423
520
  }
424
521
  notifyChanged() {
425
522
  this.invalidateSnapshot(true);
@@ -2,8 +2,9 @@
2
2
  import { readFile } from 'node:fs/promises';
3
3
  import { parse as parseYaml } from 'yaml';
4
4
  import { discoverLspEntries, listMdFiles } from '../catalog/surfaces.js';
5
+ import { effectiveSurfaces } from '../model/types.js';
5
6
  /** Build the detail response for one normalized suite. */
6
- export async function buildSuiteDetail(suite, installed, diagnostics) {
7
+ export async function buildSuiteDetail(suite, installed, diagnostics, mcpOverrides = {}) {
7
8
  const remoteUrl = suite.remote?.url;
8
9
  return {
9
10
  sourceId: suite.sourceId,
@@ -19,6 +20,8 @@ export async function buildSuiteDetail(suite, installed, diagnostics) {
19
20
  remoteUrl: remoteUrl ?? null,
20
21
  installed: installed !== undefined,
21
22
  enabled: installed?.enabled === true,
23
+ surfaceToggles: effectiveSurfaces(installed?.surfaces),
24
+ mcpOverrides,
22
25
  skills: suite.skills.map(skill => ({
23
26
  name: skill.name,
24
27
  description: skill.description,
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Native project-layout discovery: read a coding agent's own project-local
3
+ * directories (`.claude/`, `.agents/`) in place as read-only suites.
4
+ *
5
+ * Migrating a repository from Claude Code / Codex / Cursor should not
6
+ * require copying files into `.dsh/agent-plugins/`. For the layouts with a
7
+ * project-local convention (Claude Code `.claude/`, agent-plugins
8
+ * `.agents/`), the project dimension discovers each content-carrying child
9
+ * as one synthetic suite so skills and agents reuse the ordinary injection
10
+ * pipeline. The directories are the repository's own: they are never
11
+ * installed, uninstalled, or mutated by this manager.
12
+ */
13
+ import { join } from 'node:path';
14
+ import { isDirectory, sanitizeId } from './paths.js';
15
+ import { countSurfaces, discoverSkills } from './surfaces.js';
16
+ /**
17
+ * Project-local directory conventions, in precedence order. Each entry maps
18
+ * one agent convention onto the content subdirectories it owns.
19
+ */
20
+ export const NATIVE_PROJECT_DIRS = [
21
+ { dirName: '.claude', label: 'Claude Code', subdirs: ['skills', 'agents', 'commands'] },
22
+ { dirName: '.agents', label: 'agents', subdirs: ['skills', 'agents', 'commands'] }
23
+ ];
24
+ /** A native project directory that carries no relevant content is skipped. */
25
+ async function carriesContent(dir, projectRoot) {
26
+ for (const subdir of dir.subdirs) {
27
+ const path = join(projectRoot, dir.dirName, subdir);
28
+ if (await isDirectory(path))
29
+ return true;
30
+ }
31
+ return false;
32
+ }
33
+ /**
34
+ * Discover native project-layout suites under a project root. Each
35
+ * convention directory that carries content becomes one synthetic suite;
36
+ * empty or absent directories contribute nothing.
37
+ */
38
+ export async function discoverNativeProjectSuites(projectRoot, dimension) {
39
+ const suites = [];
40
+ for (const native of NATIVE_PROJECT_DIRS) {
41
+ if (!(await isDirectory(join(projectRoot, native.dirName))))
42
+ continue;
43
+ if (!(await carriesContent(native, projectRoot)))
44
+ continue;
45
+ const root = join(projectRoot, native.dirName);
46
+ const id = sanitizeId(`${native.dirName}-native`);
47
+ const manifest = {
48
+ layout: 'project-native',
49
+ path: join(root, 'native'),
50
+ id,
51
+ name: `${native.label} project files`,
52
+ description: `Skills and agents read in place from the project's ${native.dirName}/ directory.`
53
+ };
54
+ const errors = [];
55
+ // Skills live in `<dir>/skills/<name>/SKILL.md` — the same shape as the
56
+ // ordinary skills container; agents/commands only surface as counts here
57
+ // and are read by the runtime providers directly from the same files.
58
+ const skills = await discoverSkills(root, errors, 'skills');
59
+ const surfaces = await countSurfaces(root, skills, undefined);
60
+ suites.push({
61
+ sourceId: 'native',
62
+ id,
63
+ root,
64
+ manifest,
65
+ skills,
66
+ surfaces,
67
+ dimension,
68
+ enabled: true,
69
+ errors
70
+ });
71
+ }
72
+ return suites;
73
+ }
@@ -3,12 +3,15 @@
3
3
  *
4
4
  * User catalogs include only configured sources. Project catalogs additionally
5
5
  * retain unmanaged checkout ids because project install state can authorize
6
- * them without duplicating source configuration.
6
+ * them without duplicating source configuration, and discover the project's
7
+ * native agent directories (`.claude/`, `.agents/`) in place so repositories
8
+ * migrating from other coding agents need no file copying.
7
9
  */
8
10
  import { readdir } from 'node:fs/promises';
9
- import { join } from 'node:path';
11
+ import { dirname, join } from 'node:path';
10
12
  import { expandHome, isDirectory, sourcesDir } from './paths.js';
11
13
  import { discoverSuitesInSource } from './suite-scanner.js';
14
+ import { discoverNativeProjectSuites } from './native-project.js';
12
15
  /** Discover suites from the selected configured or project checkouts. */
13
16
  export async function discoverSourceList(sources, dimension, dimensionRoot) {
14
17
  const checkoutRoot = sourcesDir(dimensionRoot);
@@ -34,5 +37,11 @@ export async function discoverSourceList(sources, dimension, dimensionRoot) {
34
37
  return [];
35
38
  return discoverSuitesInSource(checkout, sourceId, dimension);
36
39
  }));
37
- return discovered.flat();
40
+ const suites = discovered.flat();
41
+ if (dimension === 'project') {
42
+ // Native project directories live two levels above the dimension root
43
+ // (`<projectRoot>/.dsh/agent-plugins`); read them in place.
44
+ suites.push(...(await discoverNativeProjectSuites(dirname(dirname(dimensionRoot)), dimension)));
45
+ }
46
+ return suites;
38
47
  }
@@ -15,7 +15,10 @@ export const MARKET_ROUTES = {
15
15
  refreshSource: `${MARKET_API_PREFIX}sources/refresh`,
16
16
  install: `${MARKET_API_PREFIX}install`,
17
17
  uninstall: `${MARKET_API_PREFIX}uninstall`,
18
- setEnabled: `${MARKET_API_PREFIX}set-enabled`
18
+ setEnabled: `${MARKET_API_PREFIX}set-enabled`,
19
+ setSurface: `${MARKET_API_PREFIX}set-surface`,
20
+ mcpOverrides: `${MARKET_API_PREFIX}mcp-overrides`,
21
+ setMcpOverride: `${MARKET_API_PREFIX}set-mcp-override`
19
22
  };
20
23
  /** Build a suite-detail URL without duplicating route or query encoding logic. */
21
24
  export function suiteRoute(sourceId, suiteId) {
package/lib/index.js CHANGED
@@ -1,17 +1,25 @@
1
- import { mountSuiteContext } from './context.js';
2
1
  import { Catalog } from './application/catalog.js';
3
2
  import { RuntimeReconciler } from './runtime/reconciler.js';
4
3
  import { inspectToolRegistry } from './runtime/tool-registry-observer.js';
5
4
  import { resolveDataRoot, resolveUserRoot } from './catalog/paths.js';
6
5
  import { mountSuiteRoutes } from './routes.js';
7
6
  import { SuiteSkillProvider } from './runtime/skills-provider.js';
7
+ import { bindHostLocale, loadHostLocale } from './runtime/host-locale.js';
8
8
  export const name = 'dsh-agent-plugins-market';
9
9
  export const inject = ['skills', 'commands'];
10
10
  export function apply(ctx, config = {}) {
11
11
  const userRoot = resolveUserRoot(config.userRoot);
12
12
  const dataRoot = resolveDataRoot(config.dataRoot);
13
13
  let providerControl;
14
- const runtime = new RuntimeReconciler(ctx, dataRoot);
14
+ // Host runtime copy resolves from the harness `locale.preference` setting;
15
+ // the async settings read lands before the first session starts in practice.
16
+ const t = bindHostLocale(undefined);
17
+ void loadHostLocale().then(locale => {
18
+ hostLocale.t = locale.t;
19
+ providerControl?.invalidate();
20
+ });
21
+ const hostLocale = { t };
22
+ const runtime = new RuntimeReconciler(ctx, dataRoot, key => hostLocale.t(key));
15
23
  const reconcileMounts = () => {
16
24
  void (async () => {
17
25
  const snapshot = await catalog.readUserCatalog();
@@ -39,6 +47,7 @@ export function apply(ctx, config = {}) {
39
47
  reconcileMounts();
40
48
  };
41
49
  const catalog = new Catalog({ userRoot, dataRoot, onChanged });
50
+ runtime.setMcpOverridesProvider(() => catalog.allMcpOverrides());
42
51
  ctx.inject(['tools'], toolsCtx => {
43
52
  catalog.setMcpToolSnapshotProvider(() => inspectToolRegistry(toolsCtx.tools));
44
53
  });
@@ -48,14 +57,12 @@ export function apply(ctx, config = {}) {
48
57
  });
49
58
  ctx.skills.registerProvider(control => {
50
59
  providerControl = control;
51
- return new SuiteSkillProvider(catalog);
60
+ return new SuiteSkillProvider(catalog, key => hostLocale.t(key));
52
61
  });
53
- const disposeContext = mountSuiteContext(ctx, catalog);
54
62
  ctx.inject(['webServer', 'loader'], hostCtx => {
55
63
  hostCtx.effect(() => mountSuiteRoutes(hostCtx, catalog), 'dsh-agent-plugins-market: http routes');
56
64
  });
57
65
  ctx.effect(() => () => {
58
66
  void runtime.dispose();
59
- disposeContext();
60
67
  }, 'dsh-agent-plugins-market: lifecycle');
61
68
  }
@@ -8,6 +8,7 @@
8
8
  */
9
9
  import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
10
10
  import { dirname } from 'node:path';
11
+ import { SUITE_SURFACE_KEYS } from './types.js';
11
12
  export const EMPTY_STATE = { version: 1, sources: [], installed: {} };
12
13
  /** Parse persisted state; unreadable or wrong-version files yield a contained empty state. */
13
14
  export async function loadState(statePath) {
@@ -49,12 +50,29 @@ function normalizeState(record) {
49
50
  installed[key] = {
50
51
  enabled: entry['enabled'] === true,
51
52
  lockCommit: typeof entry['lockCommit'] === 'string' ? entry['lockCommit'] : undefined,
52
- installedAt: typeof entry['installedAt'] === 'string' ? entry['installedAt'] : new Date(0).toISOString()
53
+ installedAt: typeof entry['installedAt'] === 'string' ? entry['installedAt'] : new Date(0).toISOString(),
54
+ ...parseSurfaceOverrides(entry['surfaces'])
53
55
  };
54
56
  }
55
57
  }
56
58
  return { version: 1, sources, installed };
57
59
  }
60
+ /** Parse a persisted `surfaces` override record, keeping only valid boolean keys. */
61
+ function parseSurfaceOverrides(raw) {
62
+ if (typeof raw !== 'object' || raw === null)
63
+ return {};
64
+ const record = raw;
65
+ const overrides = {};
66
+ let hasAny = false;
67
+ for (const key of SUITE_SURFACE_KEYS) {
68
+ const value = record[key];
69
+ if (typeof value === 'boolean') {
70
+ overrides[key] = value;
71
+ hasAny = true;
72
+ }
73
+ }
74
+ return hasAny ? { surfaces: overrides } : {};
75
+ }
58
76
  /** Persist state atomically through a sibling-temp rename. */
59
77
  export async function saveState(statePath, state) {
60
78
  await mkdir(dirname(statePath), { recursive: true });
@@ -7,4 +7,15 @@
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
- export {};
10
+ /** The full set of toggleable surfaces, in display order. */
11
+ export const SUITE_SURFACE_KEYS = ['skills', 'mcp', 'hooks', 'commands', 'agents'];
12
+ /** Merge user overrides over the enabled default into the effective surface set. */
13
+ export function effectiveSurfaces(overrides) {
14
+ return {
15
+ skills: overrides?.skills !== false,
16
+ mcp: overrides?.mcp !== false,
17
+ hooks: overrides?.hooks !== false,
18
+ commands: overrides?.commands !== false,
19
+ agents: overrides?.agents !== false
20
+ };
21
+ }
package/lib/routes.js CHANGED
@@ -9,6 +9,7 @@
9
9
  import { isAbsolute } from 'node:path';
10
10
  import { MARKET_ROUTES } from './contracts/market.js';
11
11
  import { expandHome } from './catalog/paths.js';
12
+ import { sanitizeOverridePatch } from './runtime/mcp-overrides.js';
12
13
  const MAX_BODY_BYTES = 64 * 1024;
13
14
  /** Mount every route; returns the disposer releasing them all. */
14
15
  export function mountSuiteRoutes(hostCtx, manager) {
@@ -152,6 +153,42 @@ export function mountSuiteRoutes(hostCtx, manager) {
152
153
  await manager.setEnabled(sourceId, suiteId, enabled);
153
154
  return {};
154
155
  });
156
+ post(MARKET_ROUTES.setSurface, async (body) => {
157
+ const { sourceId, suiteId } = parseTarget(body);
158
+ const surface = body['surface'];
159
+ const enabled = body['enabled'];
160
+ if (typeof surface !== 'string' || surface === '')
161
+ throw new Error('missing surface');
162
+ if (typeof enabled !== 'boolean')
163
+ throw new Error('missing boolean enabled');
164
+ await manager.setSurface(sourceId, suiteId, surface, enabled);
165
+ return {};
166
+ });
167
+ 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' });
171
+ return;
172
+ }
173
+ try {
174
+ sendJson(response, 200, { ok: true, overrides: await manager.mcpOverrides(suiteId) });
175
+ }
176
+ catch (error) {
177
+ sendJson(response, 404, { ok: false, error: error instanceof Error ? error.message : String(error) });
178
+ }
179
+ });
180
+ post(MARKET_ROUTES.setMcpOverride, async (body) => {
181
+ const { sourceId, suiteId } = parseTarget(body);
182
+ const serverKey = body['serverKey'];
183
+ if (typeof serverKey !== 'string' || serverKey === '')
184
+ throw new Error('missing serverKey');
185
+ const override = body['override'];
186
+ const sanitized = sanitizeOverridePatch(override);
187
+ if (override !== null && sanitized === undefined)
188
+ throw new Error('invalid override payload');
189
+ await manager.setMcpOverride(sourceId, suiteId, serverKey, sanitized ?? null);
190
+ return {};
191
+ });
155
192
  return () => {
156
193
  for (const dispose of disposers)
157
194
  dispose();
@@ -14,19 +14,28 @@ 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 { bindHostLocale } from './host-locale.js';
17
18
  const COMMAND_NAME = /^[a-z][a-z0-9_-]*$/;
18
19
  export class CommandMountRegistry {
19
20
  ctx;
21
+ t;
20
22
  live = new Map();
21
- constructor(ctx) {
23
+ constructor(ctx, t = bindHostLocale(undefined)) {
22
24
  this.ctx = ctx;
25
+ this.t = t;
23
26
  }
24
27
  /** Register/unregister suite commands and agent-commands to match the enabled suites exactly. */
25
28
  async reconcile(enabledSuites) {
26
29
  const diagnostics = [];
27
30
  const wanted = new Map();
28
31
  for (const suite of enabledSuites) {
29
- for (const spec of [...(await readCommands(suite.root)), ...(await readAgents(suite.root))]) {
32
+ const specs = suite.activeSurfaces?.commands === false && suite.activeSurfaces?.agents === false
33
+ ? []
34
+ : [
35
+ ...(suite.activeSurfaces?.commands === false ? [] : await readCommands(suite.root)),
36
+ ...(suite.activeSurfaces?.agents === false ? [] : await readAgents(suite.root, this.t))
37
+ ];
38
+ for (const spec of specs) {
30
39
  const key = `${suite.id}/${spec.name}`;
31
40
  wanted.set(key, { ...spec, suiteId: suite.id, suiteName: suite.manifest.name });
32
41
  }
@@ -53,9 +62,9 @@ export class CommandMountRegistry {
53
62
  ...(spec.hint === undefined ? {} : { input: { hint: spec.hint } }),
54
63
  handler: invocation => {
55
64
  const agent = invocation.agent;
56
- const text = [`[Agent Plugins 命令 /${spec.name}(来自 ${spec.suiteId})]`, '', spec.body.replaceAll('$ARGUMENTS', invocation.rawInput.trim())].join('\n');
65
+ const text = [this.t('commandForwardTitle', { command: spec.name, suite: spec.suiteId }), '', spec.body.replaceAll('$ARGUMENTS', invocation.rawInput.trim())].join('\n');
57
66
  agent.followup({ content: [{ type: 'text', text }], source: { kind: 'plugin', plugin: 'dsh-agent-plugins-market' } });
58
- return { kind: 'success', text: `/${spec.name} 已转交模型执行(${spec.suiteId})` };
67
+ return { kind: 'success', text: this.t('commandAcknowledged', { command: spec.name, suite: spec.suiteId }) };
59
68
  }
60
69
  });
61
70
  this.live.set(key, disposer);
@@ -107,7 +116,7 @@ export async function readCommands(root) {
107
116
  /** Parse `agents/*.md` of one suite root into `agent-<name>` commands so
108
117
  * subagents are selectable from the slash-command menu, grouped by the
109
118
  * `agent-` prefix (the harness command UI has no group headers). */
110
- export async function readAgents(root) {
119
+ export async function readAgents(root, t) {
111
120
  let entries;
112
121
  try {
113
122
  entries = await readdir(join(root, 'agents'));
@@ -133,7 +142,7 @@ export async function readAgents(root) {
133
142
  const description = typeof parsed === 'string' ? parsed : parsed.description;
134
143
  if (description === undefined)
135
144
  continue;
136
- specs.push({ name, description, hint: '子代理', body: stripFrontmatter(text) });
145
+ specs.push({ name, description, hint: t('agentCommandHint'), body: stripFrontmatter(text) });
137
146
  }
138
147
  return specs;
139
148
  }
@@ -21,13 +21,14 @@ export class HooksMountRegistry {
21
21
  /** Mount/unmount one bridge per suite to match the enabled suites exactly. */
22
22
  async reconcile(enabledSuites) {
23
23
  const diagnostics = [];
24
- const wanted = new Set(enabledSuites.map(suite => suite.id));
24
+ const active = enabledSuites.filter(suite => suite.activeSurfaces?.hooks !== false);
25
+ const wanted = new Set(active.map(suite => suite.id));
25
26
  for (const [suiteId, handle] of [...this.live]) {
26
27
  if (!wanted.has(suiteId)) {
27
28
  await this.unmount(suiteId, handle);
28
29
  }
29
30
  }
30
- for (const suite of enabledSuites) {
31
+ for (const suite of active) {
31
32
  if (this.live.has(suite.id))
32
33
  continue;
33
34
  const reason = await this.mount(suite);
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Host-side runtime locale: bilingual copy for strings injected into agent
3
+ * context (agent-definition wrappers and command acknowledgements), resolved
4
+ * from the harness's `locale.preference` setting.
5
+ *
6
+ * The web client resolves locale through its own injected service; the host
7
+ * process has no such service, so this module reads `$DSH_HOME/settings.yaml`
8
+ * (the same file the GUI preference editor writes) and falls back to zh.
9
+ */
10
+ import { readFile } from 'node:fs/promises';
11
+ import { join } from 'node:path';
12
+ import { resolveDshHome } from '../catalog/paths.js';
13
+ const zh = {
14
+ agentDefinitionTitle: '## 子代理定义(来自 Agent Plugins {suite},Claude Code agents 格式)',
15
+ agentDefinitionIntro: '当任务匹配下方描述时,通过 subagent 工具创建子代理并将「定义正文」原样作为指令执行。',
16
+ commandForwardTitle: '[Agent Plugins 命令 /{command}(来自 {suite})]',
17
+ commandAcknowledged: '/{command} 已转交模型执行({suite})',
18
+ agentCommandHint: '子代理'
19
+ };
20
+ const en = {
21
+ agentDefinitionTitle: '## Subagent definition (from Agent Plugins {suite}, Claude Code agents format)',
22
+ agentDefinitionIntro: 'When the task matches the description below, create a subagent through the subagent tool and pass the definition body verbatim as its instructions.',
23
+ commandForwardTitle: '[Agent Plugins command /{command} (from {suite})]',
24
+ commandAcknowledged: '/{command} forwarded to the model for execution ({suite})',
25
+ agentCommandHint: 'subagent'
26
+ };
27
+ const DICTS = { zh, en };
28
+ /** Resolve the active host language from settings; unknown values default to zh. */
29
+ export function bindHostLocale(preference) {
30
+ const dict = preference !== undefined && preference.toLowerCase().startsWith('en') ? DICTS.en : DICTS.zh;
31
+ return (key, params) => {
32
+ let text = dict[key];
33
+ if (params !== undefined) {
34
+ for (const [name, value] of Object.entries(params))
35
+ text = text.replaceAll(`{${name}}`, value);
36
+ }
37
+ return text;
38
+ };
39
+ }
40
+ /** Read `locale.preference` from `$DSH_HOME/settings.yaml` (best effort). */
41
+ export async function readLocalePreference() {
42
+ try {
43
+ const text = await readFile(join(resolveDshHome(), 'settings.yaml'), 'utf8');
44
+ const match = /^locale:\s*\n(?:[ \t]+preference:\s*'?([^'"\s#]+)'?)/m.exec(text);
45
+ return match?.[1];
46
+ }
47
+ catch {
48
+ return undefined;
49
+ }
50
+ }
51
+ /** Bind a host translator against the persisted locale preference. */
52
+ export async function loadHostLocale() {
53
+ return { t: bindHostLocale(await readLocalePreference()) };
54
+ }
@@ -11,23 +11,30 @@
11
11
  */
12
12
  import { createHash } from 'node:crypto';
13
13
  import { expandPlaceholders, resolveCwd } from '../catalog/validate.js';
14
+ import { applyOverride } from './mcp-overrides.js';
14
15
  /** The max length `dsh-mcp-client` accepts for a serverName. */
15
16
  const SERVER_NAME_MAX = 32;
16
17
  /**
17
18
  * Build one mount request per supported mcp.json server.
19
+ * @param overrides user-owned per-server overrides (url/headers/env/args
20
+ * replacement plus enable/disable); applied after source expansion, before
21
+ * mount. Disabled servers are omitted from the result entirely.
18
22
  * @returns mount requests plus per-server failures (unsupported transport,
19
23
  * invalid server key, or derived serverName collision candidates are
20
24
  * checked by the mount registry, not here).
21
25
  */
22
- export function toMcpMounts(suite, pluginDataRoot) {
26
+ export function toMcpMounts(suite, pluginDataRoot, overrides = {}) {
23
27
  if (suite.mcp === undefined)
24
28
  return { mounts: [], failures: [] };
25
29
  const mounts = [];
26
30
  const failures = [];
27
- for (const [serverKey, server] of Object.entries(suite.mcp.servers)) {
28
- const request = toMount(suite, serverKey, server, pluginDataRoot);
31
+ for (const [serverKey, source] of Object.entries(suite.mcp.servers)) {
32
+ const override = overrides[serverKey];
33
+ if (override?.enabled === false)
34
+ continue;
35
+ const request = toMount(suite, serverKey, applyOverride(source, override), pluginDataRoot);
29
36
  if (request === undefined) {
30
- failures.push({ serverKey, reason: transportReason(server) });
37
+ failures.push({ serverKey, reason: transportReason(source) });
31
38
  }
32
39
  else {
33
40
  mounts.push(request);