universal-plugin 0.9.0 → 0.11.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/dist/cli.mjs CHANGED
@@ -4,8 +4,10 @@ import { fileURLToPath } from "node:url";
4
4
  import * as childProcess from "node:child_process";
5
5
  import { execFileSync } from "node:child_process";
6
6
  import * as path from "node:path";
7
+ import { join, win32 } from "node:path";
7
8
  import * as fs from "node:fs";
8
9
  import * as os from "node:os";
10
+ import { homedir } from "node:os";
9
11
  //#region \0rolldown/runtime.js
10
12
  var __create = Object.create;
11
13
  var __defProp = Object.defineProperty;
@@ -3779,12 +3781,6 @@ function output(data, view) {
3779
3781
  if (isJsonOutput()) printJson(data);
3780
3782
  else console.log(encode(view ?? data));
3781
3783
  }
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
3784
  //#endregion
3789
3785
  //#region ../../node_modules/.pnpm/semver@7.8.1/node_modules/semver/internal/constants.js
3790
3786
  var require_constants = /* @__PURE__ */ __commonJSMin(((exports, module) => {
@@ -5247,274 +5243,6 @@ function discardedRangeWarning(index, entry, range) {
5247
5243
  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
5244
  }
5249
5245
  //#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
5246
  //#region src/hooks/hooks.ts
5519
5247
  /** Per-vendor hook support. Source: `.research/hook-event-survey/conclusion.md`, re-verified against
5520
5248
  * vendor documentation August 2026. A vendor fact decays — re-verify before trusting this table. */
@@ -5681,6 +5409,23 @@ function claudeArtifact(metadata, plugins) {
5681
5409
  })
5682
5410
  };
5683
5411
  }
