dsh-agent-plugins-market 0.7.2 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -3
- package/README.zh.md +11 -3
- package/client/client.js +47 -27
- 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/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-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/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/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-direct-config.d.ts +1 -5
- package/lib/types/runtime/plugin-message-source.d.ts +24 -0
- 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 +32 -28
- 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));
|
|
@@ -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);
|
|
@@ -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
|
};
|
|
@@ -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
|
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Direct user configuration for command hooks: the Agent layout root's own
|
|
3
|
+
* hook files, mounted through the same bridge lifecycle a suite's hooks use.
|
|
4
|
+
*
|
|
5
|
+
* The files live at `~/.agents/hooks/hooks.json` and `~/.agents/hooks.json`
|
|
6
|
+
* and are read in that order, merged additively exactly like the project
|
|
7
|
+
* `.agents` layout reads them. A malformed file fails the suite closed with a
|
|
8
|
+
* diagnostic, never a thrown discovery, and the hooks carry no
|
|
9
|
+
* `projectRoot`: the bridge defaults `${CLAUDE_PROJECT_DIR}` to the calling
|
|
10
|
+
* session's workspace.
|
|
11
|
+
*/
|
|
12
|
+
import { readFile } from 'node:fs/promises';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
import { normalizeHookDocuments, standaloneHookDocument } from '../catalog/project-hooks.js';
|
|
15
|
+
import { effectiveSurfaces } from '../model/types.js';
|
|
16
|
+
export const USER_HOOKS_SOURCE = '@user-hooks';
|
|
17
|
+
export const USER_HOOKS_SUITE = 'user-hooks';
|
|
18
|
+
/**
|
|
19
|
+
* The Agent layout root's hook documents, relative to that root and read in
|
|
20
|
+
* order. Later files merge additively over earlier ones, the same order and
|
|
21
|
+
* semantics the project `.agents` layout applies. The first entry is also the
|
|
22
|
+
* suite's manifest path and the file a suite with no documents is addressed by.
|
|
23
|
+
*/
|
|
24
|
+
export const USER_HOOK_FILES = [join('hooks', 'hooks.json'), 'hooks.json'];
|
|
25
|
+
/** Resolve one Agent-layout-relative hook document against the layout root. */
|
|
26
|
+
export function userHookPath(agentsRoot, file) {
|
|
27
|
+
return join(agentsRoot, file);
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Load the user's Agent layout hooks as one synthetic suite.
|
|
31
|
+
*
|
|
32
|
+
* The suite is always built, so one call answers with both the events and the
|
|
33
|
+
* reasons a document was rejected: an absent, malformed, or event-free set of
|
|
34
|
+
* hook files leaves `surfaces.hooks` at 0 and carries the diagnostics.
|
|
35
|
+
* {@link ../application/catalog.ts} merges the suite into the enabled set only
|
|
36
|
+
* when it declares events.
|
|
37
|
+
*/
|
|
38
|
+
export async function loadUserHooksSuite(agentsRoot) {
|
|
39
|
+
const errors = [];
|
|
40
|
+
const documents = [];
|
|
41
|
+
for (const file of USER_HOOK_FILES) {
|
|
42
|
+
const settings = await readHookDocument(agentsRoot, file, errors);
|
|
43
|
+
// A malformed file fails the whole set closed, the same way one malformed
|
|
44
|
+
// project hook layer drops every hook the layout declared.
|
|
45
|
+
if (settings === null)
|
|
46
|
+
return userHooksSuite(agentsRoot, undefined, errors);
|
|
47
|
+
// A standalone hook file may carry the bare event table; the shared rule
|
|
48
|
+
// that recognizes it is the same one the project `.agents` layout applies.
|
|
49
|
+
if (settings !== undefined)
|
|
50
|
+
documents.push({ file, settings: standaloneHookDocument(settings) });
|
|
51
|
+
}
|
|
52
|
+
return userHooksSuite(agentsRoot, normalizeHookDocuments(undefined, documents, errors), errors);
|
|
53
|
+
}
|
|
54
|
+
/** One synthetic suite for the Agent layout root's hook files. */
|
|
55
|
+
function userHooksSuite(agentsRoot, hooks, errors) {
|
|
56
|
+
return {
|
|
57
|
+
sourceId: USER_HOOKS_SOURCE,
|
|
58
|
+
id: USER_HOOKS_SUITE,
|
|
59
|
+
root: agentsRoot,
|
|
60
|
+
manifest: { layout: 'agent-plugin-v1', path: userHookPath(agentsRoot, USER_HOOK_FILES[0]), id: USER_HOOKS_SUITE, name: USER_HOOKS_SUITE },
|
|
61
|
+
skills: [],
|
|
62
|
+
...(hooks === undefined ? {} : { hooks }),
|
|
63
|
+
surfaces: { skills: 0, mcp: 0, hooks: hookCount(hooks), commands: 0, agents: 0, lsp: 0 },
|
|
64
|
+
dimension: 'user',
|
|
65
|
+
enabled: true,
|
|
66
|
+
// The suite declares hooks only; the other surfaces would otherwise make
|
|
67
|
+
// the command and LSP registries read this root a second time.
|
|
68
|
+
activeSurfaces: effectiveSurfaces({ skills: false, mcp: false, commands: false, agents: false, lsp: false }),
|
|
69
|
+
installedAt: 'user',
|
|
70
|
+
errors
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
/** One hook file's parsed document: absent is undefined, malformed is null plus a diagnostic. */
|
|
74
|
+
async function readHookDocument(agentsRoot, file, errors) {
|
|
75
|
+
let text;
|
|
76
|
+
try {
|
|
77
|
+
text = await readFile(userHookPath(agentsRoot, file), 'utf8');
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
if (error.code === 'ENOENT')
|
|
81
|
+
return undefined;
|
|
82
|
+
errors.push(`${file}: hook file is unreadable`);
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
let raw;
|
|
86
|
+
try {
|
|
87
|
+
raw = JSON.parse(text);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
errors.push(`${file}: hook file is invalid JSON`);
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
|
|
94
|
+
errors.push(`${file}: hook file must be an object`);
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
return raw;
|
|
98
|
+
}
|
|
99
|
+
/** Declared hook commands, the same count a native suite reports for `surfaces.hooks`. */
|
|
100
|
+
function hookCount(hooks) {
|
|
101
|
+
return Object.values(hooks?.events ?? {}).reduce((total, groups) => total + groups.reduce((count, group) => count + group.hooks.length, 0), 0);
|
|
102
|
+
}
|