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.
Files changed (78) hide show
  1. package/README.md +2 -2
  2. package/README.zh.md +2 -2
  3. package/client/client.js +28048 -1033
  4. package/client/client.js.map +1 -1
  5. package/lib/application/catalog-context.js +8 -1
  6. package/lib/application/catalog.js +13 -0
  7. package/lib/application/details.js +13 -2
  8. package/lib/application/mcp-service.js +17 -0
  9. package/lib/application/panel-resources.js +16 -4
  10. package/lib/application/project-agent-roles.js +7 -1
  11. package/lib/catalog/archive.js +1 -1
  12. package/lib/catalog/paths.js +8 -0
  13. package/lib/catalog/plugin-variables.js +38 -0
  14. package/lib/catalog/project-hooks.js +14 -1
  15. package/lib/catalog/skills-parse.js +47 -0
  16. package/lib/catalog/source-catalog.js +8 -3
  17. package/lib/catalog/suite-components.js +2 -2
  18. package/lib/contracts/market.js +1 -0
  19. package/lib/contracts/settings.js +63 -0
  20. package/lib/index.js +26 -11
  21. package/lib/model/layouts.js +10 -1
  22. package/lib/routes.js +12 -0
  23. package/lib/runtime/agent-role-router.js +28 -6
  24. package/lib/runtime/commands-mounts.js +50 -9
  25. package/lib/runtime/deadline.js +32 -6
  26. package/lib/runtime/dynamic-context.js +140 -0
  27. package/lib/runtime/feedback-tool.js +7 -3
  28. package/lib/runtime/host-locale.js +39 -3
  29. package/lib/runtime/lsp-mounts.js +23 -2
  30. package/lib/runtime/mcp-backend.js +14 -15
  31. package/lib/runtime/mcp-client/config.js +8 -0
  32. package/lib/runtime/mcp-client/connection.js +6 -6
  33. package/lib/runtime/mcp-client/host-seams.js +8 -88
  34. package/lib/runtime/mcp-config.js +7 -4
  35. package/lib/runtime/project-runtime.js +23 -9
  36. package/lib/runtime/reconcile-scheduler.js +6 -8
  37. package/lib/runtime/reconciler.js +2 -1
  38. package/lib/runtime/regions.js +0 -19
  39. package/lib/runtime/settings-namespace.js +21 -13
  40. package/lib/runtime/skills-provider.js +33 -11
  41. package/lib/runtime/source-auto-update.js +10 -14
  42. package/lib/runtime/timer-seat.js +80 -0
  43. package/lib/runtime/tool-registry-observer.js +20 -20
  44. package/lib/runtime/user-commands.js +5 -1
  45. package/lib/runtime/user-panels.js +110 -41
  46. package/lib/runtime/user-store.js +114 -58
  47. package/lib/types/application/catalog-context.d.ts +6 -0
  48. package/lib/types/application/catalog.d.ts +9 -0
  49. package/lib/types/application/mcp-service.d.ts +6 -0
  50. package/lib/types/application/ports.d.ts +1 -1
  51. package/lib/types/application/queries.d.ts +2 -0
  52. package/lib/types/catalog/paths.d.ts +6 -0
  53. package/lib/types/catalog/plugin-variables.d.ts +16 -0
  54. package/lib/types/catalog/skills-parse.d.ts +13 -0
  55. package/lib/types/catalog/source-catalog.d.ts +7 -2
  56. package/lib/types/contracts/market.d.ts +10 -0
  57. package/lib/types/contracts/settings.d.ts +59 -0
  58. package/lib/types/model/layouts.d.ts +10 -0
  59. package/lib/types/runtime/agent-role-router.d.ts +6 -2
  60. package/lib/types/runtime/commands-mounts.d.ts +15 -7
  61. package/lib/types/runtime/deadline.d.ts +10 -11
  62. package/lib/types/runtime/dynamic-context.d.ts +81 -0
  63. package/lib/types/runtime/host-locale.d.ts +19 -1
  64. package/lib/types/runtime/lsp-mounts.d.ts +7 -0
  65. package/lib/types/runtime/mcp-backend.d.ts +8 -15
  66. package/lib/types/runtime/mcp-client/config.d.ts +8 -0
  67. package/lib/types/runtime/mcp-client/host-seams.d.ts +20 -64
  68. package/lib/types/runtime/project-runtime.d.ts +10 -3
  69. package/lib/types/runtime/reconcile-scheduler.d.ts +1 -1
  70. package/lib/types/runtime/regions.d.ts +1 -4
  71. package/lib/types/runtime/settings-namespace.d.ts +10 -10
  72. package/lib/types/runtime/skills-provider.d.ts +11 -2
  73. package/lib/types/runtime/source-auto-update.d.ts +3 -4
  74. package/lib/types/runtime/timer-seat.d.ts +51 -0
  75. package/lib/types/runtime/tool-registry-observer.d.ts +12 -6
  76. package/lib/types/runtime/user-panels.d.ts +65 -12
  77. package/lib/types/runtime/user-store.d.ts +65 -16
  78. package/package.json +21 -4