5412
+ /** Whether a catalog entry's `source` names a place inside this repository — a `./`-prefixed path in
5413
+ * the Claude-shaped catalogs, or Codex's `{ source: "local", path }`. Every other form names a
5414
+ * plugin distributed from somewhere else: an npm package, a GitHub repository, a URL. Discovery
5415
+ * walks directories, so it can produce the first kind and can say nothing about the second. */
5416
+ function isLocalCatalogSource(source) {
5417
+ if (typeof source === "string") return true;
5418
+ if (typeof source !== "object" || source === null || Array.isArray(source)) return false;
5419
+ return source.source === "local";
5420
+ }
5421
+ /** Codex states a source as an object either way: a repository path is tagged `local`, and a source
5422
+ * that already carries its own tag passes through as it stands. */
5423
+ function codexSource(source) {
5424
+ return typeof source === "string" ? {
5425
+ source: "local",
5426
+ path: source
5427
+ } : source;
5428
+ }
5684
5429
  function codexArtifact(metadata, plugins) {
5685
5430
  return {
5686
5431
  path: TARGET_CATALOG_PATHS.codex,
@@ -5690,10 +5435,7 @@ function codexArtifact(metadata, plugins) {
5690
5435
  plugins: plugins.map((plugin) => ({
5691
5436
  name: plugin.name,
5692
5437
  version: plugin.metadata.version,
5693
- source: {
5694
- source: "local",
5695
- path: plugin.source
5696
- },
5438
+ source: codexSource(plugin.source),
5697
5439
  policy: {
5698
5440
  installation: "AVAILABLE",
5699
5441
  authentication: "ON_INSTALL"
@@ -5742,6 +5484,72 @@ const VENDOR_TARGETS = {
5742
5484
  codex: "codex",
5743
5485
  "copilot-cli": "copilot"
5744
5486
  };
5487
+ const GITHUB_URL = /^(?:https?:\/\/(?:www\.)?github\.com\/|git@github\.com:)([^/]+\/[^/]+?)(?:\.git)?\/?$/;
5488
+ /** A git URL read as an origin, recognizing a GitHub one so an entry at the repository root can use
5489
+ * the tidier `github` form. */
5490
+ function originFromUrl(url) {
5491
+ const match = GITHUB_URL.exec(url.trim());
5492
+ return match ? {
5493
+ url,
5494
+ repo: match[1]
5495
+ } : { url };
5496
+ }
5497
+ /** An `owner/repo` slug as an origin. */
5498
+ function originFromRepo(repo) {
5499
+ return {
5500
+ url: `https://github.com/${repo}.git`,
5501
+ repo
5502
+ };
5503
+ }
5504
+ /** The path part of a local source, `./` stripped, empty at the marketplace root. */
5505
+ function localSourcePath(source) {
5506
+ const raw = typeof source === "string" ? source : source.path;
5507
+ if (typeof raw !== "string") return void 0;
5508
+ return raw.replace(/^\.\/?/, "").replace(/\/+$/, "");
5509
+ }
5510
+ /** Rewrites a source that is local to *another* marketplace into one that resolves from anywhere.
5511
+ *
5512
+ * A copied entry's `./plugins/aced` is relative to the marketplace it came from, so carrying it
5513
+ * across unchanged would point at a directory this repository does not have. The path is not
5514
+ * useless, though — it is a location inside a repository whose URL is known, which is exactly what
5515
+ * `git-subdir` states. An entry at that repository's root needs no subdirectory and takes the
5516
+ * plainer `github` or `url` form. */
5517
+ function absoluteSource(origin, source) {
5518
+ const subdir = localSourcePath(source);
5519
+ if (subdir === void 0) return void 0;
5520
+ if (subdir === "" || subdir === ".") return origin.repo ? {
5521
+ source: "github",
5522
+ repo: origin.repo
5523
+ } : {
5524
+ source: "url",
5525
+ url: origin.url
5526
+ };
5527
+ return {
5528
+ source: "git-subdir",
5529
+ url: origin.url,
5530
+ path: subdir
5531
+ };
5532
+ }
5533
+ /** The source forms each runtime installs from./** The source forms each runtime installs from.
5534
+ *
5535
+ * Every runtime takes a repository path. Beyond that they diverge, and the divergence is not
5536
+ * cosmetic: a catalog is read at install time in someone else's terminal, so a source a runtime
5537
+ * cannot resolve is a failure far from here. Claude Code's schema documents the full tagged set
5538
+ * (<https://json.schemastore.org/claude-code-marketplace.json>). Codex documents npm alongside a
5539
+ * local path. Copilot CLI and Cursor document local paths only, which is why an npm entry reaches
5540
+ * two catalogs rather than four (`.research/local-marketplaces`, and issue #86). */
5541
+ const TARGET_SOURCE_KINDS = {
5542
+ claude: [
5543
+ "path",
5544
+ "npm",
5545
+ "github",
5546
+ "url",
5547
+ "git-subdir"
5548
+ ],
5549
+ codex: ["path", "npm"],
5550
+ copilot: ["path"],
5551
+ cursor: ["path"]
5552
+ };
5745
5553
  /** Folds one plugin's entry into a catalog that may already exist, and returns the artifact to
5746
5554
  * write. An existing catalog keeps its own top-level fields — its name, its owner, a description
5747
5555
  * someone wrote — and every entry it lists for other plugins, in place. Only this plugin's entry is
@@ -5750,7 +5558,7 @@ const VENDOR_TARGETS = {
5750
5558
  * `version` is derived, never authored (ADR-0010 §3): the entry carries whatever the canonical
5751
5559
  * manifest carries, and a version left behind on an entry whose manifest declares none is removed
5752
5560
  * rather than kept. */
5753
- function mergeCatalogEntry(target, metadata, plugin, readExisting) {
5561
+ function mergeCatalogEntry(target, metadata, plugin, readExisting, { keepForeignSource = true } = {}) {
5754
5562
  const artifact = serializeTarget(target, metadata, [plugin])[0];
5755
5563
  const existing = readExisting(artifact.path);
5756
5564
  if (existing === void 0) return artifact;
@@ -5759,7 +5567,7 @@ function mergeCatalogEntry(target, metadata, plugin, readExisting) {
5759
5567
  const entry = generated.plugins[0];
5760
5568
  const merged = { ...previous };
5761
5569
  for (const [key, value] of Object.entries(generated)) if (!(key in merged)) merged[key] = value;
5762
- merged.plugins = mergeEntries(previous, entry);
5570
+ merged.plugins = mergeEntries(previous, entry, { keepForeignSource });
5763
5571
  return {
5764
5572
  path: artifact.path,
5765
5573
  content: json(merged)
@@ -5775,18 +5583,78 @@ function parseCatalog(content, path) {
5775
5583
  if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error(`error: existing catalog "${path}" is not a JSON object`);
5776
5584
  return parsed;
5777
5585
  }
5778
- function mergeEntries(previous, entry) {
5586
+ function mergeEntries(previous, entry, { keepForeignSource = false } = {}) {
5779
5587
  const entries = Array.isArray(previous.plugins) ? [...previous.plugins] : [];
5780
5588
  const index = entries.findIndex((candidate) => typeof candidate === "object" && candidate !== null && candidate.name === entry.name);
5781
5589
  if (index === -1) return [...entries, entry];
5590
+ const existing = entries[index];
5782
5591
  const merged = {
5783
- ...entries[index],
5592
+ ...existing,
5784
5593
  ...entry
5785
5594
  };
5786
5595
  if (!("version" in entry)) delete merged.version;
5596
+ if (keepForeignSource && !isLocalCatalogSource(existing.source)) merged.source = existing.source;
5787
5597
  entries[index] = merged;
5788
5598
  return entries;
5789
5599
  }
5600
+ /** Re-derives a whole catalog from discovery while keeping the entries discovery cannot see.
5601
+ *
5602
+ * A repository may list plugins that live somewhere else — an npm package, a GitHub repository —
5603
+ * put there by `marketplace add`. Nothing on disk produces those, so a regeneration that trusted
5604
+ * discovery alone would report every one of them as a deletion, and `--force` would carry it out.
5605
+ * Two things survive instead: an entry whose source is not local stays even though discovery never
5606
+ * saw it, and a discovered plugin whose existing entry names a non-local source keeps that source
5607
+ * with only its derived metadata refreshed.
5608
+ *
5609
+ * That second case is the loss in issue #86 — a plugin shipped through npm, whose repository path
5610
+ * holds only gitignored build output, rewritten to that path on every regeneration.
5611
+ *
5612
+ * What discovery owns, it still owns: a local-path entry it no longer finds is dropped, so the
5613
+ * catalog keeps mirroring the repository for the part of the repository it describes. */
5614
+ function mergeDiscoveredCatalog(generated, existing) {
5615
+ if (existing === void 0) return generated;
5616
+ let previous;
5617
+ try {
5618
+ previous = parseCatalog(existing, generated.path);
5619
+ } catch {
5620
+ return generated;
5621
+ }
5622
+ const foreign = /* @__PURE__ */ new Map();
5623
+ for (const candidate of Array.isArray(previous.plugins) ? previous.plugins : []) {
5624
+ if (typeof candidate !== "object" || candidate === null || Array.isArray(candidate)) continue;
5625
+ const entry = candidate;
5626
+ if (typeof entry.name !== "string" || isLocalCatalogSource(entry.source)) continue;
5627
+ foreign.set(entry.name, entry);
5628
+ }
5629
+ if (foreign.size === 0) return generated;
5630
+ const catalog = JSON.parse(generated.content);
5631
+ const discovered = catalog.plugins ?? [];
5632
+ const merged = discovered.map((entry) => {
5633
+ const kept = typeof entry.name === "string" ? foreign.get(entry.name) : void 0;
5634
+ return kept === void 0 ? entry : {
5635
+ ...entry,
5636
+ source: kept.source
5637
+ };
5638
+ });
5639
+ const discoveredNames = new Set(discovered.map((entry) => entry.name));
5640
+ for (const [name, entry] of foreign) if (!discoveredNames.has(name)) merged.push(entry);
5641
+ catalog.plugins = merged;
5642
+ return {
5643
+ path: generated.path,
5644
+ content: json(catalog)
5645
+ };
5646
+ }
5647
+ /** The plugin names a catalog lists, so a result row can report what the file ends up saying rather
5648
+ * than only what discovery contributed to it. */
5649
+ function catalogEntryNames(content) {
5650
+ try {
5651
+ const parsed = JSON.parse(content);
5652
+ if (!Array.isArray(parsed.plugins)) return [];
5653
+ return parsed.plugins.map((entry) => typeof entry === "object" && entry !== null ? entry.name : void 0).filter((name) => typeof name === "string");
5654
+ } catch {
5655
+ return [];
5656
+ }
5657
+ }
5790
5658
  /** The catalog's own top-level identity, read back from the file the repository already carries, so
5791
5659
  * a refresh re-derives one entry without proposing a name or an owner of its own. */
5792
5660
  function existingMetadata(previous) {
@@ -5813,7 +5681,7 @@ function refreshCatalogEntry(target, plugin, existing) {
5813
5681
  path: catalogPath,
5814
5682
  content: json({
5815
5683
  ...previous,
5816
- plugins: mergeEntries(previous, entry)
5684
+ plugins: mergeEntries(previous, entry, { keepForeignSource: true })
5817
5685
  })
5818
5686
  };
5819
5687
  }
@@ -5900,6 +5768,79 @@ function gatherCatalogRepo(root) {
5900
5768
  catalogs
5901
5769
  };
5902
5770
  }
5771
+ /** Where Claude Code records the marketplaces a user has added, and clones each one.
5772
+ *
5773
+ * This is read, never written. It is the one place on the machine that already knows what
5774
+ * `<plugin>@<marketplace>` refers to, so resolving through it asks the user for nothing and reaches
5775
+ * no network. A marketplace they have not added is simply not found, and `--from` names it instead. */
5776
+ function claudePluginsHome(home = os.homedir()) {
5777
+ return path.join(home, ".claude", "plugins");
5778
+ }
5779
+ /** The origin the runtime recorded for a marketplace, in the vocabulary it records it in: a
5780
+ * `github` repo slug, or a `git`/`url` clone URL. */
5781
+ function registryOrigin(source) {
5782
+ if (typeof source !== "object" || source === null || Array.isArray(source)) return void 0;
5783
+ const record = source;
5784
+ if (record.source === "github" && typeof record.repo === "string") return originFromRepo(record.repo);
5785
+ if ((record.source === "git" || record.source === "url") && typeof record.url === "string") return originFromUrl(record.url);
5786
+ }
5787
+ /** The URL a checkout was cloned from, which is the origin for a marketplace the registry does not
5788
+ * describe — one reached through `--from`, or added before the runtime recorded a source. */
5789
+ function gitRemoteUrl(dir) {
5790
+ try {
5791
+ const url = execFileSync("git", [
5792
+ "-C",
5793
+ dir,
5794
+ "remote",
5795
+ "get-url",
5796
+ "origin"
5797
+ ], {
5798
+ encoding: "utf8",
5799
+ stdio: [
5800
+ "ignore",
5801
+ "pipe",
5802
+ "ignore"
5803
+ ]
5804
+ }).trim();
5805
+ return url === "" ? void 0 : url;
5806
+ } catch {
5807
+ return;
5808
+ }
5809
+ }
5810
+ /** The marketplace a name refers to, or `undefined` when the user has not added it. */
5811
+ function resolveKnownMarketplace(name, marketplaceFs = realMarketplaceFs, home = os.homedir()) {
5812
+ const pluginsHome = claudePluginsHome(home);
5813
+ const registry = path.join(pluginsHome, "known_marketplaces.json");
5814
+ if (marketplaceFs.exists(registry)) {
5815
+ let parsed;
5816
+ try {
5817
+ parsed = JSON.parse(marketplaceFs.read(registry));
5818
+ } catch {
5819
+ parsed = void 0;
5820
+ }
5821
+ if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
5822
+ const entry = parsed[name];
5823
+ if (typeof entry === "object" && entry !== null) {
5824
+ const record = entry;
5825
+ const location = record.installLocation;
5826
+ if (typeof location === "string" && marketplaceFs.exists(location)) return {
5827
+ dir: location,
5828
+ origin: registryOrigin(record.source)
5829
+ };
5830
+ }
5831
+ }
5832
+ }
5833
+ const conventional = path.join(pluginsHome, "marketplaces", name);
5834
+ return marketplaceFs.exists(conventional) ? { dir: conventional } : void 0;
5835
+ }
5836
+ /** The catalog a marketplace directory carries, tried in the order the runtimes agree on: the Claude
5837
+ * path first, because three of the four read it. */
5838
+ function readMarketplaceCatalog(dir, marketplaceFs = realMarketplaceFs) {
5839
+ for (const relative of Object.values(TARGET_CATALOG_PATHS)) {
5840
+ const file = path.join(dir, relative);
5841
+ if (marketplaceFs.exists(file)) return marketplaceFs.read(file);
5842
+ }
5843
+ }
5903
5844
  //#endregion
5904
5845
  //#region src/marketplace/validation.ts
5905
5846
  function isObject$1(value) {
@@ -6483,10 +6424,6 @@ function validateManifest(manifest, targets) {
6483
6424
  return errors;
6484
6425
  }
6485
6426
  function buildPlugin(root, opts = {}) {
6486
- if (opts.check) opts = {
6487
- ...opts,
6488
- dryRun: true
6489
- };
6490
6427
  const manifestPath = path.join(root, "plugin.json");
6491
6428
  if (!fs.existsSync(manifestPath)) throw new Error(`No plugin.json found at ${root}`);
6492
6429
  const indent = detectIndent(fs.readFileSync(manifestPath, "utf8"));
@@ -6521,7 +6458,6 @@ function buildPlugin(root, opts = {}) {
6521
6458
  warnings,
6522
6459
  rows,
6523
6460
  catalogs: [],
6524
- governances: [],
6525
6461
  summary: summarize(rows)
6526
6462
  };
6527
6463
  }
@@ -6532,12 +6468,6 @@ function buildPlugin(root, opts = {}) {
6532
6468
  const { vendors: _vendors, packagePath: _packagePath, harnesses: _harnesses, dependencies: declaredDependencies, ...componentConfig } = uext;
6533
6469
  warnings.push(...validateDependencies(declaredDependencies).warnings);
6534
6470
  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
6471
  const canonicalHooks = readCanonicalHooks(root, componentConfig["hooks"], warnings);
6542
6472
  const declaredMcp = readCanonicalMcpServers(root, componentConfig["mcpServers"], warnings);
6543
6473
  const mcp = declaredMcp ? pinMcpServers(declaredMcp.servers, manifest.version) : null;
@@ -6620,7 +6550,6 @@ function buildPlugin(root, opts = {}) {
6620
6550
  rows,
6621
6551
  catalogs,
6622
6552
  catalogRoot,
6623
- governances: governances.entries,
6624
6553
  summary: summarize(rows)
6625
6554
  };
6626
6555
  }
@@ -6973,27 +6902,19 @@ function nextStep$1(result, cwd) {
6973
6902
  const quoted = /\s/.test(relative) ? JSON.stringify(relative) : relative;
6974
6903
  return `→ universal-plugin marketplace validate${relative === "" ? "" : ` --root ${quoted}`}\n`;
6975
6904
  }
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
6905
  function buildCommand() {
6981
6906
  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("--check", "Write nothing; fail when a committed governance copy differs from its source").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) => {
6907
+ 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
6908
  try {
6984
6909
  const result = buildPlugin(resolveRoot(opts.root), {
6985
6910
  vendor: opts.vendor,
6986
6911
  dryRun: opts.dryRun,
6987
6912
  verbose: opts.verbose,
6988
- clean: opts.clean,
6989
- check: opts.check
6913
+ clean: opts.clean
6990
6914
  });
6991
6915
  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
6916
  const { built, skipped, failed, canonical } = result.summary;
6995
6917
  const jsonResult = {
6996
- governances: result.governances,
6997
6918
  built: result.rows.filter((r) => r.status === "built"),
6998
6919
  skipped: result.rows.filter((r) => r.status === "skipped"),
6999
6920
  failed: result.rows.filter((r) => r.status === "failed"),
@@ -7004,7 +6925,6 @@ function buildCommand() {
7004
6925
  };
7005
6926
  const counts = `built ${built}, skipped ${skipped}, failed ${failed}`;
7006
6927
  const catalogSummary = result.catalogs.length > 0 ? `, catalogs ${result.catalogs.length}` : "";
7007
- const governanceSummary = result.governances.length > 0 ? `, governance copies ${result.governances.length}` : "";
7008
6928
  output(jsonResult, {
7009
6929
  vendors: result.rows.map((r) => ({
7010
6930
  vendor: r.vendor,
@@ -7015,16 +6935,10 @@ function buildCommand() {
7015
6935
  path: r.path,
7016
6936
  status: r.status
7017
6937
  })),
7018
- ...result.governances.length > 0 ? { governances: result.governances.map((g) => ({
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
6938
+ summary: (canonical > 0 ? `${counts}, served by plugin.json ${canonical}` : counts) + catalogSummary
7024
6939
  });
7025
- process.stderr.write(opts.check && stale.length > 0 ? checkNextStep(opts.root) : nextStep$1(result, process.cwd()));
6940
+ process.stderr.write(nextStep$1(result, process.cwd()));
7026
6941
  if (failed > 0) process.exitCode = 1;
7027
- if (opts.check && stale.length > 0) process.exitCode = 1;
7028
6942
  } catch (err) {
7029
6943
  process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
7030
6944
  process.exit(1);
@@ -7389,7 +7303,7 @@ const realConfigFs = {
7389
7303
  function assertNotReserved(key) {
7390
7304
  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
7305
  }
7392
- function addCommand(fs) {
7306
+ function addCommand$1(fs) {
7393
7307
  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
7308
  try {
7395
7309
  assertNotReserved(opts.key);
@@ -7448,139 +7362,7 @@ function getCommand(fs) {
7448
7362
  });
7449
7363
  }
7450
7364
  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));
7452
- }
7453
- //#endregion
7454
- //#region src/governance/governance.ts
7455
- function getManagedDir() {
7456
- if (process.platform === "darwin") return "/Library/Application Support/UniPlugin/governances";
7457
- if (process.platform === "win32") return path.join(process.env["ProgramData"] ?? "C:\\ProgramData", "UniPlugin", "governances");
7458
- return "/etc/universal-plugin/governances";
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;
7535
- }
7536
- function listGovernances(root, govFs) {
7537
- const seen = /* @__PURE__ */ new Set();
7538
- const entries = [];
7539
- for (const { scope, dir } of getScopedPaths(root)) for (const name of govFs.list(dir)) if (!seen.has(name)) {
7540
- seen.add(name);
7541
- entries.push({
7542
- name,
7543
- scope
7544
- });
7545
- }
7546
- return entries.sort((a, b) => a.name.localeCompare(b.name));
7547
- }
7548
- //#endregion
7549
- //#region src/governance/cli.ts
7550
- function readGlobalState() {
7551
- const p = path.join(os.homedir(), ".agents", "universal-plugin.json");
7552
- try {
7553
- return mergeSafeState(JSON.parse(fs.readFileSync(p, "utf8")));
7554
- } catch {
7555
- return emptyState();
7556
- }
7557
- }
7558
- function governanceCommand() {
7559
- const cmd = new Command("governance").description("Manage plugin governances").helpCommand(false);
7560
- cmd.command("show <name>").description("Show a governance by name").option("--format <format>", "Output format: json or text (default: text)").addOption(ROOT_OPTION).addOption(new Option("--json").hideHelp()).action((name, opts) => {
7561
- const result = showGovernance(name, resolveRoot(opts.root), realGovernanceFs, {
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
- });
7583
- return cmd;
7365
+ return new Command("config").description("Read and write plugin-registered config in .agents/universal-plugin.json").addCommand(addCommand$1(fs)).addCommand(getCommand(fs));
7584
7366
  }
7585
7367
  //#endregion
7586
7368
  //#region src/init/fs.ts
@@ -7670,7 +7452,7 @@ const UP_NAMESPACE = "org.cyberuni.universal-plugin";
7670
7452
  const SCAFFOLD_DIRS = [
7671
7453
  "skills",
7672
7454
  "agents",
7673
- "governances",
7455
+ "references",
7674
7456
  "commands"
7675
7457
  ];
7676
7458
  /** The closed canonical manifest init writes: `$schema` + `name`, plus the extensions namespace
@@ -7821,7 +7603,7 @@ function collect$1(value, previous) {
7821
7603
  }
7822
7604
  function initCommand$1(deps = { fs: realInitFs }) {
7823
7605
  const cmd = new Command("init").description("Scaffold the canonical plugin.json (with --npm, wire an npm package to ship it)");
7824
- cmd.option("--name <name>", "Plugin name (default: the root directory name)").option("--vendor <id>", "Target vendor; repeatable", collect$1, []).option("--scaffold", "Create the standard skills/ agents/ governances/ commands/ directories").option("--force", "Overwrite an existing plugin.json").option("--yes", "Non-interactive (compatibility no-op; init never prompts)").option("--npm", "Wire package.json 'files' to ship the derived vendor manifests").option("--no-marketplace", "Skip the repository's local marketplace catalogs").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 init --name my-plugin --scaffold\n").action((opts) => {
7606
+ cmd.option("--name <name>", "Plugin name (default: the root directory name)").option("--vendor <id>", "Target vendor; repeatable", collect$1, []).option("--scaffold", "Create the standard skills/ agents/ references/ commands/ directories").option("--force", "Overwrite an existing plugin.json").option("--yes", "Non-interactive (compatibility no-op; init never prompts)").option("--npm", "Wire package.json 'files' to ship the derived vendor manifests").option("--no-marketplace", "Skip the repository's local marketplace catalogs").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 init --name my-plugin --scaffold\n").action((opts) => {
7825
7607
  try {
7826
7608
  const root = resolveRoot(opts.root);
7827
7609
  const plan = planInit(deps.fs.gather(root), {
@@ -7854,6 +7636,241 @@ function initCommand$1(deps = { fs: realInitFs }) {
7854
7636
  return cmd;
7855
7637
  }
7856
7638
  //#endregion
7639
+ //#region ../../node_modules/.pnpm/@cyberuni+agent-harness@0.1.0/node_modules/@cyberuni/agent-harness/dist/index.js
7640
+ const harnessIds = [
7641
+ "claude-code",
7642
+ "cursor",
7643
+ "codex",
7644
+ "copilot-cli",
7645
+ "opencode",
7646
+ "kilo",
7647
+ "gemini-cli",
7648
+ "qwen-code",
7649
+ "vscode-copilot",
7650
+ "cline"
7651
+ ];
7652
+ function resolveHarnessEnvironment(environment = {}) {
7653
+ return {
7654
+ env: environment.env ?? process.env,
7655
+ homedir: environment.homedir ?? homedir(),
7656
+ cwd: environment.cwd ?? process.cwd(),
7657
+ platform: environment.platform ?? process.platform
7658
+ };
7659
+ }
7660
+ /**
7661
+ * Where a harness keeps plugins at the user level. Paths are computed, not checked: a location may
7662
+ * not exist until the first plugin is installed.
7663
+ */
7664
+ function pluginStorage(harness, environment = {}) {
7665
+ return storages[harness](resolveHarnessEnvironment(environment));
7666
+ }
7667
+ /** `$XDG_CONFIG_HOME`, else `~/.config`, as the `xdg-basedir` package resolves it. */
7668
+ const xdgConfig = ({ env, homedir }) => env.XDG_CONFIG_HOME || join(homedir, ".config");
7669
+ /** The agent plugin folder of the stable VS Code build, per OS (E-VSC-P3). */
7670
+ function vscodeAgentPlugins({ env, homedir, platform }) {
7671
+ if (platform === "win32") {
7672
+ const configDir = win32.join(env.APPDATA || win32.join(homedir, "AppData", "Roaming"), "Code");
7673
+ return {
7674
+ configDir,
7675
+ path: win32.join(configDir, "agentPlugins")
7676
+ };
7677
+ }
7678
+ const configDir = platform === "darwin" ? join(homedir, "Library", "Application Support", "Code") : join(homedir, ".config", "Code");
7679
+ return {
7680
+ configDir,
7681
+ path: join(configDir, "agentPlugins")
7682
+ };
7683
+ }
7684
+ const storages = {
7685
+ "claude-code": ({ env, homedir }) => {
7686
+ const configDir = env.CLAUDE_CONFIG_DIR || join(homedir, ".claude");
7687
+ const plugins = join(configDir, "plugins");
7688
+ return {
7689
+ harness: "claude-code",
7690
+ configDir,
7691
+ locations: [
7692
+ {
7693
+ kind: "plugin-cache",
7694
+ path: join(plugins, "cache"),
7695
+ description: "Installed plugins as <marketplace>/<plugin>/<version>/",
7696
+ research: "E-CC-P1"
7697
+ },
7698
+ {
7699
+ kind: "marketplaces",
7700
+ path: join(plugins, "marketplaces"),
7701
+ description: "Marketplace checkouts",
7702
+ research: "E-CC-P1"
7703
+ },
7704
+ {
7705
+ kind: "install-record",
7706
+ path: join(plugins, "installed_plugins.json"),
7707
+ description: "Installs keyed by plugin@marketplace, each with scope, installPath, and version",
7708
+ research: "E-CC-P2"
7709
+ },
7710
+ {
7711
+ kind: "enabled-record",
7712
+ path: join(configDir, "settings.json"),
7713
+ description: "User settings; enabledPlugins maps plugin@marketplace to a boolean",
7714
+ research: "E-CC-P3"
7715
+ }
7716
+ ]
7717
+ };
7718
+ },
7719
+ cursor: ({ homedir }) => {
7720
+ const configDir = join(homedir, ".cursor");
7721
+ return {
7722
+ harness: "cursor",
7723
+ configDir,
7724
+ locations: [{
7725
+ kind: "local-plugins",
7726
+ path: join(configDir, "plugins", "local"),
7727
+ description: "Plugins under development, one folder each",
7728
+ research: "E-CUR-P2"
7729
+ }, {
7730
+ kind: "marketplaces",
7731
+ path: join(configDir, "plugins", "marketplaces"),
7732
+ description: "Marketplace checkouts",
7733
+ research: "E-CUR-P3"
7734
+ }]
7735
+ };
7736
+ },
7737
+ codex: ({ env, homedir }) => {
7738
+ const configDir = env.CODEX_HOME || join(homedir, ".codex");
7739
+ return {
7740
+ harness: "codex",
7741
+ configDir,
7742
+ locations: [{
7743
+ kind: "plugin-cache",
7744
+ path: join(configDir, "plugins", "cache"),
7745
+ description: "Installed plugins as <marketplace>/<plugin>/<version>/",
7746
+ research: "E-CODEX-P3"
7747
+ }, {
7748
+ kind: "enabled-record",
7749
+ path: join(configDir, "config.toml"),
7750
+ description: "User config; [plugins.\"<plugin>@<marketplace>\"] tables carry enabled = <bool>",
7751
+ research: "E-CODEX-P2"
7752
+ }]
7753
+ };
7754
+ },
7755
+ "copilot-cli": ({ env, homedir }) => {
7756
+ const configDir = env.COPILOT_HOME || join(homedir, ".copilot");
7757
+ return {
7758
+ harness: "copilot-cli",
7759
+ configDir,
7760
+ locations: [
7761
+ {
7762
+ kind: "installed-plugins",
7763
+ path: join(configDir, "installed-plugins"),
7764
+ description: "Installed plugins as <marketplace>/<plugin>/, or _direct/<source-id>/ for direct installs",
7765
+ research: "E-COPILOT-P9"
7766
+ },
7767
+ {
7768
+ kind: "install-record",
7769
+ path: join(configDir, "config.json"),
7770
+ description: "Application state, including installedPlugins",
7771
+ research: "E-COPILOT-P9"
7772
+ },
7773
+ {
7774
+ kind: "enabled-record",
7775
+ path: join(configDir, "settings.json"),
7776
+ description: "User settings (JSON with comments); enabledPlugins maps plugin specs to a boolean",
7777
+ research: "E-COPILOT-P4"
7778
+ }
7779
+ ]
7780
+ };
7781
+ },
7782
+ opencode: (environment) => {
7783
+ const configDir = join(xdgConfig(environment), "opencode");
7784
+ return {
7785
+ harness: "opencode",
7786
+ configDir,
7787
+ locations: [{
7788
+ kind: "local-plugins",
7789
+ path: join(configDir, "plugins"),
7790
+ description: "JS/TS plugin modules loaded at startup; npm plugins are named in the plugin config array",
7791
+ research: "E-OC-P2"
7792
+ }]
7793
+ };
7794
+ },
7795
+ kilo: (environment) => {
7796
+ const configDir = join(xdgConfig(environment), "kilo");
7797
+ return {
7798
+ harness: "kilo",
7799
+ configDir,
7800
+ locations: [{
7801
+ kind: "local-plugins",
7802
+ path: join(configDir, "plugin"),
7803
+ description: "JS/TS plugin modules loaded at startup; npm plugins are named in the plugin config array",
7804
+ research: "E-KILO-P2"
7805
+ }]
7806
+ };
7807
+ },
7808
+ "gemini-cli": ({ env, homedir }) => {
7809
+ const configDir = join(env.GEMINI_CLI_HOME || homedir, ".gemini");
7810
+ const extensions = join(configDir, "extensions");
7811
+ return {
7812
+ harness: "gemini-cli",
7813
+ configDir,
7814
+ locations: [{
7815
+ kind: "installed-plugins",
7816
+ path: extensions,
7817
+ description: "Installed extensions, one folder each with a gemini-extension.json manifest",
7818
+ research: "E-GEM-P1"
7819
+ }, {
7820
+ kind: "enabled-record",
7821
+ path: join(extensions, "extension-enablement.json"),
7822
+ description: "Per-extension path-glob overrides that enable or disable it by folder, not a boolean",
7823
+ research: "E-GEM-P3"
7824
+ }]
7825
+ };
7826
+ },
7827
+ "qwen-code": ({ env, homedir }) => {
7828
+ const configDir = env.QWEN_HOME || join(homedir, ".qwen");
7829
+ const extensions = join(configDir, "extensions");
7830
+ return {
7831
+ harness: "qwen-code",
7832
+ configDir,
7833
+ locations: [{
7834
+ kind: "installed-plugins",
7835
+ path: extensions,
7836
+ description: "Installed extensions, one folder each with a qwen-extension.json manifest",
7837
+ research: "E-QWEN-P1"
7838
+ }, {
7839
+ kind: "enabled-record",
7840
+ path: join(extensions, "extension-enablement.json"),
7841
+ description: "Extension enable/disable state, inherited from Gemini CLI's format",
7842
+ research: "E-QWEN-P2"
7843
+ }]
7844
+ };
7845
+ },
7846
+ "vscode-copilot": (environment) => {
7847
+ const { configDir, path } = vscodeAgentPlugins(environment);
7848
+ return {
7849
+ harness: "vscode-copilot",
7850
+ configDir,
7851
+ locations: [{
7852
+ kind: "installed-plugins",
7853
+ path,
7854
+ description: "Installed agent plugins as <host>/<org>/<repo>/",
7855
+ research: "E-VSC-P3"
7856
+ }]
7857
+ };
7858
+ },
7859
+ cline: ({ homedir }) => {
7860
+ const configDir = join(homedir, ".cline");
7861
+ return {
7862
+ harness: "cline",
7863
+ configDir,
7864
+ locations: [{
7865
+ kind: "installed-plugins",
7866
+ path: join(configDir, "plugins", "_installed"),
7867
+ description: "Installed plugins, grouped by source: npm, git, remote, or local",
7868
+ research: "E-CLINE-P3"
7869
+ }]
7870
+ };
7871
+ }
7872
+ };
7873
+ //#endregion
7857
7874
  //#region src/vendor-registry/vendor-registry.ts
7858
7875
  function lookupVendor(registry, vendorId) {
7859
7876
  return registry[vendorId] ?? null;
@@ -7866,6 +7883,15 @@ function mergeRegistries(base, override) {
7866
7883
  };
7867
7884
  return result;
7868
7885
  }
7886
+ /** Fills each vendor's `localPluginDir` from `locate`, which returns the harness's `local-plugins`
7887
+ * folder when `@cyberuni/agent-harness` knows one. A vendor it knows nothing about keeps its
7888
+ * registry value, or `null`. */
7889
+ function withLocalPluginDirs(registry, locate) {
7890
+ return Object.fromEntries(Object.entries(registry).map(([id, config]) => [id, {
7891
+ ...config,
7892
+ localPluginDir: locate(id) ?? config.localPluginDir ?? null
7893
+ }]));
7894
+ }
7869
7895
  //#endregion
7870
7896
  //#region src/vendor-registry/fs.ts
7871
7897
  function bundledRegistryPath() {
@@ -7875,8 +7901,15 @@ function bundledRegistryPath() {
7875
7901
  function userOverridePath() {
7876
7902
  return path.join(os.homedir(), ".agents", "universal-plugin-vendors.json");
7877
7903
  }
7904
+ /** The harness's `local-plugins` folder, as `@cyberuni/agent-harness` locates it. */
7905
+ function localPluginsDir(vendorId) {
7906
+ if (!harnessIds.includes(vendorId)) return void 0;
7907
+ return pluginStorage(vendorId).locations.find((l) => l.kind === "local-plugins")?.path;
7908
+ }
7909
+ /** The bundled registry, with the per-harness facts `@cyberuni/agent-harness` owns filled in, then
7910
+ * the user's override on top. */
7878
7911
  function loadRegistry() {
7879
- const bundled = JSON.parse(fs.readFileSync(bundledRegistryPath(), "utf8"));
7912
+ const bundled = withLocalPluginDirs(JSON.parse(fs.readFileSync(bundledRegistryPath(), "utf8")), localPluginsDir);
7880
7913
  try {
7881
7914
  return mergeRegistries(bundled, JSON.parse(fs.readFileSync(userOverridePath(), "utf8")));
7882
7915
  } catch (err) {
@@ -8269,6 +8302,9 @@ function manifestOwner(manifest) {
8269
8302
  return owner;
8270
8303
  }
8271
8304
  }
8305
+ /** The catalog's own identity: its name and its owner, from the root manifest unless overridden.
8306
+ * Exported because `marketplace add` creates a catalog in a repository that has no plugins of its
8307
+ * own to discover, and the two commands must name that catalog the same way. */
8272
8308
  function deriveMetadata(root, fs, opts) {
8273
8309
  const rootManifest = path.join(root, "plugin.json");
8274
8310
  if (fs.exists(rootManifest)) assertContained(root, rootManifest, fs, "root plugin.json");
@@ -8329,7 +8365,7 @@ function writeArtifacts(fs, artifacts, root) {
8329
8365
  const changed = artifacts.filter((artifact) => !sameArtifact(fs, path.join(root, artifact.path), artifact.content));
8330
8366
  for (const artifact of changed) fs.writeAtomically(path.join(root, artifact.path), artifact.content);
8331
8367
  }
8332
- function selectedTargets(targets) {
8368
+ function selectedTargets$1(targets) {
8333
8369
  return targets && targets.length > 0 ? [...new Set(targets)] : [
8334
8370
  "claude",
8335
8371
  "codex",
@@ -8337,6 +8373,9 @@ function selectedTargets(targets) {
8337
8373
  "cursor"
8338
8374
  ];
8339
8375
  }
8376
+ function readCatalog(fs, file) {
8377
+ return fs.exists(file) ? fs.read(file) : void 0;
8378
+ }
8340
8379
  function sameArtifact(fs, file, content) {
8341
8380
  if (!fs.exists(file)) return false;
8342
8381
  const existing = fs.read(file);
@@ -8358,12 +8397,16 @@ function initializeMarketplace(rootInput, opts = {}, fs = realMarketplaceFs) {
8358
8397
  const root = path.resolve(rootInput);
8359
8398
  const metadata = deriveMetadata(root, fs, opts);
8360
8399
  const plugins = discoverPlugins(root, fs, opts.scanDirs);
8361
- const planned = selectedTargets(opts.targets).map((target) => ({
8400
+ const derived = selectedTargets$1(opts.targets).map((target) => ({
8362
8401
  target,
8363
8402
  artifacts: serializeTarget(target, metadata, plugins)
8364
8403
  }));
8404
+ for (const { artifacts } of derived) for (const artifact of artifacts) assertContained(root, path.join(root, artifact.path), fs, "selected artifact");
8405
+ const planned = derived.map(({ target, artifacts }) => ({
8406
+ target,
8407
+ artifacts: artifacts.map((artifact) => mergeDiscoveredCatalog(artifact, readCatalog(fs, path.join(root, artifact.path))))
8408
+ }));
8365
8409
  for (const { target, artifacts } of planned) for (const artifact of artifacts) {
8366
- assertContained(root, path.join(root, artifact.path), fs, "selected artifact");
8367
8410
  const issues = validateCatalogContent(target, artifact.content);
8368
8411
  if (issues.length > 0) throw new Error(formatCatalogIssues(artifact.path, issues));
8369
8412
  }
@@ -8386,13 +8429,403 @@ function initializeMarketplace(rootInput, opts = {}, fs = realMarketplaceFs) {
8386
8429
  target,
8387
8430
  status: opts.dryRun ? "planned" : unchanged ? "unchanged" : "generated",
8388
8431
  paths: artifacts.map((artifact) => artifact.path),
8389
- plugins: plugins.map((plugin) => plugin.name)
8432
+ plugins: [...new Set(artifacts.flatMap((artifact) => catalogEntryNames(artifact.content)))]
8390
8433
  };
8391
8434
  });
8392
8435
  if (!opts.dryRun && plugins.length > 0) writeArtifacts(fs, planned.flatMap((entry) => entry.artifacts), root);
8393
8436
  return results;
8394
8437
  }
8395
8438
  //#endregion
8439
+ //#region src/marketplace/spec.ts
8440
+ /** What a user names on the command line when they ask for a plugin to be listed, reduced to the
8441
+ * source a catalog entry states. Pure: every rule here is a decision about the string itself, so
8442
+ * nothing consults the filesystem, the network, or the runtime's configuration. */
8443
+ const GITHUB_REPO = /^[A-Za-z0-9][A-Za-z0-9._-]*\/[A-Za-z0-9][A-Za-z0-9._-]*$/;
8444
+ const NPM_PACKAGE = /^(?:@[A-Za-z0-9][A-Za-z0-9._-]*\/)?[A-Za-z0-9][A-Za-z0-9._-]*$/;
8445
+ /** The last meaningful segment of a path or URL, with a `.git` suffix removed. */
8446
+ function basename(value) {
8447
+ const segments = value.replace(/[/\\]+$/, "").split(/[/\\]/);
8448
+ return (segments[segments.length - 1] ?? value).replace(/\.git$/, "");
8449
+ }
8450
+ /** An npm package's entry name is the package name without its scope: `@cyberuni/upx` lists as
8451
+ * `upx`, which is what a user installs and what every other source form yields. */
8452
+ function packageEntryName(pkg) {
8453
+ return pkg.startsWith("@") ? pkg.split("/")[1] ?? pkg : pkg;
8454
+ }
8455
+ function pathSpec(value) {
8456
+ const normalized = value.replace(/\\/g, "/").replace(/\/+$/, "");
8457
+ const source = normalized.startsWith("./") || normalized.startsWith("../") ? normalized : `./${normalized}`;
8458
+ return {
8459
+ kind: "path",
8460
+ name: basename(normalized),
8461
+ source
8462
+ };
8463
+ }
8464
+ function npmSpec(pkg) {
8465
+ return {
8466
+ kind: "npm",
8467
+ name: packageEntryName(pkg),
8468
+ source: {
8469
+ source: "npm",
8470
+ package: pkg
8471
+ }
8472
+ };
8473
+ }
8474
+ function githubSpec(repo) {
8475
+ return {
8476
+ kind: "github",
8477
+ name: basename(repo),
8478
+ source: {
8479
+ source: "github",
8480
+ repo
8481
+ }
8482
+ };
8483
+ }
8484
+ function urlSpec(url) {
8485
+ return {
8486
+ kind: "url",
8487
+ name: basename(url),
8488
+ source: {
8489
+ source: "url",
8490
+ url
8491
+ }
8492
+ };
8493
+ }
8494
+ /** Splits `<plugin>@<marketplace>` at the separator that is not a scope marker, so a scoped package
8495
+ * name keeps its leading `@`. */
8496
+ function splitMarketplace(value) {
8497
+ const at = value.indexOf("@", value.startsWith("@") ? 1 : 0);
8498
+ if (at <= 0) return void 0;
8499
+ const plugin = value.slice(0, at);
8500
+ const marketplace = value.slice(at + 1);
8501
+ if (plugin === "" || marketplace === "" || marketplace.includes("/")) return void 0;
8502
+ return {
8503
+ plugin,
8504
+ marketplace
8505
+ };
8506
+ }
8507
+ function marketplaceSpec(value) {
8508
+ const split = splitMarketplace(value);
8509
+ if (!split) throw new Error(`error: "${value}" is not <plugin>@<marketplace>`);
8510
+ return {
8511
+ kind: "marketplace",
8512
+ name: packageEntryName(split.plugin),
8513
+ ...split
8514
+ };
8515
+ }
8516
+ const SHA = /^[a-f0-9]{40}$/;
8517
+ /** A subdirectory as `git-subdir` states it: no leading `./`, no trailing slash. */
8518
+ function normalizeSubdir(subdir) {
8519
+ const path = subdir.trim().replace(/\\/g, "/").replace(/^\.\/?/, "").replace(/\/+$/, "");
8520
+ if (path === "") throw new Error("error: --subdir must name a directory inside the repository");
8521
+ if (path.startsWith("/")) throw new Error(`error: --subdir "${subdir}" must be relative to the repository root`);
8522
+ return path;
8523
+ }
8524
+ /** The git URL a spec's source points at, for the two kinds that name a whole repository. */
8525
+ function repositoryUrl(spec) {
8526
+ if (typeof spec.source !== "object") return void 0;
8527
+ if (spec.source.source === "github") return originFromRepo(spec.source.repo).url;
8528
+ if (spec.source.source === "url") return spec.source.url;
8529
+ }
8530
+ /** Narrows a whole-repository source to the one directory inside it that is the plugin.
8531
+ *
8532
+ * A monorepo publishes several plugins from one repository, and neither `github` nor `url` can say
8533
+ * which directory. `git-subdir` is the form that can, so `--subdir` turns the source into one —
8534
+ * and the entry takes its name from that directory rather than from the repository. */
8535
+ function withSubdir(spec, subdir) {
8536
+ const url = repositoryUrl(spec);
8537
+ if (url === void 0) throw new Error(`error: --subdir applies to a repository source, not to a ${spec.kind} one`);
8538
+ const path = normalizeSubdir(subdir);
8539
+ return {
8540
+ kind: spec.kind,
8541
+ name: basename(path),
8542
+ source: {
8543
+ source: "git-subdir",
8544
+ url,
8545
+ path
8546
+ }
8547
+ };
8548
+ }
8549
+ /** Pins a source to a branch, tag, or commit. Only the git-backed forms carry one; an npm package is
8550
+ * pinned by version and a path is whatever is on disk. */
8551
+ function withPin(spec, ref, sha) {
8552
+ if (ref === void 0 && sha === void 0) return spec;
8553
+ const source = spec.source;
8554
+ if (typeof source !== "object" || ![
8555
+ "url",
8556
+ "github",
8557
+ "git-subdir"
8558
+ ].includes(source.source)) throw new Error(`error: --ref and --sha apply to a git source, not to a ${spec.kind} one`);
8559
+ if (sha !== void 0 && !SHA.test(sha)) throw new Error(`error: --sha "${sha}" must be a full 40-character commit hash`);
8560
+ return {
8561
+ ...spec,
8562
+ source: {
8563
+ ...source,
8564
+ ...ref === void 0 ? {} : { ref },
8565
+ ...sha === void 0 ? {} : { sha }
8566
+ }
8567
+ };
8568
+ }
8569
+ function looksLikeUrl(value) {
8570
+ return value.includes("://") || value.startsWith("git@");
8571
+ }
8572
+ function looksLikePath(value) {
8573
+ return value.startsWith("./") || value.startsWith("../") || value.startsWith("/") || /^[A-Za-z]:[\\/]/.test(value);
8574
+ }
8575
+ /** Applies the kind the user named, so every guess below has an override. */
8576
+ function explicitSpec(value, kind) {
8577
+ switch (kind) {
8578
+ case "path": return pathSpec(value);
8579
+ case "npm": return npmSpec(value.replace(/^npm:/, ""));
8580
+ case "github": return githubSpec(value);
8581
+ case "url": return urlSpec(value);
8582
+ case "marketplace": return marketplaceSpec(value);
8583
+ }
8584
+ }
8585
+ /** Reads a spec string as the source it names.
8586
+ *
8587
+ * The order below is the disambiguation, and every step of it is overridable with an explicit kind:
8588
+ *
8589
+ * 1. an `npm:` prefix, which is how a bare name that looks like anything else is forced to a package
8590
+ * 2. a URL scheme or an `scp`-style `git@host:path`
8591
+ * 3. a path, which has to say so with `./`, `../`, or a leading slash
8592
+ * 4. `<plugin>@<marketplace>`, splitting after a leading scope `@` so `@scope/pkg` is not one
8593
+ * 5. `owner/repo`
8594
+ * 6. anything else left, which is an npm package name
8595
+ *
8596
+ * Step 3 is why `plugins/alpha` reads as a GitHub repository rather than a directory: both are
8597
+ * `a/b`, and a guess that reached for the filesystem would answer differently depending on where
8598
+ * the command was run. `./plugins/alpha` or `--path` says it plainly. */
8599
+ function parsePluginSpec(value, opts = {}) {
8600
+ const { kind, subdir, ref, sha } = opts;
8601
+ const spec = value.trim();
8602
+ if (spec === "") throw new Error("error: a plugin spec is required");
8603
+ const shape = (base) => withPin(subdir === void 0 ? base : withSubdir(base, subdir), ref, sha);
8604
+ if (kind) return shape(explicitSpec(spec, kind));
8605
+ if (spec.startsWith("npm:")) {
8606
+ const pkg = spec.slice(4);
8607
+ if (!NPM_PACKAGE.test(pkg)) throw new Error(`error: "${pkg}" is not an npm package name`);
8608
+ return shape(npmSpec(pkg));
8609
+ }
8610
+ if (looksLikeUrl(spec)) return shape(urlSpec(spec));
8611
+ if (looksLikePath(spec)) return shape(pathSpec(spec));
8612
+ const split = splitMarketplace(spec);
8613
+ if (split) return shape({
8614
+ kind: "marketplace",
8615
+ name: packageEntryName(split.plugin),
8616
+ ...split
8617
+ });
8618
+ if (GITHUB_REPO.test(spec)) return shape(githubSpec(spec));
8619
+ if (NPM_PACKAGE.test(spec)) return shape(npmSpec(spec));
8620
+ throw new Error(`error: cannot tell what "${spec}" names; pass --path, --npm, --github, --url, or --from-marketplace`);
8621
+ }
8622
+ //#endregion
8623
+ //#region src/marketplace/add.ts
8624
+ /** The manifest fields a catalog entry carries. Exactly the set the catalog already derives from a
8625
+ * discovered plugin, so an added entry and a discovered one say the same kinds of things. */
8626
+ const ENTRY_METADATA_FIELDS = [
8627
+ "description",
8628
+ "version",
8629
+ "homepage",
8630
+ "repository",
8631
+ "license",
8632
+ "keywords"
8633
+ ];
8634
+ /** The kind name used to decide whether a runtime accepts a source, from the source itself. */
8635
+ function sourceKindOf(source) {
8636
+ if (isLocalCatalogSource(source)) return "path";
8637
+ return typeof source === "string" ? "path" : source.source;
8638
+ }
8639
+ /** A source rendered for the result table — one short cell, not a JSON blob. */
8640
+ function describeSource(source) {
8641
+ if (typeof source === "string") return source;
8642
+ const detail = source.package ?? source.repo ?? source.url ?? source.path;
8643
+ return typeof detail === "string" ? `${source.source}:${detail}` : String(source.source);
8644
+ }
8645
+ function readJsonFile(fs, file) {
8646
+ if (!fs.exists(file)) return void 0;
8647
+ try {
8648
+ const parsed = JSON.parse(fs.read(file));
8649
+ return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed : void 0;
8650
+ } catch {
8651
+ return;
8652
+ }
8653
+ }
8654
+ /** Metadata already on this machine for the plugin being listed.
8655
+ *
8656
+ * A local path carries its own manifest, and an npm package that happens to be installed carries a
8657
+ * `package.json`. Neither is fetched: what is here is read, what is not here is left to the
8658
+ * metadata flags. An entry missing an optional field still installs. */
8659
+ function localMetadata(root, spec, fs) {
8660
+ if (spec.kind === "path" && typeof spec.source === "string") return readJsonFile(fs, path.join(root, spec.source, "plugin.json")) ?? {};
8661
+ if (spec.kind === "npm" && typeof spec.source === "object") {
8662
+ const pkg = spec.source.package;
8663
+ if (typeof pkg !== "string") return {};
8664
+ return readJsonFile(fs, path.join(root, "node_modules", pkg, "package.json")) ?? {};
8665
+ }
8666
+ return {};
8667
+ }
8668
+ /** The entry another marketplace already publishes for this plugin, copied rather than invented.
8669
+ *
8670
+ * The catalog schema has no "from another marketplace" source, so there is nothing to write until
8671
+ * that marketplace has been read. Its entry already names a source every runtime can resolve — that
8672
+ * is what makes it publishable — so the whole entry comes across. */
8673
+ function resolveFromMarketplace(spec, opts, fs) {
8674
+ const marketplace = spec.marketplace;
8675
+ const known = opts.from === void 0 ? resolveKnownMarketplace(marketplace, fs) : { dir: opts.from };
8676
+ if (known === void 0) throw new Error(`error: marketplace "${marketplace}" is not installed; add it in the runtime first, or pass --from <path>`);
8677
+ const dir = known.dir;
8678
+ const content = readMarketplaceCatalog(dir, fs);
8679
+ if (content === void 0) throw new Error(`error: no marketplace.json found under "${dir}"`);
8680
+ let catalog;
8681
+ try {
8682
+ catalog = JSON.parse(content);
8683
+ } catch {
8684
+ throw new Error(`error: the catalog in "${dir}" is not valid JSON`);
8685
+ }
8686
+ const found = (typeof catalog === "object" && catalog !== null && Array.isArray(catalog.plugins) ? catalog.plugins : []).find((entry) => typeof entry === "object" && entry !== null && entry.name === spec.plugin);
8687
+ if (!found) throw new Error(`error: marketplace "${marketplace}" lists no plugin "${spec.plugin}"`);
8688
+ const source = found.source;
8689
+ if (source === void 0) throw new Error(`error: the "${spec.plugin}" entry in "${marketplace}" names no source`);
8690
+ if (!isLocalCatalogSource(source)) return {
8691
+ source,
8692
+ metadata: found
8693
+ };
8694
+ const remote = gitRemoteUrl(dir);
8695
+ const origin = known.origin ?? (remote === void 0 ? void 0 : originFromUrl(remote));
8696
+ 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`);
8697
+ const rewritten = absoluteSource(origin, source);
8698
+ if (rewritten === void 0) throw new Error(`error: the "${spec.plugin}" entry in "${marketplace}" names a source this command cannot read`);
8699
+ return {
8700
+ source: rewritten,
8701
+ metadata: found
8702
+ };
8703
+ }
8704
+ function selectedTargets(targets) {
8705
+ return targets && targets.length > 0 ? [...new Set(targets)] : [
8706
+ "claude",
8707
+ "codex",
8708
+ "copilot",
8709
+ "cursor"
8710
+ ];
8711
+ }
8712
+ function entryMetadata(discovered, supplied = {}) {
8713
+ const metadata = {};
8714
+ for (const field of ENTRY_METADATA_FIELDS) {
8715
+ const value = supplied[field] ?? discovered[field];
8716
+ if (value !== void 0) metadata[field] = value;
8717
+ }
8718
+ return metadata;
8719
+ }
8720
+ function catalogEntry(content, name) {
8721
+ try {
8722
+ const parsed = JSON.parse(content);
8723
+ if (!Array.isArray(parsed.plugins)) return void 0;
8724
+ return parsed.plugins.find((entry) => typeof entry === "object" && entry !== null && entry.name === name);
8725
+ } catch {
8726
+ return;
8727
+ }
8728
+ }
8729
+ /** Lists a plugin that lives somewhere else in this repository's catalogs.
8730
+ *
8731
+ * Where `marketplace init` derives a catalog from the plugins a repository holds, this adds one it
8732
+ * does not: an npm package, a GitHub repository, an entry another marketplace already publishes.
8733
+ * Together they let one repository be both — a plugin's own home and a curated list.
8734
+ *
8735
+ * Nothing is fetched and nothing is published. The command reads this repository, and at most a
8736
+ * marketplace already installed on this machine, and writes catalog files. */
8737
+ function addToMarketplace(rootInput, spec, opts = {}, fs = realMarketplaceFs) {
8738
+ const root = path.resolve(rootInput);
8739
+ const parsed = parsePluginSpec(spec, {
8740
+ kind: opts.kind,
8741
+ subdir: opts.subdir,
8742
+ ref: opts.ref,
8743
+ sha: opts.sha
8744
+ });
8745
+ const name = opts.name ?? parsed.name;
8746
+ assertMarketplaceName(name, "plugin name");
8747
+ const resolved = parsed.kind === "marketplace" ? resolveFromMarketplace(parsed, opts, fs) : {
8748
+ source: parsed.source,
8749
+ metadata: localMetadata(root, parsed, fs)
8750
+ };
8751
+ const metadata = deriveMetadata(root, fs, {
8752
+ name: opts.marketplaceName,
8753
+ owner: opts.owner
8754
+ });
8755
+ const plugin = {
8756
+ name,
8757
+ source: resolved.source,
8758
+ metadata: entryMetadata(resolved.metadata, opts.metadata)
8759
+ };
8760
+ const kind = sourceKindOf(resolved.source);
8761
+ const rendered = describeSource(resolved.source);
8762
+ const planned = selectedTargets(opts.targets).map((target) => {
8763
+ const catalogPath = TARGET_CATALOG_PATHS[target];
8764
+ const file = path.join(root, catalogPath);
8765
+ const existing = fs.exists(file) ? fs.read(file) : void 0;
8766
+ if (!TARGET_SOURCE_KINDS[target].includes(kind)) return {
8767
+ target,
8768
+ catalogPath,
8769
+ file,
8770
+ existing,
8771
+ skipped: `${kind} source is not supported by ${target}`,
8772
+ artifact: void 0
8773
+ };
8774
+ return {
8775
+ target,
8776
+ catalogPath,
8777
+ file,
8778
+ existing,
8779
+ skipped: void 0,
8780
+ artifact: mergeCatalogEntry(target, metadata, plugin, () => existing, { keepForeignSource: false })
8781
+ };
8782
+ });
8783
+ for (const entry of planned) {
8784
+ if (!entry.artifact) continue;
8785
+ const issues = validateCatalogContent(entry.target, entry.artifact.content);
8786
+ if (issues.length > 0) throw new Error(formatCatalogIssues(entry.catalogPath, issues));
8787
+ }
8788
+ const conflicts = planned.filter((entry) => {
8789
+ if (!entry.artifact || entry.existing === void 0 || opts.force) return false;
8790
+ if (catalogEntry(entry.existing, name) === void 0) return false;
8791
+ return !sameCatalogContent(entry.existing, entry.artifact.content);
8792
+ }).map((entry) => entry.catalogPath);
8793
+ if (conflicts.length > 0) throw new Error(`error: "${name}" is already listed differently in ${conflicts.join(", ")}; rerun with --force to replace it`);
8794
+ const results = planned.map((entry) => {
8795
+ const row = {
8796
+ target: entry.target,
8797
+ entry: name,
8798
+ source: rendered,
8799
+ path: entry.catalogPath
8800
+ };
8801
+ if (entry.skipped || !entry.artifact) return {
8802
+ ...row,
8803
+ status: "skipped",
8804
+ source: rendered,
8805
+ reason: entry.skipped
8806
+ };
8807
+ if (entry.existing !== void 0 && sameCatalogContent(entry.existing, entry.artifact.content)) return {
8808
+ ...row,
8809
+ status: "unchanged"
8810
+ };
8811
+ if (opts.dryRun) return {
8812
+ ...row,
8813
+ status: "planned"
8814
+ };
8815
+ const existed = entry.existing !== void 0 && catalogEntry(entry.existing, name) !== void 0;
8816
+ return {
8817
+ ...row,
8818
+ status: existed ? "updated" : "added"
8819
+ };
8820
+ });
8821
+ if (!opts.dryRun) for (const entry of planned) {
8822
+ if (!entry.artifact) continue;
8823
+ if (entry.existing !== void 0 && sameCatalogContent(entry.existing, entry.artifact.content)) continue;
8824
+ fs.writeAtomically(entry.file, entry.artifact.content);
8825
+ }
8826
+ return results;
8827
+ }
8828
+ //#endregion
8396
8829
  //#region src/marketplace/validate.ts
8397
8830
  /** A `./`-prefixed source names a directory inside the repository, and Claude Code resolves it
8398
8831
  * against the directory holding `.claude-plugin/`. A source pointing nowhere passes every schema
@@ -8463,6 +8896,65 @@ function targetsFromOptions(opts) {
8463
8896
  ].filter((target) => opts[target]);
8464
8897
  return targets.length > 0 ? targets : void 0;
8465
8898
  }
8899
+ /** The kind flags, as one map, so a second one is an error rather than a silent precedence rule. */
8900
+ const KIND_FLAGS = {
8901
+ path: "path",
8902
+ npm: "npm",
8903
+ github: "github",
8904
+ url: "url",
8905
+ fromMarketplace: "marketplace"
8906
+ };
8907
+ function kindFromOptions(opts) {
8908
+ const named = Object.keys(KIND_FLAGS).filter((flag) => opts[flag]);
8909
+ if (named.length > 1) throw new Error(`error: pass one source kind, not ${named.length}`);
8910
+ return named.length === 1 ? KIND_FLAGS[named[0]] : void 0;
8911
+ }
8912
+ function metadataFromOptions(opts) {
8913
+ const metadata = {};
8914
+ for (const field of ENTRY_METADATA_FIELDS) {
8915
+ const value = opts[field];
8916
+ if (typeof value !== "string") continue;
8917
+ metadata[field] = field === "keywords" ? value.split(",").map((keyword) => keyword.trim()).filter((keyword) => keyword !== "") : value;
8918
+ }
8919
+ return metadata;
8920
+ }
8921
+ function addCommand() {
8922
+ 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) => {
8923
+ try {
8924
+ if (opts.format !== void 0 && opts.format !== "toon" && opts.format !== "json") throw new Error("error: --format must be \"toon\" or \"json\"");
8925
+ const results = addToMarketplace(resolveRoot(opts.root), spec, {
8926
+ targets: targetsFromOptions(opts),
8927
+ kind: kindFromOptions(opts),
8928
+ name: opts.name,
8929
+ from: opts.from,
8930
+ subdir: opts.subdir,
8931
+ ref: opts.ref,
8932
+ sha: opts.sha,
8933
+ metadata: metadataFromOptions(opts),
8934
+ marketplaceName: opts.marketplaceName,
8935
+ owner: opts.owner,
8936
+ dryRun: opts.dryRun,
8937
+ force: opts.force
8938
+ });
8939
+ output(results, {
8940
+ targets: results.map((row) => ({
8941
+ target: row.target,
8942
+ status: row.status,
8943
+ entry: row.entry,
8944
+ source: row.source,
8945
+ path: row.path,
8946
+ reason: row.reason ?? "-"
8947
+ })),
8948
+ summary: `${results.filter((row) => row.status === "skipped").length} skipped of ${results.length}`
8949
+ });
8950
+ for (const row of results.filter((row) => row.status === "skipped")) process.stderr.write(`skipped ${row.target}: ${row.reason}\n`);
8951
+ process.stderr.write(`${opts.dryRun ? "Planned" : "Wrote"} repository metadata only; no marketplace publication, registration, installation, authentication, or provisioning occurred.\n`);
8952
+ } catch (err) {
8953
+ process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
8954
+ process.exitCode = 1;
8955
+ }
8956
+ });
8957
+ }
8466
8958
  function initCommand() {
8467
8959
  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
8960
  try {
@@ -8520,13 +9012,70 @@ function validateCommand$1() {
8520
9012
  });
8521
9013
  }
8522
9014
  function marketplaceCommand() {
8523
- return new Command("marketplace").description("Generate repository-local marketplace metadata").addCommand(initCommand()).addCommand(validateCommand$1());
9015
+ return new Command("marketplace").description("Generate repository-local marketplace metadata").addCommand(addCommand()).addCommand(initCommand()).addCommand(validateCommand$1());
8524
9016
  }
8525
9017
  //#endregion
8526
- //#region src/prepare/fs.ts
8527
- function expandHome(p) {
8528
- return p.startsWith("~") ? path.join(os.homedir(), p.slice(1)) : p;
9018
+ //#region src/prepare/installed-plugins.ts
9019
+ /**
9020
+ * Reads Claude Code's `installed_plugins.json` (version 2): `plugins` maps `<plugin>@<marketplace>`
9021
+ * to one entry per install, each with a `scope`, `installPath`, `version`, and, for project
9022
+ * installs, `projectPath`. Global scope takes the `user` installs; project scope takes the installs
9023
+ * whose `projectPath` is the project root.
9024
+ */
9025
+ function parseClaudeInstallRecord(raw, { scope, projectRoot }) {
9026
+ const plugins = isRecord(raw) && isRecord(raw["plugins"]) ? raw["plugins"] : {};
9027
+ const result = [];
9028
+ for (const [key, entries] of Object.entries(plugins)) {
9029
+ if (!Array.isArray(entries)) continue;
9030
+ const entry = entries.filter(isRecord).find((e) => scope === "global" ? e["scope"] === "user" : e["projectPath"] === projectRoot);
9031
+ if (!entry || typeof entry["installPath"] !== "string") continue;
9032
+ result.push({
9033
+ name: pluginName(key),
9034
+ version: typeof entry["version"] === "string" ? entry["version"] : "unknown",
9035
+ root: entry["installPath"]
9036
+ });
9037
+ }
9038
+ return result;
9039
+ }
9040
+ /** Strips the `@<marketplace>` suffix from a `<plugin>@<marketplace>` key. */
9041
+ function pluginName(key) {
9042
+ const at = key.lastIndexOf("@");
9043
+ return at > 0 ? key.slice(0, at) : key;
9044
+ }
9045
+ /**
9046
+ * Picks the newest of a plugin's cached version folders. Dotted numeric versions compare
9047
+ * numerically, part by part; anything else falls back to string order.
9048
+ */
9049
+ function latestVersion(versions) {
9050
+ return [...versions].sort(compareVersions).at(-1);
9051
+ }
9052
+ function compareVersions(a, b) {
9053
+ const pa = a.split(/[.+-]/);
9054
+ const pb = b.split(/[.+-]/);
9055
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
9056
+ const x = pa[i] ?? "";
9057
+ const y = pb[i] ?? "";
9058
+ if (x === y) continue;
9059
+ const nx = Number(x);
9060
+ const ny = Number(y);
9061
+ if (x !== "" && y !== "" && Number.isInteger(nx) && Number.isInteger(ny)) return nx - ny;
9062
+ return x < y ? -1 : 1;
9063
+ }
9064
+ return 0;
9065
+ }
9066
+ /** Maps installed plugins to the name → version record `prepare` snapshots. */
9067
+ function toVersions(plugins) {
9068
+ return Object.fromEntries(plugins.map((p) => [p.name, p.version]));
9069
+ }
9070
+ /** Maps installed plugins to name → root, with the home directory written as `~`. */
9071
+ function toRoots(plugins, home) {
9072
+ return Object.fromEntries(plugins.map((p) => [p.name, p.root.startsWith(home) ? "~" + p.root.slice(home.length) : p.root]));
9073
+ }
9074
+ function isRecord(value) {
9075
+ return typeof value === "object" && value !== null && !Array.isArray(value);
8529
9076
  }
9077
+ //#endregion
9078
+ //#region src/prepare/fs.ts
8530
9079
  function globalStatePath$2() {
8531
9080
  return path.join(os.homedir(), ".agents", "universal-plugin.json");
8532
9081
  }
@@ -8545,29 +9094,14 @@ function writeStateFile(filePath, state) {
8545
9094
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
8546
9095
  fs.writeFileSync(filePath, JSON.stringify(state, null, 2) + "\n", "utf8");
8547
9096
  }
8548
- function realPrepareFs(vendor, projectRoot) {
9097
+ /** `environment` locates the harness's plugin folders; it defaults to the current process. */
9098
+ function realPrepareFs(harness, installScope, environment = {}) {
9099
+ const projectRoot = installScope.projectRoot;
9100
+ let installed;
9101
+ const readInstalled = () => installed ??= readInstalledPlugins(harness, installScope, environment);
8549
9102
  return {
8550
- readManifest() {
8551
- if (!vendor.globalManifest) return {};
8552
- const manifestPath = expandHome(vendor.globalManifest);
8553
- try {
8554
- const raw = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
8555
- return Object.fromEntries(Object.entries(raw).map(([k, v]) => [k, typeof v === "object" && v !== null && "version" in v ? String(v.version) : String(v)]));
8556
- } catch (err) {
8557
- if (err.code === "ENOENT") return {};
8558
- throw err;
8559
- }
8560
- },
8561
- readPluginRoots() {
8562
- if (!vendor.globalPluginDir) return {};
8563
- const pluginDir = expandHome(vendor.globalPluginDir);
8564
- const manifest = this.readManifest();
8565
- const home = os.homedir();
8566
- return Object.fromEntries(Object.keys(manifest).map((name) => {
8567
- const absPath = path.join(pluginDir, name);
8568
- return [name, absPath.startsWith(home) ? "~" + absPath.slice(home.length) : absPath];
8569
- }));
8570
- },
9103
+ readManifest: () => toVersions(readInstalled()),
9104
+ readPluginRoots: () => toRoots(readInstalled(), environment.homedir ?? os.homedir()),
8571
9105
  readGlobalState: () => readStateFile(globalStatePath$2()) ?? emptyState(),
8572
9106
  readProjectState: () => projectRoot ? readStateFile(projectStatePath(projectRoot)) : null,
8573
9107
  writeGlobalState: (s) => writeStateFile(globalStatePath$2(), s),
@@ -8581,7 +9115,85 @@ function populateStoreFromVendorCache(pluginRoots, versions) {
8581
9115
  for (const [pluginName, pluginRoot] of Object.entries(pluginRoots)) {
8582
9116
  const entryPath = storeEntryPath(storePath, `npm/${pluginName}@${versions[pluginName] ?? "unknown"}`);
8583
9117
  if (entryExists(entryPath)) continue;
8584
- populateEntry(entryPath, pluginRoot);
9118
+ populateEntry(entryPath, expandHome(pluginRoot));
9119
+ }
9120
+ }
9121
+ function expandHome(p) {
9122
+ return p.startsWith("~") ? path.join(os.homedir(), p.slice(1)) : p;
9123
+ }
9124
+ /** Lists what a harness has installed, the way that harness records it: Claude Code in its install
9125
+ * record, Copilot CLI and Codex only in the layout of their plugin folders. Cursor keeps no record
9126
+ * of installed plugins. */
9127
+ function readInstalledPlugins(harness, installScope, environment) {
9128
+ const storage = pluginStorage(harness, environment);
9129
+ const location = (kind) => storage.locations.find((l) => l.kind === kind)?.path;
9130
+ switch (harness) {
9131
+ case "claude-code": {
9132
+ const record = location("install-record");
9133
+ const raw = record ? readJson(record) : null;
9134
+ return raw === null ? [] : parseClaudeInstallRecord(raw, installScope);
9135
+ }
9136
+ case "copilot-cli": {
9137
+ const dir = location("installed-plugins");
9138
+ return dir && installScope.scope === "global" ? readCopilotInstalls(dir) : [];
9139
+ }
9140
+ case "codex": {
9141
+ const dir = location("plugin-cache");
9142
+ return dir && installScope.scope === "global" ? readCodexCache(dir) : [];
9143
+ }
9144
+ default: return [];
9145
+ }
9146
+ }
9147
+ /** `<dir>/<marketplace>/<plugin>/` for marketplace installs, `<dir>/_direct/<source-id>/` for
9148
+ * direct ones; the plugin's own manifest carries its name and version. */
9149
+ function readCopilotInstalls(dir) {
9150
+ return subdirs(dir).flatMap((marketplace) => subdirs(path.join(dir, marketplace)).map((folder) => {
9151
+ const root = path.join(dir, marketplace, folder);
9152
+ const manifest = readPluginManifest(root);
9153
+ return {
9154
+ name: typeof manifest["name"] === "string" ? manifest["name"] : folder,
9155
+ version: typeof manifest["version"] === "string" ? manifest["version"] : "unknown",
9156
+ root
9157
+ };
9158
+ }));
9159
+ }
9160
+ /** `<dir>/<marketplace>/<plugin>/<version>/`; the newest cached version is the installed one. */
9161
+ function readCodexCache(dir) {
9162
+ return subdirs(dir).flatMap((marketplace) => subdirs(path.join(dir, marketplace)).flatMap((name) => {
9163
+ const version = latestVersion(subdirs(path.join(dir, marketplace, name)));
9164
+ return version ? [{
9165
+ name,
9166
+ version,
9167
+ root: path.join(dir, marketplace, name, version)
9168
+ }] : [];
9169
+ }));
9170
+ }
9171
+ const PLUGIN_MANIFESTS = [
9172
+ "plugin.json",
9173
+ ".github/plugin/plugin.json",
9174
+ ".claude-plugin/plugin.json"
9175
+ ];
9176
+ function readPluginManifest(root) {
9177
+ for (const file of PLUGIN_MANIFESTS) {
9178
+ const raw = readJson(path.join(root, file));
9179
+ if (typeof raw === "object" && raw !== null) return raw;
9180
+ }
9181
+ return {};
9182
+ }
9183
+ function readJson(filePath) {
9184
+ try {
9185
+ return JSON.parse(fs.readFileSync(filePath, "utf8"));
9186
+ } catch (err) {
9187
+ if (err.code === "ENOENT") return null;
9188
+ throw err;
9189
+ }
9190
+ }
9191
+ function subdirs(dir) {
9192
+ try {
9193
+ return fs.readdirSync(dir, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name);
9194
+ } catch (err) {
9195
+ if (err.code === "ENOENT") return [];
9196
+ throw err;
8585
9197
  }
8586
9198
  }
8587
9199
  //#endregion
@@ -8698,8 +9310,7 @@ function runPrepare(opts) {
8698
9310
  //#region src/prepare/cli.ts
8699
9311
  function prepareCommand() {
8700
9312
  return new Command("prepare").description("Detect cross-vendor plugin sync actions").argument("<vendor-id>", "Vendor to read manifest from (e.g. claude-code)").addOption(new Option("--scope <scope>", "global or project").default("global").choices(["global", "project"])).option("--root <path>", "Project root for project-scope state file").option("--dry-run", "Print action count without writing state").action((vendorId, opts) => {
8701
- const vendor = lookupVendor(loadRegistry(), vendorId);
8702
- if (!vendor) {
9313
+ if (!lookupVendor(loadRegistry(), vendorId) || !harnessIds.includes(vendorId)) {
8703
9314
  process.stderr.write(`Unknown vendor: ${vendorId}\n`);
8704
9315
  process.exit(1);
8705
9316
  }
@@ -8708,12 +9319,16 @@ function prepareCommand() {
8708
9319
  process.exit(1);
8709
9320
  }
8710
9321
  const now = (/* @__PURE__ */ new Date()).toISOString();
8711
- const prepareFs = realPrepareFs(vendor, opts.root);
9322
+ const scope = opts.scope;
9323
+ const prepareFs = realPrepareFs(vendorId, {
9324
+ scope,
9325
+ projectRoot: opts.root ? path.resolve(opts.root) : void 0
9326
+ });
8712
9327
  const pluginRoots = prepareFs.readPluginRoots();
8713
9328
  const manifest = prepareFs.readManifest();
8714
9329
  const { newActionCount } = runPrepare({
8715
9330
  vendorId,
8716
- scope: opts.scope,
9331
+ scope,
8717
9332
  fs: prepareFs,
8718
9333
  now,
8719
9334
  dryRun: opts.dryRun
@@ -8775,7 +9390,7 @@ const realSyncVersionFs = realJsonIo;
8775
9390
  //#endregion
8776
9391
  //#region src/publish/sync-version.ts
8777
9392
  /** The changesets-driven direction of the version flow: the number is decided by
8778
- * `changeset version` in `<packagePath>/package.json`, and this copies it into the canonical
9393
+ * `changeset version` in `<packagePath>/package.json` (the plugin root's when none is declared), and this copies it into the canonical
8779
9394
  * manifest. `plugin version` is the other direction — the number decided here, flowing out to
8780
9395
  * `package.json`. The two differ **only** in where the version comes from, so they share
8781
9396
  * `applyVersionPlan` and cannot drift; `package.json` is the source here, never rewritten. */
@@ -8783,11 +9398,11 @@ function syncVersion(root, syncFs) {
8783
9398
  const manifestPath = path.join(root, "plugin.json");
8784
9399
  if (!syncFs.exists(manifestPath)) throw new Error(`No plugin.json found at ${root}`);
8785
9400
  const agentsConfigPath = path.join(root, ".agents", "universal-plugin.json");
8786
- const packagePath = getPackagePath(syncFs.exists(agentsConfigPath) ? JSON.parse(syncFs.read(agentsConfigPath)) : {});
8787
- if (packagePath === null) throw new Error("packagePath is required in .agents/universal-plugin.json");
9401
+ const declared = getPackagePath(syncFs.exists(agentsConfigPath) ? JSON.parse(syncFs.read(agentsConfigPath)) : {});
9402
+ const packagePath = declared ?? ".";
8788
9403
  const manifest = JSON.parse(syncFs.read(manifestPath));
8789
9404
  const pkgJsonPath = path.join(root, packagePath, "package.json");
8790
- if (!syncFs.exists(pkgJsonPath)) throw new Error(`No package.json found at ${packagePath}`);
9405
+ 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
9406
  const version = JSON.parse(syncFs.read(pkgJsonPath))["version"];
8792
9407
  if (!version || typeof version !== "string") throw new Error(`No version found in ${packagePath}/package.json`);
8793
9408
  const current = manifest["version"];
@@ -8814,7 +9429,7 @@ function syncVersion(root, syncFs) {
8814
9429
  //#region src/publish/cli.ts
8815
9430
  function publishCommand() {
8816
9431
  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) => {
9432
+ cmd.command("sync-version").description("Sync version from packagePath/package.json (default: the plugin root) into plugin.json").addOption(ROOT_OPTION).action((opts) => {
8818
9433
  try {
8819
9434
  const result = syncVersion(resolveRoot(opts.root), realSyncVersionFs);
8820
9435
  output(result, {
@@ -9229,7 +9844,6 @@ function pluginCommand() {
9229
9844
  program.addCommand(pluginCommand());
9230
9845
  program.addCommand(cleanCommand());
9231
9846
  program.addCommand(configCommand());
9232
- program.addCommand(governanceCommand());
9233
9847
  program.addCommand(marketplaceCommand());
9234
9848
  program.addCommand(prepareCommand());
9235
9849
  program.addCommand(publishCommand());