dsh-agent-plugins-market 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +171 -0
  3. package/README.zh.md +171 -0
  4. package/client/client.js +954 -0
  5. package/client/client.js.map +1 -0
  6. package/cordis.patch.yml +4 -0
  7. package/lib/commands-mounts.js +169 -0
  8. package/lib/context.js +80 -0
  9. package/lib/discovery/manifests.js +214 -0
  10. package/lib/discovery/surfaces.js +232 -0
  11. package/lib/discovery.js +234 -0
  12. package/lib/git.js +52 -0
  13. package/lib/hooks-mounts.js +95 -0
  14. package/lib/index.js +73 -0
  15. package/lib/manager.js +490 -0
  16. package/lib/mcp-config.js +100 -0
  17. package/lib/mcp-mounts.js +88 -0
  18. package/lib/paths.js +85 -0
  19. package/lib/routes.js +208 -0
  20. package/lib/skills-parse.js +114 -0
  21. package/lib/skills-provider.js +170 -0
  22. package/lib/state.js +64 -0
  23. package/lib/types/commands-mounts.d.ts +29 -0
  24. package/lib/types/context.d.ts +13 -0
  25. package/lib/types/discovery/manifests.d.ts +55 -0
  26. package/lib/types/discovery/surfaces.d.ts +15 -0
  27. package/lib/types/discovery.d.ts +16 -0
  28. package/lib/types/git.d.ts +8 -0
  29. package/lib/types/hooks-mounts.d.ts +19 -0
  30. package/lib/types/index.d.ts +27 -0
  31. package/lib/types/manager.d.ts +87 -0
  32. package/lib/types/mcp-config.d.ts +28 -0
  33. package/lib/types/mcp-mounts.d.ts +31 -0
  34. package/lib/types/paths.d.ts +29 -0
  35. package/lib/types/routes.d.ts +11 -0
  36. package/lib/types/skills-parse.d.ts +22 -0
  37. package/lib/types/skills-provider.d.ts +16 -0
  38. package/lib/types/state.d.ts +6 -0
  39. package/lib/types/types.d.ts +165 -0
  40. package/lib/types/validate.d.ts +34 -0
  41. package/lib/types.js +10 -0
  42. package/lib/validate.js +199 -0
  43. package/package.json +114 -0
  44. package/schemas/1.0.0/mcp.schema.json +120 -0
  45. package/schemas/1.0.0/plugin.schema.json +65 -0
  46. package/schemas/README.md +14 -0
