dsh-agent-plugins-market 0.6.0 → 0.6.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 +143 -169
- package/README.zh.md +136 -170
- package/client/client.js +8202 -684
- package/client/client.js.map +1 -1
- package/lib/application/catalog.js +236 -47
- package/lib/application/details.js +19 -10
- package/lib/application/panel-resources.js +95 -0
- package/lib/application/project-agent-roles.js +39 -0
- package/lib/catalog/codex-mcp.js +114 -0
- package/lib/catalog/component-files.js +151 -0
- package/lib/catalog/manifests.js +105 -44
- package/lib/catalog/native-project.js +44 -23
- package/lib/catalog/paths.js +6 -6
- package/lib/catalog/project-config.js +96 -0
- package/lib/catalog/project-hooks.js +108 -0
- package/lib/catalog/scan-pipeline.js +6 -1
- package/lib/catalog/scan-resolvers.js +70 -26
- package/lib/catalog/source-catalog.js +16 -3
- package/lib/catalog/suite-components.js +125 -0
- package/lib/catalog/surfaces.js +60 -46
- package/lib/catalog/validate.js +16 -11
- package/lib/contracts/market.js +16 -1
- package/lib/index.js +187 -35
- package/lib/model/layouts.js +49 -0
- package/lib/routes.js +106 -6
- package/lib/runtime/agent-role-router.js +226 -0
- package/lib/runtime/commands-mounts.js +27 -67
- package/lib/runtime/feedback-tool.js +181 -0
- package/lib/runtime/hooks-mounts.js +66 -13
- package/lib/runtime/host-locale.js +23 -7
- package/lib/runtime/legacy-root-migration.js +12 -0
- package/lib/runtime/lsp-direct-config.js +6 -3
- package/lib/runtime/lsp-mounts.js +38 -24
- package/lib/runtime/lsp-server-state.js +19 -0
- package/lib/runtime/lsp-status.js +3 -2
- package/lib/runtime/mcp-backend.js +6 -2
- package/lib/runtime/mcp-client/config.js +6 -0
- package/lib/runtime/mcp-client/connection.js +8 -2
- package/lib/runtime/mcp-client/tools.js +6 -3
- package/lib/runtime/mcp-config.js +26 -13
- package/lib/runtime/mcp-direct-config.js +56 -0
- package/lib/runtime/mcp-mounts.js +25 -9
- package/lib/runtime/mcp-overrides.js +11 -5
- package/lib/runtime/mcp-status.js +10 -7
- package/lib/runtime/model-catalog.js +48 -0
- package/lib/runtime/project-runtime.js +136 -0
- package/lib/runtime/reconciler.js +38 -28
- package/lib/runtime/server-config.js +75 -0
- package/lib/runtime/skills-provider.js +31 -77
- package/lib/runtime/storage-migration.js +169 -0
- package/lib/runtime/subagent-catalog.js +129 -0
- package/lib/runtime/user-commands.js +86 -0
- package/lib/runtime/user-panels.js +176 -0
- package/lib/runtime/user-store.js +142 -0
- package/lib/types/application/catalog.d.ts +39 -9
- package/lib/types/application/panel-resources.d.ts +13 -0
- package/lib/types/application/project-agent-roles.d.ts +3 -0
- package/lib/types/application/queries.d.ts +17 -1
- package/lib/types/catalog/codex-mcp.d.ts +4 -0
- package/lib/types/catalog/component-files.d.ts +17 -0
- package/lib/types/catalog/manifests.d.ts +14 -9
- package/lib/types/catalog/native-project.d.ts +2 -5
- package/lib/types/catalog/paths.d.ts +4 -4
- package/lib/types/catalog/project-config.d.ts +6 -0
- package/lib/types/catalog/project-hooks.d.ts +10 -0
- package/lib/types/catalog/scan-pipeline.d.ts +2 -0
- package/lib/types/catalog/scan-resolvers.d.ts +4 -3
- package/lib/types/catalog/source-catalog.d.ts +1 -1
- package/lib/types/catalog/suite-components.d.ts +5 -0
- package/lib/types/catalog/surfaces.d.ts +2 -2
- package/lib/types/catalog/validate.d.ts +2 -0
- package/lib/types/contracts/market.d.ts +60 -0
- package/lib/types/contracts/mcp-status.d.ts +5 -1
- package/lib/types/index.d.ts +2 -2
- package/lib/types/model/layouts.d.ts +102 -0
- package/lib/types/model/types.d.ts +51 -6
- package/lib/types/routes.d.ts +6 -1
- package/lib/types/runtime/agent-role-router.d.ts +95 -0
- package/lib/types/runtime/commands-mounts.d.ts +16 -7
- package/lib/types/runtime/feedback-tool.d.ts +49 -0
- package/lib/types/runtime/hooks-mounts.d.ts +4 -0
- package/lib/types/runtime/host-locale.d.ts +1 -1
- package/lib/types/runtime/legacy-root-migration.d.ts +2 -0
- package/lib/types/runtime/lsp-mounts.d.ts +4 -0
- package/lib/types/runtime/lsp-server-state.d.ts +3 -0
- package/lib/types/runtime/lsp-status.d.ts +1 -0
- package/lib/types/runtime/mcp-backend.d.ts +9 -1
- package/lib/types/runtime/mcp-client/config.d.ts +4 -3
- package/lib/types/runtime/mcp-client/tools.d.ts +3 -0
- package/lib/types/runtime/mcp-config.d.ts +11 -5
- package/lib/types/runtime/mcp-direct-config.d.ts +7 -0
- package/lib/types/runtime/mcp-mounts.d.ts +2 -1
- package/lib/types/runtime/mcp-overrides.d.ts +2 -0
- package/lib/types/runtime/mcp-status.d.ts +1 -1
- package/lib/types/runtime/model-catalog.d.ts +7 -0
- package/lib/types/runtime/project-runtime.d.ts +32 -0
- package/lib/types/runtime/reconciler.d.ts +2 -0
- package/lib/types/runtime/server-config.d.ts +10 -0
- package/lib/types/runtime/skills-provider.d.ts +1 -5
- package/lib/types/runtime/storage-migration.d.ts +15 -0
- package/lib/types/runtime/subagent-catalog.d.ts +52 -0
- package/lib/types/runtime/user-commands.d.ts +24 -0
- package/lib/types/runtime/user-panels.d.ts +91 -0
- package/lib/types/runtime/user-store.d.ts +51 -0
- package/package.json +10 -3
- package/schemas/README.md +69 -7
- package/schemas/agent-plugins/spec.md +48 -0
- package/schemas/claude-code/marketplace.schema.json +143 -0
- package/schemas/claude-code/plugin.schema.json +105 -0
- package/schemas/claude-code/spec.md +108 -0
- package/schemas/codex/marketplace.schema.json +78 -0
- package/schemas/codex/plugin.schema.json +77 -0
- package/schemas/codex/spec.md +91 -0
- package/schemas/cursor/marketplace.schema.json +82 -0
- package/schemas/cursor/plugin.schema.json +58 -0
- package/schemas/cursor/spec.md +92 -0
- package/schemas/github-copilot/marketplace.schema.json +107 -0
- package/schemas/github-copilot/plugin.schema.json +89 -0
- package/schemas/github-copilot/spec.md +105 -0
- package/schemas/kimi/marketplace.schema.json +38 -0
- package/schemas/kimi/plugin.schema.json +84 -0
- package/schemas/kimi/spec.md +94 -0
- package/schemas/qoder/marketplace.schema.json +115 -0
- package/schemas/qoder/plugin.schema.json +127 -0
- package/schemas/qoder/spec.md +111 -0
- package/schemas/skill-collection/spec.md +29 -0
- package/schemas/universal/marketplace.schema.json +98 -0
- package/schemas/universal/plugin.schema.json +49 -0
- package/schemas/universal/spec.md +53 -0
- package/schemas/zcode/marketplace.schema.json +161 -0
- package/schemas/zcode/plugin.schema.json +89 -0
- package/schemas/zcode/spec.md +102 -0
- package/lib/catalog/legacy-root-migration.js +0 -51
- package/lib/types/catalog/legacy-root-migration.d.ts +0 -14
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { MCP_SCHEMA_ID, validateAgainstSchema, validateMcpJson } from '../catalog/validate.js';
|
|
4
|
+
import { parseLspServers } from '../catalog/lsp-spec.js';
|
|
5
|
+
import { qualifiedSuiteId } from '../catalog/paths.js';
|
|
6
|
+
import { redactMcpConfig } from './mcp-redaction.js';
|
|
7
|
+
/** Restore only unchanged redacted leaves, rejecting invented masked values. */
|
|
8
|
+
export function restoreRedactedConfig(input, original, redacted = redactMcpConfig(original)) {
|
|
9
|
+
if (typeof input === 'string' && input.includes('[redacted]')) {
|
|
10
|
+
if (input !== redacted)
|
|
11
|
+
throw new Error('masked values must remain unchanged or be replaced');
|
|
12
|
+
return original;
|
|
13
|
+
}
|
|
14
|
+
if (Array.isArray(input))
|
|
15
|
+
return input.map((value, index) => restoreRedactedConfig(value, Array.isArray(original) ? original[index] : undefined, Array.isArray(redacted) ? redacted[index] : undefined));
|
|
16
|
+
if (typeof input === 'object' && input !== null) {
|
|
17
|
+
const source = typeof original === 'object' && original !== null ? original : {};
|
|
18
|
+
const safe = typeof redacted === 'object' && redacted !== null ? redacted : {};
|
|
19
|
+
return Object.fromEntries(Object.entries(input).map(([key, value]) => [key, restoreRedactedConfig(value, source[key], safe[key])]));
|
|
20
|
+
}
|
|
21
|
+
return input;
|
|
22
|
+
}
|
|
23
|
+
export async function validateServerMcp(root, key, config) {
|
|
24
|
+
const document = { $schema: MCP_SCHEMA_ID, mcpServers: { [key]: config } };
|
|
25
|
+
const errors = await validateAgainstSchema(MCP_SCHEMA_ID, document);
|
|
26
|
+
if (errors.length > 0)
|
|
27
|
+
throw new Error(`invalid MCP configuration: ${errors.join('; ')}`);
|
|
28
|
+
const result = await validateMcpJson(root, document);
|
|
29
|
+
const server = result.config?.servers[key];
|
|
30
|
+
if (server === undefined || result.errors.length > 0)
|
|
31
|
+
throw new Error(`invalid MCP configuration: ${result.errors.join('; ')}`);
|
|
32
|
+
return server;
|
|
33
|
+
}
|
|
34
|
+
export function validateServerLsp(key, config) {
|
|
35
|
+
const errors = [];
|
|
36
|
+
const server = parseLspServers({ [key]: config }, errors)[key];
|
|
37
|
+
if (server === undefined || errors.length > 0)
|
|
38
|
+
throw new Error(`invalid LSP configuration: ${errors.join('; ')}`);
|
|
39
|
+
return server;
|
|
40
|
+
}
|
|
41
|
+
/** Public configuration excludes the parser's derived identity. */
|
|
42
|
+
export function lspConfig(spec) {
|
|
43
|
+
return Object.fromEntries(Object.entries(spec).filter(([key]) => key !== 'key'));
|
|
44
|
+
}
|
|
45
|
+
export async function loadLspOverrides(root) {
|
|
46
|
+
try {
|
|
47
|
+
const raw = JSON.parse(await readFile(join(root, 'lsp-overrides.json'), 'utf8'));
|
|
48
|
+
return Object.fromEntries(Object.entries(raw).map(([id, config]) => [id, validateServerLsp(id.slice(id.lastIndexOf('/') + 1), config)]));
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
if (error.code === 'ENOENT')
|
|
52
|
+
return {};
|
|
53
|
+
throw error;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
export async function saveLspOverride(root, id, config) {
|
|
57
|
+
const overrides = await loadLspOverrides(root);
|
|
58
|
+
overrides[id] = config;
|
|
59
|
+
await mkdir(root, { recursive: true });
|
|
60
|
+
const path = join(root, 'lsp-overrides.json');
|
|
61
|
+
await writeFile(`${path}.tmp`, JSON.stringify(Object.fromEntries(Object.entries(overrides).map(([key, value]) => [key, lspConfig(value)])), null, 2), { mode: 0o600 });
|
|
62
|
+
await rename(`${path}.tmp`, path);
|
|
63
|
+
}
|
|
64
|
+
export async function applyLspOverrides(root, suites) {
|
|
65
|
+
const overrides = await loadLspOverrides(root);
|
|
66
|
+
return suites.map(suite => suite.lsp === undefined
|
|
67
|
+
? suite
|
|
68
|
+
: {
|
|
69
|
+
...suite,
|
|
70
|
+
lsp: {
|
|
71
|
+
...suite.lsp,
|
|
72
|
+
servers: Object.fromEntries(Object.entries(suite.lsp.servers).map(([key, spec]) => [key, overrides[`${qualifiedSuiteId(suite.sourceId, suite.id)}/${key}`] ?? spec]))
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
}
|
|
@@ -12,21 +12,19 @@
|
|
|
12
12
|
* resource base points at the skill directory, so CC-authored skills work
|
|
13
13
|
* verbatim under the harness.
|
|
14
14
|
*/
|
|
15
|
-
import { readFile
|
|
16
|
-
import { join } from 'node:path';
|
|
15
|
+
import { readFile } from 'node:fs/promises';
|
|
17
16
|
import { parseSkillFrontmatter, stripFrontmatter } from '../catalog/skills-parse.js';
|
|
18
|
-
import {
|
|
17
|
+
import { parseFrontmatterRecord } from './user-store.js';
|
|
18
|
+
import { PLUGIN_ROOT_VARIABLES } from '../model/layouts.js';
|
|
19
19
|
export const SUITE_PROJECT_SOURCE = 'agent-plugin-project';
|
|
20
20
|
export const SUITE_USER_SOURCE = 'agent-plugin-user';
|
|
21
21
|
const PROJECT_RANK = 250;
|
|
22
22
|
const USER_RANK = 450;
|
|
23
23
|
export class SuiteSkillProvider {
|
|
24
24
|
manager;
|
|
25
|
-
t;
|
|
26
25
|
name = 'agent-plugin';
|
|
27
|
-
constructor(manager
|
|
26
|
+
constructor(manager) {
|
|
28
27
|
this.manager = manager;
|
|
29
|
-
this.t = t;
|
|
30
28
|
}
|
|
31
29
|
async list(options) {
|
|
32
30
|
const located = await this.locate(options.cwd);
|
|
@@ -44,85 +42,28 @@ export class SuiteSkillProvider {
|
|
|
44
42
|
}
|
|
45
43
|
return unique.map(entry => this.candidateFor(entry));
|
|
46
44
|
}
|
|
47
|
-
/** Parse `agents/*.md` of one suite into agent-definition candidates. */
|
|
48
|
-
async agentsOf(suite) {
|
|
49
|
-
if (suite.activeSurfaces?.agents === false)
|
|
50
|
-
return [];
|
|
51
|
-
const dir = join(suite.root, 'agents');
|
|
52
|
-
let entries;
|
|
53
|
-
try {
|
|
54
|
-
entries = await readdir(dir);
|
|
55
|
-
}
|
|
56
|
-
catch {
|
|
57
|
-
return [];
|
|
58
|
-
}
|
|
59
|
-
const located = [];
|
|
60
|
-
for (const entry of entries) {
|
|
61
|
-
if (!entry.endsWith('.md'))
|
|
62
|
-
continue;
|
|
63
|
-
const file = join(dir, entry);
|
|
64
|
-
try {
|
|
65
|
-
const info = await stat(file);
|
|
66
|
-
if (!info.isFile())
|
|
67
|
-
continue;
|
|
68
|
-
}
|
|
69
|
-
catch {
|
|
70
|
-
continue;
|
|
71
|
-
}
|
|
72
|
-
const agentName = entry.slice(0, -3);
|
|
73
|
-
let text;
|
|
74
|
-
try {
|
|
75
|
-
text = await readFile(file, 'utf8');
|
|
76
|
-
}
|
|
77
|
-
catch {
|
|
78
|
-
continue;
|
|
79
|
-
}
|
|
80
|
-
const parsed = parseSkillFrontmatter(text, agentName);
|
|
81
|
-
const description = typeof parsed === 'string' ? (suite.manifest.description ?? agentName) : parsed.description;
|
|
82
|
-
const skill = {
|
|
83
|
-
name: `agent-${agentName}`,
|
|
84
|
-
directory: dir,
|
|
85
|
-
file,
|
|
86
|
-
description,
|
|
87
|
-
...(typeof parsed !== 'string' && parsed.whenToUse !== undefined ? { whenToUse: parsed.whenToUse } : {}),
|
|
88
|
-
invocation: typeof parsed === 'string' ? { modelInvocable: true, userInvocable: false } : parsed.invocation
|
|
89
|
-
};
|
|
90
|
-
located.push({
|
|
91
|
-
rank: suite.dimension === 'project' ? PROJECT_RANK : USER_RANK,
|
|
92
|
-
source: suite.dimension === 'project' ? SUITE_PROJECT_SOURCE : SUITE_USER_SOURCE,
|
|
93
|
-
suite,
|
|
94
|
-
skill
|
|
95
|
-
});
|
|
96
|
-
}
|
|
97
|
-
return located;
|
|
98
|
-
}
|
|
99
45
|
async get(candidate, _options) {
|
|
100
46
|
const locator = candidate.locator;
|
|
101
47
|
let text;
|
|
102
48
|
try {
|
|
103
|
-
text = await readFile(locator.file, 'utf8');
|
|
49
|
+
text = locator.content ?? (await readFile(locator.file, 'utf8'));
|
|
104
50
|
}
|
|
105
51
|
catch {
|
|
106
52
|
return undefined;
|
|
107
53
|
}
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
invocation: candidate.invocation,
|
|
115
|
-
source: candidate.source,
|
|
116
|
-
provider: this.name,
|
|
117
|
-
resourceBase: { kind: 'directory', path: locator.directory },
|
|
118
|
-
path: locator.file,
|
|
119
|
-
content
|
|
120
|
-
};
|
|
54
|
+
try {
|
|
55
|
+
if (parseFrontmatterRecord(text).disabled === true)
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return undefined;
|
|
121
60
|
}
|
|
122
61
|
const parsed = parseSkillFrontmatter(text, candidate.name);
|
|
123
62
|
if (typeof parsed === 'string')
|
|
124
63
|
return undefined;
|
|
125
|
-
const content = stripFrontmatter(text)
|
|
64
|
+
const content = [stripFrontmatter(text), ...(locator.skillInstructions === undefined ? [] : [locator.skillInstructions])]
|
|
65
|
+
.join('\n\n')
|
|
66
|
+
.replace(/\$\{([A-Z_]+)\}/g, (match, name) => (PLUGIN_ROOT_VARIABLES.has(name) ? locator.suiteRoot : match));
|
|
126
67
|
return {
|
|
127
68
|
name: parsed.name,
|
|
128
69
|
description: candidate.description,
|
|
@@ -145,10 +86,11 @@ export class SuiteSkillProvider {
|
|
|
145
86
|
provider: this.name,
|
|
146
87
|
rank: entry.rank,
|
|
147
88
|
locator: {
|
|
89
|
+
...(entry.content === undefined ? {} : { content: entry.content }),
|
|
90
|
+
...(entry.suite.manifest.skillInstructions === undefined ? {} : { skillInstructions: entry.suite.manifest.skillInstructions }),
|
|
148
91
|
file: entry.skill.file,
|
|
149
92
|
directory: entry.skill.directory,
|
|
150
|
-
suiteRoot: entry.suite.root
|
|
151
|
-
kind: entry.skill.name.startsWith('agent-') ? 'agent' : 'skill'
|
|
93
|
+
suiteRoot: entry.suite.root
|
|
152
94
|
},
|
|
153
95
|
path: entry.skill.file,
|
|
154
96
|
resourceBase: { kind: 'directory', path: entry.skill.directory }
|
|
@@ -159,9 +101,15 @@ export class SuiteSkillProvider {
|
|
|
159
101
|
const userSuites = await this.manager.enabledUserSuites();
|
|
160
102
|
for (const suite of userSuites) {
|
|
161
103
|
for (const skill of suite.activeSurfaces?.skills === false ? [] : suite.skills) {
|
|
104
|
+
try {
|
|
105
|
+
if (parseFrontmatterRecord(await readFile(skill.file, 'utf8')).disabled === true)
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
162
111
|
located.push({ rank: USER_RANK, source: SUITE_USER_SOURCE, suite, skill });
|
|
163
112
|
}
|
|
164
|
-
located.push(...(await this.agentsOf(suite)));
|
|
165
113
|
}
|
|
166
114
|
if (cwd !== undefined) {
|
|
167
115
|
located.push(...(await this.locateProject(cwd)));
|
|
@@ -173,9 +121,15 @@ export class SuiteSkillProvider {
|
|
|
173
121
|
const located = [];
|
|
174
122
|
for (const suite of snapshot.enabledSuites) {
|
|
175
123
|
for (const skill of suite.activeSurfaces?.skills === false ? [] : suite.skills) {
|
|
124
|
+
try {
|
|
125
|
+
if (parseFrontmatterRecord(await readFile(skill.file, 'utf8')).disabled === true)
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
176
131
|
located.push({ rank: PROJECT_RANK, source: SUITE_PROJECT_SOURCE, suite, skill });
|
|
177
132
|
}
|
|
178
|
-
located.push(...(await this.agentsOf(suite)));
|
|
179
133
|
}
|
|
180
134
|
return located;
|
|
181
135
|
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/** Consolidate plugin-owned storage before stores, routes, or providers are exposed. */
|
|
2
|
+
import { constants } from 'node:fs';
|
|
3
|
+
import { copyFile, lstat, mkdir, readdir, readFile, rename, rmdir, unlink, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
5
|
+
import { expandHome, resolveDataRoot, resolveDshHome, resolveUserRoot } from '../catalog/paths.js';
|
|
6
|
+
async function info(path) {
|
|
7
|
+
try {
|
|
8
|
+
return await lstat(path);
|
|
9
|
+
}
|
|
10
|
+
catch (error) {
|
|
11
|
+
if (error.code === 'ENOENT')
|
|
12
|
+
return undefined;
|
|
13
|
+
throw error;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
/** Move only successfully copied entries; never traverse links or overwrite a destination. */
|
|
17
|
+
export async function mergeStorageTree(from, to, result) {
|
|
18
|
+
if (resolve(from) === resolve(to))
|
|
19
|
+
return;
|
|
20
|
+
const source = await info(from);
|
|
21
|
+
if (source === undefined)
|
|
22
|
+
return;
|
|
23
|
+
const destination = await info(to);
|
|
24
|
+
if (source.isSymbolicLink() || destination?.isSymbolicLink() === true) {
|
|
25
|
+
result.conflicts.push(from);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
if (source.isDirectory()) {
|
|
29
|
+
if (destination !== undefined && !destination.isDirectory()) {
|
|
30
|
+
result.conflicts.push(from);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
await mkdir(to, { recursive: true });
|
|
34
|
+
for (const name of await readdir(from))
|
|
35
|
+
await mergeStorageTree(join(from, name), join(to, name), result);
|
|
36
|
+
if ((await readdir(from)).length === 0)
|
|
37
|
+
await rmdir(from);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (!source.isFile() || (destination !== undefined && !destination.isFile())) {
|
|
41
|
+
result.conflicts.push(from);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (destination !== undefined) {
|
|
45
|
+
if (!(await readFile(from)).equals(await readFile(to))) {
|
|
46
|
+
result.conflicts.push(from);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
await mkdir(dirname(to), { recursive: true });
|
|
52
|
+
// COPYFILE_EXCL also protects against a destination created during migration.
|
|
53
|
+
await copyFile(from, to, constants.COPYFILE_EXCL);
|
|
54
|
+
}
|
|
55
|
+
await unlink(from);
|
|
56
|
+
}
|
|
57
|
+
function contains(parent, child) {
|
|
58
|
+
const path = relative(parent, child);
|
|
59
|
+
return path === '' || (!path.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`) && path !== '..' && !isAbsolute(path));
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Migrate former root overrides and data/user panels into the canonical root.
|
|
63
|
+
* Unknown files in override directories remain untouched. Conflicts remain at
|
|
64
|
+
* the old path and are returned to block activation; reruns are safe.
|
|
65
|
+
*/
|
|
66
|
+
export async function migratePluginStorage(config = {}) {
|
|
67
|
+
const userRoot = resolveUserRoot();
|
|
68
|
+
const dataRoot = resolveDataRoot();
|
|
69
|
+
const result = { conflicts: [] };
|
|
70
|
+
for (const path of [resolveDshHome(), userRoot, dataRoot, join(userRoot, 'user'), join(userRoot, '.sources')]) {
|
|
71
|
+
if ((await info(path))?.isSymbolicLink() === true)
|
|
72
|
+
throw new Error(`Plugin storage cannot use a symbolic-link directory: ${path}`);
|
|
73
|
+
}
|
|
74
|
+
const legacyUserRoot = resolve(expandHome(config.userRoot ?? userRoot));
|
|
75
|
+
const legacyDataRoot = resolve(expandHome(config.dataRoot ?? join(legacyUserRoot, 'data')));
|
|
76
|
+
for (const path of new Set([legacyUserRoot, legacyDataRoot, join(resolveDshHome(), 'agent-plugins-data')])) {
|
|
77
|
+
if ((await info(path))?.isSymbolicLink() === true)
|
|
78
|
+
throw new Error(`Plugin storage migration cannot traverse a symbolic-link root: ${path}`);
|
|
79
|
+
}
|
|
80
|
+
if (legacyUserRoot !== userRoot) {
|
|
81
|
+
if (contains(legacyUserRoot, userRoot) || contains(userRoot, legacyUserRoot))
|
|
82
|
+
throw new Error('Legacy userRoot overlaps canonical plugin storage');
|
|
83
|
+
const legacySources = join(legacyUserRoot, '.sources');
|
|
84
|
+
const sourceInfo = await info(legacySources);
|
|
85
|
+
const sourceNames = sourceInfo?.isDirectory() === true ? await readdir(legacySources) : [];
|
|
86
|
+
const rootConflicts = [];
|
|
87
|
+
if (sourceInfo !== undefined && !sourceInfo.isDirectory())
|
|
88
|
+
rootConflicts.push(legacySources);
|
|
89
|
+
for (const name of sourceNames) {
|
|
90
|
+
if ((await info(join(userRoot, '.sources', name))) !== undefined)
|
|
91
|
+
rootConflicts.push(join(legacySources, name));
|
|
92
|
+
await collectUnsupportedEntries(join(legacySources, name), rootConflicts);
|
|
93
|
+
}
|
|
94
|
+
const legacyState = join(legacyUserRoot, 'state.json');
|
|
95
|
+
const currentState = join(userRoot, 'state.json');
|
|
96
|
+
const previous = await info(legacyState);
|
|
97
|
+
const current = await info(currentState);
|
|
98
|
+
// Validate before relocating any checkouts: malformed state must remain
|
|
99
|
+
// beside its original sources so recovery does not require finding them.
|
|
100
|
+
if (previous?.isFile() === true)
|
|
101
|
+
JSON.parse(await readFile(legacyState, 'utf8'));
|
|
102
|
+
if (previous !== undefined && (!previous.isFile() || (current !== undefined && (!current.isFile() || !(await readFile(legacyState)).equals(await readFile(currentState)))))) {
|
|
103
|
+
rootConflicts.push(legacyState);
|
|
104
|
+
}
|
|
105
|
+
if (rootConflicts.length > 0) {
|
|
106
|
+
// The install state and its checkouts are one ownership unit. Moving only
|
|
107
|
+
// one half would orphan installs or make the retained state unusable.
|
|
108
|
+
result.conflicts.push(...rootConflicts);
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
if (sourceInfo?.isDirectory() === true) {
|
|
112
|
+
for (const name of sourceNames)
|
|
113
|
+
await mergeStorageTree(join(legacySources, name), join(userRoot, '.sources', name), result);
|
|
114
|
+
if ((await readdir(legacySources)).length === 0)
|
|
115
|
+
await rmdir(legacySources);
|
|
116
|
+
}
|
|
117
|
+
await mergeStorageTree(legacyState, currentState, result);
|
|
118
|
+
await relocateManagedSourceUrls(userRoot, legacyUserRoot);
|
|
119
|
+
}
|
|
120
|
+
for (const entry of ['data', 'user'])
|
|
121
|
+
await mergeStorageTree(join(legacyUserRoot, entry), join(userRoot, entry), result);
|
|
122
|
+
}
|
|
123
|
+
for (const legacy of new Set([legacyDataRoot, join(resolveDshHome(), 'agent-plugins-data')])) {
|
|
124
|
+
if (legacy === dataRoot)
|
|
125
|
+
continue;
|
|
126
|
+
if (contains(legacy, dataRoot) || contains(dataRoot, legacy))
|
|
127
|
+
throw new Error('Legacy dataRoot overlaps canonical plugin data storage');
|
|
128
|
+
for (const entry of ['data', 'overrides', 'user', 'settings.json', 'lsp-servers.json', 'feedback']) {
|
|
129
|
+
await mergeStorageTree(join(legacy, entry), join(dataRoot, entry), result);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
await mergeStorageTree(join(dataRoot, 'user'), join(userRoot, 'user'), result);
|
|
133
|
+
return result;
|
|
134
|
+
}
|
|
135
|
+
/** Retain all state fields while rebasing adopted checkouts moved with a root. */
|
|
136
|
+
async function relocateManagedSourceUrls(userRoot, legacyUserRoot) {
|
|
137
|
+
const path = join(userRoot, 'state.json');
|
|
138
|
+
if ((await info(path)) === undefined)
|
|
139
|
+
return;
|
|
140
|
+
const state = JSON.parse(await readFile(path, 'utf8'));
|
|
141
|
+
if (typeof state !== 'object' || state === null || !('sources' in state) || !Array.isArray(state.sources))
|
|
142
|
+
return;
|
|
143
|
+
let changed = false;
|
|
144
|
+
for (const source of state.sources) {
|
|
145
|
+
if (typeof source !== 'object' || source === null || !('local' in source) || source.local !== true || !('url' in source) || typeof source.url !== 'string')
|
|
146
|
+
continue;
|
|
147
|
+
const oldCheckout = join(legacyUserRoot, '.sources');
|
|
148
|
+
if (!contains(oldCheckout, resolve(expandHome(source.url))))
|
|
149
|
+
continue;
|
|
150
|
+
source.url = join(userRoot, '.sources', relative(oldCheckout, resolve(expandHome(source.url))));
|
|
151
|
+
changed = true;
|
|
152
|
+
}
|
|
153
|
+
if (changed) {
|
|
154
|
+
const temp = `${path}.migration-${process.pid}.tmp`;
|
|
155
|
+
await writeFile(temp, `${JSON.stringify(state, null, 2)}\n`, { flag: 'wx' });
|
|
156
|
+
await rename(temp, path);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
async function collectUnsupportedEntries(path, conflicts) {
|
|
160
|
+
const entry = await info(path);
|
|
161
|
+
if (entry === undefined)
|
|
162
|
+
return;
|
|
163
|
+
if (entry.isDirectory()) {
|
|
164
|
+
for (const name of await readdir(path))
|
|
165
|
+
await collectUnsupportedEntries(join(path, name), conflicts);
|
|
166
|
+
}
|
|
167
|
+
else if (!entry.isFile())
|
|
168
|
+
conflicts.push(path);
|
|
169
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/** Durable role catalogs follow DSH tool-skill's pre-step publication and session-surface recovery. */
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { createUserMessage } from '@deepseek-ai/dsh-llm';
|
|
4
|
+
/** Register after the exact tool definition; teardown removes guidance before execution. */
|
|
5
|
+
export function mountSubagentCatalog(ctx, tool, snapshot, t) {
|
|
6
|
+
const host = ctx;
|
|
7
|
+
let disposed = false;
|
|
8
|
+
const dispose = host.on('agent/pre-step', async ({ agent, signal }, next) => {
|
|
9
|
+
const decision = await next();
|
|
10
|
+
if (decision.kind === 'reject' || disposed)
|
|
11
|
+
return decision;
|
|
12
|
+
signal.throwIfAborted();
|
|
13
|
+
let entries;
|
|
14
|
+
try {
|
|
15
|
+
entries = host.tools.get(tool.name, agent) === tool ? await snapshot(agent, signal) : [];
|
|
16
|
+
}
|
|
17
|
+
catch (error) {
|
|
18
|
+
signal.throwIfAborted();
|
|
19
|
+
host.logger?.warn(`subagent catalog snapshot incomplete: ${String(error)}`);
|
|
20
|
+
return decision;
|
|
21
|
+
}
|
|
22
|
+
signal.throwIfAborted();
|
|
23
|
+
if (disposed)
|
|
24
|
+
return decision;
|
|
25
|
+
if (host.tools.get(tool.name, agent) !== tool)
|
|
26
|
+
entries = [];
|
|
27
|
+
const digest = digestEntries(entries);
|
|
28
|
+
const history = catalogHistory(agent);
|
|
29
|
+
const existing = catalogMessage(decision.messages);
|
|
30
|
+
if (history.visibleDigest === digest) {
|
|
31
|
+
return existing === undefined ? decision : { ...decision, messages: decision.messages.filter(message => message.id !== existing.message.id) };
|
|
32
|
+
}
|
|
33
|
+
if (existing !== undefined && digestEntries(existing.entries) === digest)
|
|
34
|
+
return decision;
|
|
35
|
+
if (!history.published && entries.length === 0) {
|
|
36
|
+
return existing === undefined ? decision : { ...decision, messages: decision.messages.filter(message => message.id !== existing.message.id) };
|
|
37
|
+
}
|
|
38
|
+
const message = renderCatalog(entries, history.published, t);
|
|
39
|
+
return {
|
|
40
|
+
...decision,
|
|
41
|
+
messages: existing === undefined ? [...decision.messages, message] : decision.messages.map(item => (item.id === existing.message.id ? message : item))
|
|
42
|
+
};
|
|
43
|
+
});
|
|
44
|
+
return () => {
|
|
45
|
+
disposed = true;
|
|
46
|
+
dispose();
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
function escapeText(value) {
|
|
50
|
+
return value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", ''');
|
|
51
|
+
}
|
|
52
|
+
function renderCatalog(entries, update, t) {
|
|
53
|
+
const lines = entries.map(entry => {
|
|
54
|
+
const route = [entry.provider, entry.model].filter(value => value !== undefined).join('/') || t('subagentCatalogInherit');
|
|
55
|
+
return `<subagent id="${escapeText(entry.name)}" name="${escapeText(entry.title)}" model="${escapeText(route)}" reasoning_effort="${escapeText(entry.reasoningEffort ?? t('subagentCatalogDefaultEffort'))}">${escapeText(entry.description)}</subagent>`;
|
|
56
|
+
});
|
|
57
|
+
return createUserMessage({
|
|
58
|
+
content: [
|
|
59
|
+
{
|
|
60
|
+
type: 'text',
|
|
61
|
+
text: [
|
|
62
|
+
'<system-reminder>',
|
|
63
|
+
t(update ? 'subagentCatalogUpdated' : 'subagentCatalogIntro'),
|
|
64
|
+
'<available_subagents>',
|
|
65
|
+
...lines,
|
|
66
|
+
'</available_subagents>',
|
|
67
|
+
t(entries.length === 0 ? 'subagentCatalogEmpty' : 'subagentCatalogCall'),
|
|
68
|
+
'</system-reminder>'
|
|
69
|
+
].join('\n')
|
|
70
|
+
}
|
|
71
|
+
],
|
|
72
|
+
source: { kind: 'subagent-catalog', form: 'catalog', ...(update ? { update: true } : {}), entries }
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
function digestEntries(entries) {
|
|
76
|
+
return createHash('sha256')
|
|
77
|
+
.update(entries.map(entry => JSON.stringify([entry.name, entry.title, entry.description, entry.provider, entry.model, entry.reasoningEffort])).join('\n'))
|
|
78
|
+
.digest('hex');
|
|
79
|
+
}
|
|
80
|
+
/** Resumed and forked seeds may carry unvalidated source fields; ignore unreadable catalogs. */
|
|
81
|
+
function readEntries(source) {
|
|
82
|
+
if (typeof source !== 'object' || source === null || !('kind' in source) || source.kind !== 'subagent-catalog' || !('entries' in source) || !Array.isArray(source.entries))
|
|
83
|
+
return undefined;
|
|
84
|
+
const entries = [];
|
|
85
|
+
for (const raw of source.entries) {
|
|
86
|
+
if (typeof raw !== 'object' || raw === null)
|
|
87
|
+
return undefined;
|
|
88
|
+
const entry = raw;
|
|
89
|
+
if (typeof entry.name !== 'string' || entry.name === '' || typeof entry.title !== 'string' || typeof entry.description !== 'string')
|
|
90
|
+
return undefined;
|
|
91
|
+
if (['provider', 'model', 'reasoningEffort'].some(key => entry[key] !== undefined && (typeof entry[key] !== 'string' || entry[key] === '')))
|
|
92
|
+
return undefined;
|
|
93
|
+
entries.push({
|
|
94
|
+
name: entry.name,
|
|
95
|
+
title: entry.title,
|
|
96
|
+
description: entry.description,
|
|
97
|
+
...(entry.provider === undefined ? {} : { provider: entry.provider }),
|
|
98
|
+
...(entry.model === undefined ? {} : { model: entry.model }),
|
|
99
|
+
...(entry.reasoningEffort === undefined ? {} : { reasoningEffort: entry.reasoningEffort })
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
return entries;
|
|
103
|
+
}
|
|
104
|
+
function catalogHistory(agent) {
|
|
105
|
+
const visible = new Set(agent.session.surface.nodes);
|
|
106
|
+
let published = false;
|
|
107
|
+
for (let index = agent.session.seq - 1; index >= 0; index--) {
|
|
108
|
+
const event = agent.session.eventAt(index);
|
|
109
|
+
if (event === undefined)
|
|
110
|
+
throw new Error(`subagent catalog cannot read seq ${index} below the current Session length`);
|
|
111
|
+
if (event.type !== 'user/message')
|
|
112
|
+
continue;
|
|
113
|
+
const entries = readEntries(event.data.source);
|
|
114
|
+
if (entries === undefined)
|
|
115
|
+
continue;
|
|
116
|
+
published = true;
|
|
117
|
+
if (visible.has(event.seq))
|
|
118
|
+
return { visibleDigest: digestEntries(entries), published };
|
|
119
|
+
}
|
|
120
|
+
return { published };
|
|
121
|
+
}
|
|
122
|
+
function catalogMessage(messages) {
|
|
123
|
+
for (const message of messages) {
|
|
124
|
+
const entries = readEntries(message.source);
|
|
125
|
+
if (entries !== undefined)
|
|
126
|
+
return { message, entries };
|
|
127
|
+
}
|
|
128
|
+
return undefined;
|
|
129
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* User command mounting: every enabled entry of the user commands panel
|
|
3
|
+
* registers as a dsh slash command, so user-authored quick replies behave
|
|
4
|
+
* exactly like suite commands — the body with `$ARGUMENTS` substituted rides
|
|
5
|
+
* one follow-up message on the receiving agent. Reconciled on every catalog
|
|
6
|
+
* change, keyed `user/<name>`.
|
|
7
|
+
* @module runtime/user-commands
|
|
8
|
+
*/
|
|
9
|
+
import { USER_ENTRY_NAME } from './user-store.js';
|
|
10
|
+
/** Register/unregister user-panel commands to match the panel's enabled entries. */
|
|
11
|
+
export class UserCommandMountRegistry {
|
|
12
|
+
ctx;
|
|
13
|
+
store;
|
|
14
|
+
t;
|
|
15
|
+
fingerprints = new Map();
|
|
16
|
+
live = new Map();
|
|
17
|
+
constructor(ctx, store, t) {
|
|
18
|
+
this.ctx = ctx;
|
|
19
|
+
this.store = store;
|
|
20
|
+
this.t = t;
|
|
21
|
+
}
|
|
22
|
+
/** Sync the live registrations with the panel's enabled entries. */
|
|
23
|
+
async reconcile() {
|
|
24
|
+
const diagnostics = [];
|
|
25
|
+
const entries = await this.store.list();
|
|
26
|
+
const wanted = new Map();
|
|
27
|
+
for (const entry of entries) {
|
|
28
|
+
if (entry.disabled)
|
|
29
|
+
continue;
|
|
30
|
+
if (!USER_ENTRY_NAME.test(entry.name))
|
|
31
|
+
continue;
|
|
32
|
+
wanted.set(entry.name, {
|
|
33
|
+
name: entry.name,
|
|
34
|
+
description: entry.description === '' ? `[${this.t('userCommandSourceLabel')}] ${entry.name}` : `[${this.t('userCommandSourceLabel')}] ${entry.description}`,
|
|
35
|
+
...(typeof entry.metadata['argument-hint'] === 'string' && entry.metadata['argument-hint'] !== '' ? { hint: entry.metadata['argument-hint'] } : {}),
|
|
36
|
+
body: entry.content
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
for (const [key, disposer] of [...this.live]) {
|
|
40
|
+
if (!wanted.has(key)) {
|
|
41
|
+
disposer();
|
|
42
|
+
this.live.delete(key);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const host = this.ctx;
|
|
46
|
+
if (typeof host.commands?.register !== 'function') {
|
|
47
|
+
if (wanted.size > 0)
|
|
48
|
+
diagnostics.push('ctx.commands is not available in this profile; user commands stay unregistered');
|
|
49
|
+
return diagnostics;
|
|
50
|
+
}
|
|
51
|
+
for (const [key, spec] of wanted) {
|
|
52
|
+
const fingerprint = JSON.stringify(spec);
|
|
53
|
+
if (this.live.has(key) && this.fingerprints.get(key) === fingerprint)
|
|
54
|
+
continue;
|
|
55
|
+
this.live.get(key)?.();
|
|
56
|
+
this.live.delete(key);
|
|
57
|
+
this.fingerprints.delete(key);
|
|
58
|
+
try {
|
|
59
|
+
const disposer = host.commands.register({
|
|
60
|
+
name: spec.name,
|
|
61
|
+
description: spec.description,
|
|
62
|
+
...(spec.hint === undefined ? {} : { input: { hint: spec.hint } }),
|
|
63
|
+
handler: invocation => {
|
|
64
|
+
const agent = invocation.agent;
|
|
65
|
+
const text = [this.t('userCommandForwardTitle', { command: spec.name }), '', spec.body.replaceAll('$ARGUMENTS', invocation.rawInput.trim())].join('\n');
|
|
66
|
+
agent.followup({ content: [{ type: 'text', text }], source: { kind: 'plugin', plugin: 'dsh-agent-plugins-market' } });
|
|
67
|
+
return { kind: 'success', text: this.t('userCommandAcknowledged', { command: spec.name }) };
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
this.live.set(key, disposer);
|
|
71
|
+
this.fingerprints.set(key, fingerprint);
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
diagnostics.push(`command "${spec.name}": ${error instanceof Error ? error.message : String(error)}`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return diagnostics;
|
|
78
|
+
}
|
|
79
|
+
/** Dispose every registration (plugin teardown). */
|
|
80
|
+
disposeAll() {
|
|
81
|
+
for (const disposer of [...this.live.values()])
|
|
82
|
+
disposer();
|
|
83
|
+
this.live.clear();
|
|
84
|
+
this.fingerprints.clear();
|
|
85
|
+
}
|
|
86
|
+
}
|