universal-plugin 0.9.0 → 0.10.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/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/dist/cli.mjs +718 -450
- package/package.json +3 -4
- package/plugin.json +1 -1
- package/readme.md +12 -8
- package/{governances → references}/plugin-design.md +1 -5
- package/references/universal-plugin.md +4 -0
- package/skills/build-plugin/README.md +29 -0
- package/skills/build-plugin/SKILL.md +137 -0
- package/skills/build-plugin/scripts/build.mjs +11 -0
- package/skills/doctor-universal-plugin/SKILL.md +1 -1
- package/skills/doctor-universal-plugin/scripts/doctor.mjs +1 -1
- package/skills/init-universal-plugin/SKILL.md +3 -6
- package/skills/init-universal-plugin/references/create.md +1 -1
- package/skills/init-universal-plugin/references/standard.md +1 -1
- package/skills/init-universal-plugin/references/vendors/cursor.md +1 -1
- package/skills/marketplace/README.md +23 -7
- package/skills/marketplace/SKILL.md +54 -165
- package/skills/marketplace/references/add.md +138 -0
- package/skills/marketplace/references/init.md +131 -0
- package/skills/marketplace/references/validate.md +46 -0
- package/skills/marketplace/scripts/add.mjs +11 -0
- package/skills/migrate-plugin/SKILL.md +4 -3
- /package/{governances → references}/slash-invocation.md +0 -0
package/dist/cli.mjs
CHANGED
|
@@ -3779,12 +3779,6 @@ function output(data, view) {
|
|
|
3779
3779
|
if (isJsonOutput()) printJson(data);
|
|
3780
3780
|
else console.log(encode(view ?? data));
|
|
3781
3781
|
}
|
|
3782
|
-
/** Print a result whose default rendering is a document body rather than a record.
|
|
3783
|
-
* `--format json` still emits the structured payload. */
|
|
3784
|
-
function outputText(data, text) {
|
|
3785
|
-
if (isJsonOutput()) printJson(data);
|
|
3786
|
-
else text();
|
|
3787
|
-
}
|
|
3788
3782
|
//#endregion
|
|
3789
3783
|
//#region ../../node_modules/.pnpm/semver@7.8.1/node_modules/semver/internal/constants.js
|
|
3790
3784
|
var require_constants = /* @__PURE__ */ __commonJSMin(((exports, module) => {
|
|
@@ -5247,274 +5241,6 @@ function discardedRangeWarning(index, entry, range) {
|
|
|
5247
5241
|
return `dependencies[${index}] "${entry}" carries a version range the runtime discards — declare ${marketplace ? `{ "name": "${name}", "marketplace": "${marketplace}", "version": "${range}" }` : `{ "name": "${name}", "version": "${range}" }`} for a range that is enforced`;
|
|
5248
5242
|
}
|
|
5249
5243
|
//#endregion
|
|
5250
|
-
//#region src/governance/copy.ts
|
|
5251
|
-
/** The governance copy step: a governance is copied into every skill that uses it at build time and
|
|
5252
|
-
* read from disk at run time, so a skill never pays a registry lookup to read a rule set it was
|
|
5253
|
-
* tested against (repobuddy/buddy-agent-harness#122).
|
|
5254
|
-
*
|
|
5255
|
-
* The files already present in `<skill>/references/governances/` ARE the declaration of which
|
|
5256
|
-
* governances that skill uses — nothing is declared in plugin.json. The build refreshes every file
|
|
5257
|
-
* in that folder from its owning package, adds the governances those files reference, and fails
|
|
5258
|
-
* when a file names no governance or when SKILL.md does not list a copy. */
|
|
5259
|
-
/** Where a skill's copies live, relative to the skill folder. Posix spelling: it is both a path
|
|
5260
|
-
* segment and the text a SKILL.md References section has to carry. */
|
|
5261
|
-
const SKILL_GOVERNANCE_DIR = "references/governances";
|
|
5262
|
-
/** A retrieval pointer inside a governance body: `<runner> <package> governance show <name>`, or the
|
|
5263
|
-
* bare `governance show <name>` in prose. Only the trailing two words carry meaning here — which
|
|
5264
|
-
* package shipped the command is exactly what the copy step removes. */
|
|
5265
|
-
const POINTER = /governance show\s+([A-Za-z0-9][A-Za-z0-9._/-]*)/g;
|
|
5266
|
-
/** A fenced code block opener or closer, with any indentation and info string. */
|
|
5267
|
-
const FENCE = /^\s*(?:```|~~~)/;
|
|
5268
|
-
/** Indentation, plus a list marker when the pointer is a bullet. A pointer listed under References
|
|
5269
|
-
* is usually one bullet among several, and an instruction that loses the bullet leaves the list. */
|
|
5270
|
-
const LINE_PREFIX = /^\s*(?:[-*+]\s+|\d+\.\s+)?/;
|
|
5271
|
-
/** The copy's name for a pointer token: a namespaced `<plugin>/<name>` lookup and a plain `<name>`
|
|
5272
|
-
* resolve to the same document, so both land in the same file. */
|
|
5273
|
-
function governanceName(token) {
|
|
5274
|
-
const slash = token.lastIndexOf("/");
|
|
5275
|
-
const name = slash === -1 ? token : token.slice(slash + 1);
|
|
5276
|
-
return name.endsWith(".md") ? name.slice(0, -3) : name;
|
|
5277
|
-
}
|
|
5278
|
-
/** The governances a governance body points at, in first-appearance order, deduplicated. */
|
|
5279
|
-
function referencedGovernances(content) {
|
|
5280
|
-
const names = [];
|
|
5281
|
-
for (const match of content.matchAll(POINTER)) {
|
|
5282
|
-
const token = match[1];
|
|
5283
|
-
if (!token) continue;
|
|
5284
|
-
const name = governanceName(token);
|
|
5285
|
-
if (!names.includes(name)) names.push(name);
|
|
5286
|
-
}
|
|
5287
|
-
return names;
|
|
5288
|
-
}
|
|
5289
|
-
/** The instruction a pointer becomes. Stated once so the rewriter and its tests cannot drift. */
|
|
5290
|
-
function loadInstruction(name) {
|
|
5291
|
-
return `Load \`${SKILL_GOVERNANCE_DIR}/${name}.md\` if it is not already loaded.`;
|
|
5292
|
-
}
|
|
5293
|
-
/** Rewrites every retrieval pointer in a copy into an instruction to load the sibling copy. The
|
|
5294
|
-
* Agent Skills specification asks that every file an agent needs is one level deep from SKILL.md,
|
|
5295
|
-
* so a copy must never send the agent back to a command to reach the next document.
|
|
5296
|
-
*
|
|
5297
|
-
* A fenced block whose every line was a pointer is unwrapped: prose in a bash fence reads as a
|
|
5298
|
-
* command to run. A block that mixes pointers with real commands keeps its fence. */
|
|
5299
|
-
function rewriteGovernancePointers(content) {
|
|
5300
|
-
const eol = content.includes("\r\n") ? "\r\n" : "\n";
|
|
5301
|
-
const entries = content.split(/\r?\n/).map((line) => {
|
|
5302
|
-
if (FENCE.test(line)) return {
|
|
5303
|
-
lines: [line],
|
|
5304
|
-
rewritten: false,
|
|
5305
|
-
fence: true
|
|
5306
|
-
};
|
|
5307
|
-
const names = referencedGovernances(line);
|
|
5308
|
-
if (names.length === 0) return {
|
|
5309
|
-
lines: [line],
|
|
5310
|
-
rewritten: false,
|
|
5311
|
-
fence: false
|
|
5312
|
-
};
|
|
5313
|
-
const prefix = LINE_PREFIX.exec(line)?.[0] ?? "";
|
|
5314
|
-
return {
|
|
5315
|
-
lines: names.map((name) => `${prefix}${loadInstruction(name)}`),
|
|
5316
|
-
rewritten: true,
|
|
5317
|
-
fence: false
|
|
5318
|
-
};
|
|
5319
|
-
});
|
|
5320
|
-
const out = [];
|
|
5321
|
-
for (let i = 0; i < entries.length; i++) {
|
|
5322
|
-
const entry = entries[i];
|
|
5323
|
-
if (!entry.fence) {
|
|
5324
|
-
out.push(...entry.lines);
|
|
5325
|
-
continue;
|
|
5326
|
-
}
|
|
5327
|
-
const close = entries.findIndex((e, j) => j > i && e.fence);
|
|
5328
|
-
const inner = close === -1 ? [] : entries.slice(i + 1, close);
|
|
5329
|
-
if (!(close !== -1 && inner.some((e) => e.rewritten) && inner.every((e) => e.rewritten || e.lines.every((l) => l.trim() === "")))) {
|
|
5330
|
-
out.push(...entry.lines);
|
|
5331
|
-
continue;
|
|
5332
|
-
}
|
|
5333
|
-
for (const e of inner) out.push(...e.lines);
|
|
5334
|
-
i = close;
|
|
5335
|
-
}
|
|
5336
|
-
return out.join(eol);
|
|
5337
|
-
}
|
|
5338
|
-
/** The copies a SKILL.md fails to list under References. The specification asks SKILL.md to
|
|
5339
|
-
* reference every file directly, so a copy no References section names is a file the agent has no
|
|
5340
|
-
* documented way to reach. */
|
|
5341
|
-
function unlistedGovernanceCopies(skillMd, names) {
|
|
5342
|
-
const section = referencesSection(skillMd);
|
|
5343
|
-
return names.filter((name) => !section.includes(`${SKILL_GOVERNANCE_DIR}/${name}.md`));
|
|
5344
|
-
}
|
|
5345
|
-
/** The body of the References section, or the empty string when the document has none. */
|
|
5346
|
-
function referencesSection(skillMd) {
|
|
5347
|
-
const lines = skillMd.split(/\r?\n/);
|
|
5348
|
-
const start = lines.findIndex((line) => /^(#{1,6})\s+references\b/i.test(line));
|
|
5349
|
-
if (start === -1) return "";
|
|
5350
|
-
const level = (/^(#{1,6})/.exec(lines[start])?.[1] ?? "#").length;
|
|
5351
|
-
let end = lines.length;
|
|
5352
|
-
for (let i = start + 1; i < lines.length; i++) {
|
|
5353
|
-
const heading = /^(#{1,6})\s/.exec(lines[i]);
|
|
5354
|
-
if (heading && heading[1].length <= level) {
|
|
5355
|
-
end = i;
|
|
5356
|
-
break;
|
|
5357
|
-
}
|
|
5358
|
-
}
|
|
5359
|
-
return lines.slice(start + 1, end).join("\n");
|
|
5360
|
-
}
|
|
5361
|
-
//#endregion
|
|
5362
|
-
//#region src/governance/copy-sync.ts
|
|
5363
|
-
/** Where a package keeps the governances it owns. A governance ships in its owner's package as a
|
|
5364
|
-
* plain Markdown file — the copy step needs nothing else from the owner. */
|
|
5365
|
-
const OWNER_DIR = "governances";
|
|
5366
|
-
/** Refreshes every skill's governance copies from their owning packages.
|
|
5367
|
-
*
|
|
5368
|
-
* The files already in `<skill>/references/governances/` declare which governances the skill uses.
|
|
5369
|
-
* Each is rewritten from its owner's current copy, every governance a copy references is copied
|
|
5370
|
-
* too, and the retrieval pointers inside each copy are rewritten to name the sibling file. */
|
|
5371
|
-
function syncGovernanceCopies(root, skills, govFs, opts = {}) {
|
|
5372
|
-
const entries = [];
|
|
5373
|
-
const errors = [];
|
|
5374
|
-
const owners = ownerDirs(root, govFs);
|
|
5375
|
-
for (const skill of skills) {
|
|
5376
|
-
const skillDir = path.dirname(skill.path);
|
|
5377
|
-
const copyDir = path.join(skillDir, ...SKILL_GOVERNANCE_DIR.split("/"));
|
|
5378
|
-
if (!govFs.isDirectory(copyDir)) continue;
|
|
5379
|
-
const copied = [];
|
|
5380
|
-
const declared = govFs.list(copyDir);
|
|
5381
|
-
const queue = declared.map((name) => ({ name }));
|
|
5382
|
-
const seen = new Set(declared);
|
|
5383
|
-
while (queue.length > 0) {
|
|
5384
|
-
const wanted = queue.shift();
|
|
5385
|
-
const { name } = wanted;
|
|
5386
|
-
const source = resolveSource(name, owners, govFs);
|
|
5387
|
-
if (!source) {
|
|
5388
|
-
errors.push(unresolvedMessage(skill.name, wanted, owners));
|
|
5389
|
-
continue;
|
|
5390
|
-
}
|
|
5391
|
-
copied.push(name);
|
|
5392
|
-
for (const referenced of referencedGovernances(source.content)) {
|
|
5393
|
-
if (seen.has(referenced)) continue;
|
|
5394
|
-
seen.add(referenced);
|
|
5395
|
-
queue.push({
|
|
5396
|
-
name: referenced,
|
|
5397
|
-
via: name
|
|
5398
|
-
});
|
|
5399
|
-
}
|
|
5400
|
-
const content = rewriteGovernancePointers(source.content);
|
|
5401
|
-
const target = path.join(copyDir, `${name}.md`);
|
|
5402
|
-
if ((govFs.exists(target) ? govFs.read(target) : null) === content) {
|
|
5403
|
-
entries.push({
|
|
5404
|
-
skill: skill.name,
|
|
5405
|
-
name,
|
|
5406
|
-
path: target,
|
|
5407
|
-
owner: source.owner,
|
|
5408
|
-
status: "unchanged"
|
|
5409
|
-
});
|
|
5410
|
-
continue;
|
|
5411
|
-
}
|
|
5412
|
-
if (opts.check || opts.dryRun) {
|
|
5413
|
-
entries.push({
|
|
5414
|
-
skill: skill.name,
|
|
5415
|
-
name,
|
|
5416
|
-
path: target,
|
|
5417
|
-
owner: source.owner,
|
|
5418
|
-
status: "stale"
|
|
5419
|
-
});
|
|
5420
|
-
continue;
|
|
5421
|
-
}
|
|
5422
|
-
govFs.write(target, content);
|
|
5423
|
-
entries.push({
|
|
5424
|
-
skill: skill.name,
|
|
5425
|
-
name,
|
|
5426
|
-
path: target,
|
|
5427
|
-
owner: source.owner,
|
|
5428
|
-
status: "written"
|
|
5429
|
-
});
|
|
5430
|
-
}
|
|
5431
|
-
const unlisted = unlistedGovernanceCopies(govFs.read(skill.path), copied);
|
|
5432
|
-
for (const name of unlisted) errors.push(`skill "${skill.name}": SKILL.md does not list \`${SKILL_GOVERNANCE_DIR}/${name}.md\` under References.`);
|
|
5433
|
-
}
|
|
5434
|
-
return {
|
|
5435
|
-
entries,
|
|
5436
|
-
errors
|
|
5437
|
-
};
|
|
5438
|
-
}
|
|
5439
|
-
function unresolvedMessage(skill, wanted, owners) {
|
|
5440
|
-
const checked = `No installed package owns it — checked ${owners.map((o) => o.name).join(", ")}.`;
|
|
5441
|
-
return wanted.via ? `skill "${skill}": governance "${wanted.via}" references "${wanted.name}", which has no copy to point at. ${checked}` : `skill "${skill}": ${SKILL_GOVERNANCE_DIR}/${wanted.name}.md names no governance. ${checked}`;
|
|
5442
|
-
}
|
|
5443
|
-
function resolveSource(name, owners, govFs) {
|
|
5444
|
-
for (const owner of owners) {
|
|
5445
|
-
const filePath = path.join(owner.dir, `${name}.md`);
|
|
5446
|
-
if (govFs.exists(filePath)) return {
|
|
5447
|
-
owner: owner.name,
|
|
5448
|
-
content: govFs.read(filePath)
|
|
5449
|
-
};
|
|
5450
|
-
}
|
|
5451
|
-
return null;
|
|
5452
|
-
}
|
|
5453
|
-
/** The packages a governance may come from, in resolution order: the plugin being built, then its
|
|
5454
|
-
* declared dependencies. The plugin wins — a plugin that ships a governance owns that name, and a
|
|
5455
|
-
* build must not copy a stale published copy of a document its own tree holds. */
|
|
5456
|
-
function ownerDirs(root, govFs) {
|
|
5457
|
-
const owners = [{
|
|
5458
|
-
name: ".",
|
|
5459
|
-
dir: path.join(root, OWNER_DIR)
|
|
5460
|
-
}];
|
|
5461
|
-
for (const pkg of declaredDependencies(root, govFs)) {
|
|
5462
|
-
const dir = packageDir(root, pkg, govFs);
|
|
5463
|
-
if (dir) owners.push({
|
|
5464
|
-
name: pkg,
|
|
5465
|
-
dir: path.join(dir, OWNER_DIR)
|
|
5466
|
-
});
|
|
5467
|
-
}
|
|
5468
|
-
return owners;
|
|
5469
|
-
}
|
|
5470
|
-
/** Every package the plugin's own `package.json` declares. A governance owner is installed as a dev
|
|
5471
|
-
* dependency of the repository being built; runtime dependencies are read too, because which of
|
|
5472
|
-
* the two a plugin author picked is not this step's business. */
|
|
5473
|
-
function declaredDependencies(root, govFs) {
|
|
5474
|
-
const manifestPath = path.join(root, "package.json");
|
|
5475
|
-
if (!govFs.exists(manifestPath)) return [];
|
|
5476
|
-
let parsed;
|
|
5477
|
-
try {
|
|
5478
|
-
parsed = JSON.parse(govFs.read(manifestPath));
|
|
5479
|
-
} catch {
|
|
5480
|
-
return [];
|
|
5481
|
-
}
|
|
5482
|
-
const names = /* @__PURE__ */ new Set();
|
|
5483
|
-
for (const field of [parsed.devDependencies, parsed.dependencies]) if (field && typeof field === "object") for (const name of Object.keys(field)) names.add(name);
|
|
5484
|
-
return [...names];
|
|
5485
|
-
}
|
|
5486
|
-
/** Walks up from the plugin root looking for the installed package, the way Node resolves one. A
|
|
5487
|
-
* workspace hoists its dependencies to the repository root, so the first `node_modules` above the
|
|
5488
|
-
* plugin is usually not the one holding them. */
|
|
5489
|
-
function packageDir(root, pkg, govFs) {
|
|
5490
|
-
let dir = path.resolve(root);
|
|
5491
|
-
for (;;) {
|
|
5492
|
-
const candidate = path.join(dir, "node_modules", pkg);
|
|
5493
|
-
if (govFs.exists(path.join(candidate, "package.json"))) return candidate;
|
|
5494
|
-
const parent = path.dirname(dir);
|
|
5495
|
-
if (parent === dir) return null;
|
|
5496
|
-
dir = parent;
|
|
5497
|
-
}
|
|
5498
|
-
}
|
|
5499
|
-
//#endregion
|
|
5500
|
-
//#region src/governance/fs.ts
|
|
5501
|
-
const realGovernanceFs = {
|
|
5502
|
-
exists: (p) => fs.existsSync(p),
|
|
5503
|
-
read: (p) => fs.readFileSync(p, "utf8"),
|
|
5504
|
-
list: (dir) => {
|
|
5505
|
-
if (!fs.existsSync(dir)) return [];
|
|
5506
|
-
return fs.readdirSync(dir).filter((f) => f.endsWith(".md")).map((f) => f.slice(0, -3));
|
|
5507
|
-
}
|
|
5508
|
-
};
|
|
5509
|
-
const realGovernanceCopyFs = {
|
|
5510
|
-
...realGovernanceFs,
|
|
5511
|
-
isDirectory: (dir) => fs.existsSync(dir) && fs.statSync(dir).isDirectory(),
|
|
5512
|
-
write: (filePath, content) => {
|
|
5513
|
-
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
5514
|
-
fs.writeFileSync(filePath, content);
|
|
5515
|
-
}
|
|
5516
|
-
};
|
|
5517
|
-
//#endregion
|
|
5518
5244
|
//#region src/hooks/hooks.ts
|
|
5519
5245
|
/** Per-vendor hook support. Source: `.research/hook-event-survey/conclusion.md`, re-verified against
|
|
5520
5246
|
* vendor documentation August 2026. A vendor fact decays — re-verify before trusting this table. */
|
|
@@ -5681,6 +5407,23 @@ function claudeArtifact(metadata, plugins) {
|
|
|
5681
5407
|
})
|
|
5682
5408
|
};
|
|
5683
5409
|
}
|
|
5410
|
+
/** Whether a catalog entry's `source` names a place inside this repository — a `./`-prefixed path in
|
|
5411
|
+
* the Claude-shaped catalogs, or Codex's `{ source: "local", path }`. Every other form names a
|
|
5412
|
+
* plugin distributed from somewhere else: an npm package, a GitHub repository, a URL. Discovery
|
|
5413
|
+
* walks directories, so it can produce the first kind and can say nothing about the second. */
|
|
5414
|
+
function isLocalCatalogSource(source) {
|
|
5415
|
+
if (typeof source === "string") return true;
|
|
5416
|
+
if (typeof source !== "object" || source === null || Array.isArray(source)) return false;
|
|
5417
|
+
return source.source === "local";
|
|
5418
|
+
}
|
|
5419
|
+
/** Codex states a source as an object either way: a repository path is tagged `local`, and a source
|
|
5420
|
+
* that already carries its own tag passes through as it stands. */
|
|
5421
|
+
function codexSource(source) {
|
|
5422
|
+
return typeof source === "string" ? {
|
|
5423
|
+
source: "local",
|
|
5424
|
+
path: source
|
|
5425
|
+
} : source;
|
|
5426
|
+
}
|
|
5684
5427
|
function codexArtifact(metadata, plugins) {
|
|
5685
5428
|
return {
|
|
5686
5429
|
path: TARGET_CATALOG_PATHS.codex,
|
|
@@ -5690,10 +5433,7 @@ function codexArtifact(metadata, plugins) {
|
|
|
5690
5433
|
plugins: plugins.map((plugin) => ({
|
|
5691
5434
|
name: plugin.name,
|
|
5692
5435
|
version: plugin.metadata.version,
|
|
5693
|
-
source:
|
|
5694
|
-
source: "local",
|
|
5695
|
-
path: plugin.source
|
|
5696
|
-
},
|
|
5436
|
+
source: codexSource(plugin.source),
|
|
5697
5437
|
policy: {
|
|
5698
5438
|
installation: "AVAILABLE",
|
|
5699
5439
|
authentication: "ON_INSTALL"
|
|
@@ -5742,6 +5482,72 @@ const VENDOR_TARGETS = {
|
|
|
5742
5482
|
codex: "codex",
|
|
5743
5483
|
"copilot-cli": "copilot"
|
|
5744
5484
|
};
|
|
5485
|
+
const GITHUB_URL = /^(?:https?:\/\/(?:www\.)?github\.com\/|git@github\.com:)([^/]+\/[^/]+?)(?:\.git)?\/?$/;
|
|
5486
|
+
/** A git URL read as an origin, recognizing a GitHub one so an entry at the repository root can use
|
|
5487
|
+
* the tidier `github` form. */
|
|
5488
|
+
function originFromUrl(url) {
|
|
5489
|
+
const match = GITHUB_URL.exec(url.trim());
|
|
5490
|
+
return match ? {
|
|
5491
|
+
url,
|
|
5492
|
+
repo: match[1]
|
|
5493
|
+
} : { url };
|
|
5494
|
+
}
|
|
5495
|
+
/** An `owner/repo` slug as an origin. */
|
|
5496
|
+
function originFromRepo(repo) {
|
|
5497
|
+
return {
|
|
5498
|
+
url: `https://github.com/${repo}.git`,
|
|
5499
|
+
repo
|
|
5500
|
+
};
|
|
5501
|
+
}
|
|
5502
|
+
/** The path part of a local source, `./` stripped, empty at the marketplace root. */
|
|
5503
|
+
function localSourcePath(source) {
|
|
5504
|
+
const raw = typeof source === "string" ? source : source.path;
|
|
5505
|
+
if (typeof raw !== "string") return void 0;
|
|
5506
|
+
return raw.replace(/^\.\/?/, "").replace(/\/+$/, "");
|
|
5507
|
+
}
|
|
5508
|
+
/** Rewrites a source that is local to *another* marketplace into one that resolves from anywhere.
|
|
5509
|
+
*
|
|
5510
|
+
* A copied entry's `./plugins/aced` is relative to the marketplace it came from, so carrying it
|
|
5511
|
+
* across unchanged would point at a directory this repository does not have. The path is not
|
|
5512
|
+
* useless, though — it is a location inside a repository whose URL is known, which is exactly what
|
|
5513
|
+
* `git-subdir` states. An entry at that repository's root needs no subdirectory and takes the
|
|
5514
|
+
* plainer `github` or `url` form. */
|
|
5515
|
+
function absoluteSource(origin, source) {
|
|
5516
|
+
const subdir = localSourcePath(source);
|
|
5517
|
+
if (subdir === void 0) return void 0;
|
|
5518
|
+
if (subdir === "" || subdir === ".") return origin.repo ? {
|
|
5519
|
+
source: "github",
|
|
5520
|
+
repo: origin.repo
|
|
5521
|
+
} : {
|
|
5522
|
+
source: "url",
|
|
5523
|
+
url: origin.url
|
|
5524
|
+
};
|
|
5525
|
+
return {
|
|
5526
|
+
source: "git-subdir",
|
|
5527
|
+
url: origin.url,
|
|
5528
|
+
path: subdir
|
|
5529
|
+
};
|
|
5530
|
+
}
|
|
5531
|
+
/** The source forms each runtime installs from./** The source forms each runtime installs from.
|
|
5532
|
+
*
|
|
5533
|
+
* Every runtime takes a repository path. Beyond that they diverge, and the divergence is not
|
|
5534
|
+
* cosmetic: a catalog is read at install time in someone else's terminal, so a source a runtime
|
|
5535
|
+
* cannot resolve is a failure far from here. Claude Code's schema documents the full tagged set
|
|
5536
|
+
* (<https://json.schemastore.org/claude-code-marketplace.json>). Codex documents npm alongside a
|
|
5537
|
+
* local path. Copilot CLI and Cursor document local paths only, which is why an npm entry reaches
|
|
5538
|
+
* two catalogs rather than four (`.research/local-marketplaces`, and issue #86). */
|
|
5539
|
+
const TARGET_SOURCE_KINDS = {
|
|
5540
|
+
claude: [
|
|
5541
|
+
"path",
|
|
5542
|
+
"npm",
|
|
5543
|
+
"github",
|
|
5544
|
+
"url",
|
|
5545
|
+
"git-subdir"
|
|
5546
|
+
],
|
|
5547
|
+
codex: ["path", "npm"],
|
|
5548
|
+
copilot: ["path"],
|
|
5549
|
+
cursor: ["path"]
|
|
5550
|
+
};
|
|
5745
5551
|
/** Folds one plugin's entry into a catalog that may already exist, and returns the artifact to
|
|
5746
5552
|
* write. An existing catalog keeps its own top-level fields — its name, its owner, a description
|
|
5747
5553
|
* someone wrote — and every entry it lists for other plugins, in place. Only this plugin's entry is
|
|
@@ -5750,7 +5556,7 @@ const VENDOR_TARGETS = {
|
|
|
5750
5556
|
* `version` is derived, never authored (ADR-0010 §3): the entry carries whatever the canonical
|
|
5751
5557
|
* manifest carries, and a version left behind on an entry whose manifest declares none is removed
|
|
5752
5558
|
* rather than kept. */
|
|
5753
|
-
function mergeCatalogEntry(target, metadata, plugin, readExisting) {
|
|
5559
|
+
function mergeCatalogEntry(target, metadata, plugin, readExisting, { keepForeignSource = true } = {}) {
|
|
5754
5560
|
const artifact = serializeTarget(target, metadata, [plugin])[0];
|
|
5755
5561
|
const existing = readExisting(artifact.path);
|
|
5756
5562
|
if (existing === void 0) return artifact;
|
|
@@ -5759,7 +5565,7 @@ function mergeCatalogEntry(target, metadata, plugin, readExisting) {
|
|
|
5759
5565
|
const entry = generated.plugins[0];
|
|
5760
5566
|
const merged = { ...previous };
|
|
5761
5567
|
for (const [key, value] of Object.entries(generated)) if (!(key in merged)) merged[key] = value;
|
|
5762
|
-
merged.plugins = mergeEntries(previous, entry);
|
|
5568
|
+
merged.plugins = mergeEntries(previous, entry, { keepForeignSource });
|
|
5763
5569
|
return {
|
|
5764
5570
|
path: artifact.path,
|
|
5765
5571
|
content: json(merged)
|
|
@@ -5775,18 +5581,78 @@ function parseCatalog(content, path) {
|
|
|
5775
5581
|
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error(`error: existing catalog "${path}" is not a JSON object`);
|
|
5776
5582
|
return parsed;
|
|
5777
5583
|
}
|
|
5778
|
-
function mergeEntries(previous, entry) {
|
|
5584
|
+
function mergeEntries(previous, entry, { keepForeignSource = false } = {}) {
|
|
5779
5585
|
const entries = Array.isArray(previous.plugins) ? [...previous.plugins] : [];
|
|
5780
5586
|
const index = entries.findIndex((candidate) => typeof candidate === "object" && candidate !== null && candidate.name === entry.name);
|
|
5781
5587
|
if (index === -1) return [...entries, entry];
|
|
5588
|
+
const existing = entries[index];
|
|
5782
5589
|
const merged = {
|
|
5783
|
-
...
|
|
5590
|
+
...existing,
|
|
5784
5591
|
...entry
|
|
5785
5592
|
};
|
|
5786
5593
|
if (!("version" in entry)) delete merged.version;
|
|
5594
|
+
if (keepForeignSource && !isLocalCatalogSource(existing.source)) merged.source = existing.source;
|
|
5787
5595
|
entries[index] = merged;
|
|
5788
5596
|
return entries;
|
|
5789
5597
|
}
|
|
5598
|
+
/** Re-derives a whole catalog from discovery while keeping the entries discovery cannot see.
|
|
5599
|
+
*
|
|
5600
|
+
* A repository may list plugins that live somewhere else — an npm package, a GitHub repository —
|
|
5601
|
+
* put there by `marketplace add`. Nothing on disk produces those, so a regeneration that trusted
|
|
5602
|
+
* discovery alone would report every one of them as a deletion, and `--force` would carry it out.
|
|
5603
|
+
* Two things survive instead: an entry whose source is not local stays even though discovery never
|
|
5604
|
+
* saw it, and a discovered plugin whose existing entry names a non-local source keeps that source
|
|
5605
|
+
* with only its derived metadata refreshed.
|
|
5606
|
+
*
|
|
5607
|
+
* That second case is the loss in issue #86 — a plugin shipped through npm, whose repository path
|
|
5608
|
+
* holds only gitignored build output, rewritten to that path on every regeneration.
|
|
5609
|
+
*
|
|
5610
|
+
* What discovery owns, it still owns: a local-path entry it no longer finds is dropped, so the
|
|
5611
|
+
* catalog keeps mirroring the repository for the part of the repository it describes. */
|
|
5612
|
+
function mergeDiscoveredCatalog(generated, existing) {
|
|
5613
|
+
if (existing === void 0) return generated;
|
|
5614
|
+
let previous;
|
|
5615
|
+
try {
|
|
5616
|
+
previous = parseCatalog(existing, generated.path);
|
|
5617
|
+
} catch {
|
|
5618
|
+
return generated;
|
|
5619
|
+
}
|
|
5620
|
+
const foreign = /* @__PURE__ */ new Map();
|
|
5621
|
+
for (const candidate of Array.isArray(previous.plugins) ? previous.plugins : []) {
|
|
5622
|
+
if (typeof candidate !== "object" || candidate === null || Array.isArray(candidate)) continue;
|
|
5623
|
+
const entry = candidate;
|
|
5624
|
+
if (typeof entry.name !== "string" || isLocalCatalogSource(entry.source)) continue;
|
|
5625
|
+
foreign.set(entry.name, entry);
|
|
5626
|
+
}
|
|
5627
|
+
if (foreign.size === 0) return generated;
|
|
5628
|
+
const catalog = JSON.parse(generated.content);
|
|
5629
|
+
const discovered = catalog.plugins ?? [];
|
|
5630
|
+
const merged = discovered.map((entry) => {
|
|
5631
|
+
const kept = typeof entry.name === "string" ? foreign.get(entry.name) : void 0;
|
|
5632
|
+
return kept === void 0 ? entry : {
|
|
5633
|
+
...entry,
|
|
5634
|
+
source: kept.source
|
|
5635
|
+
};
|
|
5636
|
+
});
|
|
5637
|
+
const discoveredNames = new Set(discovered.map((entry) => entry.name));
|
|
5638
|
+
for (const [name, entry] of foreign) if (!discoveredNames.has(name)) merged.push(entry);
|
|
5639
|
+
catalog.plugins = merged;
|
|
5640
|
+
return {
|
|
5641
|
+
path: generated.path,
|
|
5642
|
+
content: json(catalog)
|
|
5643
|
+
};
|
|
5644
|
+
}
|
|
5645
|
+
/** The plugin names a catalog lists, so a result row can report what the file ends up saying rather
|
|
5646
|
+
* than only what discovery contributed to it. */
|
|
5647
|
+
function catalogEntryNames(content) {
|
|
5648
|
+
try {
|
|
5649
|
+
const parsed = JSON.parse(content);
|
|
5650
|
+
if (!Array.isArray(parsed.plugins)) return [];
|
|
5651
|
+
return parsed.plugins.map((entry) => typeof entry === "object" && entry !== null ? entry.name : void 0).filter((name) => typeof name === "string");
|
|
5652
|
+
} catch {
|
|
5653
|
+
return [];
|
|
5654
|
+
}
|
|
5655
|
+
}
|
|
5790
5656
|
/** The catalog's own top-level identity, read back from the file the repository already carries, so
|
|
5791
5657
|
* a refresh re-derives one entry without proposing a name or an owner of its own. */
|
|
5792
5658
|
function existingMetadata(previous) {
|
|
@@ -5813,7 +5679,7 @@ function refreshCatalogEntry(target, plugin, existing) {
|
|
|
5813
5679
|
path: catalogPath,
|
|
5814
5680
|
content: json({
|
|
5815
5681
|
...previous,
|
|
5816
|
-
plugins: mergeEntries(previous, entry)
|
|
5682
|
+
plugins: mergeEntries(previous, entry, { keepForeignSource: true })
|
|
5817
5683
|
})
|
|
5818
5684
|
};
|
|
5819
5685
|
}
|
|
@@ -5900,6 +5766,79 @@ function gatherCatalogRepo(root) {
|
|
|
5900
5766
|
catalogs
|
|
5901
5767
|
};
|
|
5902
5768
|
}
|
|
5769
|
+
/** Where Claude Code records the marketplaces a user has added, and clones each one.
|
|
5770
|
+
*
|
|
5771
|
+
* This is read, never written. It is the one place on the machine that already knows what
|
|
5772
|
+
* `<plugin>@<marketplace>` refers to, so resolving through it asks the user for nothing and reaches
|
|
5773
|
+
* no network. A marketplace they have not added is simply not found, and `--from` names it instead. */
|
|
5774
|
+
function claudePluginsHome(home = os.homedir()) {
|
|
5775
|
+
return path.join(home, ".claude", "plugins");
|
|
5776
|
+
}
|
|
5777
|
+
/** The origin the runtime recorded for a marketplace, in the vocabulary it records it in: a
|
|
5778
|
+
* `github` repo slug, or a `git`/`url` clone URL. */
|
|
5779
|
+
function registryOrigin(source) {
|
|
5780
|
+
if (typeof source !== "object" || source === null || Array.isArray(source)) return void 0;
|
|
5781
|
+
const record = source;
|
|
5782
|
+
if (record.source === "github" && typeof record.repo === "string") return originFromRepo(record.repo);
|
|
5783
|
+
if ((record.source === "git" || record.source === "url") && typeof record.url === "string") return originFromUrl(record.url);
|
|
5784
|
+
}
|
|
5785
|
+
/** The URL a checkout was cloned from, which is the origin for a marketplace the registry does not
|
|
5786
|
+
* describe — one reached through `--from`, or added before the runtime recorded a source. */
|
|
5787
|
+
function gitRemoteUrl(dir) {
|
|
5788
|
+
try {
|
|
5789
|
+
const url = execFileSync("git", [
|
|
5790
|
+
"-C",
|
|
5791
|
+
dir,
|
|
5792
|
+
"remote",
|
|
5793
|
+
"get-url",
|
|
5794
|
+
"origin"
|
|
5795
|
+
], {
|
|
5796
|
+
encoding: "utf8",
|
|
5797
|
+
stdio: [
|
|
5798
|
+
"ignore",
|
|
5799
|
+
"pipe",
|
|
5800
|
+
"ignore"
|
|
5801
|
+
]
|
|
5802
|
+
}).trim();
|
|
5803
|
+
return url === "" ? void 0 : url;
|
|
5804
|
+
} catch {
|
|
5805
|
+
return;
|
|
5806
|
+
}
|
|
5807
|
+
}
|
|
5808
|
+
/** The marketplace a name refers to, or `undefined` when the user has not added it. */
|
|
5809
|
+
function resolveKnownMarketplace(name, marketplaceFs = realMarketplaceFs, home = os.homedir()) {
|
|
5810
|
+
const pluginsHome = claudePluginsHome(home);
|
|
5811
|
+
const registry = path.join(pluginsHome, "known_marketplaces.json");
|
|
5812
|
+
if (marketplaceFs.exists(registry)) {
|
|
5813
|
+
let parsed;
|
|
5814
|
+
try {
|
|
5815
|
+
parsed = JSON.parse(marketplaceFs.read(registry));
|
|
5816
|
+
} catch {
|
|
5817
|
+
parsed = void 0;
|
|
5818
|
+
}
|
|
5819
|
+
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
|
|
5820
|
+
const entry = parsed[name];
|
|
5821
|
+
if (typeof entry === "object" && entry !== null) {
|
|
5822
|
+
const record = entry;
|
|
5823
|
+
const location = record.installLocation;
|
|
5824
|
+
if (typeof location === "string" && marketplaceFs.exists(location)) return {
|
|
5825
|
+
dir: location,
|
|
5826
|
+
origin: registryOrigin(record.source)
|
|
5827
|
+
};
|
|
5828
|
+
}
|
|
5829
|
+
}
|
|
5830
|
+
}
|
|
5831
|
+
const conventional = path.join(pluginsHome, "marketplaces", name);
|
|
5832
|
+
return marketplaceFs.exists(conventional) ? { dir: conventional } : void 0;
|
|
5833
|
+
}
|
|
5834
|
+
/** The catalog a marketplace directory carries, tried in the order the runtimes agree on: the Claude
|
|
5835
|
+
* path first, because three of the four read it. */
|
|
5836
|
+
function readMarketplaceCatalog(dir, marketplaceFs = realMarketplaceFs) {
|
|
5837
|
+
for (const relative of Object.values(TARGET_CATALOG_PATHS)) {
|
|
5838
|
+
const file = path.join(dir, relative);
|
|
5839
|
+
if (marketplaceFs.exists(file)) return marketplaceFs.read(file);
|
|
5840
|
+
}
|
|
5841
|
+
}
|
|
5903
5842
|
//#endregion
|
|
5904
5843
|
//#region src/marketplace/validation.ts
|
|
5905
5844
|
function isObject$1(value) {
|
|
@@ -6483,10 +6422,6 @@ function validateManifest(manifest, targets) {
|
|
|
6483
6422
|
return errors;
|
|
6484
6423
|
}
|
|
6485
6424
|
function buildPlugin(root, opts = {}) {
|
|
6486
|
-
if (opts.check) opts = {
|
|
6487
|
-
...opts,
|
|
6488
|
-
dryRun: true
|
|
6489
|
-
};
|
|
6490
6425
|
const manifestPath = path.join(root, "plugin.json");
|
|
6491
6426
|
if (!fs.existsSync(manifestPath)) throw new Error(`No plugin.json found at ${root}`);
|
|
6492
6427
|
const indent = detectIndent(fs.readFileSync(manifestPath, "utf8"));
|
|
@@ -6521,7 +6456,6 @@ function buildPlugin(root, opts = {}) {
|
|
|
6521
6456
|
warnings,
|
|
6522
6457
|
rows,
|
|
6523
6458
|
catalogs: [],
|
|
6524
|
-
governances: [],
|
|
6525
6459
|
summary: summarize(rows)
|
|
6526
6460
|
};
|
|
6527
6461
|
}
|
|
@@ -6532,12 +6466,6 @@ function buildPlugin(root, opts = {}) {
|
|
|
6532
6466
|
const { vendors: _vendors, packagePath: _packagePath, harnesses: _harnesses, dependencies: declaredDependencies, ...componentConfig } = uext;
|
|
6533
6467
|
warnings.push(...validateDependencies(declaredDependencies).warnings);
|
|
6534
6468
|
const skills = readSkills(root, manifest, warnings);
|
|
6535
|
-
const governances = syncGovernanceCopies(root, skills, realGovernanceCopyFs, {
|
|
6536
|
-
check: opts.check,
|
|
6537
|
-
dryRun: opts.dryRun
|
|
6538
|
-
});
|
|
6539
|
-
if (governances.errors.length > 0) throw new Error(`Governance copies are not buildable:\n${governances.errors.map((e) => ` - ${e}`).join("\n")}`);
|
|
6540
|
-
written.push(...governances.entries.filter((e) => e.status === "written").map((e) => e.path));
|
|
6541
6469
|
const canonicalHooks = readCanonicalHooks(root, componentConfig["hooks"], warnings);
|
|
6542
6470
|
const declaredMcp = readCanonicalMcpServers(root, componentConfig["mcpServers"], warnings);
|
|
6543
6471
|
const mcp = declaredMcp ? pinMcpServers(declaredMcp.servers, manifest.version) : null;
|
|
@@ -6620,7 +6548,6 @@ function buildPlugin(root, opts = {}) {
|
|
|
6620
6548
|
rows,
|
|
6621
6549
|
catalogs,
|
|
6622
6550
|
catalogRoot,
|
|
6623
|
-
governances: governances.entries,
|
|
6624
6551
|
summary: summarize(rows)
|
|
6625
6552
|
};
|
|
6626
6553
|
}
|
|
@@ -6973,27 +6900,19 @@ function nextStep$1(result, cwd) {
|
|
|
6973
6900
|
const quoted = /\s/.test(relative) ? JSON.stringify(relative) : relative;
|
|
6974
6901
|
return `→ universal-plugin marketplace validate${relative === "" ? "" : ` --root ${quoted}`}\n`;
|
|
6975
6902
|
}
|
|
6976
|
-
/** What repairs a stale copy: the same build, without `--check`. */
|
|
6977
|
-
function checkNextStep(root) {
|
|
6978
|
-
return `\u2192 universal-plugin plugin build${root ? ` --root ${root}` : ""} \u2014 refresh the stale governance copies\n`;
|
|
6979
|
-
}
|
|
6980
6903
|
function buildCommand() {
|
|
6981
6904
|
const cmd = new Command("build").description("Generate vendor manifests from plugin.json");
|
|
6982
|
-
cmd.option("--vendor <id>", "Build only the named vendor").option("--dry-run", "Print what would be written without writing").option("--verbose", "Print field-by-field transformation decisions").option("--clean", "Delete generated manifests before building").option("--
|
|
6905
|
+
cmd.option("--vendor <id>", "Build only the named vendor").option("--dry-run", "Print what would be written without writing").option("--verbose", "Print field-by-field transformation decisions").option("--clean", "Delete generated manifests before building").option("--format <format>", "Output format: json or toon (default: toon)").addOption(new Option("--json").hideHelp()).addOption(ROOT_OPTION).addHelpText("after", "\nExample:\n $ universal-plugin plugin build --vendor claude-code\n").action((opts) => {
|
|
6983
6906
|
try {
|
|
6984
6907
|
const result = buildPlugin(resolveRoot(opts.root), {
|
|
6985
6908
|
vendor: opts.vendor,
|
|
6986
6909
|
dryRun: opts.dryRun,
|
|
6987
6910
|
verbose: opts.verbose,
|
|
6988
|
-
clean: opts.clean
|
|
6989
|
-
check: opts.check
|
|
6911
|
+
clean: opts.clean
|
|
6990
6912
|
});
|
|
6991
6913
|
for (const warning of result.warnings) process.stderr.write(`warn: ${warning}\n`);
|
|
6992
|
-
const stale = result.governances.filter((g) => g.status === "stale");
|
|
6993
|
-
if (opts.check) for (const copy of stale) process.stderr.write(`stale: ${path.relative(process.cwd(), copy.path)} (owner ${copy.owner})\n`);
|
|
6994
6914
|
const { built, skipped, failed, canonical } = result.summary;
|
|
6995
6915
|
const jsonResult = {
|
|
6996
|
-
governances: result.governances,
|
|
6997
6916
|
built: result.rows.filter((r) => r.status === "built"),
|
|
6998
6917
|
skipped: result.rows.filter((r) => r.status === "skipped"),
|
|
6999
6918
|
failed: result.rows.filter((r) => r.status === "failed"),
|
|
@@ -7004,7 +6923,6 @@ function buildCommand() {
|
|
|
7004
6923
|
};
|
|
7005
6924
|
const counts = `built ${built}, skipped ${skipped}, failed ${failed}`;
|
|
7006
6925
|
const catalogSummary = result.catalogs.length > 0 ? `, catalogs ${result.catalogs.length}` : "";
|
|
7007
|
-
const governanceSummary = result.governances.length > 0 ? `, governance copies ${result.governances.length}` : "";
|
|
7008
6926
|
output(jsonResult, {
|
|
7009
6927
|
vendors: result.rows.map((r) => ({
|
|
7010
6928
|
vendor: r.vendor,
|
|
@@ -7015,16 +6933,10 @@ function buildCommand() {
|
|
|
7015
6933
|
path: r.path,
|
|
7016
6934
|
status: r.status
|
|
7017
6935
|
})),
|
|
7018
|
-
|
|
7019
|
-
skill: g.skill,
|
|
7020
|
-
name: g.name,
|
|
7021
|
-
status: g.status
|
|
7022
|
-
})) } : {},
|
|
7023
|
-
summary: (canonical > 0 ? `${counts}, served by plugin.json ${canonical}` : counts) + catalogSummary + governanceSummary
|
|
6936
|
+
summary: (canonical > 0 ? `${counts}, served by plugin.json ${canonical}` : counts) + catalogSummary
|
|
7024
6937
|
});
|
|
7025
|
-
process.stderr.write(
|
|
6938
|
+
process.stderr.write(nextStep$1(result, process.cwd()));
|
|
7026
6939
|
if (failed > 0) process.exitCode = 1;
|
|
7027
|
-
if (opts.check && stale.length > 0) process.exitCode = 1;
|
|
7028
6940
|
} catch (err) {
|
|
7029
6941
|
process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
|
|
7030
6942
|
process.exit(1);
|
|
@@ -7389,7 +7301,7 @@ const realConfigFs = {
|
|
|
7389
7301
|
function assertNotReserved(key) {
|
|
7390
7302
|
if (isReservedKey(key)) throw new Error(`error: "${key}" is a reserved key (universal-plugin's own config) — not a plugin-registered array; edit .agents/universal-plugin.json directly`);
|
|
7391
7303
|
}
|
|
7392
|
-
function addCommand(fs) {
|
|
7304
|
+
function addCommand$1(fs) {
|
|
7393
7305
|
return new Command("add").description("Register (append or replace-by-name) an entry in the array at a config key").requiredOption("--key <key>", "The config key whose array to write").requiredOption("--entry <json>", "The entry object as JSON (must include a \"name\" field)").option("--format <format>", "Output format: json or toon (default: toon)").addOption(ROOT_OPTION).addHelpText("after", "\nExample:\n $ universal-plugin config add --key sdd-plugins --entry '{\"name\":\"aces\",\"handles\":[\"agent evaluation\"]}'\n").action((opts) => {
|
|
7394
7306
|
try {
|
|
7395
7307
|
assertNotReserved(opts.key);
|
|
@@ -7448,138 +7360,35 @@ function getCommand(fs) {
|
|
|
7448
7360
|
});
|
|
7449
7361
|
}
|
|
7450
7362
|
function configCommand(fs = realConfigFs) {
|
|
7451
|
-
return new Command("config").description("Read and write plugin-registered config in .agents/universal-plugin.json").addCommand(addCommand(fs)).addCommand(getCommand(fs));
|
|
7363
|
+
return new Command("config").description("Read and write plugin-registered config in .agents/universal-plugin.json").addCommand(addCommand$1(fs)).addCommand(getCommand(fs));
|
|
7452
7364
|
}
|
|
7453
7365
|
//#endregion
|
|
7454
|
-
//#region src/governance/
|
|
7455
|
-
|
|
7456
|
-
|
|
7457
|
-
|
|
7458
|
-
|
|
7459
|
-
|
|
7460
|
-
function getUserDir() {
|
|
7461
|
-
return path.join(os.homedir(), ".agents", "governances");
|
|
7462
|
-
}
|
|
7463
|
-
function getProjectDir(root) {
|
|
7464
|
-
return path.join(root, "governances");
|
|
7465
|
-
}
|
|
7466
|
-
function getLocalDir(root) {
|
|
7467
|
-
return path.join(root, ".agents", "governances");
|
|
7468
|
-
}
|
|
7469
|
-
function getPackageDir() {
|
|
7470
|
-
const thisFile = fileURLToPath(import.meta.url);
|
|
7471
|
-
return path.join(path.dirname(thisFile), "..", "governances");
|
|
7472
|
-
}
|
|
7473
|
-
function getScopedPaths(root) {
|
|
7474
|
-
return [
|
|
7475
|
-
{
|
|
7476
|
-
scope: "managed",
|
|
7477
|
-
dir: getManagedDir()
|
|
7478
|
-
},
|
|
7479
|
-
{
|
|
7480
|
-
scope: "project",
|
|
7481
|
-
dir: getProjectDir(root)
|
|
7482
|
-
},
|
|
7483
|
-
{
|
|
7484
|
-
scope: "local",
|
|
7485
|
-
dir: getLocalDir(root)
|
|
7486
|
-
},
|
|
7487
|
-
{
|
|
7488
|
-
scope: "user",
|
|
7489
|
-
dir: getUserDir()
|
|
7490
|
-
},
|
|
7491
|
-
{
|
|
7492
|
-
scope: "package",
|
|
7493
|
-
dir: getPackageDir()
|
|
7494
|
-
}
|
|
7495
|
-
];
|
|
7496
|
-
}
|
|
7497
|
-
function showGovernance(name, root, govFs, storeOpts) {
|
|
7498
|
-
const slashIdx = name.indexOf("/");
|
|
7499
|
-
if (slashIdx !== -1 && storeOpts) {
|
|
7500
|
-
const pluginName = name.slice(0, slashIdx);
|
|
7501
|
-
const assetName = name.slice(slashIdx + 1);
|
|
7502
|
-
for (const scope of [
|
|
7503
|
-
"managed",
|
|
7504
|
-
"project",
|
|
7505
|
-
"user"
|
|
7506
|
-
]) {
|
|
7507
|
-
let dir;
|
|
7508
|
-
if (scope === "managed") dir = getManagedDir();
|
|
7509
|
-
else if (scope === "project") dir = getProjectDir(root);
|
|
7510
|
-
else dir = getUserDir();
|
|
7511
|
-
const filePath = path.join(dir, pluginName, `${assetName}.md`);
|
|
7512
|
-
if (govFs.exists(filePath)) return {
|
|
7513
|
-
content: govFs.read(filePath),
|
|
7514
|
-
scope
|
|
7515
|
-
};
|
|
7516
|
-
}
|
|
7517
|
-
const entry = storeOpts.state.assets[pluginName];
|
|
7518
|
-
if (!entry) return null;
|
|
7519
|
-
const segment = path.join(entry.source, `${pluginName}@${entry.version}`);
|
|
7520
|
-
const storePath = path.join(storeOpts.globalStorePath, segment, "governances", `${assetName}.md`);
|
|
7521
|
-
if (govFs.exists(storePath)) return {
|
|
7522
|
-
content: govFs.read(storePath),
|
|
7523
|
-
scope: "store"
|
|
7524
|
-
};
|
|
7525
|
-
return null;
|
|
7526
|
-
}
|
|
7527
|
-
for (const { scope, dir } of getScopedPaths(root)) {
|
|
7528
|
-
const filePath = path.join(dir, `${name}.md`);
|
|
7529
|
-
if (govFs.exists(filePath)) return {
|
|
7530
|
-
content: govFs.read(filePath),
|
|
7531
|
-
scope
|
|
7532
|
-
};
|
|
7533
|
-
}
|
|
7534
|
-
return null;
|
|
7366
|
+
//#region src/governance/cli.ts
|
|
7367
|
+
const RETIRED = "universal-plugin governance is retired; buddy-agent-harness reference reads the same documents.\n";
|
|
7368
|
+
const IN_A_SKILL = "In a skill, load a document with the load-reference skill in the buddy-agent-harness plugin.\n";
|
|
7369
|
+
function retire(replacement) {
|
|
7370
|
+
process.stderr.write(`${RETIRED}${IN_A_SKILL}→ ${replacement}\n`);
|
|
7371
|
+
process.exit(1);
|
|
7535
7372
|
}
|
|
7536
|
-
|
|
7537
|
-
|
|
7538
|
-
|
|
7539
|
-
for (
|
|
7540
|
-
|
|
7541
|
-
|
|
7542
|
-
|
|
7543
|
-
scope
|
|
7544
|
-
});
|
|
7373
|
+
/** The flags `governance show` took that carried a value, so the value is not read as the name. */
|
|
7374
|
+
const VALUE_FLAGS = new Set(["--root", "--format"]);
|
|
7375
|
+
function documentName(args) {
|
|
7376
|
+
for (let i = 0; i < args.length; i++) {
|
|
7377
|
+
const arg = args[i];
|
|
7378
|
+
if (VALUE_FLAGS.has(arg)) i++;
|
|
7379
|
+
else if (!arg.startsWith("-")) return arg;
|
|
7545
7380
|
}
|
|
7546
|
-
return
|
|
7381
|
+
return "<name>";
|
|
7547
7382
|
}
|
|
7548
|
-
|
|
7549
|
-
|
|
7550
|
-
function
|
|
7551
|
-
|
|
7552
|
-
try {
|
|
7553
|
-
return mergeSafeState(JSON.parse(fs.readFileSync(p, "utf8")));
|
|
7554
|
-
} catch {
|
|
7555
|
-
return emptyState();
|
|
7556
|
-
}
|
|
7383
|
+
/** Takes any argument or flag, so a caller still passing `--root` or `--format json` is told where
|
|
7384
|
+
* to go instead of what it typed wrong. */
|
|
7385
|
+
function retiredCommand(name) {
|
|
7386
|
+
return new Command(name).allowUnknownOption().allowExcessArguments().helpOption(false);
|
|
7557
7387
|
}
|
|
7558
7388
|
function governanceCommand() {
|
|
7559
|
-
const cmd =
|
|
7560
|
-
cmd.
|
|
7561
|
-
|
|
7562
|
-
state: readGlobalState(),
|
|
7563
|
-
globalStorePath: globalStorePath()
|
|
7564
|
-
});
|
|
7565
|
-
if (!result) {
|
|
7566
|
-
process.stderr.write(`Governance "${name}" not found\n`);
|
|
7567
|
-
process.exit(1);
|
|
7568
|
-
}
|
|
7569
|
-
outputText(result, () => {
|
|
7570
|
-
process.stdout.write(result.content);
|
|
7571
|
-
});
|
|
7572
|
-
});
|
|
7573
|
-
cmd.command("list").description("List available governances").option("--format <format>", "Output format: json or text (default: text)").addOption(ROOT_OPTION).addOption(new Option("--json").hideHelp()).action((opts) => {
|
|
7574
|
-
const entries = listGovernances(resolveRoot(opts.root), realGovernanceFs);
|
|
7575
|
-
output(entries, {
|
|
7576
|
-
governances: entries.map((e) => ({
|
|
7577
|
-
name: e.name,
|
|
7578
|
-
scope: e.scope
|
|
7579
|
-
})),
|
|
7580
|
-
summary: `${entries.length} governances across ${new Set(entries.map((e) => e.scope)).size} scopes`
|
|
7581
|
-
});
|
|
7582
|
-
});
|
|
7389
|
+
const cmd = retiredCommand("governance").description("Retired: use buddy-agent-harness reference").helpOption("-h, --help", "Show help").helpCommand(false).addHelpText("after", "\nThis command is retired. buddy-agent-harness reference show|list|search reads the same documents.\nExample:\n $ buddy-agent-harness reference show plugin-design\n").action(() => retire("buddy-agent-harness reference list"));
|
|
7390
|
+
cmd.addCommand(retiredCommand("show").action((_opts, command) => retire(`buddy-agent-harness reference show ${documentName(command.args)}`)));
|
|
7391
|
+
cmd.addCommand(retiredCommand("list").action(() => retire("buddy-agent-harness reference list")));
|
|
7583
7392
|
return cmd;
|
|
7584
7393
|
}
|
|
7585
7394
|
//#endregion
|
|
@@ -8269,6 +8078,9 @@ function manifestOwner(manifest) {
|
|
|
8269
8078
|
return owner;
|
|
8270
8079
|
}
|
|
8271
8080
|
}
|
|
8081
|
+
/** The catalog's own identity: its name and its owner, from the root manifest unless overridden.
|
|
8082
|
+
* Exported because `marketplace add` creates a catalog in a repository that has no plugins of its
|
|
8083
|
+
* own to discover, and the two commands must name that catalog the same way. */
|
|
8272
8084
|
function deriveMetadata(root, fs, opts) {
|
|
8273
8085
|
const rootManifest = path.join(root, "plugin.json");
|
|
8274
8086
|
if (fs.exists(rootManifest)) assertContained(root, rootManifest, fs, "root plugin.json");
|
|
@@ -8329,7 +8141,7 @@ function writeArtifacts(fs, artifacts, root) {
|
|
|
8329
8141
|
const changed = artifacts.filter((artifact) => !sameArtifact(fs, path.join(root, artifact.path), artifact.content));
|
|
8330
8142
|
for (const artifact of changed) fs.writeAtomically(path.join(root, artifact.path), artifact.content);
|
|
8331
8143
|
}
|
|
8332
|
-
function selectedTargets(targets) {
|
|
8144
|
+
function selectedTargets$1(targets) {
|
|
8333
8145
|
return targets && targets.length > 0 ? [...new Set(targets)] : [
|
|
8334
8146
|
"claude",
|
|
8335
8147
|
"codex",
|
|
@@ -8337,6 +8149,9 @@ function selectedTargets(targets) {
|
|
|
8337
8149
|
"cursor"
|
|
8338
8150
|
];
|
|
8339
8151
|
}
|
|
8152
|
+
function readCatalog(fs, file) {
|
|
8153
|
+
return fs.exists(file) ? fs.read(file) : void 0;
|
|
8154
|
+
}
|
|
8340
8155
|
function sameArtifact(fs, file, content) {
|
|
8341
8156
|
if (!fs.exists(file)) return false;
|
|
8342
8157
|
const existing = fs.read(file);
|
|
@@ -8358,12 +8173,16 @@ function initializeMarketplace(rootInput, opts = {}, fs = realMarketplaceFs) {
|
|
|
8358
8173
|
const root = path.resolve(rootInput);
|
|
8359
8174
|
const metadata = deriveMetadata(root, fs, opts);
|
|
8360
8175
|
const plugins = discoverPlugins(root, fs, opts.scanDirs);
|
|
8361
|
-
const
|
|
8176
|
+
const derived = selectedTargets$1(opts.targets).map((target) => ({
|
|
8362
8177
|
target,
|
|
8363
8178
|
artifacts: serializeTarget(target, metadata, plugins)
|
|
8364
8179
|
}));
|
|
8180
|
+
for (const { artifacts } of derived) for (const artifact of artifacts) assertContained(root, path.join(root, artifact.path), fs, "selected artifact");
|
|
8181
|
+
const planned = derived.map(({ target, artifacts }) => ({
|
|
8182
|
+
target,
|
|
8183
|
+
artifacts: artifacts.map((artifact) => mergeDiscoveredCatalog(artifact, readCatalog(fs, path.join(root, artifact.path))))
|
|
8184
|
+
}));
|
|
8365
8185
|
for (const { target, artifacts } of planned) for (const artifact of artifacts) {
|
|
8366
|
-
assertContained(root, path.join(root, artifact.path), fs, "selected artifact");
|
|
8367
8186
|
const issues = validateCatalogContent(target, artifact.content);
|
|
8368
8187
|
if (issues.length > 0) throw new Error(formatCatalogIssues(artifact.path, issues));
|
|
8369
8188
|
}
|
|
@@ -8386,13 +8205,403 @@ function initializeMarketplace(rootInput, opts = {}, fs = realMarketplaceFs) {
|
|
|
8386
8205
|
target,
|
|
8387
8206
|
status: opts.dryRun ? "planned" : unchanged ? "unchanged" : "generated",
|
|
8388
8207
|
paths: artifacts.map((artifact) => artifact.path),
|
|
8389
|
-
plugins:
|
|
8208
|
+
plugins: [...new Set(artifacts.flatMap((artifact) => catalogEntryNames(artifact.content)))]
|
|
8390
8209
|
};
|
|
8391
8210
|
});
|
|
8392
8211
|
if (!opts.dryRun && plugins.length > 0) writeArtifacts(fs, planned.flatMap((entry) => entry.artifacts), root);
|
|
8393
8212
|
return results;
|
|
8394
8213
|
}
|
|
8395
8214
|
//#endregion
|
|
8215
|
+
//#region src/marketplace/spec.ts
|
|
8216
|
+
/** What a user names on the command line when they ask for a plugin to be listed, reduced to the
|
|
8217
|
+
* source a catalog entry states. Pure: every rule here is a decision about the string itself, so
|
|
8218
|
+
* nothing consults the filesystem, the network, or the runtime's configuration. */
|
|
8219
|
+
const GITHUB_REPO = /^[A-Za-z0-9][A-Za-z0-9._-]*\/[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
8220
|
+
const NPM_PACKAGE = /^(?:@[A-Za-z0-9][A-Za-z0-9._-]*\/)?[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
8221
|
+
/** The last meaningful segment of a path or URL, with a `.git` suffix removed. */
|
|
8222
|
+
function basename(value) {
|
|
8223
|
+
const segments = value.replace(/[/\\]+$/, "").split(/[/\\]/);
|
|
8224
|
+
return (segments[segments.length - 1] ?? value).replace(/\.git$/, "");
|
|
8225
|
+
}
|
|
8226
|
+
/** An npm package's entry name is the package name without its scope: `@cyberuni/upx` lists as
|
|
8227
|
+
* `upx`, which is what a user installs and what every other source form yields. */
|
|
8228
|
+
function packageEntryName(pkg) {
|
|
8229
|
+
return pkg.startsWith("@") ? pkg.split("/")[1] ?? pkg : pkg;
|
|
8230
|
+
}
|
|
8231
|
+
function pathSpec(value) {
|
|
8232
|
+
const normalized = value.replace(/\\/g, "/").replace(/\/+$/, "");
|
|
8233
|
+
const source = normalized.startsWith("./") || normalized.startsWith("../") ? normalized : `./${normalized}`;
|
|
8234
|
+
return {
|
|
8235
|
+
kind: "path",
|
|
8236
|
+
name: basename(normalized),
|
|
8237
|
+
source
|
|
8238
|
+
};
|
|
8239
|
+
}
|
|
8240
|
+
function npmSpec(pkg) {
|
|
8241
|
+
return {
|
|
8242
|
+
kind: "npm",
|
|
8243
|
+
name: packageEntryName(pkg),
|
|
8244
|
+
source: {
|
|
8245
|
+
source: "npm",
|
|
8246
|
+
package: pkg
|
|
8247
|
+
}
|
|
8248
|
+
};
|
|
8249
|
+
}
|
|
8250
|
+
function githubSpec(repo) {
|
|
8251
|
+
return {
|
|
8252
|
+
kind: "github",
|
|
8253
|
+
name: basename(repo),
|
|
8254
|
+
source: {
|
|
8255
|
+
source: "github",
|
|
8256
|
+
repo
|
|
8257
|
+
}
|
|
8258
|
+
};
|
|
8259
|
+
}
|
|
8260
|
+
function urlSpec(url) {
|
|
8261
|
+
return {
|
|
8262
|
+
kind: "url",
|
|
8263
|
+
name: basename(url),
|
|
8264
|
+
source: {
|
|
8265
|
+
source: "url",
|
|
8266
|
+
url
|
|
8267
|
+
}
|
|
8268
|
+
};
|
|
8269
|
+
}
|
|
8270
|
+
/** Splits `<plugin>@<marketplace>` at the separator that is not a scope marker, so a scoped package
|
|
8271
|
+
* name keeps its leading `@`. */
|
|
8272
|
+
function splitMarketplace(value) {
|
|
8273
|
+
const at = value.indexOf("@", value.startsWith("@") ? 1 : 0);
|
|
8274
|
+
if (at <= 0) return void 0;
|
|
8275
|
+
const plugin = value.slice(0, at);
|
|
8276
|
+
const marketplace = value.slice(at + 1);
|
|
8277
|
+
if (plugin === "" || marketplace === "" || marketplace.includes("/")) return void 0;
|
|
8278
|
+
return {
|
|
8279
|
+
plugin,
|
|
8280
|
+
marketplace
|
|
8281
|
+
};
|
|
8282
|
+
}
|
|
8283
|
+
function marketplaceSpec(value) {
|
|
8284
|
+
const split = splitMarketplace(value);
|
|
8285
|
+
if (!split) throw new Error(`error: "${value}" is not <plugin>@<marketplace>`);
|
|
8286
|
+
return {
|
|
8287
|
+
kind: "marketplace",
|
|
8288
|
+
name: packageEntryName(split.plugin),
|
|
8289
|
+
...split
|
|
8290
|
+
};
|
|
8291
|
+
}
|
|
8292
|
+
const SHA = /^[a-f0-9]{40}$/;
|
|
8293
|
+
/** A subdirectory as `git-subdir` states it: no leading `./`, no trailing slash. */
|
|
8294
|
+
function normalizeSubdir(subdir) {
|
|
8295
|
+
const path = subdir.trim().replace(/\\/g, "/").replace(/^\.\/?/, "").replace(/\/+$/, "");
|
|
8296
|
+
if (path === "") throw new Error("error: --subdir must name a directory inside the repository");
|
|
8297
|
+
if (path.startsWith("/")) throw new Error(`error: --subdir "${subdir}" must be relative to the repository root`);
|
|
8298
|
+
return path;
|
|
8299
|
+
}
|
|
8300
|
+
/** The git URL a spec's source points at, for the two kinds that name a whole repository. */
|
|
8301
|
+
function repositoryUrl(spec) {
|
|
8302
|
+
if (typeof spec.source !== "object") return void 0;
|
|
8303
|
+
if (spec.source.source === "github") return originFromRepo(spec.source.repo).url;
|
|
8304
|
+
if (spec.source.source === "url") return spec.source.url;
|
|
8305
|
+
}
|
|
8306
|
+
/** Narrows a whole-repository source to the one directory inside it that is the plugin.
|
|
8307
|
+
*
|
|
8308
|
+
* A monorepo publishes several plugins from one repository, and neither `github` nor `url` can say
|
|
8309
|
+
* which directory. `git-subdir` is the form that can, so `--subdir` turns the source into one —
|
|
8310
|
+
* and the entry takes its name from that directory rather than from the repository. */
|
|
8311
|
+
function withSubdir(spec, subdir) {
|
|
8312
|
+
const url = repositoryUrl(spec);
|
|
8313
|
+
if (url === void 0) throw new Error(`error: --subdir applies to a repository source, not to a ${spec.kind} one`);
|
|
8314
|
+
const path = normalizeSubdir(subdir);
|
|
8315
|
+
return {
|
|
8316
|
+
kind: spec.kind,
|
|
8317
|
+
name: basename(path),
|
|
8318
|
+
source: {
|
|
8319
|
+
source: "git-subdir",
|
|
8320
|
+
url,
|
|
8321
|
+
path
|
|
8322
|
+
}
|
|
8323
|
+
};
|
|
8324
|
+
}
|
|
8325
|
+
/** Pins a source to a branch, tag, or commit. Only the git-backed forms carry one; an npm package is
|
|
8326
|
+
* pinned by version and a path is whatever is on disk. */
|
|
8327
|
+
function withPin(spec, ref, sha) {
|
|
8328
|
+
if (ref === void 0 && sha === void 0) return spec;
|
|
8329
|
+
const source = spec.source;
|
|
8330
|
+
if (typeof source !== "object" || ![
|
|
8331
|
+
"url",
|
|
8332
|
+
"github",
|
|
8333
|
+
"git-subdir"
|
|
8334
|
+
].includes(source.source)) throw new Error(`error: --ref and --sha apply to a git source, not to a ${spec.kind} one`);
|
|
8335
|
+
if (sha !== void 0 && !SHA.test(sha)) throw new Error(`error: --sha "${sha}" must be a full 40-character commit hash`);
|
|
8336
|
+
return {
|
|
8337
|
+
...spec,
|
|
8338
|
+
source: {
|
|
8339
|
+
...source,
|
|
8340
|
+
...ref === void 0 ? {} : { ref },
|
|
8341
|
+
...sha === void 0 ? {} : { sha }
|
|
8342
|
+
}
|
|
8343
|
+
};
|
|
8344
|
+
}
|
|
8345
|
+
function looksLikeUrl(value) {
|
|
8346
|
+
return value.includes("://") || value.startsWith("git@");
|
|
8347
|
+
}
|
|
8348
|
+
function looksLikePath(value) {
|
|
8349
|
+
return value.startsWith("./") || value.startsWith("../") || value.startsWith("/") || /^[A-Za-z]:[\\/]/.test(value);
|
|
8350
|
+
}
|
|
8351
|
+
/** Applies the kind the user named, so every guess below has an override. */
|
|
8352
|
+
function explicitSpec(value, kind) {
|
|
8353
|
+
switch (kind) {
|
|
8354
|
+
case "path": return pathSpec(value);
|
|
8355
|
+
case "npm": return npmSpec(value.replace(/^npm:/, ""));
|
|
8356
|
+
case "github": return githubSpec(value);
|
|
8357
|
+
case "url": return urlSpec(value);
|
|
8358
|
+
case "marketplace": return marketplaceSpec(value);
|
|
8359
|
+
}
|
|
8360
|
+
}
|
|
8361
|
+
/** Reads a spec string as the source it names.
|
|
8362
|
+
*
|
|
8363
|
+
* The order below is the disambiguation, and every step of it is overridable with an explicit kind:
|
|
8364
|
+
*
|
|
8365
|
+
* 1. an `npm:` prefix, which is how a bare name that looks like anything else is forced to a package
|
|
8366
|
+
* 2. a URL scheme or an `scp`-style `git@host:path`
|
|
8367
|
+
* 3. a path, which has to say so with `./`, `../`, or a leading slash
|
|
8368
|
+
* 4. `<plugin>@<marketplace>`, splitting after a leading scope `@` so `@scope/pkg` is not one
|
|
8369
|
+
* 5. `owner/repo`
|
|
8370
|
+
* 6. anything else left, which is an npm package name
|
|
8371
|
+
*
|
|
8372
|
+
* Step 3 is why `plugins/alpha` reads as a GitHub repository rather than a directory: both are
|
|
8373
|
+
* `a/b`, and a guess that reached for the filesystem would answer differently depending on where
|
|
8374
|
+
* the command was run. `./plugins/alpha` or `--path` says it plainly. */
|
|
8375
|
+
function parsePluginSpec(value, opts = {}) {
|
|
8376
|
+
const { kind, subdir, ref, sha } = opts;
|
|
8377
|
+
const spec = value.trim();
|
|
8378
|
+
if (spec === "") throw new Error("error: a plugin spec is required");
|
|
8379
|
+
const shape = (base) => withPin(subdir === void 0 ? base : withSubdir(base, subdir), ref, sha);
|
|
8380
|
+
if (kind) return shape(explicitSpec(spec, kind));
|
|
8381
|
+
if (spec.startsWith("npm:")) {
|
|
8382
|
+
const pkg = spec.slice(4);
|
|
8383
|
+
if (!NPM_PACKAGE.test(pkg)) throw new Error(`error: "${pkg}" is not an npm package name`);
|
|
8384
|
+
return shape(npmSpec(pkg));
|
|
8385
|
+
}
|
|
8386
|
+
if (looksLikeUrl(spec)) return shape(urlSpec(spec));
|
|
8387
|
+
if (looksLikePath(spec)) return shape(pathSpec(spec));
|
|
8388
|
+
const split = splitMarketplace(spec);
|
|
8389
|
+
if (split) return shape({
|
|
8390
|
+
kind: "marketplace",
|
|
8391
|
+
name: packageEntryName(split.plugin),
|
|
8392
|
+
...split
|
|
8393
|
+
});
|
|
8394
|
+
if (GITHUB_REPO.test(spec)) return shape(githubSpec(spec));
|
|
8395
|
+
if (NPM_PACKAGE.test(spec)) return shape(npmSpec(spec));
|
|
8396
|
+
throw new Error(`error: cannot tell what "${spec}" names; pass --path, --npm, --github, --url, or --from-marketplace`);
|
|
8397
|
+
}
|
|
8398
|
+
//#endregion
|
|
8399
|
+
//#region src/marketplace/add.ts
|
|
8400
|
+
/** The manifest fields a catalog entry carries. Exactly the set the catalog already derives from a
|
|
8401
|
+
* discovered plugin, so an added entry and a discovered one say the same kinds of things. */
|
|
8402
|
+
const ENTRY_METADATA_FIELDS = [
|
|
8403
|
+
"description",
|
|
8404
|
+
"version",
|
|
8405
|
+
"homepage",
|
|
8406
|
+
"repository",
|
|
8407
|
+
"license",
|
|
8408
|
+
"keywords"
|
|
8409
|
+
];
|
|
8410
|
+
/** The kind name used to decide whether a runtime accepts a source, from the source itself. */
|
|
8411
|
+
function sourceKindOf(source) {
|
|
8412
|
+
if (isLocalCatalogSource(source)) return "path";
|
|
8413
|
+
return typeof source === "string" ? "path" : source.source;
|
|
8414
|
+
}
|
|
8415
|
+
/** A source rendered for the result table — one short cell, not a JSON blob. */
|
|
8416
|
+
function describeSource(source) {
|
|
8417
|
+
if (typeof source === "string") return source;
|
|
8418
|
+
const detail = source.package ?? source.repo ?? source.url ?? source.path;
|
|
8419
|
+
return typeof detail === "string" ? `${source.source}:${detail}` : String(source.source);
|
|
8420
|
+
}
|
|
8421
|
+
function readJsonFile(fs, file) {
|
|
8422
|
+
if (!fs.exists(file)) return void 0;
|
|
8423
|
+
try {
|
|
8424
|
+
const parsed = JSON.parse(fs.read(file));
|
|
8425
|
+
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed : void 0;
|
|
8426
|
+
} catch {
|
|
8427
|
+
return;
|
|
8428
|
+
}
|
|
8429
|
+
}
|
|
8430
|
+
/** Metadata already on this machine for the plugin being listed.
|
|
8431
|
+
*
|
|
8432
|
+
* A local path carries its own manifest, and an npm package that happens to be installed carries a
|
|
8433
|
+
* `package.json`. Neither is fetched: what is here is read, what is not here is left to the
|
|
8434
|
+
* metadata flags. An entry missing an optional field still installs. */
|
|
8435
|
+
function localMetadata(root, spec, fs) {
|
|
8436
|
+
if (spec.kind === "path" && typeof spec.source === "string") return readJsonFile(fs, path.join(root, spec.source, "plugin.json")) ?? {};
|
|
8437
|
+
if (spec.kind === "npm" && typeof spec.source === "object") {
|
|
8438
|
+
const pkg = spec.source.package;
|
|
8439
|
+
if (typeof pkg !== "string") return {};
|
|
8440
|
+
return readJsonFile(fs, path.join(root, "node_modules", pkg, "package.json")) ?? {};
|
|
8441
|
+
}
|
|
8442
|
+
return {};
|
|
8443
|
+
}
|
|
8444
|
+
/** The entry another marketplace already publishes for this plugin, copied rather than invented.
|
|
8445
|
+
*
|
|
8446
|
+
* The catalog schema has no "from another marketplace" source, so there is nothing to write until
|
|
8447
|
+
* that marketplace has been read. Its entry already names a source every runtime can resolve — that
|
|
8448
|
+
* is what makes it publishable — so the whole entry comes across. */
|
|
8449
|
+
function resolveFromMarketplace(spec, opts, fs) {
|
|
8450
|
+
const marketplace = spec.marketplace;
|
|
8451
|
+
const known = opts.from === void 0 ? resolveKnownMarketplace(marketplace, fs) : { dir: opts.from };
|
|
8452
|
+
if (known === void 0) throw new Error(`error: marketplace "${marketplace}" is not installed; add it in the runtime first, or pass --from <path>`);
|
|
8453
|
+
const dir = known.dir;
|
|
8454
|
+
const content = readMarketplaceCatalog(dir, fs);
|
|
8455
|
+
if (content === void 0) throw new Error(`error: no marketplace.json found under "${dir}"`);
|
|
8456
|
+
let catalog;
|
|
8457
|
+
try {
|
|
8458
|
+
catalog = JSON.parse(content);
|
|
8459
|
+
} catch {
|
|
8460
|
+
throw new Error(`error: the catalog in "${dir}" is not valid JSON`);
|
|
8461
|
+
}
|
|
8462
|
+
const found = (typeof catalog === "object" && catalog !== null && Array.isArray(catalog.plugins) ? catalog.plugins : []).find((entry) => typeof entry === "object" && entry !== null && entry.name === spec.plugin);
|
|
8463
|
+
if (!found) throw new Error(`error: marketplace "${marketplace}" lists no plugin "${spec.plugin}"`);
|
|
8464
|
+
const source = found.source;
|
|
8465
|
+
if (source === void 0) throw new Error(`error: the "${spec.plugin}" entry in "${marketplace}" names no source`);
|
|
8466
|
+
if (!isLocalCatalogSource(source)) return {
|
|
8467
|
+
source,
|
|
8468
|
+
metadata: found
|
|
8469
|
+
};
|
|
8470
|
+
const remote = gitRemoteUrl(dir);
|
|
8471
|
+
const origin = known.origin ?? (remote === void 0 ? void 0 : originFromUrl(remote));
|
|
8472
|
+
if (origin === void 0) throw new Error(`error: "${spec.plugin}" is local to marketplace "${marketplace}", and that marketplace has no remote to rewrite its source against`);
|
|
8473
|
+
const rewritten = absoluteSource(origin, source);
|
|
8474
|
+
if (rewritten === void 0) throw new Error(`error: the "${spec.plugin}" entry in "${marketplace}" names a source this command cannot read`);
|
|
8475
|
+
return {
|
|
8476
|
+
source: rewritten,
|
|
8477
|
+
metadata: found
|
|
8478
|
+
};
|
|
8479
|
+
}
|
|
8480
|
+
function selectedTargets(targets) {
|
|
8481
|
+
return targets && targets.length > 0 ? [...new Set(targets)] : [
|
|
8482
|
+
"claude",
|
|
8483
|
+
"codex",
|
|
8484
|
+
"copilot",
|
|
8485
|
+
"cursor"
|
|
8486
|
+
];
|
|
8487
|
+
}
|
|
8488
|
+
function entryMetadata(discovered, supplied = {}) {
|
|
8489
|
+
const metadata = {};
|
|
8490
|
+
for (const field of ENTRY_METADATA_FIELDS) {
|
|
8491
|
+
const value = supplied[field] ?? discovered[field];
|
|
8492
|
+
if (value !== void 0) metadata[field] = value;
|
|
8493
|
+
}
|
|
8494
|
+
return metadata;
|
|
8495
|
+
}
|
|
8496
|
+
function catalogEntry(content, name) {
|
|
8497
|
+
try {
|
|
8498
|
+
const parsed = JSON.parse(content);
|
|
8499
|
+
if (!Array.isArray(parsed.plugins)) return void 0;
|
|
8500
|
+
return parsed.plugins.find((entry) => typeof entry === "object" && entry !== null && entry.name === name);
|
|
8501
|
+
} catch {
|
|
8502
|
+
return;
|
|
8503
|
+
}
|
|
8504
|
+
}
|
|
8505
|
+
/** Lists a plugin that lives somewhere else in this repository's catalogs.
|
|
8506
|
+
*
|
|
8507
|
+
* Where `marketplace init` derives a catalog from the plugins a repository holds, this adds one it
|
|
8508
|
+
* does not: an npm package, a GitHub repository, an entry another marketplace already publishes.
|
|
8509
|
+
* Together they let one repository be both — a plugin's own home and a curated list.
|
|
8510
|
+
*
|
|
8511
|
+
* Nothing is fetched and nothing is published. The command reads this repository, and at most a
|
|
8512
|
+
* marketplace already installed on this machine, and writes catalog files. */
|
|
8513
|
+
function addToMarketplace(rootInput, spec, opts = {}, fs = realMarketplaceFs) {
|
|
8514
|
+
const root = path.resolve(rootInput);
|
|
8515
|
+
const parsed = parsePluginSpec(spec, {
|
|
8516
|
+
kind: opts.kind,
|
|
8517
|
+
subdir: opts.subdir,
|
|
8518
|
+
ref: opts.ref,
|
|
8519
|
+
sha: opts.sha
|
|
8520
|
+
});
|
|
8521
|
+
const name = opts.name ?? parsed.name;
|
|
8522
|
+
assertMarketplaceName(name, "plugin name");
|
|
8523
|
+
const resolved = parsed.kind === "marketplace" ? resolveFromMarketplace(parsed, opts, fs) : {
|
|
8524
|
+
source: parsed.source,
|
|
8525
|
+
metadata: localMetadata(root, parsed, fs)
|
|
8526
|
+
};
|
|
8527
|
+
const metadata = deriveMetadata(root, fs, {
|
|
8528
|
+
name: opts.marketplaceName,
|
|
8529
|
+
owner: opts.owner
|
|
8530
|
+
});
|
|
8531
|
+
const plugin = {
|
|
8532
|
+
name,
|
|
8533
|
+
source: resolved.source,
|
|
8534
|
+
metadata: entryMetadata(resolved.metadata, opts.metadata)
|
|
8535
|
+
};
|
|
8536
|
+
const kind = sourceKindOf(resolved.source);
|
|
8537
|
+
const rendered = describeSource(resolved.source);
|
|
8538
|
+
const planned = selectedTargets(opts.targets).map((target) => {
|
|
8539
|
+
const catalogPath = TARGET_CATALOG_PATHS[target];
|
|
8540
|
+
const file = path.join(root, catalogPath);
|
|
8541
|
+
const existing = fs.exists(file) ? fs.read(file) : void 0;
|
|
8542
|
+
if (!TARGET_SOURCE_KINDS[target].includes(kind)) return {
|
|
8543
|
+
target,
|
|
8544
|
+
catalogPath,
|
|
8545
|
+
file,
|
|
8546
|
+
existing,
|
|
8547
|
+
skipped: `${kind} source is not supported by ${target}`,
|
|
8548
|
+
artifact: void 0
|
|
8549
|
+
};
|
|
8550
|
+
return {
|
|
8551
|
+
target,
|
|
8552
|
+
catalogPath,
|
|
8553
|
+
file,
|
|
8554
|
+
existing,
|
|
8555
|
+
skipped: void 0,
|
|
8556
|
+
artifact: mergeCatalogEntry(target, metadata, plugin, () => existing, { keepForeignSource: false })
|
|
8557
|
+
};
|
|
8558
|
+
});
|
|
8559
|
+
for (const entry of planned) {
|
|
8560
|
+
if (!entry.artifact) continue;
|
|
8561
|
+
const issues = validateCatalogContent(entry.target, entry.artifact.content);
|
|
8562
|
+
if (issues.length > 0) throw new Error(formatCatalogIssues(entry.catalogPath, issues));
|
|
8563
|
+
}
|
|
8564
|
+
const conflicts = planned.filter((entry) => {
|
|
8565
|
+
if (!entry.artifact || entry.existing === void 0 || opts.force) return false;
|
|
8566
|
+
if (catalogEntry(entry.existing, name) === void 0) return false;
|
|
8567
|
+
return !sameCatalogContent(entry.existing, entry.artifact.content);
|
|
8568
|
+
}).map((entry) => entry.catalogPath);
|
|
8569
|
+
if (conflicts.length > 0) throw new Error(`error: "${name}" is already listed differently in ${conflicts.join(", ")}; rerun with --force to replace it`);
|
|
8570
|
+
const results = planned.map((entry) => {
|
|
8571
|
+
const row = {
|
|
8572
|
+
target: entry.target,
|
|
8573
|
+
entry: name,
|
|
8574
|
+
source: rendered,
|
|
8575
|
+
path: entry.catalogPath
|
|
8576
|
+
};
|
|
8577
|
+
if (entry.skipped || !entry.artifact) return {
|
|
8578
|
+
...row,
|
|
8579
|
+
status: "skipped",
|
|
8580
|
+
source: rendered,
|
|
8581
|
+
reason: entry.skipped
|
|
8582
|
+
};
|
|
8583
|
+
if (entry.existing !== void 0 && sameCatalogContent(entry.existing, entry.artifact.content)) return {
|
|
8584
|
+
...row,
|
|
8585
|
+
status: "unchanged"
|
|
8586
|
+
};
|
|
8587
|
+
if (opts.dryRun) return {
|
|
8588
|
+
...row,
|
|
8589
|
+
status: "planned"
|
|
8590
|
+
};
|
|
8591
|
+
const existed = entry.existing !== void 0 && catalogEntry(entry.existing, name) !== void 0;
|
|
8592
|
+
return {
|
|
8593
|
+
...row,
|
|
8594
|
+
status: existed ? "updated" : "added"
|
|
8595
|
+
};
|
|
8596
|
+
});
|
|
8597
|
+
if (!opts.dryRun) for (const entry of planned) {
|
|
8598
|
+
if (!entry.artifact) continue;
|
|
8599
|
+
if (entry.existing !== void 0 && sameCatalogContent(entry.existing, entry.artifact.content)) continue;
|
|
8600
|
+
fs.writeAtomically(entry.file, entry.artifact.content);
|
|
8601
|
+
}
|
|
8602
|
+
return results;
|
|
8603
|
+
}
|
|
8604
|
+
//#endregion
|
|
8396
8605
|
//#region src/marketplace/validate.ts
|
|
8397
8606
|
/** A `./`-prefixed source names a directory inside the repository, and Claude Code resolves it
|
|
8398
8607
|
* against the directory holding `.claude-plugin/`. A source pointing nowhere passes every schema
|
|
@@ -8463,6 +8672,65 @@ function targetsFromOptions(opts) {
|
|
|
8463
8672
|
].filter((target) => opts[target]);
|
|
8464
8673
|
return targets.length > 0 ? targets : void 0;
|
|
8465
8674
|
}
|
|
8675
|
+
/** The kind flags, as one map, so a second one is an error rather than a silent precedence rule. */
|
|
8676
|
+
const KIND_FLAGS = {
|
|
8677
|
+
path: "path",
|
|
8678
|
+
npm: "npm",
|
|
8679
|
+
github: "github",
|
|
8680
|
+
url: "url",
|
|
8681
|
+
fromMarketplace: "marketplace"
|
|
8682
|
+
};
|
|
8683
|
+
function kindFromOptions(opts) {
|
|
8684
|
+
const named = Object.keys(KIND_FLAGS).filter((flag) => opts[flag]);
|
|
8685
|
+
if (named.length > 1) throw new Error(`error: pass one source kind, not ${named.length}`);
|
|
8686
|
+
return named.length === 1 ? KIND_FLAGS[named[0]] : void 0;
|
|
8687
|
+
}
|
|
8688
|
+
function metadataFromOptions(opts) {
|
|
8689
|
+
const metadata = {};
|
|
8690
|
+
for (const field of ENTRY_METADATA_FIELDS) {
|
|
8691
|
+
const value = opts[field];
|
|
8692
|
+
if (typeof value !== "string") continue;
|
|
8693
|
+
metadata[field] = field === "keywords" ? value.split(",").map((keyword) => keyword.trim()).filter((keyword) => keyword !== "") : value;
|
|
8694
|
+
}
|
|
8695
|
+
return metadata;
|
|
8696
|
+
}
|
|
8697
|
+
function addCommand() {
|
|
8698
|
+
return new Command("add").description("List a plugin that lives elsewhere in this repository's marketplace catalogs").argument("<spec>", "What to list: ./path, owner/repo, a git URL, an npm package (npm:<pkg>), or <plugin>@<marketplace>").option("--claude", "Write the Claude marketplace catalog").option("--codex", "Write the Codex marketplace catalog").option("--copilot", "Write the Copilot marketplace catalog").option("--cursor", "Write the Cursor marketplace catalog").option("--path", "Read the spec as a repository-relative path").option("--npm", "Read the spec as an npm package name").option("--github", "Read the spec as an owner/repo GitHub repository").option("--url", "Read the spec as a git or https URL").option("--from-marketplace", "Read the spec as <plugin>@<marketplace>").option("--from <dir>", "Directory holding the marketplace to copy an entry from").option("--subdir <path>", "The plugin's directory inside the repository, for a monorepo").option("--ref <ref>", "Branch or tag to pin a git source to").option("--sha <sha>", "Commit to pin a git source to (full 40-character hash)").option("--name <name>", "Entry name, when it differs from the one the spec implies").option("--description <text>", "Entry description").option("--version <version>", "Entry version").option("--homepage <url>", "Entry homepage").option("--repository <url>", "Entry repository URL").option("--license <id>", "Entry license").option("--keywords <list>", "Entry keywords, comma-separated").option("--marketplace-name <name>", "Name for the catalog, when this command has to create one").option("--owner <name>", "Owner for the catalog, when this command has to create one").option("--dry-run", "Preview the entry without writing it").option("--force", "Replace an entry of this name that is already listed differently").option("--format <format>", "Output format: toon or json (default: toon)").addOption(ROOT_OPTION).addHelpText("after", "\nExamples:\n $ universal-plugin marketplace add npm:repobuddy\n $ universal-plugin marketplace add cyberuni/universal-plugin\n $ universal-plugin marketplace add cyberuni/cyber-sdd --subdir plugins/aced\n $ universal-plugin marketplace add repobuddy@cyberplace --dry-run\n").action((spec, opts) => {
|
|
8699
|
+
try {
|
|
8700
|
+
if (opts.format !== void 0 && opts.format !== "toon" && opts.format !== "json") throw new Error("error: --format must be \"toon\" or \"json\"");
|
|
8701
|
+
const results = addToMarketplace(resolveRoot(opts.root), spec, {
|
|
8702
|
+
targets: targetsFromOptions(opts),
|
|
8703
|
+
kind: kindFromOptions(opts),
|
|
8704
|
+
name: opts.name,
|
|
8705
|
+
from: opts.from,
|
|
8706
|
+
subdir: opts.subdir,
|
|
8707
|
+
ref: opts.ref,
|
|
8708
|
+
sha: opts.sha,
|
|
8709
|
+
metadata: metadataFromOptions(opts),
|
|
8710
|
+
marketplaceName: opts.marketplaceName,
|
|
8711
|
+
owner: opts.owner,
|
|
8712
|
+
dryRun: opts.dryRun,
|
|
8713
|
+
force: opts.force
|
|
8714
|
+
});
|
|
8715
|
+
output(results, {
|
|
8716
|
+
targets: results.map((row) => ({
|
|
8717
|
+
target: row.target,
|
|
8718
|
+
status: row.status,
|
|
8719
|
+
entry: row.entry,
|
|
8720
|
+
source: row.source,
|
|
8721
|
+
path: row.path,
|
|
8722
|
+
reason: row.reason ?? "-"
|
|
8723
|
+
})),
|
|
8724
|
+
summary: `${results.filter((row) => row.status === "skipped").length} skipped of ${results.length}`
|
|
8725
|
+
});
|
|
8726
|
+
for (const row of results.filter((row) => row.status === "skipped")) process.stderr.write(`skipped ${row.target}: ${row.reason}\n`);
|
|
8727
|
+
process.stderr.write(`${opts.dryRun ? "Planned" : "Wrote"} repository metadata only; no marketplace publication, registration, installation, authentication, or provisioning occurred.\n`);
|
|
8728
|
+
} catch (err) {
|
|
8729
|
+
process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
|
|
8730
|
+
process.exitCode = 1;
|
|
8731
|
+
}
|
|
8732
|
+
});
|
|
8733
|
+
}
|
|
8466
8734
|
function initCommand() {
|
|
8467
8735
|
return new Command("init").description("Generate local marketplace metadata without publishing or provisioning").option("--claude", "Generate the Claude marketplace catalog").option("--codex", "Generate the Codex marketplace catalog").option("--copilot", "Generate the Copilot marketplace catalog").option("--cursor", "Generate the Cursor marketplace catalog").option("--plugin-scan-dir <dir>", "Directory beneath --root to scan for root-level plugin.json files (repeatable)", (value, previous = []) => [...previous, value]).option("--name <name>", "Override the marketplace name").option("--owner <name>", "Override the marketplace owner").option("--dry-run", "Preview artifacts without writing them").option("--force", "Replace differing artifacts for selected targets").option("--format <format>", "Output format: toon or json (default: toon)").addOption(new Option("--json").hideHelp()).addOption(ROOT_OPTION).addHelpText("after", "\nExample:\n $ universal-plugin marketplace init --root .\n").action((opts) => {
|
|
8468
8736
|
try {
|
|
@@ -8520,7 +8788,7 @@ function validateCommand$1() {
|
|
|
8520
8788
|
});
|
|
8521
8789
|
}
|
|
8522
8790
|
function marketplaceCommand() {
|
|
8523
|
-
return new Command("marketplace").description("Generate repository-local marketplace metadata").addCommand(initCommand()).addCommand(validateCommand$1());
|
|
8791
|
+
return new Command("marketplace").description("Generate repository-local marketplace metadata").addCommand(addCommand()).addCommand(initCommand()).addCommand(validateCommand$1());
|
|
8524
8792
|
}
|
|
8525
8793
|
//#endregion
|
|
8526
8794
|
//#region src/prepare/fs.ts
|
|
@@ -8775,7 +9043,7 @@ const realSyncVersionFs = realJsonIo;
|
|
|
8775
9043
|
//#endregion
|
|
8776
9044
|
//#region src/publish/sync-version.ts
|
|
8777
9045
|
/** The changesets-driven direction of the version flow: the number is decided by
|
|
8778
|
-
* `changeset version` in `<packagePath>/package.json
|
|
9046
|
+
* `changeset version` in `<packagePath>/package.json` (the plugin root's when none is declared), and this copies it into the canonical
|
|
8779
9047
|
* manifest. `plugin version` is the other direction — the number decided here, flowing out to
|
|
8780
9048
|
* `package.json`. The two differ **only** in where the version comes from, so they share
|
|
8781
9049
|
* `applyVersionPlan` and cannot drift; `package.json` is the source here, never rewritten. */
|
|
@@ -8783,11 +9051,11 @@ function syncVersion(root, syncFs) {
|
|
|
8783
9051
|
const manifestPath = path.join(root, "plugin.json");
|
|
8784
9052
|
if (!syncFs.exists(manifestPath)) throw new Error(`No plugin.json found at ${root}`);
|
|
8785
9053
|
const agentsConfigPath = path.join(root, ".agents", "universal-plugin.json");
|
|
8786
|
-
const
|
|
8787
|
-
|
|
9054
|
+
const declared = getPackagePath(syncFs.exists(agentsConfigPath) ? JSON.parse(syncFs.read(agentsConfigPath)) : {});
|
|
9055
|
+
const packagePath = declared ?? ".";
|
|
8788
9056
|
const manifest = JSON.parse(syncFs.read(manifestPath));
|
|
8789
9057
|
const pkgJsonPath = path.join(root, packagePath, "package.json");
|
|
8790
|
-
if (!syncFs.exists(pkgJsonPath)) throw new Error(`No package.json found at ${packagePath}`);
|
|
9058
|
+
if (!syncFs.exists(pkgJsonPath)) throw new Error(declared === null ? "No package.json to sync from: packagePath is not set in .agents/universal-plugin.json and the plugin root has no package.json" : `No package.json found at ${packagePath}`);
|
|
8791
9059
|
const version = JSON.parse(syncFs.read(pkgJsonPath))["version"];
|
|
8792
9060
|
if (!version || typeof version !== "string") throw new Error(`No version found in ${packagePath}/package.json`);
|
|
8793
9061
|
const current = manifest["version"];
|
|
@@ -8814,7 +9082,7 @@ function syncVersion(root, syncFs) {
|
|
|
8814
9082
|
//#region src/publish/cli.ts
|
|
8815
9083
|
function publishCommand() {
|
|
8816
9084
|
const cmd = new Command("publish").description("Prepare plugin for publishing").helpCommand(false);
|
|
8817
|
-
cmd.command("sync-version").description("Sync version from packagePath/package.json into plugin.json").addOption(ROOT_OPTION).action((opts) => {
|
|
9085
|
+
cmd.command("sync-version").description("Sync version from packagePath/package.json (default: the plugin root) into plugin.json").addOption(ROOT_OPTION).action((opts) => {
|
|
8818
9086
|
try {
|
|
8819
9087
|
const result = syncVersion(resolveRoot(opts.root), realSyncVersionFs);
|
|
8820
9088
|
output(result, {
|