dsh-agent-plugins-market 0.7.2 → 0.8.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.
- package/README.md +11 -3
- package/README.zh.md +11 -3
- package/client/client.js +61 -44
- package/client/client.js.map +1 -1
- package/lib/application/catalog.js +9 -2
- package/lib/catalog/component-files.js +3 -1
- package/lib/catalog/native-project.js +1 -1
- package/lib/catalog/project-config.js +0 -2
- package/lib/catalog/project-hooks.js +12 -7
- package/lib/catalog/surfaces.js +17 -10
- package/lib/index.js +23 -2
- package/lib/model/command-names.js +14 -0
- package/lib/model/layouts.js +1 -0
- package/lib/runtime/commands-mounts.js +3 -2
- package/lib/runtime/hooks-mounts.js +7 -4
- package/lib/runtime/mcp-backend.js +3 -2
- package/lib/runtime/mcp-direct-config.js +18 -9
- package/lib/runtime/plugin-message-source.js +4 -0
- package/lib/runtime/project-runtime.js +10 -8
- package/lib/runtime/settings-namespace.js +44 -66
- package/lib/runtime/user-commands.js +23 -8
- package/lib/runtime/user-hooks.js +102 -0
- package/lib/runtime/user-panels.js +34 -22
- package/lib/runtime/user-store.js +81 -19
- package/lib/types/catalog/component-files.d.ts +1 -0
- package/lib/types/catalog/project-hooks.d.ts +15 -2
- package/lib/types/catalog/surfaces.d.ts +6 -4
- package/lib/types/contracts/market.d.ts +5 -1
- package/lib/types/index.d.ts +87 -1
- package/lib/types/model/command-names.d.ts +12 -0
- package/lib/types/model/layouts.d.ts +1 -0
- package/lib/types/model/types.d.ts +2 -1
- package/lib/types/runtime/mcp-backend.d.ts +20 -13
- package/lib/types/runtime/mcp-direct-config.d.ts +1 -5
- package/lib/types/runtime/plugin-message-source.d.ts +24 -0
- package/lib/types/runtime/settings-namespace.d.ts +41 -31
- package/lib/types/runtime/user-commands.d.ts +5 -2
- package/lib/types/runtime/user-hooks.d.ts +22 -0
- package/lib/types/runtime/user-panels.d.ts +35 -15
- package/lib/types/runtime/user-store.d.ts +27 -10
- package/package.json +38 -31
- package/schemas/com.deepseek.harness/spec.md +1 -1
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
import { qualifiedSuiteId } from '../catalog/paths.js';
|
|
13
13
|
import { loadUserMcpSuite } from '../runtime/mcp-direct-config.js';
|
|
14
14
|
import { loadSuiteOverrides } from '../runtime/mcp-overrides.js';
|
|
15
|
+
import { loadUserHooksSuite } from '../runtime/user-hooks.js';
|
|
15
16
|
import { applyLspOverrides } from '../runtime/server-config.js';
|
|
16
17
|
import { buildSuiteDetail, readSkillContent } from './details.js';
|
|
17
18
|
import { CatalogContext } from './catalog-context.js';
|
|
@@ -103,8 +104,14 @@ export class Catalog {
|
|
|
103
104
|
.map(([key]) => key.slice(0, key.indexOf('/'))));
|
|
104
105
|
const sources = state.sources.filter(source => enabledSources.has(source.id));
|
|
105
106
|
const suites = sources.length === 0 ? [] : (await this.snapshots.build({ ...state, sources }, 'user', this.context.userRoot)).enabledSuites;
|
|
106
|
-
|
|
107
|
-
|
|
107
|
+
// The user's own Agent layout declarations ride the same mount registries
|
|
108
|
+
// as an installed suite: each direct suite joins the list only when it
|
|
109
|
+
// declares the content its surface count reports, so an absent or empty
|
|
110
|
+
// file costs no mount pass.
|
|
111
|
+
const mcp = await loadUserMcpSuite(this.context.agentsRoot);
|
|
112
|
+
const hooks = await loadUserHooksSuite(this.context.agentsRoot);
|
|
113
|
+
const direct = [...(Object.keys(mcp.mcp.servers).length === 0 ? [] : [mcp]), ...(hooks.surfaces.hooks === 0 ? [] : [hooks])];
|
|
114
|
+
return applyLspOverrides(this.context.dataRoot, direct.length === 0 ? suites : [...suites, ...direct]);
|
|
108
115
|
}
|
|
109
116
|
/** The full market overview from one user snapshot. */
|
|
110
117
|
async overview() {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/** Resolve declared component files once; all consumers use the same contained resource paths. */
|
|
2
2
|
import { readFile, readdir, realpath, stat } from 'node:fs/promises';
|
|
3
3
|
import { extname, join, relative, resolve } from 'node:path';
|
|
4
|
+
import { commandCallName } from '../model/command-names.js';
|
|
4
5
|
import { PLUGIN_ROOT_VARIABLES } from '../model/layouts.js';
|
|
5
6
|
import { isFile } from './fs-probes.js';
|
|
6
7
|
import { isWithin } from './paths.js';
|
|
@@ -154,6 +155,7 @@ export async function defaultMarkdownResources(root, component) {
|
|
|
154
155
|
export async function resourceText(resource) {
|
|
155
156
|
return resource.content ?? (await readFile(resource.file, 'utf8'));
|
|
156
157
|
}
|
|
158
|
+
/** Registration name of a command resource; one rule governs suite, project-native, and user-panel commands. */
|
|
157
159
|
export function resourceCommandName(name) {
|
|
158
|
-
return name
|
|
160
|
+
return commandCallName(name);
|
|
159
161
|
}
|
|
@@ -47,7 +47,7 @@ export async function discoverNativeProjectSuites(projectRoot, dimension) {
|
|
|
47
47
|
lspFiles.push('lsp.json');
|
|
48
48
|
for (const path of lspFiles) {
|
|
49
49
|
if (await isFile(join(projectRoot, path))) {
|
|
50
|
-
errors.push(`${path}: project LSP configuration is not mounted; the host LSP registry does not isolate projects`);
|
|
50
|
+
errors.push(`${path.replace(/\\/g, '/')}: project LSP configuration is not mounted; the host LSP registry does not isolate projects`);
|
|
51
51
|
}
|
|
52
52
|
}
|
|
53
53
|
// Skills live in `<dir>/skills/<name>/SKILL.md` — the same shape as the
|
|
@@ -47,8 +47,6 @@ export async function discoverProjectMcp(projectRoot, files, errors, format = 'm
|
|
|
47
47
|
found = true;
|
|
48
48
|
Object.assign(servers, table);
|
|
49
49
|
}
|
|
50
|
-
if (format === 'zcode' && Object.keys(servers).length === 0)
|
|
51
|
-
return discoverProjectMcp(projectRoot, ['.agents/mcp.json'], errors);
|
|
52
50
|
if (!found)
|
|
53
51
|
return undefined;
|
|
54
52
|
for (const [key, server] of Object.entries(servers)) {
|
|
@@ -16,9 +16,10 @@ function declarationText(value) {
|
|
|
16
16
|
* a standalone hook file may carry the bare event table instead of a `hooks`
|
|
17
17
|
* key. A recognized top-level event name is the marker — a document with no
|
|
18
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.
|
|
19
|
+
* `hooks` is still not accepted as a bare event table. Exported because the
|
|
20
|
+
* user Agent layout root reads the same standalone file shapes.
|
|
20
21
|
*/
|
|
21
|
-
function
|
|
22
|
+
export function standaloneHookDocument(settings, format = 'claude') {
|
|
22
23
|
if (format !== 'claude' || settings.hooks !== undefined)
|
|
23
24
|
return settings;
|
|
24
25
|
const isEventTable = Object.keys(settings).some(key => EVENTS.has(key));
|
|
@@ -32,11 +33,15 @@ export async function discoverProjectHooks(projectRoot, files, errors, format =
|
|
|
32
33
|
if (settings === null)
|
|
33
34
|
return undefined;
|
|
34
35
|
if (settings !== undefined)
|
|
35
|
-
documents.push({ file, settings:
|
|
36
|
+
documents.push({ file, settings: standaloneHookDocument(settings, format) });
|
|
36
37
|
}
|
|
37
38
|
return normalizeHookDocuments(projectRoot, documents, errors, format);
|
|
38
39
|
}
|
|
39
|
-
/**
|
|
40
|
+
/**
|
|
41
|
+
* Pure validation shared by native settings and plugin file/inline
|
|
42
|
+
* declarations. `projectRoot` is absent for hooks declared in the user Agent
|
|
43
|
+
* layout root, which belong to no project.
|
|
44
|
+
*/
|
|
40
45
|
export function normalizeHookDocuments(projectRoot, documents, errors, format = 'claude') {
|
|
41
46
|
const events = Object.create(null);
|
|
42
47
|
const seen = new Set();
|
|
@@ -64,7 +69,7 @@ export function normalizeHookDocuments(projectRoot, documents, errors, format =
|
|
|
64
69
|
}
|
|
65
70
|
for (const [event, groups] of Object.entries(eventTable)) {
|
|
66
71
|
if (!EVENTS.has(event)) {
|
|
67
|
-
errors.push(`${file}: unsupported
|
|
72
|
+
errors.push(`${file}: unsupported hook event ${event}`);
|
|
68
73
|
continue;
|
|
69
74
|
}
|
|
70
75
|
if (!Array.isArray(groups)) {
|
|
@@ -105,7 +110,7 @@ export function normalizeHookDocuments(projectRoot, documents, errors, format =
|
|
|
105
110
|
return undefined;
|
|
106
111
|
}
|
|
107
112
|
if (hook.async === true || hook.asyncRewake === true) {
|
|
108
|
-
errors.push(`${file}: asynchronous
|
|
113
|
+
errors.push(`${file}: asynchronous hooks are unsupported`);
|
|
109
114
|
continue;
|
|
110
115
|
}
|
|
111
116
|
const key = JSON.stringify([event, matcher, hook.command]);
|
|
@@ -125,5 +130,5 @@ export function normalizeHookDocuments(projectRoot, documents, errors, format =
|
|
|
125
130
|
}
|
|
126
131
|
}
|
|
127
132
|
}
|
|
128
|
-
return !disabled && Object.keys(events).length > 0 ? { projectRoot, events } : undefined;
|
|
133
|
+
return !disabled && Object.keys(events).length > 0 ? { ...(projectRoot === undefined ? {} : { projectRoot }), events } : undefined;
|
|
129
134
|
}
|
package/lib/catalog/surfaces.js
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Skills honor any path the winning manifest declares (`skills` may be a
|
|
6
6
|
* string, an array, or absent), scan up to three levels of nesting, and
|
|
7
|
-
* dedupe by frontmatter name.
|
|
7
|
+
* dedupe by frontmatter name. A path carrying its own `SKILL.md` is one skill
|
|
8
|
+
* and nothing else inside it is read. MCP reads both `mcp.json` and the dot-prefixed
|
|
8
9
|
* `.mcp.json`, tolerating unknown transports per server. Every read is
|
|
9
10
|
* fail-closed: broken files produce a diagnostic and are skipped, never a
|
|
10
11
|
* thrown discovery.
|
|
@@ -40,10 +41,12 @@ async function declaredSkillDirs(root, declared, errors) {
|
|
|
40
41
|
}
|
|
41
42
|
/**
|
|
42
43
|
* Discover SKILL.md files under the suite's skills directory, up to 3 levels
|
|
43
|
-
* deep.
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
44
|
+
* deep. A path that carries `SKILL.md` is one skill directory — a manifest may
|
|
45
|
+
* list those individually — and only that document is read from it. Portable
|
|
46
|
+
* v1 suites (§7.1) instead discover exactly one level of `skills/`
|
|
47
|
+
* subdirectories each carrying a `SKILL.md` — the spec forbids recursive
|
|
48
|
+
* deeper search and root-level or flat skill files are outside the portable
|
|
49
|
+
* discovery shape.
|
|
47
50
|
*/
|
|
48
51
|
export async function discoverSkills(root, errors, declared, portable = false) {
|
|
49
52
|
const skills = [];
|
|
@@ -96,16 +99,20 @@ export async function discoverSkills(root, errors, declared, portable = false) {
|
|
|
96
99
|
pushUnique(await parseOneSkill(skillsDir, dirname(skillsDir), '', errors));
|
|
97
100
|
continue;
|
|
98
101
|
}
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
//
|
|
103
|
-
//
|
|
102
|
+
// A directory carrying its own `SKILL.md` is one skill, whether a manifest
|
|
103
|
+
// declared it individually (mattpocock lists every skill directory) or the
|
|
104
|
+
// conventional container happens to be one. Everything beside that
|
|
105
|
+
// `SKILL.md` — `references/`, `scripts/`, the notes and formats a skill
|
|
106
|
+
// attaches — belongs to it, so those documents are neither parsed as flat
|
|
107
|
+
// skills nor reported as malformed ones.
|
|
104
108
|
if (await isFile(join(skillsDir, 'SKILL.md'))) {
|
|
105
109
|
const name = skillsDir.split(/[\\/]/).at(-1) ?? '';
|
|
106
110
|
pushUnique(await parseOneSkill(join(skillsDir, 'SKILL.md'), skillsDir, name, errors));
|
|
107
111
|
continue;
|
|
108
112
|
}
|
|
113
|
+
for (const name of await listMdFiles(skillsDir)) {
|
|
114
|
+
pushUnique(await parseOneSkill(join(skillsDir, name), skillsDir, name.replace(/\.md$/, ''), errors));
|
|
115
|
+
}
|
|
109
116
|
for (const child of await listChildDirs(skillsDir)) {
|
|
110
117
|
const name = child.split(/[\\/]/).at(-1) ?? '';
|
|
111
118
|
pushUnique(await parseOneSkill(join(child, 'SKILL.md'), child, name, errors));
|
package/lib/index.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import z from '@deepseek-ai/schemastery';
|
|
2
|
+
import { MarketSettingsFields } from './runtime/mcp-backend.js';
|
|
1
3
|
import { Catalog } from './application/catalog.js';
|
|
2
4
|
import { settlesWithin } from './runtime/deadline.js';
|
|
3
5
|
import { RuntimeReconciler } from './runtime/reconciler.js';
|
|
@@ -24,7 +26,26 @@ import { UserCommandMountRegistry } from './runtime/user-commands.js';
|
|
|
24
26
|
import { presetSourceRef } from './model/preset-source.js';
|
|
25
27
|
export const name = 'dsh-agent-plugins-market';
|
|
26
28
|
export const inject = ['skills', 'commands'];
|
|
27
|
-
export
|
|
29
|
+
export const Config = z.object({
|
|
30
|
+
userRoot: z.union([z.string(), z.const(undefined)]),
|
|
31
|
+
dataRoot: z.union([z.string(), z.const(undefined)]),
|
|
32
|
+
sources: z.any(),
|
|
33
|
+
git: z.object({
|
|
34
|
+
proxy: z.string(),
|
|
35
|
+
insteadOf: z.dict(z.string(), z.any()),
|
|
36
|
+
timeoutMs: z.natural(),
|
|
37
|
+
cloneRetry: z.boolean(),
|
|
38
|
+
fallbackTarball: z.boolean(),
|
|
39
|
+
allowHttpArchives: z.boolean()
|
|
40
|
+
}),
|
|
41
|
+
mcpEnhanced: MarketSettingsFields.mcpEnhanced.volatile(),
|
|
42
|
+
scanProjectLayouts: MarketSettingsFields.scanProjectLayouts.volatile(),
|
|
43
|
+
downloadRegion: MarketSettingsFields.downloadRegion.volatile(),
|
|
44
|
+
feedbackEnabled: MarketSettingsFields.feedbackEnabled.volatile(),
|
|
45
|
+
autoUpdateSources: MarketSettingsFields.autoUpdateSources.volatile()
|
|
46
|
+
});
|
|
47
|
+
const undefinedRef = { get: () => undefined };
|
|
48
|
+
export async function apply(ctx, config = { mcpEnhanced: undefinedRef, scanProjectLayouts: undefinedRef, downloadRegion: undefinedRef, feedbackEnabled: undefinedRef, autoUpdateSources: undefinedRef }) {
|
|
28
49
|
const userRoot = resolveUserRoot(config.userRoot);
|
|
29
50
|
const dataRoot = resolveDataRoot(config.dataRoot, userRoot);
|
|
30
51
|
const agentsRoot = resolveAgentsRoot();
|
|
@@ -110,7 +131,7 @@ export async function apply(ctx, config = {}) {
|
|
|
110
131
|
};
|
|
111
132
|
// Background source updates: off until the settings switch says otherwise.
|
|
112
133
|
const autoUpdate = new SourceAutoUpdater(ctx, () => catalog.refreshSource());
|
|
113
|
-
const settings = new MarketSettingsNamespace(ctx, dataRoot, hostLocale, {
|
|
134
|
+
const settings = new MarketSettingsNamespace(ctx, config, dataRoot, hostLocale, {
|
|
114
135
|
setScanProjectLayouts: enabled => catalog.setScanProjectLayouts(enabled),
|
|
115
136
|
refreshMcpMounts: () => {
|
|
116
137
|
void Promise.all([scheduler.request(), projectMcp?.refresh()]).catch(() => { });
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The call name a command resource registers under.
|
|
3
|
+
*
|
|
4
|
+
* A command resource may live at any depth under its `commands/` directory,
|
|
5
|
+
* but the host's slash-command grammar accepts single-segment names only, so
|
|
6
|
+
* registration flattens the path separators: `git/commit` registers as
|
|
7
|
+
* `git-commit`. The user panel renders the same call name, so a card never
|
|
8
|
+
* advertises a slash name the host cannot accept.
|
|
9
|
+
* @module model/command-names
|
|
10
|
+
*/
|
|
11
|
+
/** Flatten a resource path into the single-segment name a slash command registers under. */
|
|
12
|
+
export function commandCallName(name) {
|
|
13
|
+
return name.replaceAll('/', '-').replaceAll('\\', '-').toLowerCase();
|
|
14
|
+
}
|
package/lib/model/layouts.js
CHANGED
|
@@ -47,6 +47,7 @@ export const PROJECT_LAYOUTS = [
|
|
|
47
47
|
dirName: '.agents',
|
|
48
48
|
label: 'agents',
|
|
49
49
|
subdirs: ['skills', 'agents', 'commands'],
|
|
50
|
+
mcpFiles: ['.agents/mcp.json'],
|
|
50
51
|
hookFiles: ['.agents/hooks/hooks.json', '.agents/hooks.json']
|
|
51
52
|
},
|
|
52
53
|
{ dirName: '.codex', label: 'Codex', subdirs: ['skills'], mcpFiles: ['.codex/config.toml'], mcpFormat: 'codex' },
|
|
@@ -7,6 +7,7 @@ import { expandPluginPaths, pluginRootOf } from '../catalog/plugin-variables.js'
|
|
|
7
7
|
import { defaultMarkdownResources, resourceText, resourceCommandName } from '../catalog/component-files.js';
|
|
8
8
|
import { bindHostLocale } from './host-locale.js';
|
|
9
9
|
import { injectDynamicContext, shellSeamOf } from './dynamic-context.js';
|
|
10
|
+
import { pluginMarketSource } from './plugin-message-source.js';
|
|
10
11
|
const COMMAND_NAME = /^[a-z][a-z0-9_-]*$/;
|
|
11
12
|
export class CommandMountRegistry {
|
|
12
13
|
ctx;
|
|
@@ -89,7 +90,7 @@ export class CommandMountRegistry {
|
|
|
89
90
|
if (shell === undefined) {
|
|
90
91
|
agent.followup(createUserMessage({
|
|
91
92
|
content: [{ type: 'text', text: body }],
|
|
92
|
-
source:
|
|
93
|
+
source: pluginMarketSource()
|
|
93
94
|
}));
|
|
94
95
|
return { kind: 'success', text: this.t('commandAcknowledged', { command: spec.name }) };
|
|
95
96
|
}
|
|
@@ -102,7 +103,7 @@ export class CommandMountRegistry {
|
|
|
102
103
|
}
|
|
103
104
|
agent.followup(createUserMessage({
|
|
104
105
|
content: [{ type: 'text', text }],
|
|
105
|
-
source:
|
|
106
|
+
source: pluginMarketSource()
|
|
106
107
|
}));
|
|
107
108
|
return { kind: 'success', text: this.t('commandAcknowledged', { command: spec.name }) };
|
|
108
109
|
}
|
|
@@ -6,9 +6,12 @@
|
|
|
6
6
|
*
|
|
7
7
|
* The bridge is a cordis function plugin (`inject: ['shell']`) whose config
|
|
8
8
|
* is a single `configPath` plus `pluginRoot` for `${CLAUDE_PLUGIN_ROOT}`
|
|
9
|
-
* substitution — exactly the shape a suite hook file needs.
|
|
10
|
-
*
|
|
11
|
-
*
|
|
9
|
+
* substitution — exactly the shape a suite hook file needs. `projectDir` is
|
|
10
|
+
* forwarded only for hooks declared by a project layout; user Agent layout
|
|
11
|
+
* hooks carry none, so the bridge defaults `${CLAUDE_PROJECT_DIR}` to the
|
|
12
|
+
* calling session's workspace. Mounts reconcile on every
|
|
13
|
+
* enable/disable/install/uninstall; a missing bridge package, a broken hook
|
|
14
|
+
* file, or a mount failure is contained per suite.
|
|
12
15
|
*/
|
|
13
16
|
import { mkdtemp, readFile, rm, stat } from 'node:fs/promises';
|
|
14
17
|
import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write';
|
|
@@ -96,7 +99,7 @@ export class HooksMountRegistry {
|
|
|
96
99
|
handle = mountCtx.plugin(bridge, {
|
|
97
100
|
configPath,
|
|
98
101
|
pluginRoot: suite.root,
|
|
99
|
-
...(suite.hooks === undefined ? {} : { projectDir: suite.hooks.projectRoot })
|
|
102
|
+
...(suite.hooks?.projectRoot === undefined ? {} : { projectDir: suite.hooks.projectRoot })
|
|
100
103
|
});
|
|
101
104
|
await handle.await();
|
|
102
105
|
this.live.set(key, handle);
|
|
@@ -24,13 +24,14 @@ import { MARKET_SETTINGS_DEFAULTS } from '../contracts/settings.js';
|
|
|
24
24
|
* belong to {@link MARKET_SETTINGS_DEFAULTS}; this schema states them, it does
|
|
25
25
|
* not own them.
|
|
26
26
|
*/
|
|
27
|
-
export const
|
|
27
|
+
export const MarketSettingsFields = {
|
|
28
28
|
mcpEnhanced: z.boolean().default(MARKET_SETTINGS_DEFAULTS.mcpEnhanced),
|
|
29
29
|
scanProjectLayouts: z.boolean().default(MARKET_SETTINGS_DEFAULTS.scanProjectLayouts),
|
|
30
30
|
downloadRegion: z.union([z.const('auto'), z.const('global'), z.const('china')]).default(MARKET_SETTINGS_DEFAULTS.downloadRegion),
|
|
31
31
|
feedbackEnabled: z.boolean().default(MARKET_SETTINGS_DEFAULTS.feedbackEnabled),
|
|
32
32
|
autoUpdateSources: z.boolean().default(MARKET_SETTINGS_DEFAULTS.autoUpdateSources)
|
|
33
|
-
}
|
|
33
|
+
};
|
|
34
|
+
export const MarketSettingsSchema = z.object(MarketSettingsFields);
|
|
34
35
|
/** Path of the legacy persisted settings file under the plugin data root. */
|
|
35
36
|
export function marketSettingsPath(dataRoot) {
|
|
36
37
|
return join(dataRoot, 'settings.json');
|
|
@@ -6,11 +6,7 @@ import { effectiveSurfaces } from '../model/types.js';
|
|
|
6
6
|
export const USER_MCP_SOURCE = '@user-mcp';
|
|
7
7
|
export const USER_MCP_SUITE = 'user-mcp';
|
|
8
8
|
/**
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* The declarations live in the shared Agent layout root
|
|
12
|
-
* (`~/.agents/mcp.json`), so a service the user adds by hand is ordinary
|
|
13
|
-
* `mcpServers` JSON in the same place other Agent tools look.
|
|
9
|
+
* Load the user's own MCP declaration file.
|
|
14
10
|
*
|
|
15
11
|
* The suite always carries an `mcp` document — an absent or malformed
|
|
16
12
|
* `mcp.json` leaves an empty one — so callers read `.mcp.servers` directly
|
|
@@ -37,9 +33,10 @@ export async function loadUserMcpSuite(agentsRoot) {
|
|
|
37
33
|
surfaces: { skills: 0, mcp: Object.keys(mcp.servers).length, commands: 0, agents: 0, hooks: 0, lsp: 0 },
|
|
38
34
|
dimension: 'user',
|
|
39
35
|
enabled: true,
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
|
|
36
|
+
// The suite declares MCP only: the other surfaces would otherwise make the
|
|
37
|
+
// command registry read this root a second time beside the user-command
|
|
38
|
+
// panel registry.
|
|
39
|
+
activeSurfaces: effectiveSurfaces({ skills: false, hooks: false, commands: false, agents: false, lsp: false }),
|
|
43
40
|
installedAt: 'user',
|
|
44
41
|
errors
|
|
45
42
|
};
|
|
@@ -49,13 +46,25 @@ export async function loadUserMcpSuite(agentsRoot) {
|
|
|
49
46
|
* keys this client does not know ride along untouched rather than failing the
|
|
50
47
|
* whole file, and the closed-set rule the package schema applies to suites
|
|
51
48
|
* does not apply here.
|
|
49
|
+
*
|
|
50
|
+
* A hand-written file may omit `$schema` entirely, so the baseline identifier
|
|
51
|
+
* is filled in before validation: the document then reads as the baseline
|
|
52
|
+
* release while every shape and required field the client does know is still
|
|
53
|
+
* validated. A rejected document keeps the file's own declarations out of the
|
|
54
|
+
* mount and reports the reason, the same fail-closed rule the write paths
|
|
55
|
+
* apply.
|
|
52
56
|
*/
|
|
53
57
|
async function validateUserMcp(agentsRoot, raw) {
|
|
54
|
-
const
|
|
58
|
+
const document = isRecord(raw) && raw['$schema'] === undefined ? { ...raw, $schema: MCP_SCHEMA_ID } : raw;
|
|
59
|
+
const result = await validateMcpJson(agentsRoot, document, { packageRules: false });
|
|
55
60
|
if (result.config === undefined || result.errors.length > 0)
|
|
56
61
|
throw new Error(`invalid MCP configuration: ${result.errors.join('; ')}`);
|
|
57
62
|
return result.config;
|
|
58
63
|
}
|
|
64
|
+
/** Whether a parsed user document is the JSON object the MCP validator reads. */
|
|
65
|
+
function isRecord(value) {
|
|
66
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
67
|
+
}
|
|
59
68
|
/** Add one service atomically; reject collisions and malformed config before writing. */
|
|
60
69
|
export async function addUserMcpServer(agentsRoot, name, server) {
|
|
61
70
|
if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/.test(name))
|
|
@@ -125,16 +125,19 @@ function mountProjectSurface(ctx, catalog, service, create, withoutProject = fal
|
|
|
125
125
|
mounts.delete(agent);
|
|
126
126
|
await mount?.fiber.dispose();
|
|
127
127
|
};
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
})
|
|
132
|
-
|
|
133
|
-
attach(
|
|
128
|
+
// One lifecycle listener covers every startup path: the host merged the old
|
|
129
|
+
// agent/session-start emit into agent/created (which now carries the
|
|
130
|
+
// startup/resume/clear/compact source) and fires it serially.
|
|
131
|
+
const unwatchCreated = ctx.on('agent/created', ({ agent }) => {
|
|
132
|
+
const projectAgent = agent;
|
|
133
|
+
attach(projectAgent);
|
|
134
134
|
// The refresh chain can reject — its queue body re-reads the project
|
|
135
135
|
// catalog — and the host emits this event without awaiting the listener,
|
|
136
136
|
// so an unhandled rejection here would surface at every session start.
|
|
137
|
-
void mounts.get(
|
|
137
|
+
void mounts.get(projectAgent)?.refresh().catch(warn);
|
|
138
|
+
});
|
|
139
|
+
const unwatchDisposed = ctx.on('agent/disposed', ({ agent }) => {
|
|
140
|
+
void detach(agent).catch(warn);
|
|
138
141
|
});
|
|
139
142
|
for (const agent of host.agents.list())
|
|
140
143
|
attach(agent);
|
|
@@ -146,7 +149,6 @@ function mountProjectSurface(ctx, catalog, service, create, withoutProject = fal
|
|
|
146
149
|
disposed = true;
|
|
147
150
|
unwatchCreated();
|
|
148
151
|
unwatchDisposed();
|
|
149
|
-
unwatchStart();
|
|
150
152
|
await Promise.all([...mounts.keys()].map(detach));
|
|
151
153
|
}
|
|
152
154
|
};
|
|
@@ -1,12 +1,11 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { resolveMarketSettings } from '../contracts/settings.js';
|
|
2
2
|
import { FEEDBACK_TOOL_NAME, mountFeedbackTool } from './feedback-tool.js';
|
|
3
|
-
import { MarketSettingsSchema, readMcpBackend } from './mcp-backend.js';
|
|
4
3
|
export class MarketSettingsNamespace {
|
|
5
4
|
ctx;
|
|
5
|
+
refs;
|
|
6
6
|
dataRoot;
|
|
7
7
|
locale;
|
|
8
8
|
host;
|
|
9
|
-
scope;
|
|
10
9
|
watchers = [];
|
|
11
10
|
feedbackDisposer;
|
|
12
11
|
/**
|
|
@@ -16,87 +15,66 @@ export class MarketSettingsNamespace {
|
|
|
16
15
|
* switch, or skipped because the host has no tools registry to register on.
|
|
17
16
|
*/
|
|
18
17
|
feedbackMountState;
|
|
19
|
-
constructor(ctx, dataRoot, locale, host) {
|
|
18
|
+
constructor(ctx, refs, dataRoot, locale, host) {
|
|
20
19
|
this.ctx = ctx;
|
|
20
|
+
this.refs = refs;
|
|
21
21
|
this.dataRoot = dataRoot;
|
|
22
22
|
this.locale = locale;
|
|
23
23
|
this.host = host;
|
|
24
24
|
}
|
|
25
|
-
/**
|
|
25
|
+
/** Subscribe the runtime reactions to live settings updates. */
|
|
26
26
|
mount() {
|
|
27
|
-
this.
|
|
27
|
+
this.syncProjectLayouts();
|
|
28
|
+
this.syncAutoUpdateSources();
|
|
29
|
+
this.syncFeedbackTool();
|
|
30
|
+
let previousBackend = this.settings().mcpEnhanced;
|
|
31
|
+
const watcher = this.ctx.on('loader/volatile-update', () => {
|
|
32
|
+
const backend = this.settings().mcpEnhanced;
|
|
33
|
+
if (backend !== previousBackend) {
|
|
34
|
+
previousBackend = backend;
|
|
35
|
+
this.host.refreshMcpMounts();
|
|
36
|
+
}
|
|
37
|
+
this.syncProjectLayouts();
|
|
38
|
+
this.syncAutoUpdateSources();
|
|
39
|
+
this.syncFeedbackTool();
|
|
40
|
+
});
|
|
41
|
+
this.watchers.push(watcher);
|
|
42
|
+
}
|
|
43
|
+
/** Release every watcher and unmount the feedback tool. */
|
|
44
|
+
dispose() {
|
|
45
|
+
for (const release of this.watchers.splice(0))
|
|
46
|
+
release();
|
|
47
|
+
this.feedbackDisposer?.();
|
|
48
|
+
this.feedbackDisposer = undefined;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* The resolved settings, from the volatile references the host updates in place.
|
|
52
|
+
*/
|
|
53
|
+
settings() {
|
|
54
|
+
return resolveMarketSettings({
|
|
55
|
+
mcpEnhanced: this.refs.mcpEnhanced.get(),
|
|
56
|
+
scanProjectLayouts: this.refs.scanProjectLayouts.get(),
|
|
57
|
+
downloadRegion: this.refs.downloadRegion.get(),
|
|
58
|
+
feedbackEnabled: this.refs.feedbackEnabled.get(),
|
|
59
|
+
autoUpdateSources: this.refs.autoUpdateSources.get()
|
|
60
|
+
});
|
|
28
61
|
}
|
|
29
|
-
/** The persisted MCP backend choice; the built-in client until
|
|
62
|
+
/** The persisted MCP backend choice; the built-in client until the host applies a value. */
|
|
30
63
|
async backend() {
|
|
31
64
|
return this.settings().mcpEnhanced ? 'builtin' : 'host';
|
|
32
65
|
}
|
|
33
66
|
/**
|
|
34
|
-
*
|
|
35
|
-
*
|
|
67
|
+
* Legacy write path for the backend choice: the value now lives in the host
|
|
68
|
+
* settings document, edited from the Plugins panel's configuration page, so
|
|
69
|
+
* this accepts the request and lets the volatile reference decide.
|
|
36
70
|
*/
|
|
37
71
|
async setBackend(backend) {
|
|
38
|
-
|
|
39
|
-
throw new Error('the settings service is not mounted');
|
|
40
|
-
await this.scope.update({ mcpEnhanced: backend !== 'host' });
|
|
72
|
+
this.ctx.logger?.info?.(`[dsh-agent-plugins-market] legacy MCP backend write (${backend}) ignored — the value lives in the host settings document`);
|
|
41
73
|
}
|
|
42
74
|
/** The persisted download-region setting. */
|
|
43
75
|
async downloadRegion() {
|
|
44
76
|
return this.settings().downloadRegion;
|
|
45
77
|
}
|
|
46
|
-
/**
|
|
47
|
-
* The resolved settings, whether or not the namespace is registered yet.
|
|
48
|
-
*
|
|
49
|
-
* Every reader goes through here so "the field is absent" has exactly one
|
|
50
|
-
* answer — {@link resolveMarketSettings} — instead of each caller inventing
|
|
51
|
-
* its own fallback for the window before registration lands.
|
|
52
|
-
*/
|
|
53
|
-
settings() {
|
|
54
|
-
return resolveMarketSettings(this.scope?.get());
|
|
55
|
-
}
|
|
56
|
-
/** Release every watcher and unmount the feedback tool. */
|
|
57
|
-
dispose() {
|
|
58
|
-
for (const release of this.watchers.splice(0))
|
|
59
|
-
release();
|
|
60
|
-
this.feedbackDisposer?.();
|
|
61
|
-
this.feedbackDisposer = undefined;
|
|
62
|
-
}
|
|
63
|
-
register(settingsCtx) {
|
|
64
|
-
try {
|
|
65
|
-
this.ctx.logger?.info?.('[dsh-agent-plugins-market] settings inject resolved — registering namespace');
|
|
66
|
-
const settings = settingsCtx.settings;
|
|
67
|
-
const scope = settings.register(MARKET_SETTINGS_NAMESPACE, MarketSettingsSchema);
|
|
68
|
-
this.scope = scope;
|
|
69
|
-
this.ctx.logger?.info?.('[dsh-agent-plugins-market] settings namespace registered — plugin-config card will serve');
|
|
70
|
-
this.syncProjectLayouts();
|
|
71
|
-
this.watchers.push(scope.watch(() => this.syncProjectLayouts()));
|
|
72
|
-
this.syncAutoUpdateSources();
|
|
73
|
-
this.watchers.push(scope.watch(() => this.syncAutoUpdateSources()));
|
|
74
|
-
// One-time migration from the earlier data-root settings.json choice.
|
|
75
|
-
void readMcpBackend(this.dataRoot).then(backend => {
|
|
76
|
-
if (backend === 'host')
|
|
77
|
-
void scope.update({ mcpEnhanced: false }).catch(() => { });
|
|
78
|
-
});
|
|
79
|
-
let previousBackend = this.settings().mcpEnhanced;
|
|
80
|
-
this.watchers.push(scope.watch(() => {
|
|
81
|
-
const backend = this.settings().mcpEnhanced;
|
|
82
|
-
if (backend === previousBackend)
|
|
83
|
-
return;
|
|
84
|
-
previousBackend = backend;
|
|
85
|
-
this.host.refreshMcpMounts();
|
|
86
|
-
}));
|
|
87
|
-
// The experience-feedback model tool: gated by the namespace's
|
|
88
|
-
// `feedbackEnabled` field (default on); the switch unregisters it. The
|
|
89
|
-
// tool mount is doubly contained — its failure must never take the
|
|
90
|
-
// settings namespace (and with it the config card) down with it. The
|
|
91
|
-
// mount outcome is logged on every transition so an absent
|
|
92
|
-
// `report_market_issue` in a session is traceable to its cause.
|
|
93
|
-
this.syncFeedbackTool();
|
|
94
|
-
this.watchers.push(scope.watch(() => this.syncFeedbackTool()));
|
|
95
|
-
}
|
|
96
|
-
catch (error) {
|
|
97
|
-
this.ctx.logger?.error?.(`[dsh-agent-plugins-market] settings namespace registration failed — the plugin-config card will be hidden this boot: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`);
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
78
|
/** Apply the background source-update switch. */
|
|
101
79
|
syncAutoUpdateSources() {
|
|
102
80
|
try {
|
|
@@ -2,12 +2,17 @@
|
|
|
2
2
|
* User command mounting: every enabled entry of the user commands panel
|
|
3
3
|
* registers as a dsh slash command, so user-authored quick replies behave
|
|
4
4
|
* exactly like suite commands — the body with `$ARGUMENTS` substituted rides
|
|
5
|
-
* one follow-up message on the receiving agent.
|
|
6
|
-
*
|
|
5
|
+
* one follow-up message on the receiving agent. A nested entry registers
|
|
6
|
+
* under its flattened call name (`git/commit` → `git-commit`) while the panel
|
|
7
|
+
* keeps addressing it by path; when two entries flatten to one call name the
|
|
8
|
+
* first registers and the other is reported. Reconciled on every catalog
|
|
9
|
+
* change, keyed by the call name.
|
|
7
10
|
* @module runtime/user-commands
|
|
8
11
|
*/
|
|
9
12
|
import { createUserMessage } from '@deepseek-ai/dsh-llm';
|
|
10
|
-
import {
|
|
13
|
+
import { commandCallName } from '../model/command-names.js';
|
|
14
|
+
import { USER_ENTRY_PATH } from './user-store.js';
|
|
15
|
+
import { pluginMarketSource } from './plugin-message-source.js';
|
|
11
16
|
/** Register/unregister user-panel commands to match the panel's enabled entries. */
|
|
12
17
|
export class UserCommandMountRegistry {
|
|
13
18
|
ctx;
|
|
@@ -25,14 +30,24 @@ export class UserCommandMountRegistry {
|
|
|
25
30
|
const diagnostics = [];
|
|
26
31
|
const entries = await this.store.list();
|
|
27
32
|
const wanted = new Map();
|
|
33
|
+
// Registration is keyed by call name, so two entries that flatten to the
|
|
34
|
+
// same one cannot silently shadow each other.
|
|
35
|
+
const owners = new Map();
|
|
28
36
|
for (const entry of entries) {
|
|
29
37
|
if (entry.disabled)
|
|
30
38
|
continue;
|
|
31
|
-
if (!
|
|
39
|
+
if (!USER_ENTRY_PATH.test(entry.name))
|
|
32
40
|
continue;
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
41
|
+
const callName = commandCallName(entry.name);
|
|
42
|
+
const owner = owners.get(callName);
|
|
43
|
+
if (owner !== undefined) {
|
|
44
|
+
diagnostics.push(`command "${callName}": "${entry.name}" is shadowed by "${owner}" — both flatten to the same call name`);
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
owners.set(callName, entry.name);
|
|
48
|
+
wanted.set(callName, {
|
|
49
|
+
name: callName,
|
|
50
|
+
description: entry.description === '' ? `[${this.t('userCommandSourceLabel')}] ${callName}` : `[${this.t('userCommandSourceLabel')}] ${entry.description}`,
|
|
36
51
|
...(typeof entry.metadata['argument-hint'] === 'string' && entry.metadata['argument-hint'] !== '' ? { hint: entry.metadata['argument-hint'] } : {}),
|
|
37
52
|
body: entry.content
|
|
38
53
|
});
|
|
@@ -66,7 +81,7 @@ export class UserCommandMountRegistry {
|
|
|
66
81
|
const text = spec.body.replaceAll('$ARGUMENTS', invocation.rawInput.trim());
|
|
67
82
|
agent.followup(createUserMessage({
|
|
68
83
|
content: [{ type: 'text', text }],
|
|
69
|
-
source:
|
|
84
|
+
source: pluginMarketSource()
|
|
70
85
|
}));
|
|
71
86
|
return { kind: 'success', text: this.t('commandAcknowledged', { command: spec.name }) };
|
|
72
87
|
}
|