opencode-skill-autodiscovery 1.6.1 → 2.1.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 +345 -45
- package/dist/agents.d.ts +3 -1
- package/dist/agents.d.ts.map +1 -1
- package/dist/agents.js +111 -27
- package/dist/agents.js.map +1 -1
- package/dist/cache.d.ts +5 -0
- package/dist/cache.d.ts.map +1 -0
- package/dist/cache.js +94 -0
- package/dist/cache.js.map +1 -0
- package/dist/discovery-cache.d.ts +9 -0
- package/dist/discovery-cache.d.ts.map +1 -0
- package/dist/discovery-cache.js +161 -0
- package/dist/discovery-cache.js.map +1 -0
- package/dist/discovery.d.ts +31 -9
- package/dist/discovery.d.ts.map +1 -1
- package/dist/discovery.js +531 -178
- package/dist/discovery.js.map +1 -1
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +64 -10
- package/dist/index.js.map +1 -1
- package/dist/mcp.d.ts +6 -2
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +104 -17
- package/dist/mcp.js.map +1 -1
- package/dist/schema.d.ts +1 -0
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +10 -0
- package/dist/schema.js.map +1 -1
- package/dist/schemas/mcp-schema-1.0.0.d.ts +117 -0
- package/dist/schemas/mcp-schema-1.0.0.d.ts.map +1 -0
- package/dist/schemas/mcp-schema-1.0.0.js +139 -0
- package/dist/schemas/mcp-schema-1.0.0.js.map +1 -0
- package/dist/schemas/plugin-schema-1.0.0.d.ts +66 -0
- package/dist/schemas/plugin-schema-1.0.0.d.ts.map +1 -0
- package/dist/schemas/plugin-schema-1.0.0.js +72 -0
- package/dist/schemas/plugin-schema-1.0.0.js.map +1 -0
- package/dist/spec-schema.d.ts +12 -0
- package/dist/spec-schema.d.ts.map +1 -0
- package/dist/spec-schema.js +71 -0
- package/dist/spec-schema.js.map +1 -0
- package/package.json +50 -47
package/dist/discovery.js
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
|
-
import { existsSync, readFileSync, readdirSync, realpathSync, statSync, } from "node:fs";
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, statSync, } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
|
-
import { dirname, isAbsolute, join, relative, sep } from "node:path";
|
|
4
|
-
import {
|
|
3
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { cachedRead } from "./cache.js";
|
|
6
|
+
import { getCachedPackages, setCachedPackages, fingerprintTree } from "./discovery-cache.js";
|
|
7
|
+
import { log, sanitize } from "./log.js";
|
|
8
|
+
import { PLUGIN_SCHEMA_1_0_0_ID, validatePluginManifest } from "./spec-schema.js";
|
|
5
9
|
import { readAgents } from "./agents.js";
|
|
6
10
|
import { readMcp } from "./mcp.js";
|
|
7
|
-
import { NAME_PATTERN, PLUGIN_SCHEMA, VERSION } from "./schema.js";
|
|
11
|
+
import { NAME_PATTERN, PLUGIN_SCHEMA, VERSION, validateName } from "./schema.js";
|
|
8
12
|
export { readAgents } from "./agents.js";
|
|
9
13
|
export { readMcp } from "./mcp.js";
|
|
10
14
|
function isDirectory(p) {
|
|
@@ -23,6 +27,16 @@ function isRegularFile(p) {
|
|
|
23
27
|
return false;
|
|
24
28
|
}
|
|
25
29
|
}
|
|
30
|
+
// True only for a JSON object container: not null, not an array, and not some
|
|
31
|
+
// other object kind. Manifest inputs are parsed JSON, so this rejects every
|
|
32
|
+
// shape other than a plain object before its keys are iterated.
|
|
33
|
+
function isPlainObject(value) {
|
|
34
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
const proto = Object.getPrototypeOf(value);
|
|
38
|
+
return proto === Object.prototype || proto === null;
|
|
39
|
+
}
|
|
26
40
|
// True when `child` resolves (through symlinks) inside `parent`.
|
|
27
41
|
export function contains(parent, child) {
|
|
28
42
|
const p = realpathSync(parent);
|
|
@@ -32,17 +46,42 @@ export function contains(parent, child) {
|
|
|
32
46
|
const rel = relative(p, c);
|
|
33
47
|
return rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
|
|
34
48
|
}
|
|
49
|
+
// Resolves `candidate` to its real path and returns it only when it still
|
|
50
|
+
// lives inside `root` (through symlinks); returns null when it is missing or
|
|
51
|
+
// escapes. This is the read gate for package files whose bytes feed config:
|
|
52
|
+
// a package must not be able to ship a symlink (mcp.json, AGENTS.md, a flat
|
|
53
|
+
// agent .md) that reads a file it never contained. A rejected candidate is
|
|
54
|
+
// logged once, naming the package root and the outside target.
|
|
55
|
+
export function resolveContained(root, candidate) {
|
|
56
|
+
let resolved;
|
|
57
|
+
try {
|
|
58
|
+
resolved = realpathSync(candidate);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
if (!contains(root, resolved)) {
|
|
64
|
+
log(`skipping "${resolved}": resolves outside "${root}"`);
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
return resolved;
|
|
68
|
+
}
|
|
35
69
|
// Parses the YAML frontmatter of a SKILL.md (name, description). Returns null
|
|
36
70
|
// when the file is unreadable or lacks a valid `name`.
|
|
37
71
|
export function readSkillInfo(dir) {
|
|
38
72
|
const skillMd = join(dir, "SKILL.md");
|
|
39
|
-
let
|
|
73
|
+
let parsed;
|
|
40
74
|
try {
|
|
41
|
-
|
|
75
|
+
parsed = cachedRead("skill-frontmatter", skillMd, parseSkillFrontmatter);
|
|
42
76
|
}
|
|
43
77
|
catch {
|
|
44
78
|
return null;
|
|
45
79
|
}
|
|
80
|
+
if (!parsed)
|
|
81
|
+
return null;
|
|
82
|
+
return { dir, name: parsed.name, description: parsed.description };
|
|
83
|
+
}
|
|
84
|
+
function parseSkillFrontmatter(content) {
|
|
46
85
|
const frontmatter = /^---\s*\n([\s\S]*?)\n---/.exec(content)?.[1];
|
|
47
86
|
if (!frontmatter)
|
|
48
87
|
return null;
|
|
@@ -55,19 +94,23 @@ export function readSkillInfo(dir) {
|
|
|
55
94
|
const name = field("name");
|
|
56
95
|
if (!name)
|
|
57
96
|
return null;
|
|
58
|
-
|
|
97
|
+
// The description is written into config verbatim: strip ANSI escapes and
|
|
98
|
+
// C0 control characters before it can reach any config surface.
|
|
99
|
+
return { name, description: sanitize(field("description") ?? "") };
|
|
59
100
|
}
|
|
60
101
|
// Reads a conformant Agent Plugins 1.0.0 package from a directory root.
|
|
61
102
|
// Returns null when the root has no valid plugin.json, so callers can fall
|
|
62
|
-
// back to legacy discovery. The $schema URL and the manifest name
|
|
63
|
-
//
|
|
64
|
-
|
|
103
|
+
// back to legacy discovery. The $schema URL and the manifest name identify
|
|
104
|
+
// format only — never provenance or safety: any package can copy the
|
|
105
|
+
// literal schema URL. Provenance/safety is tracked separately by the
|
|
106
|
+
// caller-supplied `trusted` flag.
|
|
107
|
+
export function readPackage(root, source, trusted = false) {
|
|
65
108
|
const manifestPath = join(root, "plugin.json");
|
|
66
109
|
if (!isRegularFile(manifestPath))
|
|
67
110
|
return null;
|
|
68
111
|
let manifest;
|
|
69
112
|
try {
|
|
70
|
-
manifest =
|
|
113
|
+
manifest = cachedRead("plugin-manifest", manifestPath, (content) => JSON.parse(content));
|
|
71
114
|
}
|
|
72
115
|
catch {
|
|
73
116
|
return null;
|
|
@@ -77,12 +120,30 @@ export function readPackage(root, source) {
|
|
|
77
120
|
if (typeof manifest.$schema !== "string" || !PLUGIN_SCHEMA.test(manifest.$schema)) {
|
|
78
121
|
return null;
|
|
79
122
|
}
|
|
80
|
-
|
|
123
|
+
// Rigorous structural validation against the real published schema, when
|
|
124
|
+
// this plugin has vendored a copy matching the declared version (today,
|
|
125
|
+
// only 1.0.0). Any other conformant 1.x.x version falls back to the
|
|
126
|
+
// name-only check below, so a future minor version is never rejected just
|
|
127
|
+
// because this plugin hasn't vendored its schema yet — see spec-schema.ts.
|
|
128
|
+
if (manifest.$schema === PLUGIN_SCHEMA_1_0_0_ID) {
|
|
129
|
+
const result = validatePluginManifest(manifest);
|
|
130
|
+
if (!result.valid) {
|
|
131
|
+
log(`ignoring plugin.json at "${root}": fails Agent Plugins 1.0.0 schema (${result.errors.join("; ")})`);
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
else if (typeof manifest.name !== "string" ||
|
|
81
136
|
manifest.name.length === 0 ||
|
|
82
137
|
manifest.name.length > 64 ||
|
|
83
138
|
!NAME_PATTERN.test(manifest.name)) {
|
|
84
139
|
return null;
|
|
85
140
|
}
|
|
141
|
+
// Narrows `manifest.name` for TS: always true here (either branch above
|
|
142
|
+
// already guarantees it — ajv's `name` schema requires a string, the
|
|
143
|
+
// fallback branch checked it directly) but ajv validation doesn't carry
|
|
144
|
+
// type-level narrowing the way the explicit typeof check does.
|
|
145
|
+
if (typeof manifest.name !== "string")
|
|
146
|
+
return null;
|
|
86
147
|
const skillDirs = [];
|
|
87
148
|
const skillsRoot = join(root, "skills");
|
|
88
149
|
if (isDirectory(skillsRoot)) {
|
|
@@ -97,7 +158,14 @@ export function readPackage(root, source) {
|
|
|
97
158
|
const dir = join(skillsRoot, entry);
|
|
98
159
|
if (!isDirectory(dir))
|
|
99
160
|
continue;
|
|
100
|
-
|
|
161
|
+
const skillMd = join(dir, "SKILL.md");
|
|
162
|
+
if (!isRegularFile(skillMd))
|
|
163
|
+
continue;
|
|
164
|
+
// The directory alone is not enough: a package can keep the skill dir
|
|
165
|
+
// in-root while symlinking its SKILL.md to an outside file, whose bytes
|
|
166
|
+
// would otherwise become prompt material. Require the skill file itself
|
|
167
|
+
// to resolve inside the package too.
|
|
168
|
+
if (!resolveContained(root, skillMd))
|
|
101
169
|
continue;
|
|
102
170
|
let resolved;
|
|
103
171
|
try {
|
|
@@ -111,11 +179,14 @@ export function readPackage(root, source) {
|
|
|
111
179
|
skillDirs.push(resolved);
|
|
112
180
|
}
|
|
113
181
|
}
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
182
|
+
// mcp.json is package-supplied input read into config.mcp: resolve it
|
|
183
|
+
// through the containment gate first, so a symlink cannot point it at a
|
|
184
|
+
// file outside the package.
|
|
185
|
+
const mcpResolved = resolveContained(root, join(root, "mcp.json"));
|
|
186
|
+
const mcpPath = mcpResolved && isRegularFile(mcpResolved) ? mcpResolved : undefined;
|
|
117
187
|
return {
|
|
118
188
|
source,
|
|
189
|
+
trusted,
|
|
119
190
|
name: manifest.name,
|
|
120
191
|
root,
|
|
121
192
|
skillDirs,
|
|
@@ -123,15 +194,43 @@ export function readPackage(root, source) {
|
|
|
123
194
|
schemaVersion: VERSION.exec(manifest.$schema)?.[1],
|
|
124
195
|
};
|
|
125
196
|
}
|
|
126
|
-
//
|
|
197
|
+
// True when a discovered package matches the user's exclude list. Conformant
|
|
198
|
+
// packages match on their plugin.json manifest name; legacy packages fall back
|
|
199
|
+
// to the directory basename (which is what their name already is).
|
|
200
|
+
function isExcluded(pkg, exclude) {
|
|
201
|
+
return exclude.includes(pkg.name);
|
|
202
|
+
}
|
|
203
|
+
// Belt-and-braces bound for the legacy walk. Correctness rests entirely on
|
|
204
|
+
// real-path dedupe below (symlink cycles terminate because every visited path
|
|
205
|
+
// is keyed on its resolved form); this cap only stops pathological deep trees
|
|
206
|
+
// from exhausting the stack, and is not load-bearing for that guarantee.
|
|
207
|
+
const MAX_WALK_DEPTH = 16;
|
|
208
|
+
// Legacy tree walk: every directory containing a SKILL.md, down to a shallow
|
|
209
|
+
// depth cap (see MAX_WALK_DEPTH). Visited and emitted paths are keyed on their
|
|
210
|
+
// real (symlink-resolved) forms, so symlink cycles — self-referential or
|
|
211
|
+
// ancestor-pointing — terminate the branch instead of growing ever-longer
|
|
212
|
+
// lexical paths until stack exhaustion. Candidates whose real location
|
|
213
|
+
// resolves outside the starting root are skipped, mirroring the containment
|
|
214
|
+
// guarantee of readPackage.
|
|
127
215
|
export function findSkillDirs(root, out, seen) {
|
|
128
|
-
|
|
129
|
-
|
|
216
|
+
let resolved;
|
|
217
|
+
try {
|
|
218
|
+
resolved = realpathSync(root);
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
findSkillDirsUnder(resolved, resolved, out, seen);
|
|
224
|
+
}
|
|
225
|
+
function findSkillDirsUnder(realRoot, dir, out, seen, depth = 0) {
|
|
226
|
+
if (depth > MAX_WALK_DEPTH)
|
|
130
227
|
return;
|
|
131
|
-
seen.
|
|
228
|
+
if (seen.has(dir))
|
|
229
|
+
return;
|
|
230
|
+
seen.add(dir);
|
|
132
231
|
let entries;
|
|
133
232
|
try {
|
|
134
|
-
entries = readdirSync(
|
|
233
|
+
entries = readdirSync(dir);
|
|
135
234
|
}
|
|
136
235
|
catch {
|
|
137
236
|
return;
|
|
@@ -139,14 +238,30 @@ export function findSkillDirs(root, out, seen) {
|
|
|
139
238
|
for (const entry of entries) {
|
|
140
239
|
if (entry === ".git")
|
|
141
240
|
continue;
|
|
142
|
-
const full = join(
|
|
241
|
+
const full = join(dir, entry);
|
|
143
242
|
if (!isDirectory(full))
|
|
144
243
|
continue;
|
|
145
|
-
|
|
146
|
-
|
|
244
|
+
let child;
|
|
245
|
+
try {
|
|
246
|
+
child = realpathSync(full);
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
if (!contains(realRoot, child)) {
|
|
252
|
+
log(`skipping skill dir "${child}": resolves outside "${realRoot}"`);
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
const childSkillMd = join(child, "SKILL.md");
|
|
256
|
+
if (isRegularFile(childSkillMd)) {
|
|
257
|
+
// A skill dir whose SKILL.md symlinks outside the walk root must not be
|
|
258
|
+
// emitted: readSkillInfo (and opencode) would read the outside bytes.
|
|
259
|
+
if (!resolveContained(realRoot, childSkillMd))
|
|
260
|
+
continue;
|
|
261
|
+
out.add(child);
|
|
147
262
|
}
|
|
148
263
|
else {
|
|
149
|
-
|
|
264
|
+
findSkillDirsUnder(realRoot, child, out, seen, depth + 1);
|
|
150
265
|
}
|
|
151
266
|
}
|
|
152
267
|
}
|
|
@@ -157,7 +272,10 @@ function hasPluginLayout(root) {
|
|
|
157
272
|
const skillsRoot = join(root, "skills");
|
|
158
273
|
if (isDirectory(skillsRoot)) {
|
|
159
274
|
try {
|
|
160
|
-
if (readdirSync(skillsRoot).some((e) =>
|
|
275
|
+
if (readdirSync(skillsRoot).some((e) => {
|
|
276
|
+
const skillMd = join(skillsRoot, e, "SKILL.md");
|
|
277
|
+
return resolveContained(root, skillMd) !== null && isRegularFile(skillMd);
|
|
278
|
+
})) {
|
|
161
279
|
return true;
|
|
162
280
|
}
|
|
163
281
|
}
|
|
@@ -184,28 +302,86 @@ function hasRootAgentFiles(root) {
|
|
|
184
302
|
return entries.some((e) => {
|
|
185
303
|
if (!e.endsWith(".md"))
|
|
186
304
|
return false;
|
|
187
|
-
const
|
|
188
|
-
if (!isRegularFile(
|
|
305
|
+
const contained = resolveContained(root, join(root, e));
|
|
306
|
+
if (!contained || !isRegularFile(contained))
|
|
189
307
|
return false;
|
|
190
|
-
|
|
191
|
-
|
|
308
|
+
try {
|
|
309
|
+
return cachedRead("agent-frontmatter-check", contained, hasAgentFrontmatter);
|
|
310
|
+
}
|
|
311
|
+
catch {
|
|
192
312
|
return false;
|
|
193
|
-
|
|
313
|
+
}
|
|
194
314
|
});
|
|
195
315
|
}
|
|
316
|
+
function hasAgentFrontmatter(content) {
|
|
317
|
+
const m = /^---\s*\n([\s\S]*?)\n---/.exec(content)?.[1];
|
|
318
|
+
if (!m)
|
|
319
|
+
return false;
|
|
320
|
+
return /^name:[ \t]/.test(m) || /^description:[ \t]/.test(m);
|
|
321
|
+
}
|
|
196
322
|
// Returns directories under `root` that are individual plugin roots. This
|
|
197
323
|
// mirrors what VS Code's installed.json points at (each {host}/{org}/{repo}
|
|
198
324
|
// clone, and inside a marketplace like agency-agents each
|
|
199
325
|
// ref_plugins/plugins/<division> dir), so a clone on disk is registered
|
|
200
326
|
// per-plugin instead of as one opaque tree that hides its agents.
|
|
327
|
+
//
|
|
328
|
+
// Hardened like findSkillDirs: traversal and emitted roots are keyed on real
|
|
329
|
+
// (symlink-resolved) paths, so symlink cycles — self-referential or
|
|
330
|
+
// ancestor-pointing — terminate instead of growing ever-longer lexical paths
|
|
331
|
+
// until stack exhaustion, and nothing that resolves outside the starting root
|
|
332
|
+
// is emitted. `isDirectory` uses statSync, which follows symlinks, so the
|
|
333
|
+
// realpath dedupe below is what bounds the walk.
|
|
201
334
|
export function findPluginRoots(root, out) {
|
|
202
|
-
|
|
203
|
-
|
|
335
|
+
let resolved;
|
|
336
|
+
try {
|
|
337
|
+
resolved = realpathSync(root);
|
|
338
|
+
}
|
|
339
|
+
catch {
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
findPluginRootsUnder(resolved, resolved, out, new Set());
|
|
343
|
+
}
|
|
344
|
+
// Equivalent to a caller doing `findPluginRoots(root, roots)` and converting
|
|
345
|
+
// each hit with `packageFromDir(pluginRoot, source, trusted)` — the exact
|
|
346
|
+
// pattern collectAgentPluginRoot's fallback and collectClaude's remote walk
|
|
347
|
+
// both used before discovery-cache.ts existed — except a cheap fingerprint
|
|
348
|
+
// check of `root` (see fingerprintTree) can skip the real walk, and every
|
|
349
|
+
// file read inside it, entirely when the subtree hasn't changed since the
|
|
350
|
+
// last run. A miss falls back to exactly the walk+convert callers did
|
|
351
|
+
// before, so this is never slower than the un-cached baseline.
|
|
352
|
+
//
|
|
353
|
+
// `exclude` is deliberately the caller's job, applied identically on a hit
|
|
354
|
+
// or a miss, so changing the exclude list never has to invalidate the cache.
|
|
355
|
+
function findPluginPackagesCached(root, source, trusted) {
|
|
356
|
+
const fingerprint = fingerprintTree(root);
|
|
357
|
+
const key = JSON.stringify([source, root]);
|
|
358
|
+
const cached = getCachedPackages(key, fingerprint);
|
|
359
|
+
if (cached)
|
|
360
|
+
return cached;
|
|
361
|
+
const roots = [];
|
|
362
|
+
findPluginRoots(root, roots);
|
|
363
|
+
const packages = [];
|
|
364
|
+
for (const pluginRoot of roots) {
|
|
365
|
+
const pkg = packageFromDir(pluginRoot, source, trusted);
|
|
366
|
+
if (pkg)
|
|
367
|
+
packages.push(pkg);
|
|
368
|
+
}
|
|
369
|
+
setCachedPackages(key, fingerprint, packages);
|
|
370
|
+
return packages;
|
|
371
|
+
}
|
|
372
|
+
function findPluginRootsUnder(realRoot, dir, out, seen, depth = 0) {
|
|
373
|
+
if (depth > MAX_WALK_DEPTH)
|
|
374
|
+
return;
|
|
375
|
+
if (seen.has(dir))
|
|
376
|
+
return;
|
|
377
|
+
seen.add(dir);
|
|
378
|
+
if (hasPluginLayout(dir)) {
|
|
379
|
+
out.push(dir);
|
|
204
380
|
return;
|
|
205
381
|
}
|
|
206
382
|
let entries;
|
|
207
383
|
try {
|
|
208
|
-
entries = readdirSync(
|
|
384
|
+
entries = readdirSync(dir);
|
|
209
385
|
}
|
|
210
386
|
catch {
|
|
211
387
|
return;
|
|
@@ -213,17 +389,31 @@ export function findPluginRoots(root, out) {
|
|
|
213
389
|
for (const entry of entries) {
|
|
214
390
|
if (entry === ".git" || entry === "node_modules")
|
|
215
391
|
continue;
|
|
216
|
-
const full = join(
|
|
392
|
+
const full = join(dir, entry);
|
|
217
393
|
if (!isDirectory(full))
|
|
218
394
|
continue;
|
|
219
|
-
|
|
395
|
+
let child;
|
|
396
|
+
try {
|
|
397
|
+
child = realpathSync(full);
|
|
398
|
+
}
|
|
399
|
+
catch {
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
if (!contains(realRoot, child)) {
|
|
403
|
+
log(`skipping plugin dir "${child}": resolves outside "${realRoot}"`);
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
findPluginRootsUnder(realRoot, child, out, seen, depth + 1);
|
|
220
407
|
}
|
|
221
408
|
}
|
|
222
409
|
// True when a legacy Claude Code plugin declares at least one agent, so an
|
|
223
410
|
// agents-only plugin is still discovered even though it ships no skills.
|
|
224
411
|
function hasClaudeAgents(root) {
|
|
412
|
+
const manifestPath = resolveContained(root, join(root, ".claude-plugin", "plugin.json"));
|
|
413
|
+
if (!manifestPath)
|
|
414
|
+
return false;
|
|
225
415
|
try {
|
|
226
|
-
const manifest =
|
|
416
|
+
const manifest = cachedRead("claude-legacy-manifest", manifestPath, (content) => JSON.parse(content));
|
|
227
417
|
if (typeof manifest !== "object" || manifest === null)
|
|
228
418
|
return false;
|
|
229
419
|
const agents = manifest.agents;
|
|
@@ -241,40 +431,53 @@ function hasClaudeAgents(root) {
|
|
|
241
431
|
function hasFlatAgents(root) {
|
|
242
432
|
if (hasRootAgentFiles(root))
|
|
243
433
|
return true;
|
|
434
|
+
const agentsDir = join(root, "agents");
|
|
244
435
|
try {
|
|
245
|
-
return readdirSync(
|
|
436
|
+
return readdirSync(agentsDir).some((e) => e.endsWith(".md") && resolveContained(root, join(agentsDir, e)) !== null);
|
|
246
437
|
}
|
|
247
438
|
catch {
|
|
248
439
|
return false;
|
|
249
440
|
}
|
|
250
441
|
}
|
|
442
|
+
// Sanitizes the legacy directory-basename package-name fallback before it
|
|
443
|
+
// feeds collision namespaces or dedupe keys: names that already satisfy
|
|
444
|
+
// NAME_PATTERN pass through untouched; anything else is stripped down to
|
|
445
|
+
// NAME_PATTERN-safe characters (characters outside [a-z0-9.-] removed,
|
|
446
|
+
// `--`/`..` runs collapsed, edges trimmed) and rejected outright when the
|
|
447
|
+
// result is empty or a prototype-chain key, falling back to a fixed safe
|
|
448
|
+
// identifier. Legacy packages keep their skills even when their directory
|
|
449
|
+
// name is hostile.
|
|
450
|
+
function legacyFallbackName(basename) {
|
|
451
|
+
if (validateName(basename))
|
|
452
|
+
return basename;
|
|
453
|
+
const cleaned = basename
|
|
454
|
+
.replace(/[^a-z0-9.-]/g, "")
|
|
455
|
+
.replace(/-{2,}/g, "-")
|
|
456
|
+
.replace(/\.{2,}/g, ".")
|
|
457
|
+
.replace(/^[.-]+|[.-]+$/g, "");
|
|
458
|
+
return validateName(cleaned) ?? "legacy";
|
|
459
|
+
}
|
|
251
460
|
// Prefers a conformant package manifest; falls back to a legacy tree walk so
|
|
252
461
|
// non-conformant layouts keep working. Returns null when nothing is found.
|
|
253
|
-
export function packageFromDir(root, source) {
|
|
254
|
-
const pkg = readPackage(root, source);
|
|
462
|
+
export function packageFromDir(root, source, trusted = false) {
|
|
463
|
+
const pkg = readPackage(root, source, trusted);
|
|
255
464
|
if (pkg)
|
|
256
465
|
return pkg;
|
|
257
466
|
const skillDirs = new Set();
|
|
258
467
|
findSkillDirs(root, skillDirs, new Set());
|
|
259
|
-
if (skillDirs.size === 0 && !hasClaudeAgents(root) && !hasFlatAgents(root))
|
|
468
|
+
if (skillDirs.size === 0 && !hasClaudeAgents(root) && !hasFlatAgents(root))
|
|
260
469
|
return null;
|
|
261
|
-
}
|
|
262
470
|
return {
|
|
263
471
|
source,
|
|
264
|
-
|
|
472
|
+
trusted,
|
|
473
|
+
name: legacyFallbackName(root.split(/[\\/]/).pop() ?? ""),
|
|
265
474
|
root,
|
|
266
475
|
skillDirs: [...skillDirs],
|
|
267
476
|
};
|
|
268
477
|
}
|
|
269
|
-
// --- VS Code agent plugin discovery ---------------------------------------
|
|
270
|
-
// VS Code keeps agent plugins in a per-platform "data dir". The holding
|
|
271
|
-
// folder is named `agent-plugins` on older builds (e.g. ~/.vscode) and
|
|
272
|
-
// `agentPlugins` on newer builds; remote servers nest theirs under `data/`
|
|
273
|
-
// (~/.vscode-server/data/agentPlugins). We list every known candidate so the
|
|
274
|
-
// discovery works regardless of OS, build channel, or local/remote setup.
|
|
275
478
|
function vsCodeDataRoots(extra) {
|
|
276
479
|
const home = homedir();
|
|
277
|
-
const
|
|
480
|
+
const builtIn = new Set([
|
|
278
481
|
join(home, ".vscode"),
|
|
279
482
|
// Linux
|
|
280
483
|
join(home, ".config", "Code"),
|
|
@@ -294,36 +497,63 @@ function vsCodeDataRoots(extra) {
|
|
|
294
497
|
join(home, ".vscode-server-insiders"),
|
|
295
498
|
join(home, ".vscode-remote"),
|
|
296
499
|
]);
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
500
|
+
const roots = [...builtIn].map((root) => ({ root, trusted: true }));
|
|
501
|
+
const seen = new Set(builtIn);
|
|
502
|
+
for (const root of extra) {
|
|
503
|
+
if (seen.has(root))
|
|
504
|
+
continue;
|
|
505
|
+
seen.add(root);
|
|
506
|
+
roots.push({ root, trusted: false });
|
|
507
|
+
}
|
|
508
|
+
return roots;
|
|
300
509
|
}
|
|
301
510
|
function agentPluginDirs(roots) {
|
|
302
|
-
const
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
511
|
+
const seen = new Set();
|
|
512
|
+
const dirs = [];
|
|
513
|
+
for (const { root, trusted } of roots) {
|
|
514
|
+
for (const dir of [
|
|
515
|
+
join(root, "agent-plugins"),
|
|
516
|
+
join(root, "agentPlugins"),
|
|
517
|
+
join(root, "data", "agent-plugins"),
|
|
518
|
+
join(root, "data", "agentPlugins"),
|
|
519
|
+
]) {
|
|
520
|
+
if (seen.has(dir))
|
|
521
|
+
continue;
|
|
522
|
+
seen.add(dir);
|
|
523
|
+
dirs.push({ root: dir, trusted });
|
|
524
|
+
}
|
|
308
525
|
}
|
|
309
|
-
return
|
|
526
|
+
return dirs;
|
|
310
527
|
}
|
|
528
|
+
// Resolves an `installed.json` `pluginUri` to an on-disk plugin root. Only
|
|
529
|
+
// `file:` URLs name a local directory: non-file schemes and malformed URLs are
|
|
530
|
+
// dropped (with a log) rather than echoed into the filesystem, and the
|
|
531
|
+
// resolved target must be an existing directory. `fileURLToPath` owns the
|
|
532
|
+
// slash/percent/drive-letter normalization; `resolve` then canonicalizes the
|
|
533
|
+
// result so a technically valid URL like `file:////abs` yields the same root
|
|
534
|
+
// string as its `file:///abs` spelling.
|
|
311
535
|
function vscodePluginPath(pluginUri) {
|
|
312
|
-
|
|
313
|
-
if (!m)
|
|
314
|
-
return pluginUri;
|
|
315
|
-
let raw;
|
|
536
|
+
let resolved;
|
|
316
537
|
try {
|
|
317
|
-
|
|
538
|
+
resolved = resolve(fileURLToPath(pluginUri));
|
|
318
539
|
}
|
|
319
540
|
catch {
|
|
320
|
-
|
|
541
|
+
log(`ignoring plugin URI "${pluginUri}": not a resolvable file: URL`);
|
|
542
|
+
return null;
|
|
543
|
+
}
|
|
544
|
+
// `file://` (and `file:///`) resolve to the filesystem root; a plugin root
|
|
545
|
+
// is never the root itself, and walking it would scan the whole disk.
|
|
546
|
+
if (dirname(resolved) === resolved) {
|
|
547
|
+
log(`ignoring plugin URI "${pluginUri}": filesystem root is not a plugin root`);
|
|
548
|
+
return null;
|
|
321
549
|
}
|
|
322
|
-
if (
|
|
323
|
-
|
|
324
|
-
|
|
550
|
+
if (!isDirectory(resolved)) {
|
|
551
|
+
log(`ignoring plugin URI "${pluginUri}": "${resolved}" is not a directory`);
|
|
552
|
+
return null;
|
|
553
|
+
}
|
|
554
|
+
return resolved;
|
|
325
555
|
}
|
|
326
|
-
export function collectVscodeManifest(out, installedJson) {
|
|
556
|
+
export function collectVscodeManifest(out, installedJson, exclude = [], trusted = true) {
|
|
327
557
|
if (!existsSync(installedJson))
|
|
328
558
|
return;
|
|
329
559
|
let manifest;
|
|
@@ -333,15 +563,37 @@ export function collectVscodeManifest(out, installedJson) {
|
|
|
333
563
|
catch {
|
|
334
564
|
return;
|
|
335
565
|
}
|
|
336
|
-
|
|
337
|
-
|
|
566
|
+
// installed.json is manifest input: fail closed on a malformed shape rather
|
|
567
|
+
// than iterating a non-array (which throws) or trusting entry types.
|
|
568
|
+
const installed = isPlainObject(manifest) ? manifest.installed : undefined;
|
|
569
|
+
if (!Array.isArray(installed)) {
|
|
570
|
+
log(`ignoring "${installedJson}": "installed" is not an array`);
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
const root = dirname(installedJson);
|
|
574
|
+
for (const plugin of installed) {
|
|
575
|
+
if (!isPlainObject(plugin)) {
|
|
576
|
+
log(`ignoring entry in "${installedJson}": entry is not an object`);
|
|
577
|
+
continue;
|
|
578
|
+
}
|
|
579
|
+
const pluginUri = plugin.pluginUri;
|
|
580
|
+
if (typeof pluginUri !== "string") {
|
|
581
|
+
log(`ignoring entry in "${installedJson}": "pluginUri" is not a string`);
|
|
338
582
|
continue;
|
|
339
|
-
const dir = vscodePluginPath(plugin.pluginUri);
|
|
340
|
-
if (dir) {
|
|
341
|
-
const pkg = packageFromDir(dir, "vscode");
|
|
342
|
-
if (pkg)
|
|
343
|
-
out.push(pkg);
|
|
344
583
|
}
|
|
584
|
+
const dir = vscodePluginPath(pluginUri);
|
|
585
|
+
if (!dir)
|
|
586
|
+
continue;
|
|
587
|
+
// installed.json is manifest input: under an untrusted root the recorded
|
|
588
|
+
// install location must resolve inside that root. A trusted home root is
|
|
589
|
+
// allowed to point at a global extension directory outside the data root.
|
|
590
|
+
if (!trusted && !contains(root, dir)) {
|
|
591
|
+
log(`skipping plugin URI "${pluginUri}": "${dir}" is outside untrusted root "${root}"`);
|
|
592
|
+
continue;
|
|
593
|
+
}
|
|
594
|
+
const pkg = packageFromDir(dir, "vscode", trusted);
|
|
595
|
+
if (pkg && !isExcluded(pkg, exclude))
|
|
596
|
+
out.push(pkg);
|
|
345
597
|
}
|
|
346
598
|
}
|
|
347
599
|
// Remote hosts: VS Code syncs client skills into
|
|
@@ -349,7 +601,7 @@ export function collectVscodeManifest(out, installedJson) {
|
|
|
349
601
|
// cache.json. Resolve each entry so the materialized synced-customization
|
|
350
602
|
// bundle ("VS Code Synced Data" Open Plugin, skills/<name>/SKILL.md) is
|
|
351
603
|
// discovered.
|
|
352
|
-
export function collectVscodeCache(out, cacheJson) {
|
|
604
|
+
export function collectVscodeCache(out, cacheJson, exclude = [], trusted = true) {
|
|
353
605
|
if (!existsSync(cacheJson))
|
|
354
606
|
return;
|
|
355
607
|
let entries;
|
|
@@ -369,20 +621,22 @@ export function collectVscodeCache(out, cacheJson) {
|
|
|
369
621
|
const nonce = typeof entry.nonce === "string" && entry.nonce
|
|
370
622
|
? sanitizeKey(entry.nonce)
|
|
371
623
|
: "default";
|
|
624
|
+
// Layouts without the {nonce} subdirectory materialize the bundle
|
|
625
|
+
// directly under {key}; falling back only when the nonce dir is absent
|
|
626
|
+
// avoids re-descending into it.
|
|
372
627
|
const nonceDir = join(parent, key, nonce);
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
// avoids re-descending into it.
|
|
382
|
-
const pkg = packageFromDir(join(parent, key), "vscode");
|
|
383
|
-
if (pkg)
|
|
384
|
-
out.push(pkg);
|
|
628
|
+
const dir = isDirectory(nonceDir) ? nonceDir : join(parent, key);
|
|
629
|
+
if (!isDirectory(dir))
|
|
630
|
+
continue;
|
|
631
|
+
// cache.json is manifest input: under an untrusted root the bundle must
|
|
632
|
+
// resolve inside that root, not be redirected elsewhere on disk.
|
|
633
|
+
if (!trusted && !contains(parent, dir)) {
|
|
634
|
+
log(`skipping cache entry "${entry.uri}": "${dir}" is outside untrusted root "${parent}"`);
|
|
635
|
+
continue;
|
|
385
636
|
}
|
|
637
|
+
const pkg = packageFromDir(dir, "vscode", trusted);
|
|
638
|
+
if (pkg && !isExcluded(pkg, exclude))
|
|
639
|
+
out.push(pkg);
|
|
386
640
|
}
|
|
387
641
|
}
|
|
388
642
|
// Mirrors the server-side AgentPluginManager sanitizer so we can resolve the
|
|
@@ -394,11 +648,11 @@ function sanitizeKey(value) {
|
|
|
394
648
|
.replace(/^-|-$/g, "")
|
|
395
649
|
.substring(0, 128);
|
|
396
650
|
}
|
|
397
|
-
function collectAgentPluginRoot(out, root) {
|
|
651
|
+
function collectAgentPluginRoot(out, root, exclude = [], trusted = true) {
|
|
398
652
|
const installedJson = join(root, "installed.json");
|
|
399
653
|
const cacheJson = join(root, "cache.json");
|
|
400
|
-
collectVscodeManifest(out, installedJson);
|
|
401
|
-
collectVscodeCache(out, cacheJson);
|
|
654
|
+
collectVscodeManifest(out, installedJson, exclude, trusted);
|
|
655
|
+
collectVscodeCache(out, cacheJson, exclude, trusted);
|
|
402
656
|
// `installed.json` is the authoritative record of what VS Code installed;
|
|
403
657
|
// when it exists, respect it exactly and skip the fallback walk so
|
|
404
658
|
// cloned-but-not-installed marketplaces stay hidden.
|
|
@@ -409,22 +663,23 @@ function collectAgentPluginRoot(out, root) {
|
|
|
409
663
|
// clone) has no manifest entry at all. Only suppress the walk when
|
|
410
664
|
// installed.json exists, so any clone actually on disk is still discovered.
|
|
411
665
|
if (!existsSync(installedJson)) {
|
|
412
|
-
const
|
|
413
|
-
|
|
414
|
-
for (const pluginRoot of pluginRoots) {
|
|
415
|
-
const pkg = packageFromDir(pluginRoot, "vscode");
|
|
416
|
-
if (pkg)
|
|
666
|
+
for (const pkg of findPluginPackagesCached(root, "vscode", false)) {
|
|
667
|
+
if (!isExcluded(pkg, exclude))
|
|
417
668
|
out.push(pkg);
|
|
418
669
|
}
|
|
419
670
|
}
|
|
420
671
|
}
|
|
421
|
-
export function collectVscode(out, extra) {
|
|
422
|
-
for (const
|
|
423
|
-
collectAgentPluginRoot(out,
|
|
672
|
+
export function collectVscode(out, extra, exclude = []) {
|
|
673
|
+
for (const { root, trusted } of agentPluginDirs(vsCodeDataRoots(extra))) {
|
|
674
|
+
collectAgentPluginRoot(out, root, exclude, trusted);
|
|
424
675
|
}
|
|
425
676
|
}
|
|
426
677
|
// --- Claude Code plugin discovery ------------------------------------------
|
|
427
|
-
|
|
678
|
+
// `trusted` defaults to true: every Claude root is home-scoped and
|
|
679
|
+
// host-managed (`~/.claude/...`), so it stays trusted. The parameter exists
|
|
680
|
+
// for symmetry with the VS Code collectors, letting a caller mark a non-home
|
|
681
|
+
// manifest untrusted.
|
|
682
|
+
export function collectClaudeManifest(out, installedJson, exclude = [], trusted = true) {
|
|
428
683
|
if (!existsSync(installedJson))
|
|
429
684
|
return;
|
|
430
685
|
let manifest;
|
|
@@ -434,17 +689,36 @@ export function collectClaudeManifest(out, installedJson) {
|
|
|
434
689
|
catch {
|
|
435
690
|
return;
|
|
436
691
|
}
|
|
437
|
-
|
|
692
|
+
// installed_plugins.json is manifest input: fail closed when `plugins` is not
|
|
693
|
+
// a plain object, when a version bucket is not an array, or when an entry
|
|
694
|
+
// lacks a string installPath.
|
|
695
|
+
const plugins = isPlainObject(manifest) ? manifest.plugins : undefined;
|
|
696
|
+
if (!isPlainObject(plugins)) {
|
|
697
|
+
log(`ignoring "${installedJson}": "plugins" is not an object`);
|
|
698
|
+
return;
|
|
699
|
+
}
|
|
700
|
+
for (const versions of Object.values(plugins)) {
|
|
701
|
+
if (!Array.isArray(versions)) {
|
|
702
|
+
log(`ignoring entry in "${installedJson}": plugin versions are not an array`);
|
|
703
|
+
continue;
|
|
704
|
+
}
|
|
438
705
|
for (const plugin of versions) {
|
|
439
|
-
if (plugin
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
706
|
+
if (!isPlainObject(plugin)) {
|
|
707
|
+
log(`ignoring entry in "${installedJson}": entry is not an object`);
|
|
708
|
+
continue;
|
|
709
|
+
}
|
|
710
|
+
const installPath = plugin.installPath;
|
|
711
|
+
if (typeof installPath !== "string") {
|
|
712
|
+
log(`ignoring entry in "${installedJson}": "installPath" is not a string`);
|
|
713
|
+
continue;
|
|
443
714
|
}
|
|
715
|
+
const pkg = packageFromDir(installPath, "claude", trusted);
|
|
716
|
+
if (pkg && !isExcluded(pkg, exclude))
|
|
717
|
+
out.push(pkg);
|
|
444
718
|
}
|
|
445
719
|
}
|
|
446
720
|
}
|
|
447
|
-
export function collectClaude(out) {
|
|
721
|
+
export function collectClaude(out, exclude = []) {
|
|
448
722
|
const home = homedir();
|
|
449
723
|
const installedJsons = [
|
|
450
724
|
join(home, ".claude", "plugins", "installed_plugins.json"),
|
|
@@ -452,24 +726,16 @@ export function collectClaude(out) {
|
|
|
452
726
|
];
|
|
453
727
|
for (const installedJson of installedJsons) {
|
|
454
728
|
if (existsSync(installedJson)) {
|
|
455
|
-
collectClaudeManifest(out, installedJson);
|
|
729
|
+
collectClaudeManifest(out, installedJson, exclude);
|
|
456
730
|
}
|
|
457
731
|
}
|
|
458
732
|
// Always scan the SSH-synced remote bundle too: it can exist alongside a
|
|
459
733
|
// local Claude Code install that has its own installed_plugins.json, and
|
|
460
734
|
// the same-source mirror dedup in planConfig collapses any overlap.
|
|
461
|
-
//
|
|
462
|
-
// Like the VS Code agent plugin dir, the remote plugins root holds
|
|
463
|
-
// hash-named plugin directories with no manifest. Walk it with
|
|
464
|
-
// findPluginRoots so skills and flat agent markdown files are found by
|
|
465
|
-
// filename wherever they live — no hardcoded folder names needed.
|
|
466
735
|
const remoteRoot = join(home, ".claude", "remote", "plugins");
|
|
467
736
|
if (existsSync(remoteRoot)) {
|
|
468
|
-
const
|
|
469
|
-
|
|
470
|
-
for (const pluginRoot of roots) {
|
|
471
|
-
const pkg = packageFromDir(pluginRoot, "claude");
|
|
472
|
-
if (pkg)
|
|
737
|
+
for (const pkg of findPluginPackagesCached(remoteRoot, "claude", true)) {
|
|
738
|
+
if (!isExcluded(pkg, exclude))
|
|
473
739
|
out.push(pkg);
|
|
474
740
|
}
|
|
475
741
|
}
|
|
@@ -483,7 +749,7 @@ export function opencodeCacheRoot() {
|
|
|
483
749
|
// node_modules: {cache}/packages/{name}@{version}/node_modules/{name}. Scanning
|
|
484
750
|
// it is what makes an npm-distributed Agent Plugins package work without any
|
|
485
751
|
// manual skills.paths entry.
|
|
486
|
-
export function collectOpencodeCache(packagesRoot, out) {
|
|
752
|
+
export function collectOpencodeCache(packagesRoot, out, exclude = []) {
|
|
487
753
|
let pkgEntries;
|
|
488
754
|
try {
|
|
489
755
|
pkgEntries = readdirSync(packagesRoot);
|
|
@@ -495,13 +761,14 @@ export function collectOpencodeCache(packagesRoot, out) {
|
|
|
495
761
|
const nodeModules = join(packagesRoot, pkgEntry, "node_modules");
|
|
496
762
|
if (!isDirectory(nodeModules))
|
|
497
763
|
continue;
|
|
498
|
-
|
|
764
|
+
// opencode installed these packages itself, so they are deliberate.
|
|
765
|
+
collectNodeModules(nodeModules, out, true, exclude);
|
|
499
766
|
}
|
|
500
767
|
}
|
|
501
768
|
// Enumerates immediate package roots of a node_modules dir, including scoped
|
|
502
769
|
// packages (@scope/*). Only conformant packages (root plugin.json) are read;
|
|
503
770
|
// nothing else is walked.
|
|
504
|
-
export function collectNodeModules(nodeModulesRoot, out) {
|
|
771
|
+
export function collectNodeModules(nodeModulesRoot, out, trusted = false, exclude = []) {
|
|
505
772
|
if (!isDirectory(nodeModulesRoot))
|
|
506
773
|
return;
|
|
507
774
|
let entries;
|
|
@@ -528,14 +795,14 @@ export function collectNodeModules(nodeModulesRoot, out) {
|
|
|
528
795
|
for (const sub of scoped) {
|
|
529
796
|
if (sub.startsWith("."))
|
|
530
797
|
continue;
|
|
531
|
-
const pkg = readPackage(join(candidate, sub), "node_modules");
|
|
532
|
-
if (pkg)
|
|
798
|
+
const pkg = readPackage(join(candidate, sub), "node_modules", trusted);
|
|
799
|
+
if (pkg && !isExcluded(pkg, exclude))
|
|
533
800
|
out.push(pkg);
|
|
534
801
|
}
|
|
535
802
|
}
|
|
536
803
|
else {
|
|
537
|
-
const pkg = readPackage(candidate, "node_modules");
|
|
538
|
-
if (pkg)
|
|
804
|
+
const pkg = readPackage(candidate, "node_modules", trusted);
|
|
805
|
+
if (pkg && !isExcluded(pkg, exclude))
|
|
539
806
|
out.push(pkg);
|
|
540
807
|
}
|
|
541
808
|
}
|
|
@@ -564,16 +831,34 @@ function dedupePackages(packages) {
|
|
|
564
831
|
// namespaced by package name, and MCP servers keyed by server name with
|
|
565
832
|
// collisions namespaced by package name. `taken` seeds the reserved names from
|
|
566
833
|
// the user's existing config so user-defined entries are never overwritten.
|
|
567
|
-
export function planConfig(packages, taken = {}
|
|
834
|
+
export function planConfig(packages, taken = {},
|
|
835
|
+
// Mirrors the plugin options: when mcp is false the MCP subsystem is
|
|
836
|
+
// skipped entirely — no mcp.json parsing, no filesystem side effects.
|
|
837
|
+
// Same for agents: when agents is false readAgents is never invoked.
|
|
838
|
+
enabled = {},
|
|
839
|
+
// Per-package consent for untrusted packages: a package discovered as a
|
|
840
|
+
// side effect contributes nothing to plan.mcp or plan.agents unless its
|
|
841
|
+
// name is listed in the matching consent list.
|
|
842
|
+
consent = {}) {
|
|
568
843
|
packages = dedupePackages(packages);
|
|
569
844
|
const skillPaths = [];
|
|
845
|
+
const skillTrust = [];
|
|
570
846
|
const seenDir = new Set();
|
|
571
847
|
for (const pkg of packages) {
|
|
848
|
+
// Graduated default, permissive half: skills and slash commands are
|
|
849
|
+
// read-only content registration and stay allowed for every trust tier —
|
|
850
|
+
// surfacing them is the plugin's job. An untrusted package registers
|
|
851
|
+
// exactly like a trusted one, but emits one info line so side-effect
|
|
852
|
+
// content entering the session stays visible to the user.
|
|
853
|
+
if (!pkg.trusted && pkg.skillDirs.length > 0) {
|
|
854
|
+
log(`registering skills and slash commands for untrusted package "${pkg.name}" (${pkg.source})`);
|
|
855
|
+
}
|
|
572
856
|
for (const dir of pkg.skillDirs) {
|
|
573
857
|
if (seenDir.has(dir))
|
|
574
858
|
continue;
|
|
575
859
|
seenDir.add(dir);
|
|
576
860
|
skillPaths.push(dir);
|
|
861
|
+
skillTrust.push({ dir, trusted: pkg.trusted });
|
|
577
862
|
}
|
|
578
863
|
}
|
|
579
864
|
const commands = [];
|
|
@@ -591,6 +876,12 @@ export function planConfig(packages, taken = {}) {
|
|
|
591
876
|
if (!info)
|
|
592
877
|
continue;
|
|
593
878
|
let name = info.name;
|
|
879
|
+
// SKILL.md frontmatter is package-supplied input: gate the name before
|
|
880
|
+
// it becomes any config key (applyConfigPatch writes config.command[name]).
|
|
881
|
+
if (!validateName(name)) {
|
|
882
|
+
log(`skipping skill frontmatter for package "${pkg.name}": invalid name "${name}" (must match the identifier pattern and not be a prototype-chain key)`);
|
|
883
|
+
continue;
|
|
884
|
+
}
|
|
594
885
|
const owner = commandOwner.get(name);
|
|
595
886
|
if (owner !== undefined) {
|
|
596
887
|
if (owner === pkg.source) {
|
|
@@ -611,73 +902,121 @@ export function planConfig(packages, taken = {}) {
|
|
|
611
902
|
name,
|
|
612
903
|
description: info.description || `Run the ${info.name} skill`,
|
|
613
904
|
template: [
|
|
614
|
-
|
|
905
|
+
// The name is a quoted data value (JSON string encoding), never
|
|
906
|
+
// wrapped in backticks: even a name that skipped validateName
|
|
907
|
+
// cannot terminate the quote and inject template text.
|
|
908
|
+
`Load the ${JSON.stringify(info.name)} skill and follow its instructions.`,
|
|
615
909
|
`Context: $ARGUMENTS`,
|
|
616
910
|
].join("\n"),
|
|
911
|
+
trusted: pkg.trusted,
|
|
617
912
|
});
|
|
618
913
|
}
|
|
619
914
|
}
|
|
620
915
|
const mcp = [];
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
mcpOwner
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
const
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
916
|
+
if (enabled.mcp !== false) {
|
|
917
|
+
const consentedMcp = new Set(consent.mcp ?? []);
|
|
918
|
+
const usedMcp = new Set(taken.mcp ?? []);
|
|
919
|
+
const mcpOwner = new Map();
|
|
920
|
+
for (const name of taken.mcp ?? [])
|
|
921
|
+
mcpOwner.set(name, "user");
|
|
922
|
+
const seenMcpEntry = new Set();
|
|
923
|
+
for (const pkg of packages) {
|
|
924
|
+
// Trust gate: MCP servers are both powerful and opaque to opencode.
|
|
925
|
+
// Untrusted packages are present merely as side effects, so they never
|
|
926
|
+
// contribute servers unless the user consented to the package by name.
|
|
927
|
+
// Host-vouched installs (trusted: true) pass through unchanged.
|
|
928
|
+
if (!pkg.trusted && !consentedMcp.has(pkg.name)) {
|
|
929
|
+
// Slice the credential signal out of the untrusted package's mcp.json
|
|
930
|
+
// so entries carrying headers/env/url credentials get a per-entry
|
|
931
|
+
// refusal line. readMcp runs with warnings off: a refused credential
|
|
932
|
+
// must never be told it "will be stored in opencode's config".
|
|
933
|
+
if (pkg.mcpPath) {
|
|
934
|
+
const entries = [];
|
|
935
|
+
readMcp(pkg, entries, { warn: false });
|
|
936
|
+
let named = 0;
|
|
937
|
+
for (const { key, credentialReason } of entries) {
|
|
938
|
+
if (!credentialReason)
|
|
939
|
+
continue;
|
|
940
|
+
named++;
|
|
941
|
+
log(`skipping MCP server "${pkg.name}/${key}" (${pkg.source}): ${credentialReason}; add it to consent.mcp to admit its servers`);
|
|
942
|
+
}
|
|
943
|
+
if (named === 0) {
|
|
944
|
+
log(`skipping MCP server for untrusted package "${pkg.name}" (${pkg.source}): add it to consent.mcp to admit its servers`);
|
|
945
|
+
}
|
|
946
|
+
}
|
|
632
947
|
continue;
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
const namespaced = `${pkg.name}/${key}`;
|
|
640
|
-
if (usedMcp.has(namespaced)) {
|
|
641
|
-
log(`skipping MCP server "${pkg.name}/${key}": name already taken`);
|
|
948
|
+
}
|
|
949
|
+
const entries = [];
|
|
950
|
+
readMcp(pkg, entries);
|
|
951
|
+
for (const { key, entry } of entries) {
|
|
952
|
+
const dedupeKey = `${pkg.root}\u0000${key}`;
|
|
953
|
+
if (seenMcpEntry.has(dedupeKey))
|
|
642
954
|
continue;
|
|
955
|
+
seenMcpEntry.add(dedupeKey);
|
|
956
|
+
let k = key;
|
|
957
|
+
const owner = mcpOwner.get(k);
|
|
958
|
+
if (owner !== undefined) {
|
|
959
|
+
if (owner === pkg.source)
|
|
960
|
+
continue;
|
|
961
|
+
const namespaced = `${pkg.name}/${key}`;
|
|
962
|
+
if (usedMcp.has(namespaced)) {
|
|
963
|
+
log(`skipping MCP server "${pkg.name}/${key}": name already taken`);
|
|
964
|
+
continue;
|
|
965
|
+
}
|
|
966
|
+
k = namespaced;
|
|
643
967
|
}
|
|
644
|
-
k
|
|
968
|
+
mcpOwner.set(k, pkg.source);
|
|
969
|
+
usedMcp.add(k);
|
|
970
|
+
mcp.push({ key: k, entry, trusted: pkg.trusted });
|
|
645
971
|
}
|
|
646
|
-
mcpOwner.set(k, pkg.source);
|
|
647
|
-
usedMcp.add(k);
|
|
648
|
-
mcp.push({ key: k, entry });
|
|
649
972
|
}
|
|
650
973
|
}
|
|
651
974
|
const agents = [];
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
agentOwner
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
975
|
+
if (enabled.agents !== false) {
|
|
976
|
+
const consentedAgents = new Set(consent.agents ?? []);
|
|
977
|
+
const usedAgents = new Set(taken.agents ?? []);
|
|
978
|
+
const agentOwner = new Map();
|
|
979
|
+
for (const name of taken.agents ?? [])
|
|
980
|
+
agentOwner.set(name, "user");
|
|
981
|
+
const seenAgent = new Set();
|
|
982
|
+
for (const pkg of packages) {
|
|
983
|
+
// Trust gate: agents are prompt-and-behavior material that a package
|
|
984
|
+
// registers into opencode config slots. Untrusted packages are present
|
|
985
|
+
// merely as side effects, so they contribute agents only when the user
|
|
986
|
+
// consented to the package by name. Host-vouched installs (trusted:
|
|
987
|
+
// true) pass through. Note the readAgents call is hoisted so name
|
|
988
|
+
// validation logs behave identically for every admitted package.
|
|
989
|
+
const pkgAgents = readAgents(pkg);
|
|
990
|
+
if (!pkg.trusted && !consentedAgents.has(pkg.name)) {
|
|
991
|
+
if (pkgAgents.length > 0) {
|
|
992
|
+
log(`skipping agents for untrusted package "${pkg.name}" (${pkg.source}): add it to consent.agents to admit its agents`);
|
|
993
|
+
}
|
|
661
994
|
continue;
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
if (owner === pkg.source)
|
|
667
|
-
continue;
|
|
668
|
-
const namespaced = `${pkg.name}-${agentName}`;
|
|
669
|
-
if (usedAgents.has(namespaced)) {
|
|
670
|
-
log(`skipping agent "${pkg.name}/${agentName}": name already taken`);
|
|
995
|
+
}
|
|
996
|
+
for (const { name, agent } of pkgAgents) {
|
|
997
|
+
const dedupeKey = `${pkg.root}\u0000${name}`;
|
|
998
|
+
if (seenAgent.has(dedupeKey))
|
|
671
999
|
continue;
|
|
1000
|
+
seenAgent.add(dedupeKey);
|
|
1001
|
+
let agentName = name;
|
|
1002
|
+
const owner = agentOwner.get(agentName);
|
|
1003
|
+
if (owner !== undefined) {
|
|
1004
|
+
if (owner === pkg.source)
|
|
1005
|
+
continue;
|
|
1006
|
+
const namespaced = `${pkg.name}-${agentName}`;
|
|
1007
|
+
if (usedAgents.has(namespaced)) {
|
|
1008
|
+
log(`skipping agent "${pkg.name}/${agentName}": name already taken`);
|
|
1009
|
+
continue;
|
|
1010
|
+
}
|
|
1011
|
+
agentName = namespaced;
|
|
672
1012
|
}
|
|
673
|
-
agentName
|
|
1013
|
+
agentOwner.set(agentName, pkg.source);
|
|
1014
|
+
usedAgents.add(agentName);
|
|
1015
|
+
agents.push({ name: agentName, agent, trusted: pkg.trusted });
|
|
674
1016
|
}
|
|
675
|
-
agentOwner.set(agentName, pkg.source);
|
|
676
|
-
usedAgents.add(agentName);
|
|
677
|
-
agents.push({ name: agentName, agent });
|
|
678
1017
|
}
|
|
679
1018
|
}
|
|
680
|
-
return { skillPaths, commands, mcp, agents };
|
|
1019
|
+
return { skillPaths, skillTrust, commands, mcp, agents };
|
|
681
1020
|
}
|
|
682
1021
|
// Applies a computed plan to the resolved config. Never overwrites user-defined
|
|
683
1022
|
// entries; de-duplicates paths against what the user already configured.
|
|
@@ -699,7 +1038,10 @@ export function applyConfigPatch(config, plan, enabled) {
|
|
|
699
1038
|
}
|
|
700
1039
|
}
|
|
701
1040
|
if (plan.commands.length > 0) {
|
|
702
|
-
|
|
1041
|
+
// Containers are built prototype-free so a key can never resolve to an
|
|
1042
|
+
// inherited member (__proto__/constructor/toString), even if a future
|
|
1043
|
+
// call site skips the upstream validateName gate.
|
|
1044
|
+
config.command ??= Object.create(null);
|
|
703
1045
|
for (const cmd of plan.commands) {
|
|
704
1046
|
if (config.command[cmd.name])
|
|
705
1047
|
continue;
|
|
@@ -710,15 +1052,26 @@ export function applyConfigPatch(config, plan, enabled) {
|
|
|
710
1052
|
}
|
|
711
1053
|
}
|
|
712
1054
|
if (enabled.mcp && plan.mcp.length > 0) {
|
|
713
|
-
config.mcp ??=
|
|
1055
|
+
config.mcp ??= Object.create(null);
|
|
714
1056
|
for (const { key, entry } of plan.mcp) {
|
|
715
1057
|
if (config.mcp[key])
|
|
716
1058
|
continue;
|
|
717
1059
|
config.mcp[key] = entry;
|
|
1060
|
+
// A package stdio server runs with PLUGIN_DATA pointing at its data
|
|
1061
|
+
// dir. The dir is created only here, when the server is actually
|
|
1062
|
+
// applied -- never at plan time, so planning leaves no trace on disk.
|
|
1063
|
+
if (entry.type === "local" && entry.environment?.PLUGIN_DATA) {
|
|
1064
|
+
try {
|
|
1065
|
+
mkdirSync(entry.environment.PLUGIN_DATA, { recursive: true });
|
|
1066
|
+
}
|
|
1067
|
+
catch {
|
|
1068
|
+
// Non-fatal: the subprocess env still points at the (uncreated) dir.
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
718
1071
|
}
|
|
719
1072
|
}
|
|
720
1073
|
if (doAgents) {
|
|
721
|
-
config.agent ??=
|
|
1074
|
+
config.agent ??= Object.create(null);
|
|
722
1075
|
for (const { name, agent } of plan.agents) {
|
|
723
1076
|
if (config.agent[name])
|
|
724
1077
|
continue;
|