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