universal-plugin 0.9.0 → 0.10.0

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