package/lib/paths.js ADDED
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Root path resolution for the two install dimensions.
3
+ *
4
+ * User dimension: `~/.dsh/agent-plugins/` (or `$DSH_HOME/agent-plugins`).
5
+ * Project dimension: `<projectRoot>/.dsh/agent-plugins/`, where the project
6
+ * root is the nearest ancestor containing `.git`.
7
+ */
8
+ import { existsSync } from 'node:fs';
9
+ import { stat } from 'node:fs/promises';
10
+ import { homedir } from 'node:os';
11
+ import { dirname, join, resolve } from 'node:path';
12
+ /** Source checkouts live under `<dimensionRoot>/.sources/<sourceId>/`. */
13
+ export const SOURCES_DIR_NAME = '.sources';
14
+ /** Per-suite mutable data directory (the `${PLUGIN_DATA}` placeholder). */
15
+ export const DATA_DIR_NAME = 'data';
16
+ export const STATE_FILE_NAME = 'state.json';
17
+ /** Expand a leading `~/` (or `~\` on Windows) to the home directory; other values pass through. */
18
+ export function expandHome(path) {
19
+ if (path === '~' || path.startsWith('~/') || path.startsWith('~\\'))
20
+ return join(homedir(), path.slice(2));
21
+ return path;
22
+ }
23
+ /** Resolve the harness home (`$DSH_HOME` or `~/.dsh`). */
24
+ export function resolveDshHome() {
25
+ return process.env.DSH_HOME === undefined ? join(homedir(), '.dsh') : resolve(process.env.DSH_HOME);
26
+ }
27
+ /** Resolve the user-dimension suite root. */
28
+ export function resolveUserRoot(configUserRoot) {
29
+ return resolve(expandHome(configUserRoot ?? join(resolveDshHome(), 'agent-plugins')));
30
+ }
31
+ /** Resolve the suite data root hosting `${PLUGIN_DATA}` directories. */
32
+ export function resolveDataRoot(configDataRoot) {
33
+ return resolve(expandHome(configDataRoot ?? join(resolveDshHome(), 'agent-plugins-data')));
34
+ }
35
+ /** Resolve a project root from a workspace cwd: nearest ancestor with `.git`. */
36
+ export async function findProjectRoot(cwd) {
37
+ let current = resolve(cwd);
38
+ for (;;) {
39
+ if (existsSync(join(current, '.git')))
40
+ return current;
41
+ const parent = dirname(current);
42
+ if (parent === current)
43
+ return resolve(cwd);
44
+ current = parent;
45
+ }
46
+ }
47
+ /** Resolve the project-dimension suite root for a workspace cwd. */
48
+ export async function resolveProjectRoot(cwd) {
49
+ return join(await findProjectRoot(cwd), '.dsh', 'agent-plugins');
50
+ }
51
+ /** Source checkout directory for one source id. */
52
+ export function sourcesDir(dimensionRoot) {
53
+ return join(dimensionRoot, SOURCES_DIR_NAME);
54
+ }
55
+ /** Source checkout directory for one source id. */
56
+ export function sourceCheckoutDir(dimensionRoot, sourceId) {
57
+ return join(sourcesDir(dimensionRoot), sourceId);
58
+ }
59
+ /** Per-suite data directory for `${PLUGIN_DATA}`. */
60
+ export function suiteDataDir(dataRoot, suiteId) {
61
+ return join(dataRoot, DATA_DIR_NAME, suiteId);
62
+ }
63
+ /** Async existence probe that follows symlinks for a final component. */
64
+ export async function isDirectory(path) {
65
+ try {
66
+ const info = await stat(path);
67
+ return info.isDirectory();
68
+ }
69
+ catch {
70
+ return false;
71
+ }
72
+ }
73
+ /** Sanitize a plugin or server id into `[a-z0-9-]` (lowercased). */
74
+ export function sanitizeId(raw) {
75
+ const cleaned = raw.toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^[^a-z0-9]+|[^a-z0-9]+$/g, '').replace(/-{2,}/g, '-');
76
+ return cleaned === '' ? 'unnamed' : cleaned;
77
+ }
78
+ /** Derive a source id from a repository URL or local path: last path segment, `.git` stripped. */
79
+ export function deriveSourceId(url) {
80
+ const trimmed = url.trim().replace(/\/+$/, '');
81
+ let base = trimmed.split(/[/\\]/).at(-1) ?? '';
82
+ if (base.endsWith('.git'))
83
+ base = base.slice(0, -4);
84
+ return sanitizeId(base);
85
+ }
package/lib/routes.js ADDED
@@ -0,0 +1,208 @@
1
+ /**
2
+ * HTTP routes bridging the market page to the SuiteManager.
3
+ *
4
+ * This layer only parses requests, delegates to the manager, and serializes
5
+ * responses. Mutating routes accept same-origin POSTs exclusively: a
6
+ * cross-site form or fetch cannot trigger a clone, an uninstall, or an
7
+ * enable/disable against a local profile.
8
+ */
9
+ import { isAbsolute } from 'node:path';
10
+ import { expandHome } from './paths.js';
11
+ const API_PREFIX = '/api/agent-plugins/';
12
+ const MAX_BODY_BYTES = 64 * 1024;
13
+ /** Mount every route; returns the disposer releasing them all. */
14
+ export function mountSuiteRoutes(hostCtx, manager) {
15
+ const host = hostCtx;
16
+ const disposers = [];
17
+ const get = (path, handler) => {
18
+ disposers.push(host.webServer.register({ kind: 'exact', path, handler }));
19
+ };
20
+ const post = (path, handler) => {
21
+ disposers.push(host.webServer.register({
22
+ kind: 'exact',
23
+ path,
24
+ handler: (request, response) => {
25
+ if (!sameOrigin(request)) {
26
+ sendJson(response, 403, { ok: false, error: 'cross-origin request rejected' });
27
+ return;
28
+ }
29
+ void (async () => {
30
+ const body = await readJsonBody(request);
31
+ if (body === undefined) {
32
+ sendJson(response, 400, { ok: false, error: 'invalid JSON body' });
33
+ return;
34
+ }
35
+ try {
36
+ const value = await handler(body);
37
+ sendJson(response, 200, { ok: true, ...value });
38
+ }
39
+ catch (error) {
40
+ sendJson(response, 400, { ok: false, error: error instanceof Error ? error.message : String(error) });
41
+ }
42
+ })();
43
+ },
44
+ }));
45
+ };
46
+ get(`${API_PREFIX}overview`, async (_request, response) => {
47
+ sendJson(response, 200, await manager.overview());
48
+ });
49
+ get(`${API_PREFIX}config`, async (_request, response) => {
50
+ sendJson(response, 200, { sources: manager.sources });
51
+ });
52
+ get(`${API_PREFIX}suite`, async (request, response) => {
53
+ const query = queryOf(request);
54
+ const sourceId = query.get('sourceId');
55
+ const suiteId = query.get('suiteId');
56
+ if (sourceId === null || suiteId === null) {
57
+ sendJson(response, 400, { ok: false, error: 'missing sourceId or suiteId' });
58
+ return;
59
+ }
60
+ try {
61
+ sendJson(response, 200, await manager.suiteDetail(sourceId, suiteId));
62
+ }
63
+ catch (error) {
64
+ sendJson(response, 404, { ok: false, error: error instanceof Error ? error.message : String(error) });
65
+ }
66
+ });
67
+ get(`${API_PREFIX}skill`, async (request, response) => {
68
+ const query = queryOf(request);
69
+ const sourceId = query.get('sourceId');
70
+ const suiteId = query.get('suiteId');
71
+ const skill = query.get('skill');
72
+ if (sourceId === null || suiteId === null || skill === null) {
73
+ sendJson(response, 400, { ok: false, error: 'missing sourceId, suiteId, or skill' });
74
+ return;
75
+ }
76
+ try {
77
+ sendJson(response, 200, await manager.skillContent(sourceId, suiteId, skill));
78
+ }
79
+ catch (error) {
80
+ sendJson(response, 404, { ok: false, error: error instanceof Error ? error.message : String(error) });
81
+ }
82
+ });
83
+ post(`${API_PREFIX}sources/add`, async (body) => {
84
+ const url = String(body['url'] ?? '').trim();
85
+ if (url === '')
86
+ throw new Error('missing source url');
87
+ const local = body['local'] === true;
88
+ if (local) {
89
+ const expanded = expandHome(url);
90
+ if (!url.startsWith('~/') && url !== '~' && !isAbsolute(expanded))
91
+ throw new Error('local source url must be an absolute path or start with ~/');
92
+ }
93
+ const branch = body['branch'];
94
+ const source = await manager.addSource({
95
+ url: local ? expandHome(url) : url,
96
+ ...typeof branch === 'string' && branch.trim() !== '' ? { branch: branch.trim() } : {},
97
+ ...local ? { local: true } : {},
98
+ });
99
+ return { source };
100
+ });
101
+ post(`${API_PREFIX}sources/update`, async (body) => {
102
+ const id = body['id'];
103
+ if (typeof id !== 'string' || id === '')
104
+ throw new Error('missing source id');
105
+ const patch = {};
106
+ if (body['url'] !== undefined) {
107
+ const url = String(body['url']).trim();
108
+ if (url === '')
109
+ throw new Error('missing source url');
110
+ patch.url = url;
111
+ }
112
+ if (body['branch'] !== undefined)
113
+ patch.branch = String(body['branch']).trim();
114
+ if (body['local'] !== undefined)
115
+ patch.local = body['local'] === true;
116
+ await manager.updateSource(id, patch);
117
+ return {};
118
+ });
119
+ post(`${API_PREFIX}sources/remove`, async (body) => {
120
+ const id = body['id'];
121
+ if (typeof id !== 'string' || id === '')
122
+ throw new Error('missing source id');
123
+ await manager.removeSource(id);
124
+ return {};
125
+ });
126
+ post(`${API_PREFIX}sources/refresh`, async (body) => {
127
+ const id = body['id'];
128
+ await manager.refreshSource(typeof id === 'string' && id !== '' ? id : undefined);
129
+ return {};
130
+ });
131
+ post(`${API_PREFIX}install`, async (body) => {
132
+ const { sourceId, suiteId } = parseTarget(body);
133
+ await manager.install(sourceId, suiteId);
134
+ return {};
135
+ });
136
+ post(`${API_PREFIX}uninstall`, async (body) => {
137
+ const { sourceId, suiteId } = parseTarget(body);
138
+ await manager.uninstall(sourceId, suiteId);
139
+ return {};
140
+ });
141
+ post(`${API_PREFIX}set-enabled`, async (body) => {
142
+ const { sourceId, suiteId } = parseTarget(body);
143
+ const enabled = body['enabled'];
144
+ if (typeof enabled !== 'boolean')
145
+ throw new Error('missing boolean enabled');
146
+ await manager.setEnabled(sourceId, suiteId, enabled);
147
+ return {};
148
+ });
149
+ return () => {
150
+ for (const dispose of disposers)
151
+ dispose();
152
+ };
153
+ }
154
+ function parseTarget(body) {
155
+ const sourceId = body['sourceId'];
156
+ const suiteId = body['suiteId'];
157
+ if (typeof sourceId !== 'string' || sourceId === '')
158
+ throw new Error('missing sourceId');
159
+ if (typeof suiteId !== 'string' || suiteId === '')
160
+ throw new Error('missing suiteId');
161
+ return { sourceId, suiteId };
162
+ }
163
+ function sameOrigin(request) {
164
+ const origin = request.headers['origin'];
165
+ if (origin === undefined)
166
+ return true;
167
+ try {
168
+ return new URL(origin).host === request.headers['host'];
169
+ }
170
+ catch {
171
+ return false;
172
+ }
173
+ }
174
+ function readJsonBody(request) {
175
+ return new Promise((resolve) => {
176
+ let size = 0;
177
+ const chunks = [];
178
+ request.on('data', (chunk) => {
179
+ size += chunk.length;
180
+ if (size > MAX_BODY_BYTES) {
181
+ resolve(undefined);
182
+ request.destroy();
183
+ return;
184
+ }
185
+ chunks.push(chunk);
186
+ });
187
+ request.on('end', () => {
188
+ if (size > MAX_BODY_BYTES)
189
+ return;
190
+ try {
191
+ resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')));
192
+ }
193
+ catch {
194
+ resolve(undefined);
195
+ }
196
+ });
197
+ request.on('error', () => resolve(undefined));
198
+ });
199
+ }
200
+ /** Parse the query string of one request into a URLSearchParams. */
201
+ function queryOf(request) {
202
+ return new URL(request.url ?? '/', 'http://dsh.local').searchParams;
203
+ }
204
+ function sendJson(response, status, payload) {
205
+ const body = JSON.stringify(payload);
206
+ response.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'content-length': Buffer.byteLength(body) });
207
+ response.end(body);
208
+ }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * SKILL.md frontmatter parsing shared by suite discovery (summaries) and the
3
+ * skill provider (full definitions).
4
+ *
5
+ * Parses the open YAML object dsh-skill documents: required `name` and
6
+ * `description`, optional `whenToUse`, and the two invocation controls
7
+ * (`disable-model-invocation`, `user-invocable`) with the same boolean forms
8
+ * and fail-closed behavior as the shipped filesystem provider.
9
+ */
10
+ import { parse as parseYaml } from 'yaml';
11
+ /** Kebab-case skill names only, matching the shipped provider's rule. */
12
+ export function isSkillName(name) {
13
+ return /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name);
14
+ }
15
+ /**
16
+ * Normalize a display-style skill name into kebab-case (e.g. "Presentations"
17
+ * → "presentations"), or `undefined` when nothing usable remains.
18
+ */
19
+ export function normalizeSkillName(name) {
20
+ const normalized = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
21
+ return normalized === '' ? undefined : normalized;
22
+ }
23
+ function parseBoolean(value) {
24
+ if (typeof value === 'boolean')
25
+ return value;
26
+ if (typeof value === 'string') {
27
+ switch (value.toLowerCase()) {
28
+ case 'true':
29
+ case 'yes':
30
+ case 'on':
31
+ case '1':
32
+ return true;
33
+ case 'false':
34
+ case 'no':
35
+ case 'off':
36
+ case '0':
37
+ return false;
38
+ default:
39
+ return undefined;
40
+ }
41
+ }
42
+ return undefined;
43
+ }
44
+ /**
45
+ * Parse skill frontmatter with the shipped provider's fail-closed semantics.
46
+ * @returns the parsed frontmatter, or a rejection string explaining why the
47
+ * skill must be dropped from discovery.
48
+ */
49
+ export function parseSkillFrontmatter(text, expectedName) {
50
+ const match = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(text);
51
+ if (match === null)
52
+ return 'missing YAML frontmatter';
53
+ let raw;
54
+ try {
55
+ raw = parseYaml(match[1]);
56
+ }
57
+ catch {
58
+ // Claude Code-authored frontmatter sometimes carries unquoted `: `
59
+ // sequences in prose fields, which strict YAML rejects. A lenient
60
+ // line-based fallback recovers the standard fields first-occurrence
61
+ // wins; values that still fail the field checks below drop the skill.
62
+ raw = lenientFrontmatter(match[1]);
63
+ }
64
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw))
65
+ return 'frontmatter is not an object';
66
+ const record = raw;
67
+ const rawName = record['name'];
68
+ const description = record['description'];
69
+ // Codex plugins ship display names in `name` (e.g. "Presentations"); the
70
+ // skill identity is its kebab form, so normalize instead of dropping.
71
+ const name = typeof rawName === 'string' && !isSkillName(rawName) ? normalizeSkillName(rawName) : rawName;
72
+ if (name === undefined || typeof name !== 'string')
73
+ return 'frontmatter name is missing or not kebab-case';
74
+ if (expectedName !== undefined && name !== expectedName)
75
+ return `frontmatter name "${rawName}" does not match skill directory "${expectedName}"`;
76
+ if (typeof description !== 'string' || description.trim() === '')
77
+ return 'frontmatter description is missing or empty';
78
+ const disableModel = parseBoolean(record['disable-model-invocation']);
79
+ if (disableModel === undefined && record['disable-model-invocation'] !== undefined) {
80
+ return 'invalid disable-model-invocation value';
81
+ }
82
+ const userInvocable = parseBoolean(record['user-invocable']);
83
+ if (userInvocable === undefined && record['user-invocable'] !== undefined) {
84
+ return 'invalid user-invocable value';
85
+ }
86
+ const whenToUse = typeof record['whenToUse'] === 'string' ? record['whenToUse'] : undefined;
87
+ return {
88
+ name,
89
+ description: description.trim(),
90
+ ...whenToUse === undefined ? {} : { whenToUse },
91
+ invocation: {
92
+ modelInvocable: disableModel !== true,
93
+ userInvocable: userInvocable !== false,
94
+ },
95
+ };
96
+ }
97
+ /** Lenient frontmatter recovery for prose fields with embedded `: `. */
98
+ function lenientFrontmatter(body) {
99
+ const record = {};
100
+ for (const line of body.split(/\r?\n/)) {
101
+ const match = /^([A-Za-z][A-Za-z0-9_-]*):\s*(.*)$/.exec(line);
102
+ if (match === null)
103
+ continue;
104
+ const key = match[1];
105
+ if (record[key] === undefined)
106
+ record[key] = match[2].trim();
107
+ }
108
+ return record;
109
+ }
110
+ /** Strip the frontmatter block, returning the instruction body. */
111
+ export function stripFrontmatter(text) {
112
+ const match = /^---\r?\n[\s\S]*?\r?\n---(?:\r?\n|$)/.exec(text);
113
+ return match === null ? text : text.slice(match[0].length);
114
+ }
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Skill provider feeding installed suites into `ctx.skills`.
3
+ *
4
+ * One provider serves both dimensions. Ranks sit between the shipped
5
+ * filesystem roots so each dimension's own skills still win:
6
+ * project suites (250) lose to the project's `.dsh/skills` (100) and
7
+ * `.agents/skills` (200) but beat custom (300); user suites (450) lose to
8
+ * the user's own `~/.dsh/skills` (400) and beat `~/.agents/skills` (500).
9
+ *
10
+ * Bodies are rewritten on load: `${CLAUDE_PLUGIN_ROOT}` (which Claude Code
11
+ * authors write into skill prose) is substituted with the suite root, and the
12
+ * resource base points at the skill directory, so CC-authored skills work
13
+ * verbatim under the harness.
14
+ */
15
+ import { readFile, readdir, stat } from 'node:fs/promises';
16
+ import { join } from 'node:path';
17
+ import { loadState } from './state.js';
18
+ import { resolveProjectRoot, STATE_FILE_NAME } from './paths.js';
19
+ import { discoverSourceList } from './discovery.js';
20
+ import { parseSkillFrontmatter, stripFrontmatter } from './skills-parse.js';
21
+ export const SUITE_PROJECT_SOURCE = 'agent-plugin-project';
22
+ export const SUITE_USER_SOURCE = 'agent-plugin-user';
23
+ const PROJECT_RANK = 250;
24
+ const USER_RANK = 450;
25
+ export class SuiteSkillProvider {
26
+ manager;
27
+ name = 'agent-plugin';
28
+ constructor(manager) {
29
+ this.manager = manager;
30
+ }
31
+ async list(options) {
32
+ const located = await this.locate(options.cwd);
33
+ located.sort((a, b) => a.rank - b.rank || a.suite.id.localeCompare(b.suite.id) || a.skill.name.localeCompare(b.skill.name));
34
+ return located.map(entry => this.candidateFor(entry));
35
+ }
36
+ /** Parse `agents/*.md` of one suite into agent-definition candidates. */
37
+ async agentsOf(suite) {
38
+ const dir = join(suite.root, 'agents');
39
+ let entries;
40
+ try {
41
+ entries = await readdir(dir);
42
+ }
43
+ catch {
44
+ return [];
45
+ }
46
+ const located = [];
47
+ for (const entry of entries) {
48
+ if (!entry.endsWith('.md'))
49
+ continue;
50
+ const file = join(dir, entry);
51
+ try {
52
+ const info = await stat(file);
53
+ if (!info.isFile())
54
+ continue;
55
+ }
56
+ catch {
57
+ continue;
58
+ }
59
+ const agentName = entry.slice(0, -3);
60
+ let text;
61
+ try {
62
+ text = await readFile(file, 'utf8');
63
+ }
64
+ catch {
65
+ continue;
66
+ }
67
+ const parsed = parseSkillFrontmatter(text, agentName);
68
+ const description = typeof parsed === 'string' ? suite.manifest.description ?? agentName : parsed.description;
69
+ const skill = {
70
+ name: `agent-${agentName}`,
71
+ directory: dir,
72
+ file,
73
+ description,
74
+ ...(typeof parsed !== 'string' && parsed.whenToUse !== undefined ? { whenToUse: parsed.whenToUse } : {}),
75
+ invocation: typeof parsed === 'string' ? { modelInvocable: true, userInvocable: false } : parsed.invocation,
76
+ };
77
+ located.push({ rank: suite.dimension === 'project' ? PROJECT_RANK : USER_RANK, source: suite.dimension === 'project' ? SUITE_PROJECT_SOURCE : SUITE_USER_SOURCE, suite, skill });
78
+ }
79
+ return located;
80
+ }
81
+ async get(candidate, options) {
82
+ const locator = candidate.locator;
83
+ let text;
84
+ try {
85
+ text = await readFile(locator.file, 'utf8');
86
+ }
87
+ catch {
88
+ return undefined;
89
+ }
90
+ if (locator.kind === 'agent') {
91
+ const suiteName = locator.suiteRoot.split(/[\\/]/).at(-1) ?? 'suite';
92
+ const content = [
93
+ `## 子代理定义(来自 Agent Plugins ${suiteName},Claude Code agents 格式)`,
94
+ '',
95
+ '当任务匹配下方描述时,通过 subagent 工具创建子代理并将「定义正文」原样作为指令执行。',
96
+ '',
97
+ '```markdown',
98
+ text,
99
+ '```',
100
+ ].join('\n');
101
+ return {
102
+ name: candidate.name,
103
+ description: candidate.description,
104
+ invocation: candidate.invocation,
105
+ source: candidate.source,
106
+ provider: this.name,
107
+ resourceBase: { kind: 'directory', path: locator.directory },
108
+ path: locator.file,
109
+ content,
110
+ };
111
+ }
112
+ const parsed = parseSkillFrontmatter(text, candidate.name);
113
+ if (typeof parsed === 'string')
114
+ return undefined;
115
+ const content = stripFrontmatter(text).replaceAll('${CLAUDE_PLUGIN_ROOT}', locator.suiteRoot);
116
+ return {
117
+ name: parsed.name,
118
+ description: candidate.description,
119
+ ...parsed.whenToUse === undefined ? {} : { whenToUse: parsed.whenToUse },
120
+ invocation: parsed.invocation,
121
+ source: candidate.source,
122
+ provider: this.name,
123
+ resourceBase: { kind: 'directory', path: locator.directory },
124
+ path: locator.file,
125
+ content,
126
+ };
127
+ }
128
+ candidateFor(entry) {
129
+ return {
130
+ name: entry.skill.name,
131
+ description: `[${entry.suite.manifest.name}] ${entry.skill.description}`,
132
+ ...entry.skill.whenToUse === undefined ? {} : { whenToUse: entry.skill.whenToUse },
133
+ invocation: entry.skill.invocation,
134
+ source: entry.source,
135
+ provider: this.name,
136
+ rank: entry.rank,
137
+ locator: { file: entry.skill.file, directory: entry.skill.directory, suiteRoot: entry.suite.root, kind: entry.skill.name.startsWith('agent-') ? 'agent' : 'skill' },
138
+ path: entry.skill.file,
139
+ resourceBase: { kind: 'directory', path: entry.skill.directory },
140
+ };
141
+ }
142
+ async locate(cwd) {
143
+ const located = [];
144
+ const userSuites = await this.manager.enabledUserSuites();
145
+ for (const suite of userSuites) {
146
+ for (const skill of suite.skills) {
147
+ located.push({ rank: USER_RANK, source: SUITE_USER_SOURCE, suite, skill });
148
+ }
149
+ located.push(...await this.agentsOf(suite));
150
+ }
151
+ if (cwd !== undefined) {
152
+ located.push(...await this.locateProject(cwd));
153
+ }
154
+ return located;
155
+ }
156
+ async locateProject(cwd) {
157
+ const projectRoot = await resolveProjectRoot(cwd);
158
+ const state = await loadState(join(projectRoot, STATE_FILE_NAME));
159
+ const located = [];
160
+ const suites = await discoverSourceList(state.sources, 'project', projectRoot);
161
+ for (const suite of suites) {
162
+ if (state.installed[`${suite.sourceId}/${suite.id}`]?.enabled !== true)
163
+ continue;
164
+ for (const skill of suite.skills) {
165
+ located.push({ rank: PROJECT_RANK, source: SUITE_PROJECT_SOURCE, suite, skill });
166
+ }
167
+ }
168
+ return located;
169
+ }
170
+ }
package/lib/state.js ADDED
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Persisted suite state: configured sources and per-suite install entries.
3
+ *
4
+ * State is a plain JSON file at `<dimensionRoot>/state.json`. The host is the
5
+ * only writer (through manager actions and the HTTP routes); the manager
6
+ * re-reads the file whenever a mutation races, keeping the on-disk copy
7
+ * authoritative like the market profile state this pattern follows.
8
+ */
9
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
10
+ import { dirname } from 'node:path';
11
+ export const EMPTY_STATE = { version: 1, sources: [], installed: {} };
12
+ /** Parse persisted state; unreadable or wrong-version files yield a contained empty state. */
13
+ export async function loadState(statePath) {
14
+ try {
15
+ const text = await readFile(statePath, 'utf8');
16
+ const parsed = JSON.parse(text);
17
+ if (typeof parsed !== 'object' || parsed === null)
18
+ return EMPTY_STATE;
19
+ const record = parsed;
20
+ if (record['version'] !== 1)
21
+ return EMPTY_STATE;
22
+ return normalizeState(record);
23
+ }
24
+ catch {
25
+ return EMPTY_STATE;
26
+ }
27
+ }
28
+ function normalizeState(record) {
29
+ const sources = Array.isArray(record['sources'])
30
+ ? record['sources'].flatMap((entry) => {
31
+ if (typeof entry !== 'object' || entry === null)
32
+ return [];
33
+ const source = entry;
34
+ const id = typeof source['id'] === 'string' ? source['id'] : '';
35
+ const url = typeof source['url'] === 'string' ? source['url'] : '';
36
+ if (id === '' || url === '')
37
+ return [];
38
+ const branch = typeof source['branch'] === 'string' ? source['branch'] : undefined;
39
+ const local = source['local'] === true;
40
+ return [{ id, url, ...branch === undefined ? {} : { branch }, ...local ? { local: true } : {} }];
41
+ })
42
+ : [];
43
+ const installed = {};
44
+ if (typeof record['installed'] === 'object' && record['installed'] !== null) {
45
+ for (const [key, value] of Object.entries(record['installed'])) {
46
+ if (typeof value !== 'object' || value === null)
47
+ continue;
48
+ const entry = value;
49
+ installed[key] = {
50
+ enabled: entry['enabled'] === true,
51
+ lockCommit: typeof entry['lockCommit'] === 'string' ? entry['lockCommit'] : undefined,
52
+ installedAt: typeof entry['installedAt'] === 'string' ? entry['installedAt'] : new Date(0).toISOString(),
53
+ };
54
+ }
55
+ }
56
+ return { version: 1, sources, installed };
57
+ }
58
+ /** Persist state atomically through a sibling-temp rename. */
59
+ export async function saveState(statePath, state) {
60
+ await mkdir(dirname(statePath), { recursive: true });
61
+ const temp = `${statePath}.${process.pid}.tmp`;
62
+ await writeFile(temp, `${JSON.stringify(state, null, 2)}\n`, 'utf8');
63
+ await rename(temp, statePath);
64
+ }
@@ -0,0 +1,29 @@
1
+ import type { Context } from '@deepseek-ai/cordis';
2
+ import type { Suite } from './types.js';
3
+ export interface CommandMountDiagnostic {
4
+ suiteId: string;
5
+ command: string;
6
+ reason: string;
7
+ }
8
+ interface CommandSpec {
9
+ name: string;
10
+ description: string;
11
+ body: string;
12
+ hint?: string;
13
+ }
14
+ export declare class CommandMountRegistry {
15
+ private readonly ctx;
16
+ private readonly live;
17
+ constructor(ctx: Context);
18
+ /** Register/unregister suite commands and agent-commands to match the enabled suites exactly. */
19
+ reconcile(enabledSuites: Suite[]): Promise<CommandMountDiagnostic[]>;
20
+ /** Dispose every registered command; used at plugin teardown. */
21
+ disposeAll(): void;
22
+ }
23
+ /** Parse `commands/*.md` of one suite root (Claude Code format). */
24
+ export declare function readCommands(root: string): Promise<CommandSpec[]>;
25
+ /** Parse `agents/*.md` of one suite root into `agent-<name>` commands so
26
+ * subagents are selectable from the slash-command menu, grouped by the
27
+ * `agent-` prefix (the harness command UI has no group headers). */
28
+ export declare function readAgents(root: string): Promise<CommandSpec[]>;
29
+ export {};
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Runtime suite context: the `agent_plugins` query tool.
3
+ *
4
+ * Skills are injected by the native skill catalog (`dsh-tool-skill` renders
5
+ * `ctx.skills` providers into the model's `<available_skills>` catalog), so
6
+ * this module does not inject a duplicate session-start message. The tool
7
+ * exists for facts the catalog does not carry: enabled plugins per
8
+ * dimension, skill names per plugin, and MCP server prefixes.
9
+ */
10
+ import type { Context } from '@deepseek-ai/cordis';
11
+ import type { SuiteManager } from './manager.js';
12
+ /** Mount the agent_plugins query tool. */
13
+ export declare function mountSuiteContext(ctx: Context, manager: SuiteManager): () => void;