universal-plugin 0.2.2 → 0.3.1

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.
Files changed (38) hide show
  1. package/.claude-plugin/plugin.json +18 -0
  2. package/.codex-plugin/plugin.json +18 -0
  3. package/.cursor-plugin/plugin.json +18 -0
  4. package/agents/agentskills-specialist.md +132 -0
  5. package/bin/upx.mjs +6 -0
  6. package/dist/cli.mjs +998 -70
  7. package/dist/run.mjs +271 -0
  8. package/governances/plugin-design.md +22 -17
  9. package/governances/slash-invocation.md +30 -0
  10. package/package.json +15 -7
  11. package/plugin.json +24 -0
  12. package/readme.md +37 -3
  13. package/skills/adopt-upx/README.md +38 -0
  14. package/skills/adopt-upx/SKILL.md +120 -0
  15. package/skills/adopt-upx/scripts/rewrite-upx.mjs +168 -0
  16. package/skills/migrate-plugin/SKILL.md +106 -0
  17. package/skills/migrate-plugin/evals/evals.json +11 -0
  18. package/skills/migrate-plugin/evals/trigger-queries.json +35 -0
  19. package/skills/plugin/README.md +37 -0
  20. package/skills/plugin/SKILL.md +105 -0
  21. package/skills/plugin/assets/templates/agent.md +7 -0
  22. package/skills/plugin/assets/templates/command.md +9 -0
  23. package/skills/plugin/assets/templates/hooks.json +9 -0
  24. package/skills/plugin/assets/templates/plugin.json +19 -0
  25. package/skills/plugin/assets/templates/setup-command.md +15 -0
  26. package/skills/plugin/assets/templates/skill.md +15 -0
  27. package/skills/plugin/references/adopt.md +114 -0
  28. package/skills/plugin/references/create.md +163 -0
  29. package/skills/plugin/references/delete.md +23 -0
  30. package/skills/plugin/references/inspect.md +21 -0
  31. package/skills/plugin/references/update.md +26 -0
  32. package/skills/plugin/references/version.md +97 -0
  33. package/skills/publish-plugin/SKILL.md +246 -0
  34. package/skills/publish-plugin/evals/evals.json +23 -0
  35. package/skills/publish-plugin/references/vendor-requirements.md +38 -0
  36. package/skills/upgrade-plugin/README.md +23 -0
  37. package/skills/upgrade-plugin/SKILL.md +86 -0
  38. package/LICENSE +0 -21
package/dist/cli.mjs CHANGED
@@ -5,15 +5,12 @@ import * as os from "node:os";
5
5
  import * as path from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import * as childProcess from "node:child_process";
8
+ import * as semver from "semver";
8
9
  //#region src/cli-options.ts
9
10
  /** Repo root; resolves to cwd when omitted. */
10
11
  const ROOT_OPTION = new Option("--root <path>", "Plugin root directory");
11
- /** Resolves `--root` to an absolute path (cwd when omitted). Absolute is the contract: a relative
12
- * root such as `.` breaks any ancestor walk downstream (`path.dirname('.') === '.'` terminates the
13
- * walk on its first step), which would make the same operation behave differently depending on the
14
- * cwd it was invoked from. */
15
12
  function resolveRoot(root) {
16
- return root === void 0 ? process.cwd() : path.resolve(root);
13
+ return root ?? process.cwd();
17
14
  }
18
15
  //#endregion
19
16
  //#region src/state/state.ts
@@ -200,6 +197,8 @@ function detectIndent(json) {
200
197
  }
201
198
  //#endregion
202
199
  //#region src/build/build.ts