@@ -1,31 +1,27 @@
1
- /** Host-private adapter for observing model-facing MCP tools. */
1
+ /** Reading the model-facing MCP tools the host registry currently exposes. */
2
2
  /**
3
- * Read MCP tool names from the dsh-tools runtime when available.
3
+ * The MCP tools the host currently publishes to the model.
4
4
  *
5
- * The registry has no public listing API in the current host release. This
6
- * adapter uses the observed `layers.merge(...).tools.entries()` data shape and
7
- * returns an empty observation when a host changes it.
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(runtime) {
10
- if (typeof runtime !== 'object' || runtime === null)
12
+ export function inspectToolRegistry(tools) {
13
+ const service = tools;
14
+ if (service === undefined || typeof service !== 'object' || service === null)
11
15
  return [];
12
- const layers = runtime.layers;
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 [name, definition] of visible.entries()) {
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 definition === 'object' && definition !== null && typeof definition.description === 'string'
26
- ? definition.description
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({ content: [{ type: 'text', text }], source: { kind: 'plugin', plugin: 'dsh-agent-plugins-market' } });
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 files
4
- * under the shared Agent layout root (`~/.agents/<kind>/`), so user-authored
5
- * entries survive restarts, are trivially hand-editable, and stay outside
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 { join } from 'node:path';
17
- import { deleteEntryFile, entryExists, listEntryFiles, readEntryFile, USER_ENTRY_NAME, userEntryDir, writeEntryFile } from './user-store.js';
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 outrank both the harness's reader of this directory and the
22
- * suite skills an install adds.
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
- * `dsh-skill-filesystem` maps `~/.agents/skills` as its `user-agents` root at
25
- * rank 500, and a lower rank wins a duplicated skill name. Sitting behind it
26
- * handed every entry to that reader, which knows neither this panel's
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
- constructor(agentsRoot, kind,
40
- /** Extra name grammar for this panel (runs after USER_ENTRY_NAME). */
41
- extraNameCheck = () => true) {
51
+ validate;
52
+ honorInvocationControls;
53
+ constructor(agentsRoot, kind, options = {}) {
42
54
  this.agentsRoot = agentsRoot;
43
55
  this.kind = kind;
44
- this.extraNameCheck = extraNameCheck;
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
- if (!USER_ENTRY_NAME.test(name))
61
- return undefined;
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
- return this.serialize(file);
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: file.fallbackName,
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 (!USER_ENTRY_NAME.test(name) || !this.extraNameCheck(name))
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 file content wholesale. */
148
+ /** Replace one entry's content wholesale, keeping the spelling it already has. */
99
149
  async update(name, text) {
100
- if (!USER_ENTRY_NAME.test(name) || !this.extraNameCheck(name))
150
+ if (!this.validName(name))
101
151
  throw new Error(`invalid entry name "${name}"`);
102
- if (!(await entryExists(this.dir, name)))
152
+ const document = await this.document(name);
153
+ if (document === undefined)
103
154
  throw new Error(`no entry named "${name}"`);
104
- await writeEntryFile(this.dir, name, text);
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 deleteEntryFile(this.dir, name);
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', isUserSkillEntryName),
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.skills.dirPath() }
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: store.dirPath() },
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 files under the plugin data root, parsed with the same frontmatter
5
- * rules the catalog uses, so user entries look exactly like suite entries to
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 readEntryFile(file, fallbackName, strict = false) {
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
- const parsed = parseSkillFrontmatter(text, fallbackName);
60
- const name = typeof parsed === 'string' ? fallbackName : typeof parsed.name === 'string' && parsed.name !== '' ? parsed.name : fallbackName;
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
- fallbackName,
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 `.md` entries; strict runtime snapshots propagate I/O errors instead of publishing partial inventories. */
72
- export async function listEntryFiles(dir, strict = false) {
73
- let entries;
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 entry of entries.sort()) {
84
- if (!entry.endsWith('.md'))
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
- /** Write one panel entry; the name becomes `<name>.md`. */
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(join(dir, `${name}.md`), text, { mode: 0o644, dirMode: 0o700 });
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
- /** Delete one panel entry file; missing files resolve silently. */
120
- export async function deleteEntryFile(dir, name) {
121
- assertEntryName(name);
122
- await rm(join(dir, `${name}.md`), { force: true });
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 file with that name already exists. */
125
- export async function entryExists(dir, name) {
126
- if (!USER_ENTRY_NAME.test(name))
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;