dsh-agent-plugins-market 0.6.2 → 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 (155) hide show
  1. package/README.md +117 -111
  2. package/README.zh.md +112 -98
  3. package/client/client.js +28533 -1511
  4. package/client/client.js.map +1 -1
  5. package/lib/application/catalog-context.js +188 -0
  6. package/lib/application/catalog.js +229 -1109
  7. package/lib/application/details.js +19 -8
  8. package/lib/application/install-store.js +95 -0
  9. package/lib/application/lsp-service.js +97 -0
  10. package/lib/application/mcp-service.js +263 -0
  11. package/lib/application/panel-resources.js +43 -14
  12. package/lib/application/ports.js +21 -0
  13. package/lib/application/project-agent-roles.js +8 -2
  14. package/lib/application/snapshot-cache.js +160 -0
  15. package/lib/application/source-store.js +521 -0
  16. package/lib/catalog/archive.js +35 -30
  17. package/lib/catalog/component-files.js +14 -6
  18. package/lib/catalog/fs-probes.js +43 -0
  19. package/lib/catalog/manifests.js +53 -92
  20. package/lib/catalog/native-project.js +2 -2
  21. package/lib/catalog/paths.js +74 -55
  22. package/lib/catalog/plugin-variables.js +38 -0
  23. package/lib/catalog/project-config.js +2 -1
  24. package/lib/catalog/project-hooks.js +23 -2
  25. package/lib/catalog/scan-resolvers.js +29 -45
  26. package/lib/catalog/skills-parse.js +61 -10
  27. package/lib/catalog/source-catalog.js +10 -4
  28. package/lib/catalog/suite-components.js +7 -7
  29. package/lib/catalog/suite-scanner.js +3 -1
  30. package/lib/catalog/surfaces.js +5 -26
  31. package/lib/catalog/validate.js +7 -11
  32. package/lib/contracts/market.js +2 -0
  33. package/lib/contracts/settings.js +63 -0
  34. package/lib/index.js +115 -217
  35. package/lib/model/layouts.js +10 -1
  36. package/lib/model/types.js +26 -3
  37. package/lib/routes.js +57 -15
  38. package/lib/runtime/agent-role-names.js +70 -0
  39. package/lib/runtime/agent-role-router.js +142 -106
  40. package/lib/runtime/commands-mounts.js +55 -11
  41. package/lib/runtime/deadline.js +54 -0
  42. package/lib/runtime/dynamic-context.js +140 -0
  43. package/lib/runtime/feedback-tool.js +101 -46
  44. package/lib/runtime/hooks-mounts.js +9 -11
  45. package/lib/runtime/host-locale.js +49 -24
  46. package/lib/runtime/lsp-direct-config.js +19 -19
  47. package/lib/runtime/lsp-mounts.js +182 -70
  48. package/lib/runtime/lsp-server-state.js +4 -4
  49. package/lib/runtime/lsp-status.js +1 -1
  50. package/lib/runtime/mcp-auth-record.js +42 -0
  51. package/lib/runtime/mcp-backend.js +14 -14
  52. package/lib/runtime/mcp-client/config.js +8 -1
  53. package/lib/runtime/mcp-client/connection.js +38 -43
  54. package/lib/runtime/mcp-client/host-contract.js +10 -0
  55. package/lib/runtime/mcp-client/host-seams.js +8 -88
  56. package/lib/runtime/mcp-client/oauth.js +5 -7
  57. package/lib/runtime/mcp-client/plugin-identity.js +28 -0
  58. package/lib/runtime/mcp-client/projection.js +202 -0
  59. package/lib/runtime/mcp-client/tools.js +4 -189
  60. package/lib/runtime/mcp-config.js +36 -14
  61. package/lib/runtime/mcp-direct-config.js +33 -16
  62. package/lib/runtime/mcp-mounts.js +35 -75
  63. package/lib/runtime/mcp-overrides.js +5 -5
  64. package/lib/runtime/mcp-redaction.js +3 -2
  65. package/lib/runtime/mcp-status.js +7 -8
  66. package/lib/runtime/mount-lifecycle.js +73 -0
  67. package/lib/runtime/profile-seam.js +321 -0
  68. package/lib/runtime/project-runtime.js +34 -17
  69. package/lib/runtime/reconcile-scheduler.js +97 -0
  70. package/lib/runtime/reconciler.js +6 -1
  71. package/lib/runtime/regions.js +0 -19
  72. package/lib/runtime/server-config.js +6 -4
  73. package/lib/runtime/settings-namespace.js +144 -0
  74. package/lib/runtime/skills-provider.js +38 -14
  75. package/lib/runtime/source-auto-update.js +66 -0
  76. package/lib/{model/state.js → runtime/state-store.js} +9 -11
  77. package/lib/runtime/storage-migration.js +35 -18
  78. package/lib/runtime/subagent-catalog.js +5 -4
  79. package/lib/runtime/timer-seat.js +80 -0
  80. package/lib/runtime/tool-registry-observer.js +20 -20
  81. package/lib/runtime/user-commands.js +7 -3
  82. package/lib/runtime/user-panels.js +125 -46
  83. package/lib/runtime/user-store.js +124 -69
  84. package/lib/types/application/catalog-context.d.ts +124 -0
  85. package/lib/types/application/catalog.d.ts +108 -304
  86. package/lib/types/application/install-store.d.ts +21 -0
  87. package/lib/types/application/lsp-service.d.ts +36 -0
  88. package/lib/types/application/mcp-service.d.ts +77 -0
  89. package/lib/types/application/panel-resources.d.ts +8 -0
  90. package/lib/types/application/ports.d.ts +72 -0
  91. package/lib/types/application/queries.d.ts +15 -26
  92. package/lib/types/application/snapshot-cache.d.ts +51 -0
  93. package/lib/types/application/source-store.d.ts +100 -0
  94. package/lib/types/catalog/archive.d.ts +3 -11
  95. package/lib/types/catalog/component-files.d.ts +4 -0
  96. package/lib/types/catalog/fs-probes.d.ts +10 -0
  97. package/lib/types/catalog/manifests.d.ts +17 -16
  98. package/lib/types/catalog/native-project.d.ts +2 -2
  99. package/lib/types/catalog/paths.d.ts +53 -15
  100. package/lib/types/catalog/plugin-variables.d.ts +16 -0
  101. package/lib/types/catalog/scan-pipeline.d.ts +3 -3
  102. package/lib/types/catalog/scan-resolvers.d.ts +3 -9
  103. package/lib/types/catalog/skills-parse.d.ts +13 -7
  104. package/lib/types/catalog/source-catalog.d.ts +10 -5
  105. package/lib/types/catalog/suite-scanner.d.ts +8 -6
  106. package/lib/types/catalog/validate.d.ts +0 -2
  107. package/lib/types/contracts/lsp-status.d.ts +35 -0
  108. package/lib/types/contracts/market.d.ts +13 -2
  109. package/lib/types/contracts/mcp-status.d.ts +4 -0
  110. package/lib/types/contracts/settings.d.ts +59 -0
  111. package/lib/types/model/layouts.d.ts +10 -0
  112. package/lib/types/model/types.d.ts +29 -5
  113. package/lib/types/runtime/agent-role-names.d.ts +6 -0
  114. package/lib/types/runtime/agent-role-router.d.ts +59 -52
  115. package/lib/types/runtime/commands-mounts.d.ts +15 -7
  116. package/lib/types/runtime/deadline.d.ts +17 -0
  117. package/lib/types/runtime/dynamic-context.d.ts +81 -0
  118. package/lib/types/runtime/feedback-tool.d.ts +33 -11
  119. package/lib/types/runtime/hooks-mounts.d.ts +1 -1
  120. package/lib/types/runtime/host-locale.d.ts +20 -2
  121. package/lib/types/runtime/lsp-direct-config.d.ts +3 -3
  122. package/lib/types/runtime/lsp-mounts.d.ts +69 -19
  123. package/lib/types/runtime/lsp-status.d.ts +2 -1
  124. package/lib/types/runtime/mcp-auth-record.d.ts +29 -0
  125. package/lib/types/runtime/mcp-backend.d.ts +10 -14
  126. package/lib/types/runtime/mcp-client/config.d.ts +8 -2
  127. package/lib/types/runtime/mcp-client/host-contract.d.ts +78 -0
  128. package/lib/types/runtime/mcp-client/host-seams.d.ts +20 -64
  129. package/lib/types/runtime/mcp-client/oauth.d.ts +1 -1
  130. package/lib/types/runtime/mcp-client/plugin-identity.d.ts +16 -0
  131. package/lib/types/runtime/mcp-client/projection.d.ts +45 -0
  132. package/lib/types/runtime/mcp-client/tools.d.ts +5 -72
  133. package/lib/types/runtime/mcp-config.d.ts +25 -4
  134. package/lib/types/runtime/mcp-direct-config.d.ts +18 -4
  135. package/lib/types/runtime/mcp-mounts.d.ts +10 -15
  136. package/lib/types/runtime/mcp-overrides.d.ts +1 -1
  137. package/lib/types/runtime/mcp-status.d.ts +1 -1
  138. package/lib/types/runtime/mount-lifecycle.d.ts +53 -0
  139. package/lib/types/runtime/profile-seam.d.ts +64 -0
  140. package/lib/types/runtime/project-runtime.d.ts +10 -3
  141. package/lib/types/runtime/reconcile-scheduler.d.ts +45 -0
  142. package/lib/types/runtime/reconciler.d.ts +2 -0
  143. package/lib/types/runtime/regions.d.ts +1 -4
  144. package/lib/types/runtime/settings-namespace.d.ts +76 -0
  145. package/lib/types/runtime/skills-provider.d.ts +11 -2
  146. package/lib/types/runtime/source-auto-update.d.ts +32 -0
  147. package/lib/types/{model/state.d.ts → runtime/state-store.d.ts} +2 -2
  148. package/lib/types/runtime/storage-migration.d.ts +5 -3
  149. package/lib/types/runtime/subagent-catalog.d.ts +2 -0
  150. package/lib/types/runtime/timer-seat.d.ts +51 -0
  151. package/lib/types/runtime/tool-registry-observer.d.ts +12 -6
  152. package/lib/types/runtime/user-panels.d.ts +69 -19
  153. package/lib/types/runtime/user-store.d.ts +67 -20
  154. package/package.json +68 -24
  155. package/schemas/README.md +1 -1
