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,23 +1,31 @@
1
1
  /** Resolve declared component files once; all consumers use the same contained resource paths. */
2
2
  import { readFile, readdir, realpath, stat } from 'node:fs/promises';
3
- import { extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
3
+ import { extname, join, relative, resolve } from 'node:path';
4
4
  import { PLUGIN_ROOT_VARIABLES } from '../model/layouts.js';
5
+ import { isFile } from './fs-probes.js';
6
+ import { isWithin } from './paths.js';
5
7
  export function isRecord(value) {
6
8
  return typeof value === 'object' && value !== null && !Array.isArray(value);
7
9
  }
10
+ /** `Array.isArray` narrows to `any[]`; declarations are untrusted, so the elements stay `unknown`. */
11
+ export function isUnknownArray(value) {
12
+ return Array.isArray(value);
13
+ }
14
+ /** True only for an array whose every element is a string. */
15
+ export function isStringArray(value) {
16
+ return Array.isArray(value) && value.every(entry => typeof entry === 'string');
17
+ }
8
18
  /** Reject both lexical and symlink escapes. Optional defaults may be absent without a diagnostic. */
9
19
  export async function componentPath(root, value, errors, required = true) {
10
20
  const clean = value.replace(/^\$\{([A-Z_]+)\}\//, (match, name) => (PLUGIN_ROOT_VARIABLES.has(name) ? './' : match));
11
21
  const path = resolve(root, clean);
12
- const rel = relative(resolve(root), path);
13
- if (isAbsolute(rel) || rel === '..' || rel.startsWith(`..${sep}`)) {
22
+ if (!isWithin(resolve(root), path)) {
14
23
  errors.push(`component path ${value}: escapes the suite root`);
15
24
  return undefined;
16
25
  }
17
26
  try {
18
27
  const [base, target] = await Promise.all([realpath(root), realpath(path)]);
19
- const actual = relative(base, target);
20
- if (isAbsolute(actual) || actual === '..' || actual.startsWith(`..${sep}`)) {
28
+ if (!isWithin(base, target)) {
21
29
  errors.push(`component path ${value}: escapes the suite root through a symlink`);
22
30
  return undefined;
23
31
  }
@@ -135,7 +143,7 @@ export async function componentDocuments(root, declaration, errors, allowDirecto
135
143
  }
136
144
  export async function firstComponentFile(root, candidates) {
137
145
  for (const path of candidates)
138
- if ((await stat(join(root, path)).catch(() => undefined))?.isFile())
146
+ if (await isFile(join(root, path)))
139
147
  return path;
140
148
  return undefined;
141
149
  }
@@ -0,0 +1,43 @@
1
+ import { readdir, stat } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ /** Dot-directories and dependency trees that never hold suite content. */
4
+ export const DOT_DIRS = new Set(['.git', '.github', '.claude', '.cursor', '.kimi', '.plugin', '.sources', 'node_modules']);
5
+ /** Whether `path` is an existing directory (symlinks followed for the final component). */
6
+ export async function isDirectory(path) {
7
+ try {
8
+ return (await stat(path)).isDirectory();
9
+ }
10
+ catch {
11
+ return false;
12
+ }
13
+ }
14
+ /** Whether `path` is an existing regular file. */
15
+ export async function isFile(path) {
16
+ try {
17
+ return (await stat(path)).isFile();
18
+ }
19
+ catch {
20
+ return false;
21
+ }
22
+ }
23
+ /** Whether any entry (file, directory, symlink) exists at `path`. */
24
+ export async function pathExists(path) {
25
+ try {
26
+ await stat(path);
27
+ return true;
28
+ }
29
+ catch {
30
+ return false;
31
+ }
32
+ }
33
+ /** Absolute paths of the non-hidden child directories of `dir`, in readdir order. */
34
+ export async function listChildDirs(dir) {
35
+ let entries;
36
+ try {
37
+ entries = await readdir(dir, { withFileTypes: true });
38
+ }
39
+ catch {
40
+ return [];
41
+ }
42
+ return entries.filter(entry => entry.isDirectory() && !DOT_DIRS.has(entry.name) && !entry.name.startsWith('.')).map(entry => join(dir, entry.name));
43
+ }
@@ -18,26 +18,31 @@
18
18
  * present, while surfaces (skills/commands/agents/hooks/mcp) are scanned from
19
19
  * the directories regardless of which dialect won.
20
20
  */
21
- import { readFile, stat } from 'node:fs/promises';
22
- import { join, resolve } from 'node:path';
21
+ import { readFile } from 'node:fs/promises';
22
+ import { join } from 'node:path';
23
23
  import { sanitizeId } from './paths.js';
24
+ import { isFile } from './fs-probes.js';
25
+ import { isRecord } from './component-files.js';
24
26
  import { isRecognizedSchema, validatePluginManifest } from './validate.js';
25
27
  import { PLUGIN_LAYOUTS, MANIFEST_ALIASES, MARKETPLACE_PATHS } from '../model/layouts.js';
26
- /** The highest-precedence manifest file a directory carries, if any. */
27
- export async function detectManifest(dir) {
28
+ /**
29
+ * Every manifest file a directory carries, in selection order: highest
30
+ * precedence first, aliases of one dialect in their own listed order.
31
+ */
32
+ export async function detectManifests(dir) {
33
+ const found = [];
28
34
  for (const { kind, manifest } of PLUGIN_LAYOUTS) {
29
35
  for (const relative of [...(MANIFEST_ALIASES[kind] ?? []), manifest]) {
30
36
  const path = join(dir, relative);
31
- try {
32
- if ((await stat(path)).isFile())
33
- return { kind, path };
34
- }
35
- catch {
36
- // try the next candidate
37
- }
37
+ if (await isFile(path))
38
+ found.push({ kind, path });
38
39
  }
39
40
  }
40
- return undefined;
41
+ return found;
42
+ }
43
+ /** The highest-precedence manifest file a directory carries, if any. */
44
+ export async function detectManifest(dir) {
45
+ return (await detectManifests(dir))[0];
41
46
  }
42
47
  /** Whether a directory carries any known suite manifest. */
43
48
  export async function hasSuiteManifest(dir) {
@@ -52,14 +57,33 @@ export function componentDeclarations(record) {
52
57
  return Object.fromEntries(['skills', 'commands', 'agents', 'hooks', 'mcpServers', 'lspServers'].filter(key => value[key] !== undefined).map(key => [key, value[key]]));
53
58
  }
54
59
  /**
55
- * Parse one manifest document into a normalized SuiteManifest. The v1 dialect
56
- * is schema-validated (fail-closed); the others are structurally read with
57
- * light tolerance, and `hint` (a marketplace plugin entry) fills in gaps.
60
+ * Read a suite manifest by walking the layout priority list from the top.
61
+ *
62
+ * A candidate that cannot be parsed or validated fails closed *for itself*:
63
+ * its diagnostics are recorded and the next candidate by priority is tried.
64
+ * Diagnostics of a rejected candidate that a lower-priority manifest replaced
65
+ * go to `fallbacks`, so the winner stays usable while the caller can still
66
+ * report why the higher-priority declaration was ignored. The winner's own
67
+ * diagnostics stay in `errors` and remain fatal.
58
68
  */
59
- export async function readManifest(root, errors, hint) {
60
- const candidate = await detectManifest(root);
61
- if (candidate === undefined)
62
- return undefined;
69
+ export async function readManifest(root, errors, hint, fallbacks = []) {
70
+ const candidates = await detectManifests(root);
71
+ for (const [index, candidate] of candidates.entries()) {
72
+ const attempt = [];
73
+ const manifest = await readOneManifest(root, candidate, attempt, hint);
74
+ if (manifest !== undefined) {
75
+ errors.push(...attempt);
76
+ return manifest;
77
+ }
78
+ if (index === candidates.length - 1)
79
+ errors.push(...attempt);
80
+ else
81
+ fallbacks.push(...attempt);
82
+ }
83
+ return undefined;
84
+ }
85
+ /** Parse one manifest candidate; undefined when this candidate is unusable. */
86
+ async function readOneManifest(root, candidate, errors, hint) {
63
87
  let raw;
64
88
  try {
65
89
  raw = JSON.parse(await readFile(candidate.path, 'utf8'));
@@ -107,7 +131,10 @@ export async function readManifest(root, errors, hint) {
107
131
  }
108
132
  }
109
133
  const problems = kind === 'agent-plugin-v1' ? await validatePluginManifest(raw) : [];
110
- errors.push(...problems.map(problem => `${candidate.path}: ${problem}`));
134
+ if (problems.length > 0) {
135
+ errors.push(...problems.map(problem => `${candidate.path}: ${problem}`));
136
+ return undefined;
137
+ }
111
138
  const name = pickString(record.name) ?? hint?.name ?? syntheticManifestName(root);
112
139
  const version = pickString(record.version) ?? hint?.version;
113
140
  const description = pickString(record.description) ?? hint?.description;
@@ -165,8 +192,8 @@ async function readOneMarketplace(path, errors) {
165
192
  const rawPlugins = record['plugins'];
166
193
  const plugins = Array.isArray(rawPlugins)
167
194
  ? rawPlugins
168
- : typeof rawPlugins === 'object' && rawPlugins !== null
169
- ? Object.entries(rawPlugins).map(([name, entry]) => (typeof entry === 'object' && entry !== null && !Array.isArray(entry) ? { ...entry, name } : entry))
195
+ : isRecord(rawPlugins)
196
+ ? Object.entries(rawPlugins).map(([name, entry]) => (isRecord(entry) ? { ...entry, name } : entry))
170
197
  : undefined;
171
198
  if (plugins === undefined) {
172
199
  errors.push(`marketplace ${path}: "plugins" is not an array`);
@@ -226,20 +253,10 @@ export async function readMarketplaces(checkoutDir, errors = []) {
226
253
  return results;
227
254
  }
228
255
  /** Read the highest-precedence marketplace manifest, or undefined when absent. */
229
- export async function readMarketplace(checkoutDir) {
256
+ async function readMarketplace(checkoutDir) {
230
257
  const results = await readMarketplaces(checkoutDir);
231
258
  return results[0];
232
259
  }
233
- /** Resolve one marketplace entry to a local checkout-relative directory, or
234
- * `undefined` for remote-URL entries that are not present in the clone. */
235
- export function marketplaceEntryDir(checkoutDir, entry) {
236
- const source = entry.source;
237
- if (typeof source === 'string')
238
- return resolve(checkoutDir, source);
239
- if (source?.path !== undefined)
240
- return resolve(checkoutDir, source.path);
241
- return undefined;
242
- }
243
260
  /**
244
261
  * Resolve a repo-level name for source-id derivation, in precedence order:
245
262
  * marketplace plugin entry name > marketplace name > root manifest name >
@@ -250,20 +267,16 @@ export async function repoName(checkoutDir) {
250
267
  const marketplace = await readMarketplace(checkoutDir);
251
268
  if (marketplace !== undefined) {
252
269
  const entries = marketplace.entries;
253
- if (entries.length === 1) {
270
+ const soleEntry = entries.length === 1 ? entries[0] : undefined;
271
+ if (soleEntry !== undefined) {
254
272
  // A single-suite marketplace: the plugin entry names the repo (vercel → vercel-plugin).
255
- const entryName = pickString(entries[0].name);
273
+ const entryName = pickString(soleEntry.name);
256
274
  if (entryName !== undefined)
257
275
  return entryName;
258
276
  }
259
277
  const marketplaceName = pickString(marketplace.name);
260
278
  if (marketplaceName !== undefined)
261
279
  return marketplaceName;
262
- if (entries.length === 1) {
263
- const entryName = pickString(entries[0].name);
264
- if (entryName !== undefined)
265
- return entryName;
266
- }
267
280
  }
268
281
  const candidate = await detectManifest(checkoutDir);
269
282
  if (candidate !== undefined) {
@@ -281,55 +294,3 @@ export async function repoName(checkoutDir) {
281
294
  }
282
295
  return syntheticManifestName(checkoutDir);
283
296
  }
284
- /** The winning manifest's declared `skills` path (string or array), or undefined. */
285
- export async function declaredSkillsPath(root) {
286
- const candidate = await detectManifest(root);
287
- if (candidate === undefined)
288
- return undefined;
289
- try {
290
- const raw = JSON.parse(await readFile(candidate.path, 'utf8'));
291
- if (typeof raw === 'object' && raw !== null) {
292
- return raw['skills'];
293
- }
294
- }
295
- catch {
296
- return undefined;
297
- }
298
- return undefined;
299
- }
300
- /** The winning manifest's inline `mcpServers`, or undefined. */
301
- export async function declaredMcpServers(root) {
302
- const candidate = await detectManifest(root);
303
- if (candidate === undefined)
304
- return undefined;
305
- try {
306
- const raw = JSON.parse(await readFile(candidate.path, 'utf8'));
307
- if (typeof raw === 'object' && raw !== null) {
308
- const servers = raw['mcpServers'];
309
- if (typeof servers === 'object' && servers !== null)
310
- return servers;
311
- }
312
- }
313
- catch {
314
- return undefined;
315
- }
316
- return undefined;
317
- }
318
- /** The winning manifest's inline `lspServers`, or undefined. */
319
- export async function declaredLspServers(root) {
320
- const candidate = await detectManifest(root);
321
- if (candidate === undefined)
322
- return undefined;
323
- try {
324
- const raw = JSON.parse(await readFile(candidate.path, 'utf8'));
325
- if (typeof raw === 'object' && raw !== null) {
326
- const servers = raw['lspServers'];
327
- if (typeof servers === 'object' && servers !== null)
328
- return servers;
329
- }
330
- }
331
- catch {
332
- return undefined;
333
- }
334
- return undefined;
335
- }
@@ -10,8 +10,8 @@
10
10
  * installed, uninstalled, or mutated by this manager.
11
11
  */
12
12
  import { join } from 'node:path';
13
- import { stat } from 'node:fs/promises';
14
13
  import { sanitizeId } from './paths.js';
14
+ import { isFile } from './fs-probes.js';
15
15
  import { countSurfaces, discoverSkills } from './surfaces.js';
16
16
  import { PROJECT_LAYOUTS } from '../model/layouts.js';
17
17
  import { discoverProjectMcp } from './project-config.js';
@@ -46,7 +46,7 @@ export async function discoverNativeProjectSuites(projectRoot, dimension) {
46
46
  if (native.dirName === '.github')
47
47
  lspFiles.push('lsp.json');
48
48
  for (const path of lspFiles) {
49
- if ((await stat(join(projectRoot, path)).catch(() => undefined))?.isFile()) {
49
+ if (await isFile(join(projectRoot, path))) {
50
50
  errors.push(`${path}: project LSP configuration is not mounted; the host LSP registry does not isolate projects`);
51
51
  }
52
52
  }
@@ -1,38 +1,57 @@
1
1
  /**
2
- * Root path resolution for the two install dimensions.
2
+ * Root path resolution for the two install dimensions plus the shared
3
+ * user-level Agent layout root.
3
4
  *
4
- * User dimension: `~/.dsh/agent-plugins/` (or `$DSH_HOME/agent-plugins`).
5
+ * User dimension: `~/.dsh/agent-plugins/` (or `$DSH_HOME/agent-plugins`) —
6
+ * `.sources/` (checkouts), `state.json` (install state), and `data/`
7
+ * (`overrides/`, suite `${PLUGIN_DATA}` directories, and the LSP enable set
8
+ * with the feedback rate-limit stamp).
5
9
  * Project dimension: `<projectRoot>/.dsh/agent-plugins/`, where the project
6
10
  * root is the nearest ancestor containing `.git`.
7
11
  *
8
- * Everything the plugin persists lives under one root per dimension —
9
- * `.sources/` (checkouts) and `state.json` (install state) alongside `data/`
10
- * (suite `${PLUGIN_DATA}` directories) and `overrides/` (MCP configuration
11
- * rewrites) — so no sibling `agent-plugins-data` root exists.
12
+ * Content the user authors, and the services they declare by hand, live in the
13
+ * cross-tool Agent layout root instead: `~/.agents/{skills,commands,agents}/`
14
+ * and `~/.agents/{mcp,lsp}.json`. That is the same directory shape the plugin
15
+ * already reads from a project's `.agents/`, so user-authored resources are
16
+ * plain Markdown and ordinary JSON that other Agent tools can consume too.
12
17
  */
13
18
  import { existsSync } from 'node:fs';
14
- import { stat } from 'node:fs/promises';
15
19
  import { homedir } from 'node:os';
16
- import { dirname, join, resolve } from 'node:path';
20
+ import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
21
+ import { expandHomePath, resolveDshHome as resolveHarnessHome } from '@deepseek-ai/dsh-home-paths';
22
+ import { stripArchiveSuffix } from '../model/types.js';
17
23
  /** Source checkouts live under `<dimensionRoot>/.sources/<sourceId>/`. */
18
24
  export const SOURCES_DIR_NAME = '.sources';
19
- /** Per-suite mutable data directory (the `${PLUGIN_DATA}` placeholder). */
20
- export const DATA_DIR_NAME = 'data';
21
25
  export const STATE_FILE_NAME = 'state.json';
22
- /** Expand a leading `~/` (or `~\` on Windows) to the home directory; other values pass through. */
23
- export function expandHome(path) {
24
- if (path === '~' || path.startsWith('~/') || path.startsWith('~\\'))
25
- return join(homedir(), path.slice(2));
26
- return path;
27
- }
28
- /** Resolve the harness home (`$DSH_HOME` or `~/.dsh`). */
26
+ /**
27
+ * Expand a leading `~`, `~/`, or `~\` to the OS home; other values pass
28
+ * through. The harness helper owns the platform rules, so a configured path is
29
+ * read back the same way on every host.
30
+ */
31
+ export const expandHome = expandHomePath;
32
+ /**
33
+ * Resolve the harness home (`$DSH_HOME` or `~/.dsh`) through the harness
34
+ * helper: precedence and tilde expansion stay the harness's, and a blank
35
+ * `$DSH_HOME` reads as unset rather than resolving the home to the current
36
+ * working directory.
37
+ */
29
38
  export function resolveDshHome() {
30
- return process.env.DSH_HOME === undefined ? join(homedir(), '.dsh') : resolve(process.env.DSH_HOME);
39
+ return resolveHarnessHome();
31
40
  }
32
41
  /** Resolve the canonical user-dimension root. Legacy overrides are migration inputs only. */
33
42
  export function resolveUserRoot(_configUserRoot) {
34
43
  return join(resolveDshHome(), 'agent-plugins');
35
44
  }
45
+ /**
46
+ * Resolve the shared user-level Agent layout root (`$DSH_AGENTS_HOME` or
47
+ * `~/.agents`): where this plugin stores the resources and service
48
+ * declarations the user authors by hand. A blank override reads as unset, the
49
+ * same rule the harness applies to its own home.
50
+ */
51
+ export function resolveAgentsRoot() {
52
+ const configured = process.env.DSH_AGENTS_HOME;
53
+ return configured === undefined || configured.trim().length === 0 ? join(homedir(), '.agents') : resolve(expandHome(configured));
54
+ }
36
55
  /**
37
56
  * Resolve the suite data root hosting `${PLUGIN_DATA}` directories and the
38
57
  * MCP overrides. Defaults under the user root so the whole plugin persists
@@ -57,18 +76,14 @@ export async function findProjectRoot(cwd) {
57
76
  export async function resolveProjectRoot(cwd) {
58
77
  return join(await findProjectRoot(cwd), '.dsh', 'agent-plugins');
59
78
  }
60
- /** Source checkout directory for one source id. */
79
+ /** Directory holding every source checkout of one dimension root. */
61
80
  export function sourcesDir(dimensionRoot) {
62
81
  return join(dimensionRoot, SOURCES_DIR_NAME);
63
82
  }
64
- /** Source checkout directory for one source id. */
83
+ /** Checkout directory of one source inside a dimension root. */
65
84
  export function sourceCheckoutDir(dimensionRoot, sourceId) {
66
85
  return join(sourcesDir(dimensionRoot), sourceId);
67
86
  }
68
- /** Per-suite data directory for `${PLUGIN_DATA}`. */
69
- export function suiteDataDir(dataRoot, suiteId) {
70
- return join(dataRoot, DATA_DIR_NAME, suiteId);
71
- }
72
87
  /**
73
88
  * Suite-qualified key: `${sourceId}/${suiteId}`. Suite ids are unique within a
74
89
  * source only (two sources may both ship a suite named "utils"), so every
@@ -78,25 +93,37 @@ export function suiteDataDir(dataRoot, suiteId) {
78
93
  export function qualifiedSuiteId(sourceId, suiteId) {
79
94
  return `${sourceId}/${suiteId}`;
80
95
  }
81
- /** Async existence probe that follows symlinks for a final component. */
82
- export async function isDirectory(path) {
83
- try {
84
- const info = await stat(path);
85
- return info.isDirectory();
86
- }
87
- catch {
88
- return false;
89
- }
96
+ /**
97
+ * The data directory `${PLUGIN_DATA}` resolves to for one suite: a
98
+ * source-qualified directory under the plugin storage root, shared by every
99
+ * surface of that suite so MCP servers, skills and commands see one location.
100
+ */
101
+ export function suiteDataDir(dataRoot, sourceId, suiteId) {
102
+ return `${dataRoot.replace(/[\\/]$/, '')}/${qualifiedSuiteId(sourceId, suiteId)}`;
90
103
  }
91
- /** Async existence probe for any entry (file, directory, symlink). */
92
- export async function pathExists(path) {
93
- try {
94
- await stat(path);
95
- return true;
96
- }
97
- catch {
98
- return false;
99
- }
104
+ /**
105
+ * Whether `candidate` is `root` itself or a path below it, compared on whole
106
+ * path segments by `flavor`'s rules so a sibling whose name merely starts with
107
+ * the root's (`/a/bc` against `/a/b`) does not count as contained.
108
+ *
109
+ * `relative` resolves both operands with those rules before comparing, so the
110
+ * answer never depends on how each side was spelled: a checkout read from
111
+ * configuration as `C:/x/y` and an entry resolved to `C:\x\y\plugins\a` stay
112
+ * contained, Windows compares drive letters and segments case-insensitively,
113
+ * and an unnormalized `..` inside the candidate is resolved rather than
114
+ * trusted as text. On POSIX a backslash stays the ordinary filename character
115
+ * it is — folding separators there would invent containment that does not
116
+ * exist.
117
+ *
118
+ * `flavor` is injectable so the win32 rules stay covered by a POSIX test run.
119
+ */
120
+ export function isWithinUnder(flavor, root, candidate) {
121
+ const rel = flavor.relative(root, candidate);
122
+ return rel === '' || (!rel.startsWith(`..${flavor.sep}`) && rel !== '..' && !flavor.isAbsolute(rel));
123
+ }
124
+ /** Whether `candidate` is `root` itself or a path below it, by the host's path rules. */
125
+ export function isWithin(root, candidate) {
126
+ return isWithinUnder({ relative, isAbsolute, sep }, root, candidate);
100
127
  }
101
128
  /** Sanitize a plugin or server id into `[a-z0-9-]` (lowercased). */
102
129
  export function sanitizeId(raw) {
@@ -107,19 +134,10 @@ export function sanitizeId(raw) {
107
134
  .replace(/-{2,}/g, '-');
108
135
  return cleaned === '' ? 'unnamed' : cleaned;
109
136
  }
110
- /** Archive extensions stripped from a URL basename before id derivation. */
111
- const ARCHIVE_SUFFIX_PATTERN = /\.(zip|tgz|tar\.gz|tar)$/i;
112
137
  /** Strip a trailing `.git` or archive suffix (`plugin-0.1.zip` → `plugin-0-1`). */
113
138
  function stripSourceSuffix(base) {
114
- if (base.endsWith('.git'))
115
- base = base.slice(0, -4);
116
- return base.replace(ARCHIVE_SUFFIX_PATTERN, '');
117
- }
118
- /** Derive a source id from a repository URL or local path: last path segment, `.git` stripped. */
119
- export function deriveSourceId(url) {
120
- const trimmed = url.trim().replace(/\/+$/, '');
121
- const base = trimmed.split(/[/\\]/).at(-1) ?? '';
122
- return sanitizeId(stripSourceSuffix(base));
139
+ const withoutGit = base.endsWith('.git') ? base.slice(0, -4) : base;
140
+ return stripArchiveSuffix(withoutGit);
123
141
  }
124
142
  /**
125
143
  * Derive candidate source ids from a git URL or local path, most preferred
@@ -137,8 +155,9 @@ export function deriveSourceIdCandidates(url) {
137
155
  if (isRemote) {
138
156
  const hostPath = trimmed.replace(/^[a-z][a-z0-9+.-]*:\/\//i, '').replace(/^[\w.-]+@([\w.-]+):/, '$1/');
139
157
  const segments = hostPath.split(/[/\\]/).filter(Boolean);
140
- if (segments.length >= 2) {
141
- const owner = sanitizeId(segments[segments.length - 2]);
158
+ const ownerSegment = segments.at(-2);
159
+ if (ownerSegment !== undefined) {
160
+ const owner = sanitizeId(ownerSegment);
142
161
  if (owner !== '' && owner !== primary)
143
162
  candidates.push(`${owner}-${primary}`);
144
163
  }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Path-variable expansion for author-written suite text.
3
+ *
4
+ * A suite authored for Claude Code or its dialects writes `${CLAUDE_PLUGIN_ROOT}`
5
+ * (or the dialect's own spelling) into command bodies, agent personas, hook
6
+ * commands, LSP declarations, startup instructions and skill prose, and uses
7
+ * `${CLAUDE_PLUGIN_DATA}`, `${CLAUDE_PROJECT_DIR}` and `${CLAUDE_SKILL_DIR}` the
8
+ * same way. Its home ecosystem replaces those variables before the text reaches
9
+ * the model or a process; every surface here has to do the same, or the command
10
+ * runs with an empty path. One implementation keeps the surfaces agreeing on
11
+ * what each spelling means.
12
+ *
13
+ * A variable whose value the calling layer does not hold is left exactly as
14
+ * written: an author's `${NAME:-default}`-style intent is never silently
15
+ * emptied, and a surface that cannot supply a session directory does not
16
+ * invent one.
17
+ */
18
+ import { PLUGIN_DATA_VARIABLES, PLUGIN_ROOT_VARIABLES, PROJECT_DIR_VARIABLES, SKILL_DIR_VARIABLE } from '../model/layouts.js';
19
+ /** The suite root `${PLUGIN_ROOT}` and its dialect aliases resolve to. */
20
+ export function pluginRootOf(suite) {
21
+ // Project-native directories are the repository's own files, read in place;
22
+ // no plugin root exists for them, so the variable stays as the author wrote it.
23
+ return suite.manifest.layout === 'project-native' ? undefined : suite.root;
24
+ }
25
+ /** Replace every path variable the context can resolve; other text stays exactly as written. */
26
+ export function expandPluginPaths(text, context) {
27
+ return text.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (match, name) => {
28
+ if (PLUGIN_ROOT_VARIABLES.has(name))
29
+ return context.root ?? match;
30
+ if (PLUGIN_DATA_VARIABLES.has(name))
31
+ return context.data ?? match;
32
+ if (name === SKILL_DIR_VARIABLE)
33
+ return context.skillDir ?? match;
34
+ if (PROJECT_DIR_VARIABLES.has(name))
35
+ return context.projectDir ?? match;
36
+ return match;
37
+ });
38
+ }
@@ -4,6 +4,7 @@ import { join } from 'node:path';
4
4
  import { validateMcpJson } from './validate.js';
5
5
  import { parse as parseToml } from 'smol-toml';
6
6
  import { normalizeCodexMcp } from './codex-mcp.js';
7
+ import { isWithin } from './paths.js';
7
8
  /** Later files override earlier keys; a malformed layer invalidates the whole layout's table. */
8
9
  export async function discoverProjectMcp(projectRoot, files, errors, format = 'mcpServers') {
9
10
  const servers = Object.create(null);
@@ -64,7 +65,7 @@ export async function readProjectDocument(projectRoot, file, errors, format = 'J
64
65
  let text;
65
66
  try {
66
67
  const [base, target] = await Promise.all([realpath(projectRoot), realpath(path)]);
67
- if (!target.startsWith(`${base}/`)) {
68
+ if (!isWithin(base, target)) {
68
69
  errors.push(`${file}: project configuration escapes the project root`);
69
70
  return null;
70
71
  }
@@ -3,6 +3,27 @@ const EVENTS = new Set(['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostT
3
3
  function object(value) {
4
4
  return typeof value === 'object' && value !== null && !Array.isArray(value);
5
5
  }
6
+ /**
7
+ * Render a declared hook field inside a diagnostic. Declarations are
8
+ * untrusted JSON, so the value is whatever the file carried — including an
9
+ * object, which reads as `[object Object]` in the message.
10
+ */
11
+ function declarationText(value) {
12
+ return String(value);
13
+ }
14
+ /**
15
+ * Present one read document as the settings shape the shared validator reads:
16
+ * a standalone hook file may carry the bare event table instead of a `hooks`
17
+ * key. A recognized top-level event name is the marker — a document with no
18
+ * event name at the top stays verbatim, so an ordinary settings file missing
19
+ * `hooks` is still not accepted as a bare event table.
20
+ */
21
+ function settingsDocument(settings, format) {
22
+ if (format !== 'claude' || settings.hooks !== undefined)
23
+ return settings;
24
+ const isEventTable = Object.keys(settings).some(key => EVENTS.has(key));
25
+ return isEventTable ? { ...settings, hooks: settings } : settings;
26
+ }
6
27
  /** Merge settings layers additively, deduplicating identical event/matcher/command triples. */
7
28
  export async function discoverProjectHooks(projectRoot, files, errors, format = 'claude') {
8
29
  const documents = [];
@@ -11,7 +32,7 @@ export async function discoverProjectHooks(projectRoot, files, errors, format =
11
32
  if (settings === null)
12
33
  return undefined;
13
34
  if (settings !== undefined)
14
- documents.push({ file, settings });
35
+ documents.push({ file, settings: settingsDocument(settings, format) });
15
36
  }
16
37
  return normalizeHookDocuments(projectRoot, documents, errors, format);
17
38
  }
@@ -74,7 +95,7 @@ export function normalizeHookDocuments(projectRoot, documents, errors, format =
74
95
  if (hook.enabled === false)
75
96
  continue;
76
97
  if (hook.type !== undefined && hook.type !== 'command') {
77
- errors.push(`${file}: unsupported ${event} hook type ${String(hook.type)}`);
98
+ errors.push(`${file}: unsupported ${event} hook type ${declarationText(hook.type)}`);
78
99
  continue;
79
100
  }
80
101
  if (typeof hook.command !== 'string' ||