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.
- package/README.md +4 -2
- package/README.zh.md +4 -2
- package/client/client.js +310 -17
- package/client/client.js.map +1 -1
- package/lib/application/catalog.js +101 -4
- package/lib/application/details.js +4 -1
- package/lib/catalog/native-project.js +73 -0
- package/lib/catalog/source-catalog.js +12 -3
- package/lib/contracts/market.js +4 -1
- package/lib/index.js +12 -5
- package/lib/model/state.js +19 -1
- package/lib/model/types.js +12 -1
- package/lib/routes.js +37 -0
- package/lib/runtime/commands-mounts.js +15 -6
- package/lib/runtime/hooks-mounts.js +3 -2
- package/lib/runtime/host-locale.js +54 -0
- package/lib/runtime/mcp-config.js +11 -4
- package/lib/runtime/mcp-mounts.js +33 -17
- package/lib/runtime/mcp-overrides.js +113 -0
- package/lib/runtime/mcp-status.js +8 -4
- package/lib/runtime/reconciler.js +7 -2
- package/lib/runtime/skills-provider.js +22 -13
- package/lib/types/application/catalog.d.ts +30 -2
- package/lib/types/application/details.d.ts +2 -2
- package/lib/types/application/queries.d.ts +5 -1
- package/lib/types/catalog/native-project.d.ts +17 -0
- package/lib/types/contracts/market.d.ts +26 -0
- package/lib/types/index.d.ts +4 -3
- package/lib/types/model/state.d.ts +1 -1
- package/lib/types/model/types.d.ts +13 -1
- package/lib/types/runtime/commands-mounts.d.ts +4 -2
- package/lib/types/runtime/host-locale.d.ts +13 -0
- package/lib/types/runtime/mcp-config.d.ts +5 -1
- package/lib/types/runtime/mcp-mounts.d.ts +12 -3
- package/lib/types/runtime/mcp-overrides.d.ts +37 -0
- package/lib/types/runtime/mcp-status.d.ts +2 -1
- package/lib/types/runtime/reconciler.d.ts +4 -1
- package/lib/types/runtime/skills-provider.d.ts +3 -1
- package/package.json +1 -1
- package/lib/context.js +0 -78
- 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
|
|
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
|
-
|
|
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
|
}
|
package/lib/contracts/market.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/lib/model/state.js
CHANGED
|
@@ -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 });
|
package/lib/model/types.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 = [
|
|
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:
|
|
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: '
|
|
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
|
|
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
|
|
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,
|
|
28
|
-
const
|
|
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(
|
|
37
|
+
failures.push({ serverKey, reason: transportReason(source) });
|
|
31
38
|
}
|
|
32
39
|
else {
|
|
33
40
|
mounts.push(request);
|