@@ -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 {
@@ -62,9 +63,12 @@ export class UserCommandMountRegistry {
62
63
  ...(spec.hint === undefined ? {} : { input: { hint: spec.hint } }),
63
64
  handler: invocation => {
64
65
  const agent = invocation.agent;
65
- const text = [this.t('userCommandForwardTitle', { command: spec.name }), '', spec.body.replaceAll('$ARGUMENTS', invocation.rawInput.trim())].join('\n');
66
- agent.followup({ content: [{ type: 'text', text }], source: { kind: 'plugin', plugin: 'dsh-agent-plugins-market' } });
67
- return { kind: 'success', text: this.t('userCommandAcknowledged', { command: spec.name }) };
66
+ const text = spec.body.replaceAll('$ARGUMENTS', invocation.rawInput.trim());
67
+ agent.followup(createUserMessage({
68
+ content: [{ type: 'text', text }],
69
+ source: { kind: 'plugin', plugin: 'dsh-agent-plugins-market' }
70
+ }));
71
+ return { kind: 'success', text: this.t('commandAcknowledged', { command: spec.name }) };
68
72
  }
69
73
  });
70
74
  this.live.set(key, disposer);
@@ -1,8 +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 `<userRoot>/user/<kind>/`, so user-authored entries survive restarts,
5
- * are trivially hand-editable, and stay outside 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.
6
7
  *