200
+ /** Where each vendor reads its manifest, relative to the project root. Shared with
201
+ * `plugin init --npm`, which wires exactly these paths into `package.json` `files`. */
203
202
  const VENDOR_OUTPUT = {
204
203
  "claude-code": ".claude-plugin/plugin.json",
205
204
  cursor: ".cursor-plugin/plugin.json",
@@ -207,31 +206,44 @@ const VENDOR_OUTPUT = {
207
206
  "copilot-cli": "plugin.json"
208
207
  };
209
208
  const KNOWN_VENDORS = new Set(Object.keys(VENDOR_OUTPUT));
209
+ /** Vendors the canonical root manifest serves as-is. The build derives no file for these — writing
210
+ * one would either be shadowed by root (a lower-precedence path) or clobber root itself. */
211
+ const CANONICAL_SERVED = new Set(["copilot-cli"]);
212
+ const UP_NAMESPACE$1 = "org.cyberuni.universal-plugin";
213
+ /** Reads universal-plugin's config block from the canonical manifest's extensions map. */
214
+ function universalPluginExtension(manifest) {
215
+ return manifest.extensions?.[UP_NAMESPACE$1] ?? {};
216
+ }
210
217
  function readManifest(root) {
211
- const manifestPath = path.join(root, ".plugin", "plugin.json");
212
- if (!fsNode.existsSync(manifestPath)) throw new Error(`No .plugin/plugin.json found at ${root}`);
218
+ const manifestPath = path.join(root, "plugin.json");
219
+ if (!fsNode.existsSync(manifestPath)) throw new Error(`No plugin.json found at ${root}`);
213
220
  return JSON.parse(fsNode.readFileSync(manifestPath, "utf8"));
214
221
  }
215
- function validateManifest(manifest) {
222
+ /** Validates the manifest. Vendor rules apply only to the vendors actually being built: pass
223
+ * `targets` to scope the check (build passes its selected targets), else it defaults to the
224
+ * manifest's own selection (`vendors ?? harnesses` keys). */
225
+ function validateManifest(manifest, targets) {
216
226
  const errors = [];
217
227
  if (!manifest.name) errors.push("name is required");
218
- if (manifest.vendorExtensions?.codex && !manifest.description) errors.push("description is required when targeting codex");
219
- if (manifest.vendorExtensions?.codex && !manifest.version) errors.push("version is required when targeting codex");
228
+ const uext = universalPluginExtension(manifest);
229
+ const harnesses = uext.harnesses ?? {};
230
+ const codexTargeted = (targets ?? uext.vendors ?? Object.keys(harnesses)).includes("codex") && Boolean(harnesses["codex"]);
231
+ if (codexTargeted && !manifest.description) errors.push("description is required when targeting codex");
232
+ if (codexTargeted && !manifest.version) errors.push("version is required when targeting codex");
220
233
  return errors;
221
234
  }
222
235
  function buildPlugin(root, opts = {}) {
223
- const manifestPath = path.join(root, ".plugin", "plugin.json");
224
- if (!fsNode.existsSync(manifestPath)) throw new Error(`No .plugin/plugin.json found at ${root}`);
236
+ const manifestPath = path.join(root, "plugin.json");
237
+ if (!fsNode.existsSync(manifestPath)) throw new Error(`No plugin.json found at ${root}`);
225
238
  const indent = detectIndent(fsNode.readFileSync(manifestPath, "utf8"));
226
239
  const manifest = readManifest(root);
227
- const errors = validateManifest(manifest);
228
- if (errors.length > 0) throw new Error(`plugin.json validation failed:\n${errors.map((e) => ` - ${e}`).join("\n")}`);
229
240
  const warnings = [];
230
241
  const rows = [];
231
- const vendorExtensions = manifest.vendorExtensions ?? {};
232
- let vendors = Object.keys(vendorExtensions).filter((v) => {
242
+ const uext = universalPluginExtension(manifest);
243
+ const harnesses = uext.harnesses ?? {};
244
+ let vendors = (uext.vendors ?? Object.keys(harnesses)).filter((v) => {
233
245
  if (!KNOWN_VENDORS.has(v)) {
234
- warnings.push(`Unknown vendor "${v}" in vendorExtensions — skipped`);
246
+ warnings.push(`Unknown vendor "${v}" in harnesses — skipped`);
235
247
  rows.push({
236
248
  vendor: v,
237
249
  path: "-",
@@ -242,11 +254,11 @@ function buildPlugin(root, opts = {}) {
242
254
  return true;
243
255
  });
244
256
  if (opts.vendor) {
245
- if (!vendors.includes(opts.vendor)) throw new Error(`Vendor "${opts.vendor}" not declared in vendorExtensions`);
257
+ if (!vendors.includes(opts.vendor)) throw new Error(`Vendor "${opts.vendor}" not declared in harnesses`);
246
258
  vendors = [opts.vendor];
247
259
  }
248
260
  if (vendors.length === 0) {
249
- warnings.push("No vendors declared in vendorExtensions — nothing to build");
261
+ warnings.push("No vendors declared in harnesses — nothing to build");
250
262
  return {
251
263
  vendors: [],
252
264
  written: [],
@@ -255,20 +267,36 @@ function buildPlugin(root, opts = {}) {
255
267
  summary: summarize(rows)
256
268
  };
257
269
  }
270
+ const errors = validateManifest(manifest, vendors);
271
+ if (errors.length > 0) throw new Error(`plugin.json validation failed:\n${errors.map((e) => ` - ${e}`).join("\n")}`);
258
272
  const written = [];
259
- const { vendorExtensions: _ext, $schema: _schema, ...canonical } = manifest;
273
+ const { $schema: _schema, extensions: _extensions, ...metadata } = manifest;
274
+ const { vendors: _vendors, packagePath: _packagePath, harnesses: _harnesses, ...componentConfig } = uext;
275
+ const skills = readSkills(root, manifest);
260
276
  for (const vendor of vendors) {
261
277
  const relPath = VENDOR_OUTPUT[vendor];
262
278
  const outputPath = path.join(root, relPath);
263
279
  const outputDir = path.dirname(outputPath);
264
- const vendorFields = vendorExtensions[vendor] ?? {};
280
+ const vendorFields = harnesses[vendor] ?? {};
265
281
  const vendorManifest = {
266
- ...canonical,
282
+ ...metadata,
283
+ ...componentConfig,
267
284
  ...vendorFields
268
285
  };
286
+ if (CANONICAL_SERVED.has(vendor)) {
287
+ const overrides = Object.keys(vendorFields);
288
+ if (overrides.length > 0) warnings.push(`harnesses.${vendor} sets ${overrides.join(", ")}, but ${vendor} reads the canonical plugin.json directly — these fields are not delivered`);
289
+ writeSkillArtifacts(root, vendor, skills, opts, written, warnings);
290
+ rows.push({
291
+ vendor,
292
+ path: relPath,
293
+ status: "canonical"
294
+ });
295
+ continue;
296
+ }
269
297
  if (opts.verbose) {
270
298
  console.log(`[${vendor}] → ${outputPath}`);
271
- for (const key of Object.keys(vendorFields)) console.log(` + ${key} (from vendorExtensions)`);
299
+ for (const key of Object.keys(vendorFields)) console.log(` + ${key} (from harnesses.${vendor})`);
272
300
  }
273
301
  try {
274
302
  if (!opts.dryRun) {
@@ -277,6 +305,7 @@ function buildPlugin(root, opts = {}) {
277
305
  fsNode.writeFileSync(outputPath, `${JSON.stringify(vendorManifest, null, indent)}\n`);
278
306
  }
279
307
  written.push(outputPath);
308
+ writeSkillArtifacts(root, vendor, skills, opts, written, warnings);
280
309
  rows.push({
281
310
  vendor,
282
311
  path: relPath,
@@ -299,18 +328,87 @@ function buildPlugin(root, opts = {}) {
299
328
  summary: summarize(rows)
300
329
  };
301
330
  }
331
+ function writeSkillArtifacts(root, vendor, skills, opts, written, warnings) {
332
+ for (const skill of skills) {
333
+ if (vendor === "claude-code") {
334
+ writeClaudeSkill(skill, opts, written);
335
+ continue;
336
+ }
337
+ if (vendor === "cursor") continue;
338
+ if (skill.invocationPolicy === "model") continue;
339
+ if (vendor === "codex") try {
340
+ writeArtifact(path.join(os.homedir(), ".codex", "prompts", `${skill.name}.md`), skill.body, opts, written);
341
+ } catch (err) {
342
+ warnings.push(`Failed to write Codex prompt for skill "${skill.name}" (best-effort): ${err instanceof Error ? err.message : String(err)}`);
343
+ }
344
+ }
345
+ }
346
+ function readSkills(root, manifest) {
347
+ const skillsCfg = universalPluginExtension(manifest).skills;
348
+ const skillsPath = typeof skillsCfg === "string" ? skillsCfg : "./skills/";
349
+ const skillsDir = path.resolve(root, skillsPath);
350
+ if (!fsNode.existsSync(skillsDir)) return [];
351
+ return listSkillFiles(skillsDir).map((skillPath) => parseSkill(skillPath));
352
+ }
353
+ function listSkillFiles(dir) {
354
+ return fsNode.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
355
+ const entryPath = path.join(dir, entry.name);
356
+ if (entry.isDirectory()) return listSkillFiles(entryPath);
357
+ return entry.isFile() && entry.name === "SKILL.md" ? [entryPath] : [];
358
+ });
359
+ }
360
+ function parseSkill(skillPath) {
361
+ const content = fsNode.readFileSync(skillPath, "utf8");
362
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
363
+ const frontmatter = match?.[1] ?? "";
364
+ const rawPolicy = frontmatter.match(/^invocation-policy:\s*['"]?(user|model|both)['"]?\s*(?:#.*)?$/m)?.[1];
365
+ const declaredPolicy = frontmatter.match(/^invocation-policy:\s*(\S+)/m)?.[1];
366
+ if (declaredPolicy && !rawPolicy) throw new Error(`Invalid invocation-policy "${declaredPolicy}" in ${skillPath}; expected user, model, or both`);
367
+ return {
368
+ path: skillPath,
369
+ name: path.basename(path.dirname(skillPath)),
370
+ invocationPolicy: rawPolicy ?? "both",
371
+ hasInvocationPolicy: rawPolicy !== void 0,
372
+ body: match ? content.slice(match[0].length) : content,
373
+ content
374
+ };
375
+ }
376
+ function writeClaudeSkill(skill, opts, written) {
377
+ if (!skill.hasInvocationPolicy) return;
378
+ const content = withClaudeInvocationFlags(skill);
379
+ if (content === skill.content) return;
380
+ if (!opts.dryRun) fsNode.writeFileSync(skill.path, content);
381
+ written.push(skill.path);
382
+ }
383
+ function withClaudeInvocationFlags(skill) {
384
+ const match = skill.content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
385
+ if (!match) return skill.content;
386
+ const lines = match[1].split(/\r?\n/).filter((line) => !/^(disable-model-invocation|user-invocable):\s*/.test(line));
387
+ if (skill.invocationPolicy === "user") lines.push("disable-model-invocation: true");
388
+ if (skill.invocationPolicy === "model") lines.push("user-invocable: false");
389
+ return `${skill.content.slice(0, match.index)}---\n${lines.join("\n")}\n---${skill.content.slice(match.index + match[0].length)}`;
390
+ }
391
+ function writeArtifact(outputPath, content, opts, written) {
392
+ if (!opts.dryRun) {
393
+ if (opts.clean && fsNode.existsSync(outputPath)) fsNode.unlinkSync(outputPath);
394
+ fsNode.mkdirSync(path.dirname(outputPath), { recursive: true });
395
+ fsNode.writeFileSync(outputPath, content);
396
+ }
397
+ written.push(outputPath);
398
+ }
302
399
  function summarize(rows) {
303
400
  return {
304
401
  built: rows.filter((r) => r.status === "built").length,
305
402
  skipped: rows.filter((r) => r.status === "skipped").length,
306
- failed: rows.filter((r) => r.status === "failed").length
403
+ failed: rows.filter((r) => r.status === "failed").length,
404
+ canonical: rows.filter((r) => r.status === "canonical").length
307
405
  };
308
406
  }
309
407
  //#endregion
310
408
  //#region src/build/cli.ts
311
- const NEXT_STEP$1 = "→ universal-plugin plugin validate\n";
409
+ const NEXT_STEP$2 = "→ universal-plugin plugin validate\n";
312
410
  function buildCommand() {
313
- const cmd = new Command("build").description("Generate vendor manifests from .plugin/plugin.json");
411
+ const cmd = new Command("build").description("Generate vendor manifests from plugin.json");
314
412
  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) => {
315
413
  try {
316
414
  const result = buildPlugin(resolveRoot(opts.root), {
@@ -320,11 +418,12 @@ function buildCommand() {
320
418
  clean: opts.clean
321
419
  });
322
420
  for (const warning of result.warnings) process.stderr.write(`warn: ${warning}\n`);
323
- const { built, skipped, failed } = result.summary;
421
+ const { built, skipped, failed, canonical } = result.summary;
324
422
  output({
325
423
  built: result.rows.filter((r) => r.status === "built"),
326
424
  skipped: result.rows.filter((r) => r.status === "skipped"),
327
425
  failed: result.rows.filter((r) => r.status === "failed"),
426
+ canonical: result.rows.filter((r) => r.status === "canonical"),
328
427
  summary: result.summary,
329
428
  warnings: result.warnings
330
429
  }, () => {
@@ -342,9 +441,10 @@ function buildCommand() {
342
441
  get: (r) => r.status
343
442
  }
344
443
  ]);
345
- console.log(`built ${built}, skipped ${skipped}, failed ${failed}`);
444
+ const counts = `built ${built}, skipped ${skipped}, failed ${failed}`;
445
+ console.log(canonical > 0 ? `${counts}, served by plugin.json ${canonical}` : counts);
346
446
  });
347
- process.stderr.write(NEXT_STEP$1);
447
+ process.stderr.write(NEXT_STEP$2);
348
448
  if (failed > 0) process.exitCode = 1;
349
449
  } catch (err) {
350
450
  process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
@@ -388,7 +488,7 @@ function realPinFs(skillsDir) {
388
488
  }
389
489
  //#endregion
390
490
  //#region src/pin/pin.ts
391
- const PIN_PATTERN = /npx\s+(?:--yes\s+|-y\s+)?([@a-z0-9/._-]+)@(\S+)/g;
491
+ const PIN_PATTERN = /(npx|upx)\s+(?:--yes\s+|-y\s+)?([@a-z0-9/._-]+)@(\S+)/g;
392
492
  /** Strips a trailing backtick, quote, or paren that isn't part of the version token. */
393
493
  function stripTrailing(raw) {
394
494
  return raw.replace(/[`'")]+$/, "");
@@ -396,13 +496,15 @@ function stripTrailing(raw) {
396
496
  function extractPins(text) {
397
497
  const pins = [];
398
498
  for (const match of text.matchAll(PIN_PATTERN)) {
399
- const pkg = match[1];
400
- const current = match[2];
401
- if (!pkg || !current) continue;
499
+ const runner = match[1];
500
+ const pkg = match[2];
501
+ const current = match[3];
502
+ if (!runner || !pkg || !current) continue;
402
503
  pins.push({
403
504
  pkg,
404
505
  current: stripTrailing(current),
405
- file: ""
506
+ file: "",
507
+ runner
406
508
  });
407
509
  }
408
510
  return pins;
@@ -412,12 +514,14 @@ function extractPins(text) {
412
514
  function escapeRegExp(value) {
413
515
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
414
516
  }
415
- /** Rewrites every `npx <pkg>@<anything>` occurrence in `content` to `npx <pkg>@<next>`, regardless
416
- * of whether the occurrence is a concrete version or a placeholder like `<version>` — so every
417
- * reference to one CLI converges on the same resolved value. */
418
- function rewritePin(content, pkg, next) {
419
- const pattern = new RegExp(`(npx\\s+(?:--yes\\s+|-y\\s+)?${escapeRegExp(pkg)}@)[^\\s\`'")]+`, "g");
420
- return content.replace(pattern, `$1${next}`);
517
+ /** Rewrites every `npx <pkg>@<anything>` or `upx <pkg>@<anything>` occurrence in `content` to
518
+ * `<runner> <pkg>@<next>`, regardless of whether the occurrence is a concrete version or a
519
+ * placeholder like `<version>` — so every reference to one CLI converges on the same resolved
520
+ * value. `forceRunner` omitted preserves each occurrence's existing runner word (an `npx`
521
+ * reference stays `npx`, an `upx` reference stays `upx`). */
522
+ function rewritePin(content, pkg, next, forceRunner) {
523
+ const pattern = new RegExp(`(npx|upx)(\\s+(?:--yes\\s+|-y\\s+)?${escapeRegExp(pkg)}@)[^\\s\`'")]+`, "g");
524
+ return content.replace(pattern, (_match, runner, mid) => `${forceRunner ?? runner}${mid}${next}`);
421
525
  }
422
526
  const FRONTMATTER_PATTERN = /^---\r?\n([\s\S]*?)\r?\n---/;
423
527
  const PIN_EXEMPT_PATTERN = /(^|\n)\s*pin-exempt:\s*true\s*(\n|$)/;
@@ -486,7 +590,7 @@ function bundlePins(pinFs, versionSource, opts = {}) {
486
590
  let changed = false;
487
591
  for (const file of inScope) {
488
592
  const content = contents.get(file);
489
- const rewritten = rewritePin(content, pkg, target);
593
+ const rewritten = rewritePin(content, pkg, target, opts.runner);
490
594
  if (rewritten !== content) {
491
595
  changed = true;
492
596
  contents.set(file, rewritten);
@@ -500,7 +604,6 @@ function bundlePins(pinFs, versionSource, opts = {}) {
500
604
  status: changed ? "pinned" : "unchanged"
501
605
  });
502
606
  }
503
- if (pins.length > 0 && pins.every((pin) => pin.status === "skipped")) warnings.push(`resolved 0 of ${pins.length} referenced package(s) against the workspace — no pins were rewritten`);
504
607
  return {
505
608
  pins,
506
609
  warnings
@@ -510,16 +613,13 @@ function bundlePins(pinFs, versionSource, opts = {}) {
510
613
  //#region src/bundle/fs.ts
511
614
  const DEFAULT_GLOBS = ["packages/*"];
512
615
  /** Walks up from `start` looking for `pnpm-workspace.yaml`; falls back to `start` itself when no
513
- * ancestor declares one (the workspace glob then resolves relative to `start`). `start` is resolved
514
- * to an absolute path first — a relative one (`.`) would end the walk on its first step, silently
515
- * making workspace discovery depend on the cwd the command was invoked from. */
616
+ * ancestor declares one (the workspace glob then resolves relative to `start`). */
516
617
  function findMonorepoRoot(start) {
517
- const from = path.resolve(start);
518
- let dir = from;
618
+ let dir = start;
519
619
  for (;;) {
520
620
  if (fsNode.existsSync(path.join(dir, "pnpm-workspace.yaml"))) return dir;
521
621
  const parent = path.dirname(dir);
522
- if (parent === dir) return from;
622
+ if (parent === dir) return start;
523
623
  dir = parent;
524
624
  }
525
625
  }
@@ -613,16 +713,18 @@ function realVersionSource(workspace) {
613
713
  //#endregion
614
714
  //#region src/bundle/cli.ts
615
715
  const TRUNCATE_THRESHOLD = 20;
616
- const NEXT_STEP = "→ review and commit the pinned skills\n";
617
- /** Every referenced package skipped: nothing was rewritten, so the next step is to check the
618
- * workspace resolution rather than to commit a bundle that does not exist. */
619
- const NOTHING_RESOLVED_STEP = "→ nothing was pinned — confirm --root is inside the workspace that owns these packages before releasing\n";
716
+ const NEXT_STEP$1 = "→ review and commit the pinned skills\n";
620
717
  function bundleCommand() {
621
718
  const cmd = new Command("bundle").description("Pin the plugin's skill npx references to their workspace package.json versions (release form)");
622
- cmd.option("--dry-run", "Resolve and report pins without writing them").option("--full", "Show every pins row without truncation").option("--format <format>", "Output format: json or toon (default: toon)").addOption(ROOT_OPTION).addHelpText("after", "\nExample:\n $ universal-plugin plugin bundle --dry-run --full\n").action((opts) => {
719
+ cmd.option("--dry-run", "Resolve and report pins without writing them").option("--full", "Show every pins row without truncation").option("--format <format>", "Output format: json or toon (default: toon)").option("--runner <runner>", "Runner word to emit on rewritten refs: npx or upx (default: preserve each ref's runner)").addOption(ROOT_OPTION).addHelpText("after", "\nExample:\n $ universal-plugin plugin bundle --dry-run --full\n").action((opts) => {
623
720
  try {
721
+ const runner = opts.runner;
722
+ if (runner !== void 0 && runner !== "npx" && runner !== "upx") throw new Error(`error: unknown --runner value "${runner}" (expected npx or upx)`);
624
723
  const root = resolveRoot(opts.root);
625
- const result = bundlePins(realPinFs(resolveSkillsDir(root, readManifest(root).skills)), realVersionSource(discoverWorkspace(root)), { dryRun: opts.dryRun });
724
+ const result = bundlePins(realPinFs(resolveSkillsDir(root, universalPluginExtension(readManifest(root)).skills)), realVersionSource(discoverWorkspace(root)), {
725
+ dryRun: opts.dryRun,
726
+ runner
727
+ });
626
728
  if (!opts.dryRun) writePinsMap(root, result.pins);
627
729
  for (const warning of result.warnings) process.stderr.write(`warn: ${warning}\n`);
628
730
  const pinned = result.pins.filter((p) => p.status === "pinned").length;
@@ -653,8 +755,7 @@ function bundleCommand() {
653
755
  console.log(`pinned ${pinned}, unchanged ${unchanged}, skipped ${skipped}`);
654
756
  });
655
757
  if (result.pins.length === 0) process.stderr.write("nothing to bundle\n");
656
- const noneResolved = result.pins.length > 0 && skipped === result.pins.length;
657
- process.stderr.write(noneResolved ? NOTHING_RESOLVED_STEP : NEXT_STEP);
758
+ process.stderr.write(NEXT_STEP$1);
658
759
  } catch (err) {
659
760
  process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
660
761
  process.exit(1);
@@ -663,6 +764,143 @@ function bundleCommand() {
663
764
  return cmd;
664
765
  }
665
766
  //#endregion
767
+ //#region src/config/config.ts
768
+ /** Pure domain for the plugin-registered config store.
769
+ *
770
+ * `.agents/universal-plugin.json` holds the CLI's own config plus, under
771
+ * plugin-registered keys, arrays of `{ name, … }` entry objects. This module owns
772
+ * the rules: which keys are reserved, how an entry merges into a key's array
773
+ * (append or replace-by-name, position preserved), and how a key's array is read.
774
+ * No I/O — the caller supplies the parsed config object. */
775
+ /** Keys `universal-plugin` owns for its own config — never plugin-registered arrays.
776
+ * `packagePath` is a string read by `publish sync-version`. */
777
+ const RESERVED_KEYS = ["packagePath"];
778
+ function isReservedKey(key) {
779
+ return RESERVED_KEYS.includes(key);
780
+ }
781
+ /** The array registered at `key`, or an empty array when the key is absent or non-array. */
782
+ function getEntries(config, key) {
783
+ const value = config[key];
784
+ return Array.isArray(value) ? value : [];
785
+ }
786
+ /** Merge `entry` into the array at `key`: append when no element shares its `name`,
787
+ * else replace that element in place (array position preserved). Returns a new config
788
+ * object; the input is not mutated. Throws when `entry` is not a JSON object or lacks a
789
+ * non-empty `name` (the merge dedup key). Reserved-key rejection is the caller's guard. */
790
+ function addEntry(config, key, entry) {
791
+ if (typeof entry !== "object" || entry === null || Array.isArray(entry)) throw new Error("error: --entry must be a JSON object");
792
+ const name = entry.name;
793
+ if (typeof name !== "string" || name.length === 0) throw new Error("error: --entry must include a non-empty \"name\" field");
794
+ const existing = getEntries(config, key);
795
+ const index = existing.findIndex((e) => typeof e === "object" && e !== null && e.name === name);
796
+ let next;
797
+ let action;
798
+ if (index === -1) {
799
+ next = [...existing, entry];
800
+ action = "appended";
801
+ } else {
802
+ next = existing.slice();
803
+ next[index] = entry;
804
+ action = "replaced";
805
+ }
806
+ return {
807
+ config: {
808
+ ...config,
809
+ [key]: next
810
+ },
811
+ action,
812
+ name
813
+ };
814
+ }
815
+ //#endregion
816
+ //#region src/config/fs.ts
817
+ function configPath(root) {
818
+ return path.join(root, ".agents", "universal-plugin.json");
819
+ }
820
+ const realConfigFs = {
821
+ read(root) {
822
+ const file = configPath(root);
823
+ if (!fsNode.existsSync(file)) return {};
824
+ return JSON.parse(fsNode.readFileSync(file, "utf8"));
825
+ },
826
+ write(root, config) {
827
+ const file = configPath(root);
828
+ const indent = fsNode.existsSync(file) ? detectIndent(fsNode.readFileSync(file, "utf8")) : " ";
829
+ fsNode.mkdirSync(path.dirname(file), { recursive: true });
830
+ fsNode.writeFileSync(file, `${JSON.stringify(config, null, indent)}\n`);
831
+ }
832
+ };
833
+ //#endregion
834
+ //#region src/config/cli.ts
835
+ function assertNotReserved(key) {
836
+ if (isReservedKey(key)) throw new Error(`error: "${key}" is a reserved key (universal-plugin's own config) — not a plugin-registered array`);
837
+ }
838
+ function addCommand(fs) {
839
+ 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) => {
840
+ try {
841
+ assertNotReserved(opts.key);
842
+ let parsed;
843
+ try {
844
+ parsed = JSON.parse(opts.entry);
845
+ } catch {
846
+ throw new Error("error: --entry is not valid JSON");
847
+ }
848
+ const root = resolveRoot(opts.root);
849
+ const result = addEntry(fs.read(root), opts.key, parsed);
850
+ fs.write(root, result.config);
851
+ const count = getEntries(result.config, opts.key).length;
852
+ output({
853
+ key: opts.key,
854
+ name: result.name,
855
+ action: result.action
856
+ }, () => {
857
+ printTable([result], [
858
+ {
859
+ label: "key",
860
+ get: () => opts.key
861
+ },
862
+ {
863
+ label: "name",
864
+ get: (r) => r.name
865
+ },
866
+ {
867
+ label: "action",
868
+ get: (r) => r.action
869
+ }
870
+ ]);
871
+ console.log(`${opts.key}: ${count} entries`);
872
+ });
873
+ process.stderr.write(`→ universal-plugin config get --key ${opts.key}\n`);
874
+ } catch (err) {
875
+ process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
876
+ process.exit(1);
877
+ }
878
+ });
879
+ }
880
+ function getCommand(fs) {
881
+ return new Command("get").description("Read the array of entries registered at a config key").requiredOption("--key <key>", "The config key whose array to read").option("--format <format>", "Output format: json or toon (default: toon)").addOption(ROOT_OPTION).addHelpText("after", "\nExample:\n $ universal-plugin config get --key sdd-plugins\n").action((opts) => {
882
+ try {
883
+ assertNotReserved(opts.key);
884
+ const root = resolveRoot(opts.root);
885
+ const entries = getEntries(fs.read(root), opts.key);
886
+ output(entries, () => {
887
+ printTable(entries, [{
888
+ label: "name",
889
+ get: (e) => String(e.name ?? "")
890
+ }]);
891
+ console.log(`${opts.key}: ${entries.length} entries`);
892
+ });
893
+ process.stderr.write(`→ universal-plugin config add --key ${opts.key} --entry <json>\n`);
894
+ } catch (err) {
895
+ process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
896
+ process.exit(1);
897
+ }
898
+ });
899
+ }
900
+ function configCommand(fs = realConfigFs) {
901
+ return new Command("config").description("Read and write plugin-registered config in .agents/universal-plugin.json").addCommand(addCommand(fs)).addCommand(getCommand(fs));
902
+ }
903
+ //#endregion
666
904
  //#region src/governance/fs.ts
667
905
  const realGovernanceFs = {
668
906
  exists: (p) => fsNode.existsSync(p),
@@ -807,6 +1045,486 @@ function governanceCommand() {
807
1045
  return cmd;
808
1046
  }
809
1047
  //#endregion
1048
+ //#region src/init/fs.ts
1049
+ function manifestPath(root) {
1050
+ return path.join(root, "plugin.json");
1051
+ }
1052
+ function packageJsonPath(root) {
1053
+ return path.join(root, "package.json");
1054
+ }
1055
+ const realInitFs = {
1056
+ gather(root) {
1057
+ const pj = packageJsonPath(root);
1058
+ const packageJson = fsNode.existsSync(pj) ? JSON.parse(fsNode.readFileSync(pj, "utf8")) : null;
1059
+ return {
1060
+ manifestExists: fsNode.existsSync(manifestPath(root)),
1061
+ packageJson
1062
+ };
1063
+ },
1064
+ apply(root, plan) {
1065
+ fsNode.writeFileSync(manifestPath(root), `${JSON.stringify(plan.manifest, null, " ")}\n`);
1066
+ for (const dir of plan.dirs) fsNode.mkdirSync(path.join(root, dir), { recursive: true });
1067
+ if (plan.packageJson) {
1068
+ const pj = packageJsonPath(root);
1069
+ const indent = detectIndent(fsNode.readFileSync(pj, "utf8"));
1070
+ fsNode.writeFileSync(pj, `${JSON.stringify(plan.packageJson, null, indent)}\n`);
1071
+ }
1072
+ }
1073
+ };
1074
+ //#endregion
1075
+ //#region src/init/init.ts
1076
+ /** Pure domain for `plugin init` — scaffolding the canonical manifest and (with `--npm`)
1077
+ * wiring an npm package to ship it.
1078
+ *
1079
+ * No I/O: the caller gathers the current filesystem state, calls `planInit`, and applies the
1080
+ * returned plan. `planInit` owns the rules — the guard order, the closed manifest shape, the
1081
+ * no-`--vendor` default (omit the `vendors` key, never write `vendors: []` — `plugin build`'s
1082
+ * `vendors ?? harnesses`-keys fallback engages only on an absent key), and the `--npm`
1083
+ * files-wiring default (`claude-code`). The vendor→derived-manifest-path map is injected so the
1084
+ * domain stays free of the build/registry layer. */
1085
+ const SCHEMA_URL = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json";
1086
+ const UP_NAMESPACE = "org.cyberuni.universal-plugin";
1087
+ const SCAFFOLD_DIRS = [
1088
+ "skills",
1089
+ "agents",
1090
+ "governances",
1091
+ "commands"
1092
+ ];
1093
+ /** The closed canonical manifest init writes: `$schema` + `name`, plus the extensions namespace
1094
+ * carrying the `vendors` list only when `--vendor` was passed. */
1095
+ function buildManifest(name, vendors) {
1096
+ const manifest = {
1097
+ $schema: SCHEMA_URL,
1098
+ name
1099
+ };
1100
+ if (vendors.length > 0) manifest.extensions = { [UP_NAMESPACE]: { vendors } };
1101
+ return manifest;
1102
+ }
1103
+ /** The open-standard base every published plugin ships, whatever it targets: the canonical
1104
+ * Agent Plugins Spec manifest and the skills directory every runtime reads. Vendor-derived
1105
+ * manifests layer on top of this — they never replace it. */
1106
+ const STANDARD_FILES = ["plugin.json", "skills/"];
1107
+ /** Adds the open-standard base plus each derived manifest path to `package.json` `files`, creating
1108
+ * the array when absent and never duplicating an entry. Other fields and existing entries are
1109
+ * preserved. The base goes in regardless of `--vendor`: a package that ships only
1110
+ * `.claude-plugin/plugin.json` has published a Claude Code plugin, not a standard one. */
1111
+ function wireFiles(pkg, manifestPaths) {
1112
+ const files = Array.isArray(pkg.files) ? [...pkg.files] : [];
1113
+ for (const entry of [...STANDARD_FILES, ...manifestPaths]) if (!files.includes(entry)) files.push(entry);
1114
+ return {
1115
+ ...pkg,
1116
+ files
1117
+ };
1118
+ }
1119
+ /** Plans the init run. Throws on a guard failure (an existing manifest without `--force`; `--npm`
1120
+ * with no `package.json`) before returning any plan, so the caller writes nothing on a guard trip. */
1121
+ function planInit(state, opts, rootDirName, resolveManifestPath) {
1122
+ if (opts.npm && state.packageJson === null) throw new Error("error: --npm requires a package.json at the project root");
1123
+ if (state.manifestExists && !opts.force) throw new Error("plugin.json already exists — pass --force to overwrite");
1124
+ const manifest = buildManifest(opts.name ?? rootDirName, opts.vendors);
1125
+ const dirs = opts.scaffold ? [...SCAFFOLD_DIRS] : [];
1126
+ const rows = [{
1127
+ path: "plugin.json",
1128
+ action: "created"
1129
+ }];
1130
+ let packageJson = null;
1131
+ if (opts.npm) {
1132
+ const manifestPaths = (opts.vendors.length > 0 ? opts.vendors : ["claude-code"]).map(resolveManifestPath).filter((p) => Boolean(p));
1133
+ packageJson = wireFiles(state.packageJson, manifestPaths);
1134
+ rows.push({
1135
+ path: "package.json",
1136
+ action: "updated"
1137
+ });
1138
+ }
1139
+ const created = rows.filter((r) => r.action === "created").length;
1140
+ const updated = rows.filter((r) => r.action === "updated").length;
1141
+ return {
1142
+ manifest,
1143
+ dirs,
1144
+ packageJson,
1145
+ rows,
1146
+ summary: {
1147
+ created,
1148
+ updated
1149
+ }
1150
+ };
1151
+ }
1152
+ //#endregion
1153
+ //#region src/init/cli.ts
1154
+ const NEXT_STEP = "→ add skills to skills/, then run universal-plugin plugin build\n";
1155
+ function collect(value, previous) {
1156
+ return [...previous, value];
1157
+ }
1158
+ function initCommand$1(deps = { fs: realInitFs }) {
1159
+ const cmd = new Command("init").description("Scaffold the canonical plugin.json (with --npm, wire an npm package to ship it)");
1160
+ cmd.option("--name <name>", "Plugin name (default: the root directory name)").option("--vendor <id>", "Target vendor; repeatable", collect, []).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("--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) => {
1161
+ try {
1162
+ const root = resolveRoot(opts.root);
1163
+ const plan = planInit(deps.fs.gather(root), {
1164
+ name: opts.name,
1165
+ vendors: opts.vendor ?? [],
1166
+ scaffold: Boolean(opts.scaffold),
1167
+ force: Boolean(opts.force),
1168
+ npm: Boolean(opts.npm)
1169
+ }, path.basename(root), (vendor) => VENDOR_OUTPUT[vendor]);
1170
+ deps.fs.apply(root, plan);
1171
+ output({
1172
+ created: plan.rows.filter((r) => r.action === "created").map((r) => r.path),
1173
+ updated: plan.rows.filter((r) => r.action === "updated").map((r) => r.path),
1174
+ summary: plan.summary
1175
+ }, () => {
1176
+ printTable(plan.rows, [{
1177
+ label: "path",
1178
+ get: (r) => r.path
1179
+ }, {
1180
+ label: "action",
1181
+ get: (r) => r.action
1182
+ }]);
1183
+ console.log(`created ${plan.summary.created}, updated ${plan.summary.updated}`);
1184
+ });
1185
+ process.stderr.write(NEXT_STEP);
1186
+ } catch (err) {
1187
+ process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
1188
+ process.exit(1);
1189
+ }
1190
+ });
1191
+ return cmd;
1192
+ }
1193
+ //#endregion
1194
+ //#region src/marketplace/fs.ts
1195
+ const realMarketplaceFs = {
1196
+ exists: fsNode.existsSync,
1197
+ isDirectory(file) {
1198
+ return fsNode.statSync(file).isDirectory();
1199
+ },
1200
+ realpath(file) {
1201
+ return fsNode.realpathSync(file);
1202
+ },
1203
+ read(file) {
1204
+ return fsNode.readFileSync(file, "utf8");
1205
+ },
1206
+ listEntries(dir) {
1207
+ return fsNode.readdirSync(dir);
1208
+ },
1209
+ writeAtomically(file, content) {
1210
+ fsNode.mkdirSync(path.dirname(file), { recursive: true });
1211
+ const temporary = `${file}.${process.pid}.${Math.random().toString(16).slice(2)}.tmp`;
1212
+ fsNode.writeFileSync(temporary, content);
1213
+ fsNode.renameSync(temporary, file);
1214
+ }
1215
+ };
1216
+ //#endregion
1217
+ //#region src/marketplace/marketplace.ts
1218
+ const COMMON_METADATA = [
1219
+ "description",
1220
+ "version",
1221
+ "homepage",
1222
+ "repository",
1223
+ "license",
1224
+ "keywords"
1225
+ ];
1226
+ function assertMarketplaceName(value, label) {
1227
+ if (!/^[a-z0-9][a-z0-9._-]*$/i.test(value)) throw new Error(`error: ${label} "${value}" must contain only letters, digits, dots, underscores, or hyphens`);
1228
+ }
1229
+ function commonMetadata(plugin) {
1230
+ const result = {};
1231
+ for (const field of COMMON_METADATA) if (plugin.metadata[field] !== void 0) result[field] = plugin.metadata[field];
1232
+ return result;
1233
+ }
1234
+ function json(value) {
1235
+ return `${JSON.stringify(value, null, 2)}\n`;
1236
+ }
1237
+ function claudeArtifact(metadata, plugins) {
1238
+ return {
1239
+ path: ".claude-plugin/marketplace.json",
1240
+ content: json({
1241
+ ...metadata,
1242
+ plugins: plugins.map((plugin) => ({
1243
+ name: plugin.name,
1244
+ source: plugin.source,
1245
+ ...commonMetadata(plugin)
1246
+ }))
1247
+ })
1248
+ };
1249
+ }
1250
+ function codexArtifact(metadata, plugins) {
1251
+ return {
1252
+ path: ".agents/plugins/marketplace.json",
1253
+ content: json({
1254
+ name: metadata.name,
1255
+ interface: { displayName: metadata.name },
1256
+ plugins: plugins.map((plugin) => ({
1257
+ name: plugin.name,
1258
+ version: plugin.metadata.version,
1259
+ source: {
1260
+ source: "local",
1261
+ path: plugin.source
1262
+ },
1263
+ policy: {
1264
+ installation: "AVAILABLE",
1265
+ authentication: "ON_INSTALL"
1266
+ },
1267
+ category: "Productivity"
1268
+ }))
1269
+ })
1270
+ };
1271
+ }
1272
+ function copilotArtifact(metadata, plugins) {
1273
+ return {
1274
+ path: ".github/plugin/marketplace.json",
1275
+ content: json({
1276
+ ...metadata,
1277
+ metadata: { displayName: metadata.name },
1278
+ plugins: plugins.map((plugin) => ({
1279
+ name: plugin.name,
1280
+ source: plugin.source,
1281
+ ...commonMetadata(plugin)
1282
+ }))
1283
+ })
1284
+ };
1285
+ }
1286
+ function cursorArtifacts(metadata, plugins) {
1287
+ const sources = plugins.map((plugin) => ({
1288
+ name: plugin.name,
1289
+ source: plugin.source,
1290
+ ...commonMetadata(plugin)
1291
+ }));
1292
+ return [{
1293
+ path: ".cursor-plugin/marketplace-submission.json",
1294
+ content: json({
1295
+ ...metadata,
1296
+ plugins: sources,
1297
+ dashboard: "https://cursor.com/dashboard"
1298
+ })
1299
+ }, {
1300
+ path: "CURSOR_MARKETPLACE_SUBMISSION.md",
1301
+ content: `# Cursor Marketplace Submission\n\nMarketplace: ${metadata.name}\nOwner: ${metadata.owner}\n\nPlugins:\n${sources.map((plugin) => `- ${plugin.name}: ${plugin.source}`).join("\n")}\n\nSubmit this metadata through the [Cursor dashboard](https://cursor.com/dashboard). This command generated local submission metadata only; no publication or provisioning occurred.\n`
1302
+ }];
1303
+ }
1304
+ function serializeTarget(target, metadata, plugins) {
1305
+ switch (target) {
1306
+ case "claude": return [claudeArtifact(metadata, plugins)];
1307
+ case "codex": return [codexArtifact(metadata, plugins)];
1308
+ case "copilot": return [copilotArtifact(metadata, plugins)];
1309
+ case "cursor": return cursorArtifacts(metadata, plugins);
1310
+ }
1311
+ }
1312
+ //#endregion
1313
+ //#region src/marketplace/init.ts
1314
+ function isInside(root, candidate) {
1315
+ const relative = path.relative(root, candidate);
1316
+ return relative === "" || !relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative);
1317
+ }
1318
+ function containingExistingPath(file, fs) {
1319
+ let current = file;
1320
+ while (!fs.exists(current)) {
1321
+ const parent = path.dirname(current);
1322
+ if (parent === current) throw new Error(`error: path "${file}" must resolve within --root`);
1323
+ current = parent;
1324
+ }
1325
+ return current;
1326
+ }
1327
+ function assertContained(root, candidate, fs, label) {
1328
+ if (!isInside(fs.realpath(root), fs.realpath(containingExistingPath(candidate, fs)))) throw new Error(`error: ${label} "${candidate}" must resolve within --root`);
1329
+ }
1330
+ function parseManifest(fs, file) {
1331
+ try {
1332
+ const parsed = JSON.parse(fs.read(file));
1333
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("not an object");
1334
+ return parsed;
1335
+ } catch {
1336
+ throw new Error(`error: manifest "${file}" is not valid JSON`);
1337
+ }
1338
+ }
1339
+ function manifestOwner(manifest) {
1340
+ if (typeof manifest.author === "string") return manifest.author;
1341
+ if (typeof manifest.author === "object" && manifest.author !== null) {
1342
+ const author = manifest.author;
1343
+ if (typeof author.name === "string") return author.name;
1344
+ }
1345
+ }
1346
+ function deriveMetadata(root, fs, opts) {
1347
+ const rootManifest = path.join(root, "plugin.json");
1348
+ if (fs.exists(rootManifest)) assertContained(root, rootManifest, fs, "root plugin.json");
1349
+ const manifest = fs.exists(rootManifest) ? parseManifest(fs, rootManifest) : {};
1350
+ const name = opts.name ?? path.basename(root);
1351
+ const owner = opts.owner ?? manifestOwner(manifest);
1352
+ assertMarketplaceName(name, "marketplace name");
1353
+ if (!owner || owner.trim() === "") throw new Error("error: marketplace owner is required; set --owner or root plugin.json author");
1354
+ return {
1355
+ name,
1356
+ owner
1357
+ };
1358
+ }
1359
+ function scanRoots(root, fs, scanDirs) {
1360
+ const roots = [...new Set((scanDirs ?? ["plugins"]).map((dir) => path.resolve(root, dir)))].sort();
1361
+ for (const scanRoot of roots) {
1362
+ if (!isInside(root, scanRoot)) throw new Error(`error: --plugin-scan-dir "${scanRoot}" must resolve within --root`);
1363
+ if (!fs.exists(scanRoot)) {
1364
+ if (scanDirs === void 0) continue;
1365
+ throw new Error(`error: plugin scan directory "${scanRoot}" does not exist`);
1366
+ }
1367
+ if (!fs.isDirectory(scanRoot)) throw new Error(`error: plugin scan directory "${scanRoot}" is not a directory`);
1368
+ assertContained(root, scanRoot, fs, "--plugin-scan-dir");
1369
+ }
1370
+ return roots.filter((scanRoot) => fs.exists(scanRoot));
1371
+ }
1372
+ function discoverPlugins(root, fs, scanDirs) {
1373
+ const plugins = [];
1374
+ const names = /* @__PURE__ */ new Set();
1375
+ for (const scanRoot of scanRoots(root, fs, scanDirs)) for (const entry of fs.listEntries(scanRoot).sort()) {
1376
+ if ([
1377
+ ".plugin",
1378
+ ".claude-plugin",
1379
+ ".codex-plugin",
1380
+ ".cursor-plugin"
1381
+ ].includes(entry)) continue;
1382
+ const pluginRoot = path.join(scanRoot, entry);
1383
+ if (!fs.isDirectory(pluginRoot)) continue;
1384
+ assertContained(root, pluginRoot, fs, "plugin directory");
1385
+ const manifestPath = path.join(pluginRoot, "plugin.json");
1386
+ if (!fs.exists(manifestPath)) continue;
1387
+ assertContained(root, manifestPath, fs, "plugin manifest");
1388
+ const manifest = parseManifest(fs, manifestPath);
1389
+ if (typeof manifest.name !== "string" || manifest.name.trim() === "") throw new Error(`error: manifest "${manifestPath}" must contain a non-empty name`);
1390
+ assertMarketplaceName(manifest.name, "plugin name");
1391
+ if (names.has(manifest.name)) throw new Error(`error: duplicate plugin name "${manifest.name}"`);
1392
+ names.add(manifest.name);
1393
+ const source = `./${path.relative(root, pluginRoot).split(path.sep).join("/")}`;
1394
+ plugins.push({
1395
+ name: manifest.name,
1396
+ source,
1397
+ metadata: manifest
1398
+ });
1399
+ }
1400
+ return plugins.sort((a, b) => a.name.localeCompare(b.name));
1401
+ }
1402
+ function assertCodexPluginVersions(plugins) {
1403
+ for (const plugin of plugins) if (typeof plugin.metadata.version !== "string" || plugin.metadata.version.trim() === "") throw new Error(`error: canonical plugin manifest for Codex entry "${plugin.name}" requires a version`);
1404
+ }
1405
+ function writeArtifacts(fs, artifacts, root) {
1406
+ const changed = artifacts.filter((artifact) => !sameArtifact(fs, path.join(root, artifact.path), artifact.content));
1407
+ for (const artifact of changed) fs.writeAtomically(path.join(root, artifact.path), artifact.content);
1408
+ }
1409
+ function selectedTargets(targets) {
1410
+ return targets && targets.length > 0 ? [...new Set(targets)] : [
1411
+ "claude",
1412
+ "codex",
1413
+ "copilot"
1414
+ ];
1415
+ }
1416
+ function sameArtifact(fs, file, content) {
1417
+ if (!fs.exists(file)) return false;
1418
+ const existing = fs.read(file);
1419
+ if (!file.endsWith(".json")) return existing === content;
1420
+ try {
1421
+ return JSON.stringify(normalizeJson(JSON.parse(existing))) === JSON.stringify(normalizeJson(JSON.parse(content)));
1422
+ } catch {
1423
+ return false;
1424
+ }
1425
+ }
1426
+ /** JSON object key order and whitespace do not change a catalog's meaning; array order does. */
1427
+ function normalizeJson(value) {
1428
+ if (Array.isArray(value)) return value.map(normalizeJson);
1429
+ if (typeof value !== "object" || value === null) return value;
1430
+ const record = value;
1431
+ return Object.fromEntries(Object.keys(record).sort().map((key) => [key, normalizeJson(record[key])]));
1432
+ }
1433
+ function initializeMarketplace(rootInput, opts = {}, fs = realMarketplaceFs) {
1434
+ const root = path.resolve(rootInput);
1435
+ const metadata = deriveMetadata(root, fs, opts);
1436
+ const plugins = discoverPlugins(root, fs, opts.scanDirs);
1437
+ const targets = selectedTargets(opts.targets);
1438
+ if (targets.includes("codex")) assertCodexPluginVersions(plugins);
1439
+ const planned = targets.map((target) => ({
1440
+ target,
1441
+ artifacts: serializeTarget(target, metadata, plugins)
1442
+ }));
1443
+ for (const { artifacts } of planned) for (const artifact of artifacts) assertContained(root, path.join(root, artifact.path), fs, "selected artifact");
1444
+ const conflicts = [];
1445
+ for (const entry of planned) for (const artifact of entry.artifacts) {
1446
+ const output = path.join(root, artifact.path);
1447
+ if (fs.exists(output) && !sameArtifact(fs, output, artifact.content) && !opts.force) conflicts.push(artifact.path);
1448
+ }
1449
+ if (conflicts.length > 0) throw new Error(`error: generated artifact differs: ${conflicts.join(", ")}; rerun with --force to replace selected artifacts`);
1450
+ const results = planned.map(({ target, artifacts }) => {
1451
+ if (plugins.length === 0) return {
1452
+ target,
1453
+ status: "empty",
1454
+ paths: [],
1455
+ plugins: [],
1456
+ reason: "no plugins discovered"
1457
+ };
1458
+ const unchanged = artifacts.every((artifact) => sameArtifact(fs, path.join(root, artifact.path), artifact.content));
1459
+ return {
1460
+ target,
1461
+ status: opts.dryRun ? "planned" : unchanged ? "unchanged" : "generated",
1462
+ paths: artifacts.map((artifact) => artifact.path),
1463
+ plugins: plugins.map((plugin) => plugin.name)
1464
+ };
1465
+ });
1466
+ if (!opts.dryRun && plugins.length > 0) writeArtifacts(fs, planned.flatMap((entry) => entry.artifacts), root);
1467
+ if (!opts.targets || opts.targets.length === 0) results.push({
1468
+ target: "cursor",
1469
+ status: "skipped-default",
1470
+ paths: [],
1471
+ plugins: [],
1472
+ reason: "Cursor requires an explicit submission scaffold"
1473
+ });
1474
+ return results;
1475
+ }
1476
+ //#endregion
1477
+ //#region src/marketplace/cli.ts
1478
+ function targetsFromOptions(opts) {
1479
+ const targets = [
1480
+ "claude",
1481
+ "codex",
1482
+ "copilot",
1483
+ "cursor"
1484
+ ].filter((target) => opts[target]);
1485
+ return targets.length > 0 ? targets : void 0;
1486
+ }
1487
+ function initCommand() {
1488
+ 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 submission scaffold").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) => {
1489
+ try {
1490
+ if (opts.format !== void 0 && opts.format !== "toon" && opts.format !== "json") throw new Error("error: --format must be \"toon\" or \"json\"");
1491
+ const results = initializeMarketplace(resolveRoot(opts.root), {
1492
+ targets: targetsFromOptions(opts),
1493
+ scanDirs: opts.pluginScanDir,
1494
+ name: opts.name,
1495
+ owner: opts.owner,
1496
+ dryRun: opts.dryRun,
1497
+ force: opts.force
1498
+ });
1499
+ output(results, () => printTable(results, [
1500
+ {
1501
+ label: "target",
1502
+ get: (row) => row.target
1503
+ },
1504
+ {
1505
+ label: "status",
1506
+ get: (row) => row.status
1507
+ },
1508
+ {
1509
+ label: "paths",
1510
+ get: (row) => row.paths.join(", ") || "-"
1511
+ },
1512
+ {
1513
+ label: "plugins",
1514
+ get: (row) => row.plugins.join(", ") || "-"
1515
+ }
1516
+ ]));
1517
+ process.stderr.write("Generated repository metadata only; no marketplace publication, registration, installation, authentication, or provisioning occurred.\n");
1518
+ } catch (err) {
1519
+ process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
1520
+ process.exitCode = 1;
1521
+ }
1522
+ });
1523
+ }
1524
+ function marketplaceCommand() {
1525
+ return new Command("marketplace").description("Generate repository-local marketplace metadata").addCommand(initCommand());
1526
+ }
1527
+ //#endregion
810
1528
  //#region src/vendor-registry/vendor-registry.ts
811
1529
  function lookupVendor(registry, vendorId) {
812
1530
  return registry[vendorId] ?? null;
@@ -1038,32 +1756,87 @@ function prepareCommand() {
1038
1756
  });
1039
1757
  }
1040
1758
  //#endregion
1041
- //#region src/publish/fs.ts
1042
- const realSyncVersionFs = {
1759
+ //#region src/version/fs.ts
1760
+ const realJsonIo = {
1043
1761
  exists: (p) => fsNode.existsSync(p),
1044
1762
  read: (p) => fsNode.readFileSync(p, "utf8"),
1045
1763
  write: (p, content) => fsNode.writeFileSync(p, content)
1046
1764
  };
1765
+ /** Reads `packagePath` from `.agents/universal-plugin.json`. Absent file, absent key, or a
1766
+ * non-string value all mean "this plugin declares no npm package" — the manifest is then the only
1767
+ * authored file. (A *declared* path whose `package.json` is missing is a different case, and the
1768
+ * domain rejects it.) */
1769
+ function readPackagePath(root, io) {
1770
+ const configPath = path.join(root, ".agents", "universal-plugin.json");
1771
+ if (!io.exists(configPath)) return null;
1772
+ const packagePath = JSON.parse(io.read(configPath))["packagePath"];
1773
+ return typeof packagePath === "string" && packagePath.length > 0 ? packagePath : null;
1774
+ }
1775
+ /** Writes `value` over `filePath`, keeping whatever indentation that file already used. */
1776
+ function writeJson(io, filePath, value) {
1777
+ const indent = io.exists(filePath) ? detectIndent(io.read(filePath)) : " ";
1778
+ io.write(filePath, `${JSON.stringify(value, null, indent)}\n`);
1779
+ }
1780
+ /** The one applier. Writes every authored file the plan names — and nothing else: the derived
1781
+ * vendor manifests are re-derived by calling `plugin build`, which owns them. */
1782
+ function applyVersionPlan(root, plan, io) {
1783
+ writeJson(io, path.join(root, "plugin.json"), plan.manifest);
1784
+ if (plan.packageJson) writeJson(io, path.join(root, plan.packageJson.path), plan.packageJson.content);
1785
+ }
1786
+ const realVersionFs = {
1787
+ gather(root) {
1788
+ const manifestPath = path.join(root, "plugin.json");
1789
+ const manifestExists = realJsonIo.exists(manifestPath);
1790
+ const manifest = manifestExists ? JSON.parse(realJsonIo.read(manifestPath)) : null;
1791
+ const packagePath = readPackagePath(root, realJsonIo);
1792
+ const pkgJsonPath = packagePath === null ? null : path.join(root, packagePath, "package.json");
1793
+ return {
1794
+ manifestExists,
1795
+ manifest,
1796
+ packagePath,
1797
+ packageJson: pkgJsonPath !== null && realJsonIo.exists(pkgJsonPath) ? JSON.parse(realJsonIo.read(pkgJsonPath)) : null
1798
+ };
1799
+ },
1800
+ apply(root, plan) {
1801
+ applyVersionPlan(root, plan, realJsonIo);
1802
+ }
1803
+ };
1804
+ //#endregion
1805
+ //#region src/publish/fs.ts
1806
+ const realSyncVersionFs = realJsonIo;
1047
1807
  //#endregion
1048
1808
  //#region src/publish/sync-version.ts
1809
+ /** The changesets-driven direction of the version flow: the number is decided by
1810
+ * `changeset version` in `<packagePath>/package.json`, and this copies it into the canonical
1811
+ * manifest. `plugin version` is the other direction — the number decided here, flowing out to
1812
+ * `package.json`. The two differ **only** in where the version comes from, so they share
1813
+ * `applyVersionPlan` and cannot drift; `package.json` is the source here, never rewritten. */
1049
1814
  function syncVersion(root, syncFs) {
1050
- const manifestPath = path.join(root, ".plugin", "plugin.json");
1051
- if (!syncFs.exists(manifestPath)) throw new Error(`No .plugin/plugin.json found at ${root}`);
1815
+ const manifestPath = path.join(root, "plugin.json");
1816
+ if (!syncFs.exists(manifestPath)) throw new Error(`No plugin.json found at ${root}`);
1052
1817
  const agentsConfigPath = path.join(root, ".agents", "universal-plugin.json");
1053
1818
  const packagePath = (syncFs.exists(agentsConfigPath) ? JSON.parse(syncFs.read(agentsConfigPath)) : {})["packagePath"];
1054
1819
  if (!packagePath || typeof packagePath !== "string") throw new Error("packagePath is required in .agents/universal-plugin.json");
1055
- const raw = syncFs.read(manifestPath);
1056
- const manifest = JSON.parse(raw);
1820
+ const manifest = JSON.parse(syncFs.read(manifestPath));
1057
1821
  const pkgJsonPath = path.join(root, packagePath, "package.json");
1058
1822
  if (!syncFs.exists(pkgJsonPath)) throw new Error(`No package.json found at ${packagePath}`);
1059
1823
  const version = JSON.parse(syncFs.read(pkgJsonPath))["version"];
1060
1824
  if (!version || typeof version !== "string") throw new Error(`No version found in ${packagePath}/package.json`);
1061
- const indent = detectIndent(raw);
1062
- const updated = {
1063
- ...manifest,
1064
- version
1065
- };
1066
- syncFs.write(manifestPath, `${JSON.stringify(updated, null, indent)}\n`);
1825
+ const current = manifest["version"];
1826
+ applyVersionPlan(root, {
1827
+ from: typeof current === "string" ? current : null,
1828
+ to: version,
1829
+ manifest: {
1830
+ ...manifest,
1831
+ version
1832
+ },
1833
+ packageJson: null,
1834
+ rows: [{
1835
+ path: "plugin.json",
1836
+ action: "updated"
1837
+ }],
1838
+ summary: { updated: 1 }
1839
+ }, syncFs);
1067
1840
  return {
1068
1841
  version,
1069
1842
  manifestPath
@@ -1073,7 +1846,7 @@ function syncVersion(root, syncFs) {
1073
1846
  //#region src/publish/cli.ts
1074
1847
  function publishCommand() {
1075
1848
  const cmd = new Command("publish").description("Prepare plugin for publishing").helpCommand(false);
1076
- cmd.command("sync-version").description("Sync version from packagePath/package.json into .plugin/plugin.json").addOption(ROOT_OPTION).action((opts) => {
1849
+ cmd.command("sync-version").description("Sync version from packagePath/package.json into plugin.json").addOption(ROOT_OPTION).action((opts) => {
1077
1850
  try {
1078
1851
  const result = syncVersion(resolveRoot(opts.root), realSyncVersionFs);
1079
1852
  output(result, () => {
@@ -1232,18 +2005,173 @@ function syncCommand() {
1232
2005
  return cmd;
1233
2006
  }
1234
2007
  //#endregion
2008
+ //#region src/version/version.ts
2009
+ /** Pure domain for `plugin version` — moving a plugin's version.
2010
+ *
2011
+ * No I/O: the caller gathers the current filesystem state, calls `planVersion`, and applies the
2012
+ * returned plan. `planVersion` owns the rules — the guard order (every guard resolves before the
2013
+ * first write, so a failing run leaves the tree untouched), the bump arithmetic, and the fact that
2014
+ * only the **authored** files are planned.
2015
+ *
2016
+ * A version lives in five places, but only two are authored: the canonical `plugin.json` and, when
2017
+ * the project declares a `packagePath`, that `package.json`. The per-vendor manifests
2018
+ * (`plugin build`), the local marketplace catalogs (`marketplace init`), and the `npx`/`upx`
2019
+ * pins in `skills/**` (`plugin bundle`) are all **derived** — re-derived by calling the command
2020
+ * that owns them, never written a second time here. */
2021
+ const RELEASE_TYPES = [
2022
+ "major",
2023
+ "minor",
2024
+ "patch",
2025
+ "premajor",
2026
+ "preminor",
2027
+ "prepatch",
2028
+ "prerelease"
2029
+ ];
2030
+ function isReleaseType(value) {
2031
+ return RELEASE_TYPES.includes(value);
2032
+ }
2033
+ /** Reads the canonical manifest's current version, or `null` when it carries none. */
2034
+ function currentVersion(manifest) {
2035
+ const version = manifest?.["version"];
2036
+ return typeof version === "string" && version.length > 0 ? version : null;
2037
+ }
2038
+ /** Resolves the bump argument against the current version. An explicit version is used as given;
2039
+ * a release type is applied to the current one, which must therefore exist. */
2040
+ function resolveTarget(current, opts) {
2041
+ if (semver.valid(opts.bump)) return semver.valid(opts.bump);
2042
+ if (!isReleaseType(opts.bump)) throw new Error(`Unknown version or release type "${opts.bump}" — expected an explicit version (1.4.0) or one of: ${RELEASE_TYPES.join(", ")}`);
2043
+ if (current === null) throw new Error("plugin.json has no version to bump from — pass an explicit version (e.g. 0.1.0) to set the first one");
2044
+ const next = opts.preid === void 0 ? semver.inc(current, opts.bump) : semver.inc(current, opts.bump, opts.preid);
2045
+ if (next === null) throw new Error(`Current version "${current}" in plugin.json is not valid semver`);
2046
+ return next;
2047
+ }
2048
+ /** Plans the version move. Throws on any guard failure before returning a plan, so the caller
2049
+ * writes nothing on a guard trip. */
2050
+ function planVersion(state, opts) {
2051
+ if (!state.manifestExists || state.manifest === null) throw new Error("No plugin.json found at the project root");
2052
+ const from = currentVersion(state.manifest);
2053
+ const to = resolveTarget(from, opts);
2054
+ if (from !== null && !semver.gt(to, from) && !opts.force) throw new Error(`Target version "${to}" does not advance on the current version "${from}" — pass --force to set it anyway`);
2055
+ if (state.packagePath !== null && state.packageJson === null) throw new Error(`No package.json found at packagePath "${state.packagePath}"`);
2056
+ const rows = [{
2057
+ path: "plugin.json",
2058
+ action: "updated"
2059
+ }];
2060
+ let packageJson = null;
2061
+ if (state.packagePath !== null && state.packageJson !== null) {
2062
+ const relPath = joinRelative(state.packagePath, "package.json");
2063
+ packageJson = {
2064
+ path: relPath,
2065
+ content: {
2066
+ ...state.packageJson,
2067
+ version: to
2068
+ }
2069
+ };
2070
+ rows.push({
2071
+ path: relPath,
2072
+ action: "updated"
2073
+ });
2074
+ }
2075
+ return {
2076
+ from,
2077
+ to,
2078
+ manifest: {
2079
+ ...state.manifest,
2080
+ version: to
2081
+ },
2082
+ packageJson,
2083
+ rows,
2084
+ summary: { updated: rows.length }
2085
+ };
2086
+ }
2087
+ /** Root-relative POSIX join — the domain reports paths as the user typed `packagePath`, so the
2088
+ * result stays stable across platforms and readable in the TOON output. */
2089
+ function joinRelative(dir, file) {
2090
+ const normalized = dir.replace(/\\/g, "/").replace(/\/+$/, "");
2091
+ return normalized === "" || normalized === "." ? file : `${normalized}/${file}`;
2092
+ }
2093
+ //#endregion
2094
+ //#region src/version/cli.ts
2095
+ function versionCommand(deps = { fs: realVersionFs }) {
2096
+ const cmd = new Command("version").description("Move the plugin version across every file that carries one").argument("<bump>", `Release type (${RELEASE_TYPES.join(", ")}) or an explicit version (1.4.0)`);
2097
+ cmd.option("--preid <id>", "Prerelease identifier for the pre* release types").option("--force", "Allow a version that does not advance on the current one").option("--no-build", "Skip re-deriving the vendor manifests").option("--dry-run", "Print what would be written without writing").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 version minor\n").action((bump, opts) => {
2098
+ try {
2099
+ const root = resolveRoot(opts.root);
2100
+ const plan = planVersion(deps.fs.gather(root), {
2101
+ bump,
2102
+ preid: opts.preid,
2103
+ force: Boolean(opts.force)
2104
+ });
2105
+ const dryRun = Boolean(opts.dryRun);
2106
+ const rows = plan.rows.map((r) => ({
2107
+ path: r.path,
2108
+ action: dryRun ? "planned" : r.action
2109
+ }));
2110
+ if (!dryRun) {
2111
+ deps.fs.apply(root, plan);
2112
+ if (opts.build !== false) {
2113
+ const result = buildPlugin(root, {});
2114
+ for (const warning of result.warnings) process.stderr.write(`warn: ${warning}\n`);
2115
+ for (const written of result.written) rows.push({
2116
+ path: path.relative(root, written).split(path.sep).join("/"),
2117
+ action: "derived"
2118
+ });
2119
+ }
2120
+ }
2121
+ const jsonResult = {
2122
+ from: plan.from,
2123
+ to: plan.to,
2124
+ dryRun,
2125
+ written: dryRun ? [] : rows.map((r) => r.path),
2126
+ planned: rows.filter((r) => r.action === "planned").map((r) => r.path),
2127
+ summary: {
2128
+ updated: plan.summary.updated,
2129
+ derived: rows.filter((r) => r.action === "derived").length
2130
+ }
2131
+ };
2132
+ output(jsonResult, () => {
2133
+ printFields({
2134
+ from: plan.from ?? "(none)",
2135
+ to: plan.to
2136
+ });
2137
+ printTable(rows, [{
2138
+ label: "path",
2139
+ get: (r) => r.path
2140
+ }, {
2141
+ label: "action",
2142
+ get: (r) => r.action
2143
+ }]);
2144
+ console.log(dryRun ? `planned ${plan.summary.updated}, updated 0 (dry run)` : `updated ${plan.summary.updated}, derived ${jsonResult.summary.derived}`);
2145
+ });
2146
+ process.stderr.write(nextStep(dryRun, opts.build !== false));
2147
+ } catch (err) {
2148
+ process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
2149
+ process.exit(1);
2150
+ }
2151
+ });
2152
+ return cmd;
2153
+ }
2154
+ function nextStep(dryRun, built) {
2155
+ if (dryRun) return "→ re-run without --dry-run to apply\n";
2156
+ return built ? "→ universal-plugin plugin bundle\n" : "→ universal-plugin plugin build\n";
2157
+ }
2158
+ //#endregion
1235
2159
  //#region src/cli.ts
1236
2160
  const program = new Command();
1237
2161
  program.name("universal-plugin").description("Universal AI agent plugin build tool").version("0.0.0").helpCommand(false);
1238
2162
  function pluginCommand() {
1239
- const cmd = new Command("plugin").description("Author the canonical plugin manifest (build, bundle; validate, init planned)");
2163
+ const cmd = new Command("plugin").description("Author the canonical plugin manifest (build, bundle, init, version; validate planned)");
1240
2164
  cmd.addCommand(buildCommand());
1241
2165
  cmd.addCommand(bundleCommand());
2166
+ cmd.addCommand(initCommand$1());
2167
+ cmd.addCommand(versionCommand());
1242
2168
  return cmd;
1243
2169
  }
1244
2170
  program.addCommand(pluginCommand());
1245
2171
  program.addCommand(cleanCommand());
2172
+ program.addCommand(configCommand());
1246
2173
  program.addCommand(governanceCommand());
2174
+ program.addCommand(marketplaceCommand());
1247
2175
  program.addCommand(prepareCommand());
1248
2176
  program.addCommand(publishCommand());
1249
2177
  program.addCommand(syncCommand());