dsh-agent-plugins-market 0.7.0 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/README.zh.md +2 -2
- package/client/client.js +28048 -1033
- package/client/client.js.map +1 -1
- package/lib/application/catalog-context.js +8 -1
- package/lib/application/catalog.js +13 -0
- package/lib/application/details.js +13 -2
- package/lib/application/mcp-service.js +17 -0
- package/lib/application/panel-resources.js +16 -4
- package/lib/application/project-agent-roles.js +7 -1
- package/lib/catalog/archive.js +1 -1
- package/lib/catalog/paths.js +8 -0
- package/lib/catalog/plugin-variables.js +38 -0
- package/lib/catalog/project-hooks.js +14 -1
- package/lib/catalog/skills-parse.js +47 -0
- package/lib/catalog/source-catalog.js +8 -3
- package/lib/catalog/suite-components.js +2 -2
- package/lib/contracts/market.js +1 -0
- package/lib/contracts/settings.js +63 -0
- package/lib/index.js +26 -11
- package/lib/model/layouts.js +10 -1
- package/lib/routes.js +12 -0
- package/lib/runtime/agent-role-router.js +28 -6
- package/lib/runtime/commands-mounts.js +50 -9
- package/lib/runtime/deadline.js +32 -6
- package/lib/runtime/dynamic-context.js +140 -0
- package/lib/runtime/feedback-tool.js +7 -3
- package/lib/runtime/host-locale.js +39 -3
- package/lib/runtime/lsp-mounts.js +23 -2
- package/lib/runtime/mcp-backend.js +14 -15
- package/lib/runtime/mcp-client/config.js +8 -0
- package/lib/runtime/mcp-client/connection.js +6 -6
- package/lib/runtime/mcp-client/host-seams.js +8 -88
- package/lib/runtime/mcp-config.js +7 -4
- package/lib/runtime/project-runtime.js +23 -9
- package/lib/runtime/reconcile-scheduler.js +6 -8
- package/lib/runtime/reconciler.js +2 -1
- package/lib/runtime/regions.js +0 -19
- package/lib/runtime/settings-namespace.js +21 -13
- package/lib/runtime/skills-provider.js +33 -11
- package/lib/runtime/source-auto-update.js +10 -14
- package/lib/runtime/timer-seat.js +80 -0
- package/lib/runtime/tool-registry-observer.js +20 -20
- package/lib/runtime/user-commands.js +5 -1
- package/lib/runtime/user-panels.js +110 -41
- package/lib/runtime/user-store.js +114 -58
- package/lib/types/application/catalog-context.d.ts +6 -0
- package/lib/types/application/catalog.d.ts +9 -0
- package/lib/types/application/mcp-service.d.ts +6 -0
- package/lib/types/application/ports.d.ts +1 -1
- package/lib/types/application/queries.d.ts +2 -0
- package/lib/types/catalog/paths.d.ts +6 -0
- package/lib/types/catalog/plugin-variables.d.ts +16 -0
- package/lib/types/catalog/skills-parse.d.ts +13 -0
- package/lib/types/catalog/source-catalog.d.ts +7 -2
- package/lib/types/contracts/market.d.ts +10 -0
- package/lib/types/contracts/settings.d.ts +59 -0
- package/lib/types/model/layouts.d.ts +10 -0
- package/lib/types/runtime/agent-role-router.d.ts +6 -2
- package/lib/types/runtime/commands-mounts.d.ts +15 -7
- package/lib/types/runtime/deadline.d.ts +10 -11
- package/lib/types/runtime/dynamic-context.d.ts +81 -0
- package/lib/types/runtime/host-locale.d.ts +19 -1
- package/lib/types/runtime/lsp-mounts.d.ts +7 -0
- package/lib/types/runtime/mcp-backend.d.ts +8 -15
- package/lib/types/runtime/mcp-client/config.d.ts +8 -0
- package/lib/types/runtime/mcp-client/host-seams.d.ts +20 -64
- package/lib/types/runtime/project-runtime.d.ts +10 -3
- package/lib/types/runtime/reconcile-scheduler.d.ts +1 -1
- package/lib/types/runtime/regions.d.ts +1 -4
- package/lib/types/runtime/settings-namespace.d.ts +10 -10
- package/lib/types/runtime/skills-provider.d.ts +11 -2
- package/lib/types/runtime/source-auto-update.d.ts +3 -4
- package/lib/types/runtime/timer-seat.d.ts +51 -0
- package/lib/types/runtime/tool-registry-observer.d.ts +12 -6
- package/lib/types/runtime/user-panels.d.ts +65 -12
- package/lib/types/runtime/user-store.d.ts +65 -16
- package/package.json +21 -4
|
@@ -1,31 +1,27 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/** Reading the model-facing MCP tools the host registry currently exposes. */
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
3
|
+
* The MCP tools the host currently publishes to the model.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* Read through the tools service's own listing API rather than its internal
|
|
6
|
+
* layer structure. `schemas()` deep-clones each tool's parameter schema, which
|
|
7
|
+
* this snapshot has no use for, so a failure inside it degrades to an empty
|
|
8
|
+
* observation instead of taking the status surface down.
|
|
9
|
+
* @param tools - the host tools service, when it is mounted.
|
|
10
|
+
* @returns one entry per `mcp__`-namespaced tool.
|
|
8
11
|
*/
|
|
9
|
-
export function inspectToolRegistry(
|
|
10
|
-
|
|
12
|
+
export function inspectToolRegistry(tools) {
|
|
13
|
+
const service = tools;
|
|
14
|
+
if (service === undefined || typeof service !== 'object' || service === null)
|
|
11
15
|
return [];
|
|
12
|
-
|
|
13
|
-
if (typeof layers !== 'object' || layers === null)
|
|
16
|
+
if (typeof service.schemas !== 'function')
|
|
14
17
|
return [];
|
|
15
|
-
const merge = layers.merge;
|
|
16
|
-
if (typeof merge !== 'function')
|
|
17
|
-
return [];
|
|
18
|
-
const empty = { entries: () => [] };
|
|
19
18
|
try {
|
|
20
|
-
const visible = merge.call(layers, undefined, layer => layer.tools ?? empty);
|
|
21
19
|
const output = [];
|
|
22
|
-
for (const
|
|
23
|
-
if (!name.startsWith('mcp__'))
|
|
20
|
+
for (const schema of service.schemas()) {
|
|
21
|
+
if (!schema.name.startsWith('mcp__'))
|
|
24
22
|
continue;
|
|
25
|
-
const description = typeof
|
|
26
|
-
|
|
27
|
-
: undefined;
|
|
28
|
-
output.push({ name, ...(description === undefined ? {} : { description }) });
|
|
23
|
+
const description = typeof schema.description === 'string' ? schema.description : undefined;
|
|
24
|
+
output.push({ name: schema.name, ...(description === undefined ? {} : { description }) });
|
|
29
25
|
}
|
|
30
26
|
return output;
|
|
31
27
|
}
|
|
@@ -33,3 +29,7 @@ export function inspectToolRegistry(runtime) {
|
|
|
33
29
|
return [];
|
|
34
30
|
}
|
|
35
31
|
}
|
|
32
|
+
/** Read the tools service off the plugin context, when the host mounts one. */
|
|
33
|
+
export function toolsServiceOf(ctx) {
|
|
34
|
+
return ctx.get?.('tools');
|
|
35
|
+
}
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* change, keyed `user/<name>`.
|
|
7
7
|
* @module runtime/user-commands
|
|
8
8
|
*/
|
|
9
|
+
import { createUserMessage } from '@deepseek-ai/dsh-llm';
|
|
9
10
|
import { USER_ENTRY_NAME } from './user-store.js';
|
|
10
11
|
/** Register/unregister user-panel commands to match the panel's enabled entries. */
|
|
11
12
|
export class UserCommandMountRegistry {
|
|
@@ -63,7 +64,10 @@ export class UserCommandMountRegistry {
|
|
|
63
64
|
handler: invocation => {
|
|
64
65
|
const agent = invocation.agent;
|
|
65
66
|
const text = spec.body.replaceAll('$ARGUMENTS', invocation.rawInput.trim());
|
|
66
|
-
agent.followup({
|
|
67
|
+
agent.followup(createUserMessage({
|
|
68
|
+
content: [{ type: 'text', text }],
|
|
69
|
+
source: { kind: 'plugin', plugin: 'dsh-agent-plugins-market' }
|
|
70
|
+
}));
|
|
67
71
|
return { kind: 'success', text: this.t('commandAcknowledged', { command: spec.name }) };
|
|
68
72
|
}
|
|
69
73
|
});
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* User panel storage: the persistence layer behind the skills / commands /
|
|
3
|
-
* agent-personas panels. Each surface owns one directory of Markdown
|
|
4
|
-
* under the shared Agent layout root (`~/.agents/<kind>/`), so
|
|
5
|
-
* entries survive restarts, are trivially hand-editable, and
|
|
6
|
-
* suite checkouts.
|
|
3
|
+
* agent-personas panels. Each surface owns one directory of Markdown
|
|
4
|
+
* documents under the shared Agent layout root (`~/.agents/<kind>/`), so
|
|
5
|
+
* user-authored entries survive restarts, are trivially hand-editable, and
|
|
6
|
+
* stay outside suite checkouts.
|
|
7
7
|
*
|
|
8
8
|
* Skills entries follow the SKILL.md frontmatter grammar (`name`,
|
|
9
9
|
* `description`, optional `whenToUse`, invocation controls) plus a
|
|
@@ -11,37 +11,52 @@
|
|
|
11
11
|
* frontmatter (`description`, optional `argument-hint`, `disabled`); agent
|
|
12
12
|
* personas follow Claude Code agents frontmatter (`name`, `description`,
|
|
13
13
|
* optional `whenToUse`, `tools`, `model`, `disabled`).
|
|
14
|
+
*
|
|
15
|
+
* Only the skills panel serves the cross-tool `<name>/SKILL.md` directory
|
|
16
|
+
* spelling beside its own flat entries; commands and personas stay flat.
|
|
17
|
+
*
|
|
18
|
+
* An entry's name is the one its document declares — the same name the
|
|
19
|
+
* harness reader derives from that file — and the panel addresses, edits, and
|
|
20
|
+
* deletes the entry by that name. A document that reader would reject is
|
|
21
|
+
* listed disabled with the reason instead of joining the catalog, and a skill
|
|
22
|
+
* is switched off by writing that reader's own invocation controls, so the off
|
|
23
|
+
* state lives in the file rather than in this provider.
|
|
14
24
|
* @module runtime/user-panels
|
|
15
25
|
*/
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
26
|
+
import { dirname } from 'node:path';
|
|
27
|
+
import { skillEntryRejection } from '../catalog/skills-parse.js';
|
|
28
|
+
import { deleteEntryDocument, entryExists, listEntryDocuments, listEntryFiles, readEntryDocument, resolveEntryDocument, SKILL_ENTRY_SHAPES, USER_ENTRY_NAME, userEntryDir, writeEntryDocument, writeEntryFile } from './user-store.js';
|
|
18
29
|
/** The skill-source label user-panel entries carry into the skill registry. */
|
|
19
30
|
export const USER_PANEL_SKILL_SOURCE = 'user-panel';
|
|
20
31
|
/**
|
|
21
|
-
* Panel skills
|
|
22
|
-
*
|
|
32
|
+
* Panel skills register ahead of the harness reader that maps this same
|
|
33
|
+
* directory (`dsh-skill-filesystem` roots `~/.agents/skills` as `user-agents`
|
|
34
|
+
* at rank 500), so the panel's localized description and its name precedence
|
|
35
|
+
* apply to the entries it serves. Project roots (100-300) and the user's
|
|
36
|
+
* `~/.dsh` skills (400) still outrank it, and it beats the suite user rank
|
|
37
|
+
* (450), so a skill the user wrote by hand wins over one an installed suite
|
|
38
|
+
* ships under the same name.
|
|
23
39
|
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* `disabled` frontmatter nor its localized description, so a skill the user
|
|
28
|
-
* disabled stayed loaded. At 440 the panel keeps its own controls; project
|
|
29
|
-
* roots (100-300) and the user's `~/.dsh` skills (400) still outrank it, while
|
|
30
|
-
* a skill the user wrote by hand now beats one an installed suite ships under
|
|
31
|
-
* the same name (the suite user rank is 450).
|
|
40
|
+
* Switching a skill off is not this provider's job: the panel writes the
|
|
41
|
+
* harness's own invocation pair into the document, which every reader of that
|
|
42
|
+
* file honors.
|
|
32
43
|
*/
|
|
33
44
|
const USER_PANEL_RANK = 440;
|
|
34
45
|
/** Throwing CRUD over one panel directory, shared by the three panels. */
|
|
35
46
|
export class UserPanelStore {
|
|
36
47
|
agentsRoot;
|
|
37
48
|
kind;
|
|
49
|
+
shapes;
|
|
38
50
|
extraNameCheck;
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
51
|
+
validate;
|
|
52
|
+
honorInvocationControls;
|
|
53
|
+
constructor(agentsRoot, kind, options = {}) {
|
|
42
54
|
this.agentsRoot = agentsRoot;
|
|
43
55
|
this.kind = kind;
|
|
44
|
-
this.
|
|
56
|
+
this.shapes = options.shapes ?? ['file'];
|
|
57
|
+
this.extraNameCheck = options.extraNameCheck ?? (() => true);
|
|
58
|
+
this.validate = options.validate;
|
|
59
|
+
this.honorInvocationControls = options.honorInvocationControls === true;
|
|
45
60
|
}
|
|
46
61
|
/** The panel's directory under the Agent layout root. */
|
|
47
62
|
dirPath() {
|
|
@@ -50,44 +65,79 @@ export class UserPanelStore {
|
|
|
50
65
|
get dir() {
|
|
51
66
|
return this.dirPath();
|
|
52
67
|
}
|
|
68
|
+
validName(name) {
|
|
69
|
+
return USER_ENTRY_NAME.test(name) && this.extraNameCheck(name);
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Resolve the document one entry name addresses. The name a panel entry
|
|
73
|
+
* carries is the one its document declares, which is only the file name by
|
|
74
|
+
* convention, so a name that matches no path is matched against the
|
|
75
|
+
* declared names of the served documents.
|
|
76
|
+
*/
|
|
77
|
+
async document(name) {
|
|
78
|
+
const direct = await resolveEntryDocument(this.dir, name, this.shapes);
|
|
79
|
+
if (direct !== undefined)
|
|
80
|
+
return direct;
|
|
81
|
+
for (const candidate of await listEntryDocuments(this.dir, false, this.shapes)) {
|
|
82
|
+
const file = await readEntryDocument(candidate);
|
|
83
|
+
if (file?.name === name)
|
|
84
|
+
return candidate;
|
|
85
|
+
}
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
53
88
|
/** Every entry, sorted by name; strict snapshots propagate temporary I/O failures. */
|
|
54
89
|
async list(strict = false) {
|
|
55
|
-
const files = await listEntryFiles(this.dir, strict);
|
|
90
|
+
const files = await listEntryFiles(this.dir, strict, this.shapes);
|
|
56
91
|
return files.map(file => this.serialize(file));
|
|
57
92
|
}
|
|
58
93
|
/** One entry's full record, including the raw file text. */
|
|
59
94
|
async get(name) {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
const file = await readEntryFile(join(this.dir, `${name}.md`), name);
|
|
63
|
-
if (file === undefined)
|
|
95
|
+
const document = await this.document(name);
|
|
96
|
+
if (document === undefined)
|
|
64
97
|
return undefined;
|
|
65
|
-
|
|
98
|
+
const file = await readEntryDocument(document);
|
|
99
|
+
return file === undefined ? undefined : this.serialize(file);
|
|
66
100
|
}
|
|
67
101
|
/**
|
|
68
102
|
* Project one parsed file onto the wire shape. The description prefers the
|
|
69
103
|
* strict frontmatter parse (multi-line YAML like `description: |` survives
|
|
70
104
|
* intact there) and falls back to the shallow line record.
|
|
105
|
+
*
|
|
106
|
+
* A document the panel must not register stays listed and editable but reads
|
|
107
|
+
* as disabled with the reason in its metadata, the same shape an unparseable
|
|
108
|
+
* frontmatter already produced — hiding it would leave the user no way to
|
|
109
|
+
* find or fix it.
|
|
71
110
|
*/
|
|
72
111
|
serialize(file) {
|
|
73
112
|
const strictDescription = typeof file.description === 'string' && file.description !== '' ? file.description : undefined;
|
|
74
113
|
const shallowDescription = typeof file.meta['description'] === 'string' && file.meta['description'] !== '' ? file.meta['description'] : '';
|
|
114
|
+
const parseError = typeof file.meta['validationError'] === 'string' ? file.meta['validationError'] : undefined;
|
|
115
|
+
const rejection = parseError ?? this.validate?.(file);
|
|
116
|
+
// Both invocation controls off is the panel's off state as well: the switch
|
|
117
|
+
// writes that pair, and either key alone is an authoring choice (a
|
|
118
|
+
// user-invocable-only skill, for instance) that stays switched on.
|
|
119
|
+
const invocationOff = this.honorInvocationControls && file.invocation !== undefined && !file.invocation.modelInvocable && !file.invocation.userInvocable;
|
|
75
120
|
return {
|
|
76
|
-
name
|
|
121
|
+
// The declared name is the entry's name; a document that declares none
|
|
122
|
+
// the registry would accept is listed under its own file name, which is
|
|
123
|
+
// the name that keeps it addressable and fixable. `path` carries the
|
|
124
|
+
// document either way.
|
|
125
|
+
name: rejection === undefined ? file.name : file.documentName,
|
|
77
126
|
description: strictDescription ?? shallowDescription,
|
|
78
|
-
disabled: file.meta['disabled'] === true,
|
|
79
|
-
metadata: file.meta,
|
|
127
|
+
disabled: file.meta['disabled'] === true || invocationOff || rejection !== undefined,
|
|
128
|
+
metadata: rejection === undefined ? file.meta : { ...file.meta, disabled: true, validationError: rejection },
|
|
80
129
|
path: file.file,
|
|
130
|
+
shape: file.shape,
|
|
81
131
|
content: file.body,
|
|
82
132
|
rawText: file.text,
|
|
83
133
|
origin: 'user'
|
|
84
134
|
};
|
|
85
135
|
}
|
|
86
|
-
/** Create an entry; refuses an occupied name. */
|
|
136
|
+
/** Create an entry; refuses an occupied name. New entries use the flat spelling. */
|
|
87
137
|
async create(name, text) {
|
|
88
|
-
if (!
|
|
138
|
+
if (!this.validName(name))
|
|
89
139
|
throw new Error(`invalid name "${name}" — use lowercase letters, digits, and dashes, starting with a letter or digit`);
|
|
90
|
-
if (await entryExists(this.dir, name))
|
|
140
|
+
if (await entryExists(this.dir, name, this.shapes))
|
|
91
141
|
throw new Error(`an entry named "${name}" already exists`);
|
|
92
142
|
await writeEntryFile(this.dir, name, text);
|
|
93
143
|
const created = await this.get(name);
|
|
@@ -95,27 +145,36 @@ export class UserPanelStore {
|
|
|
95
145
|
throw new Error(`entry "${name}" vanished after write`);
|
|
96
146
|
return created;
|
|
97
147
|
}
|
|
98
|
-
/** Replace one entry's
|
|
148
|
+
/** Replace one entry's content wholesale, keeping the spelling it already has. */
|
|
99
149
|
async update(name, text) {
|
|
100
|
-
if (!
|
|
150
|
+
if (!this.validName(name))
|
|
101
151
|
throw new Error(`invalid entry name "${name}"`);
|
|
102
|
-
|
|
152
|
+
const document = await this.document(name);
|
|
153
|
+
if (document === undefined)
|
|
103
154
|
throw new Error(`no entry named "${name}"`);
|
|
104
|
-
await
|
|
155
|
+
await writeEntryDocument(document, text);
|
|
105
156
|
}
|
|
106
157
|
/** Delete one entry; a missing file is a no-op (idempotent delete). */
|
|
107
158
|
async remove(name) {
|
|
108
159
|
if (!USER_ENTRY_NAME.test(name))
|
|
109
160
|
throw new Error(`invalid entry name "${name}"`);
|
|
110
|
-
await
|
|
161
|
+
const document = await this.document(name);
|
|
162
|
+
if (document === undefined)
|
|
163
|
+
return;
|
|
164
|
+
await deleteEntryDocument(document);
|
|
111
165
|
}
|
|
112
166
|
}
|
|
113
167
|
/** Locate the three stores; constructed once per plugin activation. */
|
|
114
168
|
export function createUserPanelStores(agentsRoot) {
|
|
115
169
|
return {
|
|
116
|
-
skills: new UserPanelStore(agentsRoot, 'skills',
|
|
170
|
+
skills: new UserPanelStore(agentsRoot, 'skills', {
|
|
171
|
+
extraNameCheck: isUserSkillEntryName,
|
|
172
|
+
shapes: SKILL_ENTRY_SHAPES,
|
|
173
|
+
validate: file => skillEntryRejection(file.text),
|
|
174
|
+
honorInvocationControls: true
|
|
175
|
+
}),
|
|
117
176
|
commands: new UserPanelStore(agentsRoot, 'commands'),
|
|
118
|
-
agents: new UserPanelStore(agentsRoot, 'agents', isUserSkillEntryName)
|
|
177
|
+
agents: new UserPanelStore(agentsRoot, 'agents', { extraNameCheck: isUserSkillEntryName })
|
|
119
178
|
};
|
|
120
179
|
}
|
|
121
180
|
/**
|
|
@@ -145,6 +204,8 @@ export class UserPanelSkillProvider {
|
|
|
145
204
|
if (entry.disabled || !isUserSkillEntryName(entry.name))
|
|
146
205
|
continue;
|
|
147
206
|
candidates.push({
|
|
207
|
+
// The entry's name is the one its document declares, which is also the
|
|
208
|
+
// name the harness's own reader derives from the same file.
|
|
148
209
|
name: entry.name,
|
|
149
210
|
// The entry's own description verbatim; a user-entry label is our
|
|
150
211
|
// packaging, and this description reaches the same model-facing
|
|
@@ -160,7 +221,7 @@ export class UserPanelSkillProvider {
|
|
|
160
221
|
rank: USER_PANEL_RANK,
|
|
161
222
|
locator: { name: entry.name },
|
|
162
223
|
path: entry.path,
|
|
163
|
-
resourceBase: { kind: 'directory', path: this.
|
|
224
|
+
resourceBase: { kind: 'directory', path: this.resourceDirectory(entry) }
|
|
164
225
|
});
|
|
165
226
|
}
|
|
166
227
|
return candidates;
|
|
@@ -178,9 +239,17 @@ export class UserPanelSkillProvider {
|
|
|
178
239
|
invocation: candidate.invocation,
|
|
179
240
|
source: candidate.source,
|
|
180
241
|
provider: this.name,
|
|
181
|
-
resourceBase: { kind: 'directory', path:
|
|
242
|
+
resourceBase: { kind: 'directory', path: this.resourceDirectory(entry) },
|
|
182
243
|
path: entry.path,
|
|
183
244
|
content: entry.content
|
|
184
245
|
};
|
|
185
246
|
}
|
|
247
|
+
/**
|
|
248
|
+
* Relative resources resolve beside the document that references them: a
|
|
249
|
+
* directory-shaped skill owns `references/`, `scripts/` and the like next to
|
|
250
|
+
* its `SKILL.md`, while a flat entry resolves against the panel directory.
|
|
251
|
+
*/
|
|
252
|
+
resourceDirectory(entry) {
|
|
253
|
+
return entry.shape === 'skill-directory' ? dirname(entry.path) : this.skills.dirPath();
|
|
254
|
+
}
|
|
186
255
|
}
|
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Shared persistence primitives for the market's user-authored panels:
|
|
3
3
|
* skills, commands, and agent personas. Each panel owns one directory of
|
|
4
|
-
* Markdown
|
|
5
|
-
* rules the catalog uses, so user entries look exactly like suite
|
|
6
|
-
* the runtime mounts.
|
|
4
|
+
* Markdown documents under the Agent layout root, parsed with the same
|
|
5
|
+
* frontmatter rules the catalog uses, so user entries look exactly like suite
|
|
6
|
+
* entries to the runtime mounts.
|
|
7
|
+
*
|
|
8
|
+
* An entry is one document. Skills also accept the cross-tool directory
|
|
9
|
+
* spelling (`<name>/SKILL.md`) that other Agent tools and the harness's own
|
|
10
|
+
* reader of `~/.agents/skills` author; commands and personas are flat only.
|
|
7
11
|
* @module runtime/user-store
|
|
8
12
|
*/
|
|
9
|
-
import { readdir, readFile, rm, stat } from 'node:fs/promises';
|
|
10
|
-
import { join } from 'node:path';
|
|
13
|
+
import { readdir, readFile, rmdir, rm, stat } from 'node:fs/promises';
|
|
14
|
+
import { dirname, join } from 'node:path';
|
|
11
15
|
import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write';
|
|
12
16
|
import { parseDocument, stringify } from 'yaml';
|
|
13
17
|
import { parseSkillFrontmatter, stripFrontmatter } from '../catalog/skills-parse.js';
|
|
@@ -17,6 +21,70 @@ export function userEntryDir(agentsRoot, kind) {
|
|
|
17
21
|
}
|
|
18
22
|
/** `[a-z][a-z0-9_-]*` — the grammar every panel entry name must satisfy. */
|
|
19
23
|
export const USER_ENTRY_NAME = /^[a-z][a-z0-9_-]*$/;
|
|
24
|
+
/** The file name of a directory-shaped skill's document. */
|
|
25
|
+
export const SKILL_ENTRY_FILE = 'SKILL.md';
|
|
26
|
+
/**
|
|
27
|
+
* The spellings the skills panel serves, most preferred first: a name present
|
|
28
|
+
* as both a directory and a file resolves to the directory, which is the
|
|
29
|
+
* canonical cross-tool spelling and the one the harness's own reader of
|
|
30
|
+
* `~/.agents/skills` reaches first.
|
|
31
|
+
*/
|
|
32
|
+
export const SKILL_ENTRY_SHAPES = ['skill-directory', 'file'];
|
|
33
|
+
/** The document path one entry name maps to under its kind directory. */
|
|
34
|
+
export function entryDocumentPath(dir, name, shape) {
|
|
35
|
+
return shape === 'skill-directory' ? join(dir, name, SKILL_ENTRY_FILE) : join(dir, `${name}.md`);
|
|
36
|
+
}
|
|
37
|
+
/** Whether a path is a regular file; strict runtime snapshots propagate real I/O failures. */
|
|
38
|
+
async function isFile(file, strict) {
|
|
39
|
+
try {
|
|
40
|
+
return (await stat(file)).isFile();
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
if (strict && error.code !== 'ENOENT')
|
|
44
|
+
throw error;
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Resolve one entry name to its document, taking the first shape the path
|
|
50
|
+
* exists in. Names outside the entry grammar never touch the filesystem.
|
|
51
|
+
*/
|
|
52
|
+
export async function resolveEntryDocument(dir, name, shapes, strict = false) {
|
|
53
|
+
if (!USER_ENTRY_NAME.test(name))
|
|
54
|
+
return undefined;
|
|
55
|
+
for (const shape of shapes) {
|
|
56
|
+
const file = entryDocumentPath(dir, name, shape);
|
|
57
|
+
if (await isFile(file, strict))
|
|
58
|
+
return { name, file, directory: dirname(file), shape };
|
|
59
|
+
}
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
/** Every entry document the served shapes expose, deduplicated by name at the first shape that has it. */
|
|
63
|
+
export async function listEntryDocuments(dir, strict = false, shapes = ['file']) {
|
|
64
|
+
let entries;
|
|
65
|
+
try {
|
|
66
|
+
entries = await readdir(dir);
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
if (strict && error.code !== 'ENOENT')
|
|
70
|
+
throw error;
|
|
71
|
+
return [];
|
|
72
|
+
}
|
|
73
|
+
const found = new Map();
|
|
74
|
+
for (const entry of entries.sort()) {
|
|
75
|
+
const name = entry.endsWith('.md') ? entry.slice(0, -3) : shapes.includes('skill-directory') ? entry : undefined;
|
|
76
|
+
if (name === undefined || !USER_ENTRY_NAME.test(name))
|
|
77
|
+
continue;
|
|
78
|
+
for (const shape of shapes) {
|
|
79
|
+
const file = entryDocumentPath(dir, name, shape);
|
|
80
|
+
if (!(await isFile(file, strict)))
|
|
81
|
+
continue;
|
|
82
|
+
found.set(name, { name, file, directory: dirname(file), shape });
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return [...found.values()];
|
|
87
|
+
}
|
|
20
88
|
/** Parse full YAML metadata without flattening arrays, mappings, or multiline strings. */
|
|
21
89
|
export function parseFrontmatterRecord(text) {
|
|
22
90
|
const frontmatter = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(text)?.[1];
|
|
@@ -36,13 +104,11 @@ export function parseFrontmatterRecord(text) {
|
|
|
36
104
|
throw new Error('Frontmatter must be a mapping');
|
|
37
105
|
return value;
|
|
38
106
|
}
|
|
39
|
-
/** Read one entry; strict runtime snapshots throw on I/O errors other than a vanished file. */
|
|
40
|
-
export async function
|
|
41
|
-
if (fallbackName !== '' && !USER_ENTRY_NAME.test(fallbackName))
|
|
42
|
-
return undefined;
|
|
107
|
+
/** Read one resolved entry; strict runtime snapshots throw on I/O errors other than a vanished file. */
|
|
108
|
+
export async function readEntryDocument(document, strict = false) {
|
|
43
109
|
let text;
|
|
44
110
|
try {
|
|
45
|
-
text = await readFile(file, 'utf8');
|
|
111
|
+
text = await readFile(document.file, 'utf8');
|
|
46
112
|
}
|
|
47
113
|
catch (error) {
|
|
48
114
|
if (strict && error.code !== 'ENOENT')
|
|
@@ -56,55 +122,45 @@ export async function readEntryFile(file, fallbackName, strict = false) {
|
|
|
56
122
|
catch (error) {
|
|
57
123
|
meta = { disabled: true, validationError: String(error) };
|
|
58
124
|
}
|
|
59
|
-
|
|
60
|
-
|
|
125
|
+
// The declared name is read without an expected-value check: the harness's
|
|
126
|
+
// own reader of this directory takes the frontmatter name as the skill's
|
|
127
|
+
// name and never compares it to the file or directory name.
|
|
128
|
+
const parsed = parseSkillFrontmatter(text, undefined);
|
|
129
|
+
const name = typeof parsed === 'string' ? document.name : typeof parsed.name === 'string' && parsed.name !== '' ? parsed.name : document.name;
|
|
61
130
|
return {
|
|
62
|
-
|
|
131
|
+
documentName: document.name,
|
|
63
132
|
name,
|
|
64
|
-
file,
|
|
133
|
+
file: document.file,
|
|
134
|
+
directory: document.directory,
|
|
135
|
+
shape: document.shape,
|
|
65
136
|
text,
|
|
66
137
|
body: stripFrontmatter(text),
|
|
67
138
|
meta,
|
|
68
139
|
...(typeof parsed === 'string' ? {} : parsed)
|
|
69
140
|
};
|
|
70
141
|
}
|
|
71
|
-
/** List
|
|
72
|
-
export async function listEntryFiles(dir, strict = false) {
|
|
73
|
-
|
|
74
|
-
try {
|
|
75
|
-
entries = await readdir(dir);
|
|
76
|
-
}
|
|
77
|
-
catch (error) {
|
|
78
|
-
if (strict && error.code !== 'ENOENT')
|
|
79
|
-
throw error;
|
|
80
|
-
return [];
|
|
81
|
-
}
|
|
142
|
+
/** List entries across the served shapes; strict runtime snapshots propagate I/O errors instead of publishing partial inventories. */
|
|
143
|
+
export async function listEntryFiles(dir, strict = false, shapes = ['file']) {
|
|
144
|
+
const documents = await listEntryDocuments(dir, strict, shapes);
|
|
82
145
|
const found = [];
|
|
83
|
-
for (const
|
|
84
|
-
|
|
85
|
-
continue;
|
|
86
|
-
const file = join(dir, entry);
|
|
87
|
-
try {
|
|
88
|
-
const info = await stat(file);
|
|
89
|
-
if (!info.isFile())
|
|
90
|
-
continue;
|
|
91
|
-
}
|
|
92
|
-
catch (error) {
|
|
93
|
-
if (strict && error.code !== 'ENOENT')
|
|
94
|
-
throw error;
|
|
95
|
-
continue;
|
|
96
|
-
}
|
|
97
|
-
const parsed = await readEntryFile(file, entry.slice(0, -3), strict);
|
|
146
|
+
for (const document of documents) {
|
|
147
|
+
const parsed = await readEntryDocument(document, strict);
|
|
98
148
|
if (parsed !== undefined)
|
|
99
149
|
found.push(parsed);
|
|
100
150
|
}
|
|
101
151
|
return found;
|
|
102
152
|
}
|
|
103
|
-
/**
|
|
104
|
-
export async function writeEntryFile(dir, name, text) {
|
|
153
|
+
/** Create one panel entry's document from its name: `<name>.md`, or `<name>/SKILL.md` in the directory shape. */
|
|
154
|
+
export async function writeEntryFile(dir, name, text, shape = 'file') {
|
|
105
155
|
assertEntryName(name);
|
|
156
|
+
const file = entryDocumentPath(dir, name, shape);
|
|
157
|
+
await writeEntryDocument({ name, file, directory: dirname(file), shape }, text);
|
|
158
|
+
}
|
|
159
|
+
/** Replace one resolved entry's document in place, wherever it lives. */
|
|
160
|
+
export async function writeEntryDocument(document, text) {
|
|
161
|
+
assertEntryName(document.name);
|
|
106
162
|
parseFrontmatterRecord(text);
|
|
107
|
-
await writeFileAtomic(
|
|
163
|
+
await writeFileAtomic(document.file, text, { mode: 0o644, dirMode: 0o700 });
|
|
108
164
|
}
|
|
109
165
|
/**
|
|
110
166
|
* Enforce the entry-name grammar at every filesystem choke point. The name
|
|
@@ -116,22 +172,22 @@ function assertEntryName(name) {
|
|
|
116
172
|
if (!USER_ENTRY_NAME.test(name))
|
|
117
173
|
throw new Error(`invalid entry name "${name}" — use lowercase letters, digits, dashes, or underscores, starting with a letter`);
|
|
118
174
|
}
|
|
119
|
-
/**
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
175
|
+
/**
|
|
176
|
+
* Delete one resolved entry's document; a missing file resolves silently. A
|
|
177
|
+
* skill directory belongs to whichever tool authored it, so removal deletes
|
|
178
|
+
* the document it served and the directory only once nothing else is left in
|
|
179
|
+
* it — never the `references/`, `scripts/`, or persona files beside its
|
|
180
|
+
* `SKILL.md`.
|
|
181
|
+
*/
|
|
182
|
+
export async function deleteEntryDocument(document) {
|
|
183
|
+
assertEntryName(document.name);
|
|
184
|
+
await rm(document.file, { force: true });
|
|
185
|
+
if (document.shape === 'skill-directory')
|
|
186
|
+
await rmdir(dirname(document.file)).catch(() => undefined);
|
|
123
187
|
}
|
|
124
|
-
/** Whether an entry
|
|
125
|
-
export async function entryExists(dir, name) {
|
|
126
|
-
|
|
127
|
-
return false;
|
|
128
|
-
try {
|
|
129
|
-
await stat(join(dir, `${name}.md`));
|
|
130
|
-
return true;
|
|
131
|
-
}
|
|
132
|
-
catch {
|
|
133
|
-
return false;
|
|
134
|
-
}
|
|
188
|
+
/** Whether an entry document with that name exists in any served shape. */
|
|
189
|
+
export async function entryExists(dir, name, shapes = ['file']) {
|
|
190
|
+
return (await resolveEntryDocument(dir, name, shapes)) !== undefined;
|
|
135
191
|
}
|
|
136
192
|
/** Serialize a frontmatter block from a shallow record (deterministic key order). */
|
|
137
193
|
export function serializeFrontmatter(meta) {
|
|
@@ -53,6 +53,12 @@ export declare class CatalogContext implements SnapshotHost {
|
|
|
53
53
|
private mutationQueue;
|
|
54
54
|
private currentRevision;
|
|
55
55
|
private currentScanGeneration;
|
|
56
|
+
/**
|
|
57
|
+
* The project-layout switch in effect. The host settings document owns the
|
|
58
|
+
* value and pushes it here on registration and on every change; the initial
|
|
59
|
+
* value is the contract's declared default, which is what the catalog would
|
|
60
|
+
* read from the document before the settings service resolves.
|
|
61
|
+
*/
|
|
56
62
|
private scanProjectLayoutsEnabled;
|
|
57
63
|
/** The derived refresh in flight, shared by every change that lands while it runs. */
|
|
58
64
|
private refreshPass;
|
|
@@ -24,6 +24,8 @@ export declare class Catalog implements MarketService {
|
|
|
24
24
|
set mcpDiagnostics(value: McpMountDiagnostic[]);
|
|
25
25
|
/** The user-dimension suite root this catalog operates. */
|
|
26
26
|
get userRoot(): string;
|
|
27
|
+
/** The plugin storage root holding per-suite `${PLUGIN_DATA}` directories and overrides. */
|
|
28
|
+
get dataRoot(): string;
|
|
27
29
|
get sources(): SourceRef[];
|
|
28
30
|
/** Whether one suite currently carries an install entry. */
|
|
29
31
|
isInstalled(sourceId: string, suiteId: string): boolean;
|
|
@@ -100,6 +102,13 @@ export declare class Catalog implements MarketService {
|
|
|
100
102
|
mcpOverrides(sourceId: string, suiteId: string): Promise<McpSuiteOverrides>;
|
|
101
103
|
/** Set or clear one server's MCP override and remount it. */
|
|
102
104
|
setMcpOverride(sourceId: string, suiteId: string, serverKey: string, override: McpServerOverride | null): Promise<void>;
|
|
105
|
+
/**
|
|
106
|
+
* Enable or disable one declared MCP server, addressed by the same
|
|
107
|
+
* source-qualified suite id its status row carries. Disabling writes an
|
|
108
|
+
* override rather than editing the suite's `mcp.json`, so a refresh cannot
|
|
109
|
+
* clobber the user's choice.
|
|
110
|
+
*/
|
|
111
|
+
setMcpServerEnabled(suiteKey: string, serverKey: string, enabled: boolean): Promise<void>;
|
|
103
112
|
/** Re-run the MCP reconcile pass without changing any catalog state. */
|
|
104
113
|
retryMounts(): Promise<void>;
|
|
105
114
|
/** The persisted MCP backend choice without the host-client probe. */
|
|
@@ -30,6 +30,12 @@ export declare class McpService {
|
|
|
30
30
|
* in the suite's parsed mcp.json.
|
|
31
31
|
*/
|
|
32
32
|
setOverride(sourceId: string, suiteId: string, serverKey: string, override: McpServerOverride | null): Promise<void>;
|
|
33
|
+
/**
|
|
34
|
+
* Enable or disable one declared server, addressed by the source-qualified
|
|
35
|
+
* suite id the status rows carry. The qualified id is split here rather than
|
|
36
|
+
* by callers so the separator stays a server-side detail.
|
|
37
|
+
*/
|
|
38
|
+
setServerEnabled(suiteKey: string, serverKey: string, enabled: boolean): Promise<void>;
|
|
33
39
|
/**
|
|
34
40
|
* Re-run the MCP reconcile pass: retries failed mounts and clears residual
|
|
35
41
|
* tools, without changing any catalog state.
|
|
@@ -11,8 +11,8 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import type { LspServerSpec, SourceKind } from '../model/types.js';
|
|
13
13
|
export type { McpBackendInfo } from '../contracts/market.js';
|
|
14
|
+
import type { DownloadRegionSetting } from '../contracts/settings.js';
|
|
14
15
|
import type { McpBackend } from '../runtime/mcp-backend.js';
|
|
15
|
-
import type { DownloadRegionSetting } from '../runtime/regions.js';
|
|
16
16
|
import type { LspMountStatusSource } from '../runtime/lsp-status.js';
|
|
17
17
|
import type { McpToolSnapshot } from '../runtime/mcp-status.js';
|
|
18
18
|
/** A new source: its location, optional branch, and acquisition kind. */
|
|
@@ -40,6 +40,8 @@ export interface MarketMutations {
|
|
|
40
40
|
setEnabled(sourceId: string, suiteId: string, enabled: boolean): Promise<void>;
|
|
41
41
|
setSurface(sourceId: string, suiteId: string, surface: SuiteSurfaceKey, enabled: boolean): Promise<void>;
|
|
42
42
|
setMcpOverride(sourceId: string, suiteId: string, serverKey: string, override: McpServerOverride | null): Promise<void>;
|
|
43
|
+
/** Enable or disable one declared MCP server by its source-qualified suite id. */
|
|
44
|
+
setMcpServerEnabled(suiteKey: string, serverKey: string, enabled: boolean): Promise<void>;
|
|
43
45
|
/** Validate and persist the user's direct LSP server table. */
|
|
44
46
|
setLspServers(raw: unknown): Promise<LspServerTable>;
|
|
45
47
|
setLspServerEnabled(id: string, enabled: boolean): Promise<void>;
|
|
@@ -45,6 +45,12 @@ export declare function sourceCheckoutDir(dimensionRoot: string, sourceId: strin
|
|
|
45
45
|
* bare suite id silently collides across sources.
|
|
46
46
|
*/
|
|
47
47
|
export declare function qualifiedSuiteId(sourceId: string, suiteId: string): string;
|
|
48
|
+
/**
|
|
49
|
+
* The data directory `${PLUGIN_DATA}` resolves to for one suite: a
|
|
50
|
+
* source-qualified directory under the plugin storage root, shared by every
|
|
51
|
+
* surface of that suite so MCP servers, skills and commands see one location.
|
|
52
|
+
*/
|
|
53
|
+
export declare function suiteDataDir(dataRoot: string, sourceId: string, suiteId: string): string;
|
|
48
54
|
/** The slice of a `node:path` implementation a containment test needs. */
|
|
49
55
|
export interface PathFlavor {
|
|
50
56
|
relative(from: string, to: string): string;
|