7
8
  * Skills entries follow the SKILL.md frontmatter grammar (`name`,
8
9
  * `description`, optional `whenToUse`, invocation controls) plus a
@@ -10,75 +11,133 @@
10
11
  * frontmatter (`description`, optional `argument-hint`, `disabled`); agent
11
12
  * personas follow Claude Code agents frontmatter (`name`, `description`,
12
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.
13
24
  * @module runtime/user-panels
14
25
  */
15
- import { join } from 'node:path';
16
- 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';
17
29
  /** The skill-source label user-panel entries carry into the skill registry. */
18
30
  export const USER_PANEL_SKILL_SOURCE = 'user-panel';
19
- /** Ranks sit behind every shipped root so real files always win a name clash. */
20
- const USER_PANEL_RANK = 600;
31
+ /**
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.
39
+ *
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.
43
+ */
44
+ const USER_PANEL_RANK = 440;
21
45
  /** Throwing CRUD over one panel directory, shared by the three panels. */
22
46
  export class UserPanelStore {
23
- dataRoot;
47
+ agentsRoot;
24
48
  kind;
49
+ shapes;
25
50
  extraNameCheck;
26
- constructor(dataRoot, kind,
27
- /** Extra name grammar for this panel (runs after USER_ENTRY_NAME). */
28
- extraNameCheck = () => true) {
29
- this.dataRoot = dataRoot;
51
+ validate;
52
+ honorInvocationControls;
53
+ constructor(agentsRoot, kind, options = {}) {
54
+ this.agentsRoot = agentsRoot;
30
55
  this.kind = kind;
31
- 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;
32
60
  }
33
- /** The panel's directory under the data root. */
61
+ /** The panel's directory under the Agent layout root. */
34
62
  dirPath() {
35
- return userEntryDir(this.dataRoot, this.kind);
36
- }
37
- /** The plugin data root the panel lives under. */
38
- root() {
39
- return this.dataRoot;
63
+ return userEntryDir(this.agentsRoot, this.kind);
40
64
  }
41
65
  get dir() {
42
66
  return this.dirPath();
43
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
+ }
44
88
  /** Every entry, sorted by name; strict snapshots propagate temporary I/O failures. */
45
89
  async list(strict = false) {
46
- const files = await listEntryFiles(this.dir, strict);
90
+ const files = await listEntryFiles(this.dir, strict, this.shapes);
47
91
  return files.map(file => this.serialize(file));
48
92
  }
49
93
  /** One entry's full record, including the raw file text. */
50
94
  async get(name) {
51
- if (!USER_ENTRY_NAME.test(name))
52
- return undefined;
53
- const file = await readEntryFile(join(this.dir, `${name}.md`), name);
54
- if (file === undefined)
95
+ const document = await this.document(name);
96
+ if (document === undefined)
55
97
  return undefined;
56
- return this.serialize(file);
98
+ const file = await readEntryDocument(document);
99
+ return file === undefined ? undefined : this.serialize(file);
57
100
  }
58
101
  /**
59
102
  * Project one parsed file onto the wire shape. The description prefers the
60
103
  * strict frontmatter parse (multi-line YAML like `description: |` survives
61
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.
62
110
  */
63
111
  serialize(file) {
64
112
  const strictDescription = typeof file.description === 'string' && file.description !== '' ? file.description : undefined;
65
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;
66
120
  return {
67
- 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,
68
126
  description: strictDescription ?? shallowDescription,
69
- disabled: file.meta['disabled'] === true,
70
- metadata: file.meta,
127
+ disabled: file.meta['disabled'] === true || invocationOff || rejection !== undefined,
128
+ metadata: rejection === undefined ? file.meta : { ...file.meta, disabled: true, validationError: rejection },
71
129
  path: file.file,
130
+ shape: file.shape,
72
131
  content: file.body,
73
132
  rawText: file.text,
74
133
  origin: 'user'
75
134
  };
76
135
  }
77
- /** Create an entry; refuses an occupied name. */
136
+ /** Create an entry; refuses an occupied name. New entries use the flat spelling. */
78
137
  async create(name, text) {
79
- if (!USER_ENTRY_NAME.test(name) || !this.extraNameCheck(name))
138
+ if (!this.validName(name))
80
139
  throw new Error(`invalid name "${name}" — use lowercase letters, digits, and dashes, starting with a letter or digit`);
81
- if (await entryExists(this.dir, name))
140
+ if (await entryExists(this.dir, name, this.shapes))
82
141
  throw new Error(`an entry named "${name}" already exists`);
83
142
  await writeEntryFile(this.dir, name, text);
84
143
  const created = await this.get(name);
@@ -86,27 +145,36 @@ export class UserPanelStore {
86
145
  throw new Error(`entry "${name}" vanished after write`);
87
146
  return created;
88
147
  }
89
- /** Replace one entry's file content wholesale. */
148
+ /** Replace one entry's content wholesale, keeping the spelling it already has. */
90
149
  async update(name, text) {
91
- if (!USER_ENTRY_NAME.test(name) || !this.extraNameCheck(name))
150
+ if (!this.validName(name))
92
151
  throw new Error(`invalid entry name "${name}"`);
93
- if (!(await entryExists(this.dir, name)))
152
+ const document = await this.document(name);
153
+ if (document === undefined)
94
154
  throw new Error(`no entry named "${name}"`);
95
- await writeEntryFile(this.dir, name, text);
155
+ await writeEntryDocument(document, text);
96
156
  }
97
157
  /** Delete one entry; a missing file is a no-op (idempotent delete). */
98
158
  async remove(name) {
99
159
  if (!USER_ENTRY_NAME.test(name))
100
160
  throw new Error(`invalid entry name "${name}"`);
101
- await deleteEntryFile(this.dir, name);
161
+ const document = await this.document(name);
162
+ if (document === undefined)
163
+ return;
164
+ await deleteEntryDocument(document);
102
165
  }
103
166
  }
104
167
  /** Locate the three stores; constructed once per plugin activation. */
105
- export function createUserPanelStores(dataRoot) {
168
+ export function createUserPanelStores(agentsRoot) {
106
169
  return {
107
- skills: new UserPanelStore(dataRoot, 'skills', isUserSkillEntryName),
108
- commands: new UserPanelStore(dataRoot, 'commands'),
109
- agents: new UserPanelStore(dataRoot, 'agents', 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
+ }),
176
+ commands: new UserPanelStore(agentsRoot, 'commands'),
177
+ agents: new UserPanelStore(agentsRoot, 'agents', { extraNameCheck: isUserSkillEntryName })
110
178
  };
111
179
  }
112
180
  /**
@@ -125,11 +193,9 @@ export function isUserSkillEntryName(name) {
125
193
  */
126
194
  export class UserPanelSkillProvider {
127
195
  skills;
128
- t;
129
196
  name = 'user-panel';
130
- constructor(skills, t) {
197
+ constructor(skills) {
131
198
  this.skills = skills;
132
- this.t = t;
133
199
  }
134
200
  async list(_options) {
135
201
  const candidates = [];
@@ -138,8 +204,13 @@ export class UserPanelSkillProvider {
138
204
  if (entry.disabled || !isUserSkillEntryName(entry.name))
139
205
  continue;
140
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.
141
209
  name: entry.name,
142
- description: this.t('userSkillDescription', { description: entry.description === '' ? entry.name : entry.description }),
210
+ // The entry's own description verbatim; a user-entry label is our
211
+ // packaging, and this description reaches the same model-facing
212
+ // catalog a suite skill does.
213
+ description: entry.description === '' ? entry.name : entry.description,
143
214
  ...(typeof entry.metadata['whenToUse'] === 'string' && entry.metadata['whenToUse'] !== '' ? { whenToUse: entry.metadata['whenToUse'] } : {}),
144
215
  invocation: {
145
216
  modelInvocable: entry.metadata['disable-model-invocation'] !== true,
@@ -150,7 +221,7 @@ export class UserPanelSkillProvider {
150
221
  rank: USER_PANEL_RANK,
151
222
  locator: { name: entry.name },
152
223
  path: entry.path,
153
- resourceBase: { kind: 'directory', path: this.skills.dirPath() }
224
+ resourceBase: { kind: 'directory', path: this.resourceDirectory(entry) }
154
225
  });
155
226
  }
156
227
  return candidates;
@@ -168,9 +239,17 @@ export class UserPanelSkillProvider {
168
239
  invocation: candidate.invocation,
169
240
  source: candidate.source,
170
241
  provider: this.name,
171
- resourceBase: { kind: 'directory', path: store.dirPath() },
242
+ resourceBase: { kind: 'directory', path: this.resourceDirectory(entry) },
172
243
  path: entry.path,
173
244
  content: entry.content
174
245
  };
175
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
+ }
176
255
  }
@@ -1,34 +1,102 @@
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 { mkdir, readdir, readFile, rm, stat, writeFile } 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';
15
+ import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write';
11
16
  import { parseDocument, stringify } from 'yaml';
12
17
  import { parseSkillFrontmatter, stripFrontmatter } from '../catalog/skills-parse.js';
13
- /** Where user panel entries persist: `<userRoot>/user/<kind>/`. */
14
- export function userEntryDir(dataRoot, kind) {
15
- return join(dataRoot, 'user', kind);
18
+ /** Where user panel entries persist: `<agentsRoot>/<kind>/`. */
19
+ export function userEntryDir(agentsRoot, kind) {
20
+ return join(agentsRoot, kind);
16
21
  }
17
22
  /** `[a-z][a-z0-9_-]*` — the grammar every panel entry name must satisfy. */
18
23
  export const USER_ENTRY_NAME = /^[a-z][a-z0-9_-]*$/;
19
- /** The enablement frontmatter key shared by skills and commands. */
20
- export const DISABLED_KEY = 'disabled';
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
+ }
21
88
  /** Parse full YAML metadata without flattening arrays, mappings, or multiline strings. */
22
89
  export function parseFrontmatterRecord(text) {
23
- const match = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(text);
24
- if (match === null) {
90
+ const frontmatter = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(text)?.[1];
91
+ if (frontmatter === undefined) {
25
92
  if (/^---(?:\r?\n|$)/.test(text))
26
93
  throw new Error('Invalid frontmatter: missing closing delimiter');
27
94
  return {};
28
95
  }
29
- const doc = parseDocument(match[1]);
30
- if (doc.errors.length > 0)
31
- throw new Error(`Invalid frontmatter: ${doc.errors[0].message}`);
96
+ const doc = parseDocument(frontmatter);
97
+ const error = doc.errors[0];
98
+ if (error !== undefined)
99
+ throw new Error(`Invalid frontmatter: ${error.message}`);
32
100
  const value = doc.toJS({ maxAliasCount: 100 });
33
101
  if (value === null)
34
102
  return {};
@@ -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,56 +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 mkdir(dir, { recursive: true });
108
- await writeFile(join(dir, `${name}.md`), text, 'utf8');
163
+ await writeFileAtomic(document.file, text, { mode: 0o644, dirMode: 0o700 });
109
164
  }
110
165
  /**
111
166
  * Enforce the entry-name grammar at every filesystem choke point. The name
@@ -117,22 +172,22 @@ function assertEntryName(name) {
117
172
  if (!USER_ENTRY_NAME.test(name))
118
173
  throw new Error(`invalid entry name "${name}" — use lowercase letters, digits, dashes, or underscores, starting with a letter`);
119
174
  }
120
- /** Delete one panel entry file; missing files resolve silently. */
121
- export async function deleteEntryFile(dir, name) {
122
- assertEntryName(name);
123
- 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);
124
187
  }
125
- /** Whether an entry file with that name already exists. */
126
- export async function entryExists(dir, name) {
127
- if (!USER_ENTRY_NAME.test(name))
128
- return false;
129
- try {
130
- await stat(join(dir, `${name}.md`));
131
- return true;
132
- }
133
- catch {
134
- return false;
135
- }
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;
136
191
  }
137
192
  /** Serialize a frontmatter block from a shallow record (deterministic key order). */
138
193
  export function serializeFrontmatter(meta) {