dsh-agent-plugins-market 0.6.2 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +117 -111
- package/README.zh.md +112 -98
- package/client/client.js +547 -540
- package/client/client.js.map +1 -1
- package/lib/application/catalog-context.js +181 -0
- package/lib/application/catalog.js +217 -1110
- package/lib/application/details.js +6 -6
- package/lib/application/install-store.js +95 -0
- package/lib/application/lsp-service.js +97 -0
- package/lib/application/mcp-service.js +246 -0
- package/lib/application/panel-resources.js +29 -12
- package/lib/application/ports.js +21 -0
- package/lib/application/project-agent-roles.js +1 -1
- package/lib/application/snapshot-cache.js +160 -0
- package/lib/application/source-store.js +521 -0
- package/lib/catalog/archive.js +34 -29
- package/lib/catalog/component-files.js +14 -6
- package/lib/catalog/fs-probes.js +43 -0
- package/lib/catalog/manifests.js +53 -92
- package/lib/catalog/native-project.js +2 -2
- package/lib/catalog/paths.js +66 -55
- package/lib/catalog/project-config.js +2 -1
- package/lib/catalog/project-hooks.js +9 -1
- package/lib/catalog/scan-resolvers.js +29 -45
- package/lib/catalog/skills-parse.js +14 -10
- package/lib/catalog/source-catalog.js +3 -2
- package/lib/catalog/suite-components.js +5 -5
- package/lib/catalog/suite-scanner.js +3 -1
- package/lib/catalog/surfaces.js +5 -26
- package/lib/catalog/validate.js +7 -11
- package/lib/contracts/market.js +1 -0
- package/lib/index.js +91 -208
- package/lib/model/types.js +26 -3
- package/lib/routes.js +45 -15
- package/lib/runtime/agent-role-names.js +70 -0
- package/lib/runtime/agent-role-router.js +116 -102
- package/lib/runtime/commands-mounts.js +9 -6
- package/lib/runtime/deadline.js +28 -0
- package/lib/runtime/feedback-tool.js +94 -43
- package/lib/runtime/hooks-mounts.js +9 -11
- package/lib/runtime/host-locale.js +10 -21
- package/lib/runtime/lsp-direct-config.js +19 -19
- package/lib/runtime/lsp-mounts.js +159 -68
- package/lib/runtime/lsp-server-state.js +4 -4
- package/lib/runtime/lsp-status.js +1 -1
- package/lib/runtime/mcp-auth-record.js +42 -0
- package/lib/runtime/mcp-backend.js +6 -5
- package/lib/runtime/mcp-client/config.js +0 -1
- package/lib/runtime/mcp-client/connection.js +32 -37
- package/lib/runtime/mcp-client/host-contract.js +10 -0
- package/lib/runtime/mcp-client/oauth.js +5 -7
- package/lib/runtime/mcp-client/plugin-identity.js +28 -0
- package/lib/runtime/mcp-client/projection.js +202 -0
- package/lib/runtime/mcp-client/tools.js +4 -189
- package/lib/runtime/mcp-config.js +29 -10
- package/lib/runtime/mcp-direct-config.js +33 -16
- package/lib/runtime/mcp-mounts.js +35 -75
- package/lib/runtime/mcp-overrides.js +5 -5
- package/lib/runtime/mcp-redaction.js +3 -2
- package/lib/runtime/mcp-status.js +7 -8
- package/lib/runtime/mount-lifecycle.js +73 -0
- package/lib/runtime/profile-seam.js +321 -0
- package/lib/runtime/project-runtime.js +11 -8
- package/lib/runtime/reconcile-scheduler.js +99 -0
- package/lib/runtime/reconciler.js +4 -0
- package/lib/runtime/server-config.js +6 -4
- package/lib/runtime/settings-namespace.js +136 -0
- package/lib/runtime/skills-provider.js +5 -3
- package/lib/runtime/source-auto-update.js +70 -0
- package/lib/{model/state.js → runtime/state-store.js} +9 -11
- package/lib/runtime/storage-migration.js +35 -18
- package/lib/runtime/subagent-catalog.js +5 -4
- package/lib/runtime/user-commands.js +2 -2
- package/lib/runtime/user-panels.js +31 -21
- package/lib/runtime/user-store.js +12 -13
- package/lib/types/application/catalog-context.d.ts +118 -0
- package/lib/types/application/catalog.d.ts +99 -304
- package/lib/types/application/install-store.d.ts +21 -0
- package/lib/types/application/lsp-service.d.ts +36 -0
- package/lib/types/application/mcp-service.d.ts +71 -0
- package/lib/types/application/panel-resources.d.ts +8 -0
- package/lib/types/application/ports.d.ts +72 -0
- package/lib/types/application/queries.d.ts +13 -26
- package/lib/types/application/snapshot-cache.d.ts +51 -0
- package/lib/types/application/source-store.d.ts +100 -0
- package/lib/types/catalog/archive.d.ts +3 -11
- package/lib/types/catalog/component-files.d.ts +4 -0
- package/lib/types/catalog/fs-probes.d.ts +10 -0
- package/lib/types/catalog/manifests.d.ts +17 -16
- package/lib/types/catalog/native-project.d.ts +2 -2
- package/lib/types/catalog/paths.d.ts +47 -15
- package/lib/types/catalog/scan-pipeline.d.ts +3 -3
- package/lib/types/catalog/scan-resolvers.d.ts +3 -9
- package/lib/types/catalog/skills-parse.d.ts +0 -7
- package/lib/types/catalog/source-catalog.d.ts +3 -3
- package/lib/types/catalog/suite-scanner.d.ts +8 -6
- package/lib/types/catalog/validate.d.ts +0 -2
- package/lib/types/contracts/lsp-status.d.ts +35 -0
- package/lib/types/contracts/market.d.ts +3 -2
- package/lib/types/contracts/mcp-status.d.ts +4 -0
- package/lib/types/model/types.d.ts +29 -5
- package/lib/types/runtime/agent-role-names.d.ts +6 -0
- package/lib/types/runtime/agent-role-router.d.ts +54 -51
- package/lib/types/runtime/deadline.d.ts +18 -0
- package/lib/types/runtime/feedback-tool.d.ts +33 -11
- package/lib/types/runtime/hooks-mounts.d.ts +1 -1
- package/lib/types/runtime/host-locale.d.ts +1 -1
- package/lib/types/runtime/lsp-direct-config.d.ts +3 -3
- package/lib/types/runtime/lsp-mounts.d.ts +62 -19
- package/lib/types/runtime/lsp-status.d.ts +2 -1
- package/lib/types/runtime/mcp-auth-record.d.ts +29 -0
- package/lib/types/runtime/mcp-backend.d.ts +7 -4
- package/lib/types/runtime/mcp-client/config.d.ts +0 -2
- package/lib/types/runtime/mcp-client/host-contract.d.ts +78 -0
- package/lib/types/runtime/mcp-client/oauth.d.ts +1 -1
- package/lib/types/runtime/mcp-client/plugin-identity.d.ts +16 -0
- package/lib/types/runtime/mcp-client/projection.d.ts +45 -0
- package/lib/types/runtime/mcp-client/tools.d.ts +5 -72
- package/lib/types/runtime/mcp-config.d.ts +25 -4
- package/lib/types/runtime/mcp-direct-config.d.ts +18 -4
- package/lib/types/runtime/mcp-mounts.d.ts +10 -15
- package/lib/types/runtime/mcp-overrides.d.ts +1 -1
- package/lib/types/runtime/mcp-status.d.ts +1 -1
- package/lib/types/runtime/mount-lifecycle.d.ts +53 -0
- package/lib/types/runtime/profile-seam.d.ts +64 -0
- package/lib/types/runtime/reconcile-scheduler.d.ts +45 -0
- package/lib/types/runtime/reconciler.d.ts +2 -0
- package/lib/types/runtime/settings-namespace.d.ts +76 -0
- package/lib/types/runtime/source-auto-update.d.ts +33 -0
- package/lib/types/{model/state.d.ts → runtime/state-store.d.ts} +2 -2
- package/lib/types/runtime/storage-migration.d.ts +5 -3
- package/lib/types/runtime/subagent-catalog.d.ts +2 -0
- package/lib/types/runtime/user-panels.d.ts +8 -11
- package/lib/types/runtime/user-store.d.ts +2 -4
- package/package.json +48 -21
- package/schemas/README.md +1 -1
package/lib/catalog/archive.js
CHANGED
|
@@ -19,6 +19,8 @@ import { mkdir, open, readdir, readFile, readlink, rename, rm, stat } from 'node
|
|
|
19
19
|
import { join, resolve } from 'node:path';
|
|
20
20
|
import { promisify } from 'node:util';
|
|
21
21
|
import { Unzip, UnzipInflate } from 'fflate';
|
|
22
|
+
import { archiveFormatOf } from '../model/types.js';
|
|
23
|
+
import { isWithin } from './paths.js';
|
|
22
24
|
const run = promisify(execFile);
|
|
23
25
|
/** Hard cap on archive download size (mirrors Claude Code's archive limit). */
|
|
24
26
|
export const ARCHIVE_MAX_BYTES = 256 * 1024 * 1024;
|
|
@@ -29,17 +31,8 @@ export const ARCHIVE_MAX_ENTRIES = 20_000;
|
|
|
29
31
|
/** Hard cap on the decompressed size of a single archive entry. */
|
|
30
32
|
export const ARCHIVE_MAX_ENTRY_BYTES = 128 * 1024 * 1024;
|
|
31
33
|
export const DEFAULT_ARCHIVE_TIMEOUT_MS = 180_000;
|
|
32
|
-
/**
|
|
33
|
-
export
|
|
34
|
-
const clean = url.trim().toLowerCase();
|
|
35
|
-
if (clean.endsWith('.zip'))
|
|
36
|
-
return 'zip';
|
|
37
|
-
if (clean.endsWith('.tar.gz') || clean.endsWith('.tgz'))
|
|
38
|
-
return 'targz';
|
|
39
|
-
if (clean.endsWith('.tar'))
|
|
40
|
-
return 'tar';
|
|
41
|
-
return undefined;
|
|
42
|
-
}
|
|
34
|
+
/** Archive vocabulary (format kinds and URL classification) shared with the model layer. */
|
|
35
|
+
export { archiveFormatOf };
|
|
43
36
|
/** Download the payload to a temp file, enforcing the size cap and digest. */
|
|
44
37
|
export async function downloadArchive(url, tempFile, options = {}) {
|
|
45
38
|
const trimmed = url.trim();
|
|
@@ -143,7 +136,21 @@ async function extractZip(archiveFile, dest) {
|
|
|
143
136
|
};
|
|
144
137
|
const unzip = new Unzip();
|
|
145
138
|
unzip.register(UnzipInflate);
|
|
146
|
-
|
|
139
|
+
// Entry writes go through a fixed set of slots. The stream drives `onfile`
|
|
140
|
+
// synchronously, so an unbounded fan-out opens one descriptor per entry and
|
|
141
|
+
// hits the process limit before the entry cap does — on Windows, around the
|
|
142
|
+
// 8192nd member of a whole-archive zip. A failure recorded during the stream
|
|
143
|
+
// turns writes that have not started yet into no-ops, and a failed write lands
|
|
144
|
+
// in the same `failure` slot, so the first error is the one reported.
|
|
145
|
+
const WRITE_SLOTS = 32;
|
|
146
|
+
const writes = Array.from({ length: WRITE_SLOTS }, () => Promise.resolve());
|
|
147
|
+
let writeSlot = 0;
|
|
148
|
+
const queueWrite = (target, chunks) => {
|
|
149
|
+
const slot = writeSlot++ % WRITE_SLOTS;
|
|
150
|
+
writes[slot] = writes[slot].then(() => (failure === undefined ? writeZipEntry(target, chunks) : undefined)).catch((error) => {
|
|
151
|
+
fail(error instanceof Error ? error : new Error(String(error)));
|
|
152
|
+
});
|
|
153
|
+
};
|
|
147
154
|
unzip.onfile = file => {
|
|
148
155
|
if (failure !== undefined)
|
|
149
156
|
return;
|
|
@@ -163,7 +170,7 @@ async function extractZip(archiveFile, dest) {
|
|
|
163
170
|
return;
|
|
164
171
|
}
|
|
165
172
|
const target = join(dest, normalized);
|
|
166
|
-
if (
|
|
173
|
+
if (!isWithin(dest, target)) {
|
|
167
174
|
fail(new Error(`zip entry escapes the extraction root: ${file.name}`));
|
|
168
175
|
return;
|
|
169
176
|
}
|
|
@@ -186,7 +193,7 @@ async function extractZip(archiveFile, dest) {
|
|
|
186
193
|
}
|
|
187
194
|
chunks.push(Buffer.from(data));
|
|
188
195
|
if (final)
|
|
189
|
-
|
|
196
|
+
queueWrite(target, chunks);
|
|
190
197
|
};
|
|
191
198
|
file.start();
|
|
192
199
|
};
|
|
@@ -196,9 +203,13 @@ async function extractZip(archiveFile, dest) {
|
|
|
196
203
|
for (let offset = 0; offset < view.length && failure === undefined; offset += slice) {
|
|
197
204
|
unzip.push(view.subarray(offset, Math.min(offset + slice, view.length)), offset + slice >= view.length);
|
|
198
205
|
}
|
|
206
|
+
// Report only once every write has settled: the caller removes the staging
|
|
207
|
+
// directory in its own cleanup, and a write still in flight would recreate
|
|
208
|
+
// entries underneath that removal. A failure set during the stream makes the
|
|
209
|
+
// writes that have not started yet no-ops.
|
|
210
|
+
await Promise.all(writes);
|
|
199
211
|
if (failure !== undefined)
|
|
200
212
|
throw failure;
|
|
201
|
-
await Promise.all(writes);
|
|
202
213
|
}
|
|
203
214
|
/** Write one buffered zip entry to disk after its data completed inflating. */
|
|
204
215
|
async function writeZipEntry(target, chunks) {
|
|
@@ -237,6 +248,8 @@ async function assertBoundedTree(root) {
|
|
|
237
248
|
let totalBytes = 0;
|
|
238
249
|
while (stack.length > 0) {
|
|
239
250
|
const dir = stack.pop();
|
|
251
|
+
if (dir === undefined)
|
|
252
|
+
break;
|
|
240
253
|
let dirents;
|
|
241
254
|
try {
|
|
242
255
|
dirents = await readdir(dir, { withFileTypes: true });
|
|
@@ -278,6 +291,8 @@ async function assertNoEscapingSymlinks(root) {
|
|
|
278
291
|
const stack = [root];
|
|
279
292
|
while (stack.length > 0) {
|
|
280
293
|
const dir = stack.pop();
|
|
294
|
+
if (dir === undefined)
|
|
295
|
+
break;
|
|
281
296
|
let entries;
|
|
282
297
|
try {
|
|
283
298
|
entries = await readdir(dir, { withFileTypes: true });
|
|
@@ -293,7 +308,7 @@ async function assertNoEscapingSymlinks(root) {
|
|
|
293
308
|
// bogus in-root path and the escape survives extraction.
|
|
294
309
|
const target = await readlink(path, 'utf8').catch(() => '');
|
|
295
310
|
const resolved = resolve(dir, target);
|
|
296
|
-
if (
|
|
311
|
+
if (!isWithin(root, resolved)) {
|
|
297
312
|
throw new Error(`archive contains a symlink escaping the extraction root: ${entry.name} -> ${target}`);
|
|
298
313
|
}
|
|
299
314
|
continue;
|
|
@@ -303,13 +318,12 @@ async function assertNoEscapingSymlinks(root) {
|
|
|
303
318
|
}
|
|
304
319
|
}
|
|
305
320
|
}
|
|
306
|
-
/** Test seam: run the symlink-containment walk against a prepared tree. */
|
|
307
|
-
export const assertNoEscapingSymlinksForTest = assertNoEscapingSymlinks;
|
|
308
321
|
/** If the extraction produced exactly one top-level directory and nothing else, use it as the root. */
|
|
309
322
|
async function unwrapSingleRoot(extractDir) {
|
|
310
323
|
const entries = await readdir(extractDir, { withFileTypes: true });
|
|
311
|
-
|
|
312
|
-
|
|
324
|
+
const soleEntry = entries.length === 1 ? entries[0] : undefined;
|
|
325
|
+
if (soleEntry !== undefined && soleEntry.isDirectory())
|
|
326
|
+
return join(extractDir, soleEntry.name);
|
|
313
327
|
return extractDir;
|
|
314
328
|
}
|
|
315
329
|
async function copyTree(from, to) {
|
|
@@ -330,12 +344,3 @@ async function copyTree(from, to) {
|
|
|
330
344
|
}
|
|
331
345
|
}
|
|
332
346
|
}
|
|
333
|
-
/** Probe whether a path exists and is a directory. */
|
|
334
|
-
export async function isDirectory(path) {
|
|
335
|
-
try {
|
|
336
|
-
return (await stat(path)).isDirectory();
|
|
337
|
-
}
|
|
338
|
-
catch {
|
|
339
|
-
return false;
|
|
340
|
-
}
|
|
341
|
-
}
|
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
+
}
|
package/lib/catalog/manifests.js
CHANGED
|
@@ -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
|
|
22
|
-
import { join
|
|
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
|
-
/**
|
|
27
|
-
|
|
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
|
-
|
|
32
|
-
|
|
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
|
|
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
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
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
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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
|
-
:
|
|
169
|
-
? Object.entries(rawPlugins).map(([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
|
-
|
|
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
|
-
|
|
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(
|
|
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 (
|
|
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
|
}
|
package/lib/catalog/paths.js
CHANGED
|
@@ -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
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
-
/**
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
/**
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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,29 @@ export function suiteDataDir(dataRoot, suiteId) {
|
|
|
78
93
|
export function qualifiedSuiteId(sourceId, suiteId) {
|
|
79
94
|
return `${sourceId}/${suiteId}`;
|
|
80
95
|
}
|
|
81
|
-
/**
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
96
|
+
/**
|
|
97
|
+
* Whether `candidate` is `root` itself or a path below it, compared on whole
|
|
98
|
+
* path segments by `flavor`'s rules so a sibling whose name merely starts with
|
|
99
|
+
* the root's (`/a/bc` against `/a/b`) does not count as contained.
|
|
100
|
+
*
|
|
101
|
+
* `relative` resolves both operands with those rules before comparing, so the
|
|
102
|
+
* answer never depends on how each side was spelled: a checkout read from
|
|
103
|
+
* configuration as `C:/x/y` and an entry resolved to `C:\x\y\plugins\a` stay
|
|
104
|
+
* contained, Windows compares drive letters and segments case-insensitively,
|
|
105
|
+
* and an unnormalized `..` inside the candidate is resolved rather than
|
|
106
|
+
* trusted as text. On POSIX a backslash stays the ordinary filename character
|
|
107
|
+
* it is — folding separators there would invent containment that does not
|
|
108
|
+
* exist.
|
|
109
|
+
*
|
|
110
|
+
* `flavor` is injectable so the win32 rules stay covered by a POSIX test run.
|
|
111
|
+
*/
|
|
112
|
+
export function isWithinUnder(flavor, root, candidate) {
|
|
113
|
+
const rel = flavor.relative(root, candidate);
|
|
114
|
+
return rel === '' || (!rel.startsWith(`..${flavor.sep}`) && rel !== '..' && !flavor.isAbsolute(rel));
|
|
90
115
|
}
|
|
91
|
-
/**
|
|
92
|
-
export
|
|
93
|
-
|
|
94
|
-
await stat(path);
|
|
95
|
-
return true;
|
|
96
|
-
}
|
|
97
|
-
catch {
|
|
98
|
-
return false;
|
|
99
|
-
}
|
|
116
|
+
/** Whether `candidate` is `root` itself or a path below it, by the host's path rules. */
|
|
117
|
+
export function isWithin(root, candidate) {
|
|
118
|
+
return isWithinUnder({ relative, isAbsolute, sep }, root, candidate);
|
|
100
119
|
}
|
|
101
120
|
/** Sanitize a plugin or server id into `[a-z0-9-]` (lowercased). */
|
|
102
121
|
export function sanitizeId(raw) {
|
|
@@ -107,19 +126,10 @@ export function sanitizeId(raw) {
|
|
|
107
126
|
.replace(/-{2,}/g, '-');
|
|
108
127
|
return cleaned === '' ? 'unnamed' : cleaned;
|
|
109
128
|
}
|
|
110
|
-
/** Archive extensions stripped from a URL basename before id derivation. */
|
|
111
|
-
const ARCHIVE_SUFFIX_PATTERN = /\.(zip|tgz|tar\.gz|tar)$/i;
|
|
112
129
|
/** Strip a trailing `.git` or archive suffix (`plugin-0.1.zip` → `plugin-0-1`). */
|
|
113
130
|
function stripSourceSuffix(base) {
|
|
114
|
-
|
|
115
|
-
|
|
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));
|
|
131
|
+
const withoutGit = base.endsWith('.git') ? base.slice(0, -4) : base;
|
|
132
|
+
return stripArchiveSuffix(withoutGit);
|
|
123
133
|
}
|
|
124
134
|
/**
|
|
125
135
|
* Derive candidate source ids from a git URL or local path, most preferred
|
|
@@ -137,8 +147,9 @@ export function deriveSourceIdCandidates(url) {
|
|
|
137
147
|
if (isRemote) {
|
|
138
148
|
const hostPath = trimmed.replace(/^[a-z][a-z0-9+.-]*:\/\//i, '').replace(/^[\w.-]+@([\w.-]+):/, '$1/');
|
|
139
149
|
const segments = hostPath.split(/[/\\]/).filter(Boolean);
|
|
140
|
-
|
|
141
|
-
|
|
150
|
+
const ownerSegment = segments.at(-2);
|
|
151
|
+
if (ownerSegment !== undefined) {
|
|
152
|
+
const owner = sanitizeId(ownerSegment);
|
|
142
153
|
if (owner !== '' && owner !== primary)
|
|
143
154
|
candidates.push(`${owner}-${primary}`);
|
|
144
155
|
}
|
|
@@ -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 (!
|
|
68
|
+
if (!isWithin(base, target)) {
|
|
68
69
|
errors.push(`${file}: project configuration escapes the project root`);
|
|
69
70
|
return null;
|
|
70
71
|
}
|