universal-plugin 0.2.1 → 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 +1330 -255
  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 +14 -5
  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,6 +5,7 @@ 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");
@@ -186,141 +187,18 @@ function output(data, readable) {
186
187
  else readable();
187
188
  }
188
189
  //#endregion
189
- //#region src/pin/fs.ts
190
- const TEXT_EXTENSIONS = new Set([
191
- ".md",
192
- ".mdx",
193
- ".markdown",
194
- ".json",
195
- ".yaml",
196
- ".yml",
197
- ".txt"
198
- ]);
199
- /** Resolves the plugin's skills directory: the manifest's `skills` field under `root`,
200
- * defaulting to `<root>/skills/`. */
201
- function resolveSkillsDir(root, manifestSkills) {
202
- return path.join(root, manifestSkills ?? "skills/");
203
- }
204
- function walk(dir) {
205
- if (!fsNode.existsSync(dir)) return [];
206
- const files = [];
207
- for (const entry of fsNode.readdirSync(dir, { withFileTypes: true })) {
208
- const entryPath = path.join(dir, entry.name);
209
- if (entry.isDirectory()) files.push(...walk(entryPath));
210
- else if (entry.isFile() && TEXT_EXTENSIONS.has(path.extname(entry.name))) files.push(entryPath);
211
- }
212
- return files;
213
- }
214
- function realPinFs(skillsDir) {
215
- return {
216
- listSkillFiles: () => walk(skillsDir),
217
- readFile: (p) => fsNode.readFileSync(p, "utf8"),
218
- writeFile: (p, c) => fsNode.writeFileSync(p, c, "utf8")
219
- };
220
- }
221
- //#endregion
222
- //#region src/pin/pin.ts
223
- const PIN_PATTERN = /npx\s+(?:--yes\s+|-y\s+)?([@a-z0-9/._-]+)@(\S+)/g;
224
- /** Strips a trailing backtick, quote, or paren that isn't part of the version token. */
225
- function stripTrailing(raw) {
226
- return raw.replace(/[`'")]+$/, "");
227
- }
228
- function extractPins(text) {
229
- const pins = [];
230
- for (const match of text.matchAll(PIN_PATTERN)) {
231
- const pkg = match[1];
232
- const current = match[2];
233
- if (!pkg || !current) continue;
234
- pins.push({
235
- pkg,
236
- current: stripTrailing(current),
237
- file: ""
238
- });
239
- }
240
- return pins;
241
- }
242
- const SEMVER_PATTERN = /^[~^]?\d+\.\d+\.\d+$/;
243
- function isValidSemver(version) {
244
- return SEMVER_PATTERN.test(version);
245
- }
246
- /** True when a pin is a concrete version (optionally range-prefixed), not a placeholder like `<version>`. */
247
- function isConcreteVersion(version) {
248
- return isValidSemver(version);
249
- }
250
- function majorOf(version) {
251
- const [major] = version.replace(/^[~^]/, "").split(".");
252
- return Number(major);
253
- }
254
- /** Pure semver compare: -1 if a < b, 0 if equal, 1 if a > b. Ignores build/prerelease metadata. */
255
- function compareSemver(a, b) {
256
- const partsA = a.split(".").map(Number);
257
- const partsB = b.split(".").map(Number);
258
- for (let i = 0; i < 3; i++) {
259
- const diff = (partsA[i] ?? 0) - (partsB[i] ?? 0);
260
- if (diff !== 0) return diff > 0 ? 1 : -1;
261
- }
262
- return 0;
263
- }
264
- function pickTarget(current, available, opts) {
265
- if (!isValidSemver(current)) return available.latest;
266
- const currentBare = current.replace(/^[~^]/, "");
267
- const currentMajor = majorOf(current);
268
- const candidates = available.versions.filter((v) => {
269
- if (!isValidSemver(v)) return false;
270
- if (opts.allowMajor) return true;
271
- return majorOf(v) === currentMajor;
272
- });
273
- if (candidates.length === 0) return currentBare;
274
- let best = candidates[0];
275
- for (const candidate of candidates.slice(1)) if (compareSemver(candidate, best) > 0) best = candidate;
276
- if (compareSemver(best, currentBare) < 0) return currentBare;
277
- return best;
278
- }
279
- function styleRange(version, style) {
280
- switch (style) {
281
- case "exact": return version;
282
- case "tilde": return `~${version}`;
283
- case "caret": return `^${version}`;
284
- }
285
- }
286
- function normalizeRange(raw) {
287
- switch (raw) {
288
- case "exact": return "exact";
289
- case "tilde":
290
- case "~": return "tilde";
291
- case "caret":
292
- case "^": return "caret";
293
- default: throw new Error(`Invalid --range value "${raw}" — expected exact, tilde, caret, ~, or ^`);
294
- }
295
- }
296
- //#endregion
297
- //#region src/pin/registry.ts
298
- function encodePkg(pkg) {
299
- return pkg.replace("/", "%2f");
300
- }
301
- /** Registry client backed by the global `fetch` (Node ≥22). Resilient — network errors and
302
- * non-2xx responses resolve to `null` rather than throwing. */
303
- function realRegistryClient(registryBase) {
304
- const base = registryBase.replace(/\/+$/, "");
305
- return { async fetchVersions(pkg) {
306
- try {
307
- const res = await fetch(`${base}/${encodePkg(pkg)}`);
308
- if (!res.ok) return null;
309
- const doc = await res.json();
310
- const latest = doc["dist-tags"]?.latest;
311
- const versions = doc.versions ? Object.keys(doc.versions) : [];
312
- if (!latest) return null;
313
- return {
314
- latest,
315
- versions
316
- };
317
- } catch {
318
- return null;
319
- }
320
- } };
190
+ //#region src/json.ts
191
+ /** Detects indentation style from JSON text. Returns `'\t'` for tabs or a number for space count.
192
+ * Falls back to `'\t'` when no indentation is detected. */
193
+ function detectIndent(json) {
194
+ const match = json.match(/\n([ \t]+)/);
195
+ if (!match) return " ";
196
+ return match[1].startsWith(" ") ? " " : match[1].length;
321
197
  }
322
198
  //#endregion
323
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`. */
324
202
  const VENDOR_OUTPUT = {
325
203
  "claude-code": ".claude-plugin/plugin.json",
326
204
  cursor: ".cursor-plugin/plugin.json",
@@ -328,191 +206,702 @@ const VENDOR_OUTPUT = {
328
206
  "copilot-cli": "plugin.json"
329
207
  };
330
208
  const KNOWN_VENDORS = new Set(Object.keys(VENDOR_OUTPUT));
331
- function detectIndent$1(json) {
332
- const match = json.match(/\n([ \t]+)/);
333
- if (!match) return " ";
334
- return match[1].startsWith(" ") ? " " : match[1].length;
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] ?? {};
335
216
  }
336
217
  function readManifest(root) {
337
- const manifestPath = path.join(root, ".plugin", "plugin.json");
338
- 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}`);
339
220
  return JSON.parse(fsNode.readFileSync(manifestPath, "utf8"));
340
221
  }
341
- 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) {
342
226
  const errors = [];
343
227
  if (!manifest.name) errors.push("name is required");
344
- if (manifest.vendorExtensions?.codex && !manifest.description) errors.push("description is required when targeting codex");
345
- 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");
346
233
  return errors;
347
234
  }
348
235
  function buildPlugin(root, opts = {}) {
349
- const manifestPath = path.join(root, ".plugin", "plugin.json");
350
- if (!fsNode.existsSync(manifestPath)) throw new Error(`No .plugin/plugin.json found at ${root}`);
351
- const indent = detectIndent$1(fsNode.readFileSync(manifestPath, "utf8"));
236
+ const manifestPath = path.join(root, "plugin.json");
237
+ if (!fsNode.existsSync(manifestPath)) throw new Error(`No plugin.json found at ${root}`);
238
+ const indent = detectIndent(fsNode.readFileSync(manifestPath, "utf8"));
352
239
  const manifest = readManifest(root);
353
- const errors = validateManifest(manifest);
354
- if (errors.length > 0) throw new Error(`plugin.json validation failed:\n${errors.map((e) => ` - ${e}`).join("\n")}`);
355
240
  const warnings = [];
356
- const vendorExtensions = manifest.vendorExtensions ?? {};
357
- let vendors = Object.keys(vendorExtensions).filter((v) => {
241
+ const rows = [];
242
+ const uext = universalPluginExtension(manifest);
243
+ const harnesses = uext.harnesses ?? {};
244
+ let vendors = (uext.vendors ?? Object.keys(harnesses)).filter((v) => {
358
245
  if (!KNOWN_VENDORS.has(v)) {
359
- warnings.push(`Unknown vendor "${v}" in vendorExtensions — skipped`);
246
+ warnings.push(`Unknown vendor "${v}" in harnesses — skipped`);
247
+ rows.push({
248
+ vendor: v,
249
+ path: "-",
250
+ status: "skipped"
251
+ });
360
252
  return false;
361
253
  }
362
254
  return true;
363
255
  });
364
256
  if (opts.vendor) {
365
- 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`);
366
258
  vendors = [opts.vendor];
367
259
  }
368
260
  if (vendors.length === 0) {
369
- warnings.push("No vendors declared in vendorExtensions — nothing to build");
261
+ warnings.push("No vendors declared in harnesses — nothing to build");
370
262
  return {
371
263
  vendors: [],
372
264
  written: [],
373
- warnings
265
+ warnings,
266
+ rows,
267
+ summary: summarize(rows)
374
268
  };
375
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")}`);
376
272
  const written = [];
377
- 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);
378
276
  for (const vendor of vendors) {
379
- const outputPath = path.join(root, VENDOR_OUTPUT[vendor]);
277
+ const relPath = VENDOR_OUTPUT[vendor];
278
+ const outputPath = path.join(root, relPath);
380
279
  const outputDir = path.dirname(outputPath);
381
- const vendorFields = vendorExtensions[vendor] ?? {};
280
+ const vendorFields = harnesses[vendor] ?? {};
382
281
  const vendorManifest = {
383
- ...canonical,
282
+ ...metadata,
283
+ ...componentConfig,
384
284
  ...vendorFields
385
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
+ }
386
297
  if (opts.verbose) {
387
298
  console.log(`[${vendor}] → ${outputPath}`);
388
- 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})`);
389
300
  }
390
- if (!opts.dryRun) {
391
- if (opts.clean && fsNode.existsSync(outputPath)) fsNode.unlinkSync(outputPath);
392
- fsNode.mkdirSync(outputDir, { recursive: true });
393
- fsNode.writeFileSync(outputPath, `${JSON.stringify(vendorManifest, null, indent)}\n`);
301
+ try {
302
+ if (!opts.dryRun) {
303
+ if (opts.clean && fsNode.existsSync(outputPath)) fsNode.unlinkSync(outputPath);
304
+ fsNode.mkdirSync(outputDir, { recursive: true });
305
+ fsNode.writeFileSync(outputPath, `${JSON.stringify(vendorManifest, null, indent)}\n`);
306
+ }
307
+ written.push(outputPath);
308
+ writeSkillArtifacts(root, vendor, skills, opts, written, warnings);
309
+ rows.push({
310
+ vendor,
311
+ path: relPath,
312
+ status: "built"
313
+ });
314
+ } catch (err) {
315
+ warnings.push(`Failed to write "${vendor}" → ${relPath}: ${err instanceof Error ? err.message : String(err)}`);
316
+ rows.push({
317
+ vendor,
318
+ path: relPath,
319
+ status: "failed"
320
+ });
394
321
  }
395
- written.push(outputPath);
396
322
  }
397
323
  return {
398
324
  vendors,
399
325
  written,
400
- warnings
326
+ warnings,
327
+ rows,
328
+ summary: summarize(rows)
401
329
  };
402
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
+ }
402
+ function summarize(rows) {
403
+ return {
404
+ built: rows.filter((r) => r.status === "built").length,
405
+ skipped: rows.filter((r) => r.status === "skipped").length,
406
+ failed: rows.filter((r) => r.status === "failed").length,
407
+ canonical: rows.filter((r) => r.status === "canonical").length
408
+ };
409
+ }
410
+ //#endregion
411
+ //#region src/build/cli.ts
412
+ const NEXT_STEP$2 = "→ universal-plugin plugin validate\n";
413
+ function buildCommand() {
414
+ const cmd = new Command("build").description("Generate vendor manifests from plugin.json");
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) => {
416
+ try {
417
+ const result = buildPlugin(resolveRoot(opts.root), {
418
+ vendor: opts.vendor,
419
+ dryRun: opts.dryRun,
420
+ verbose: opts.verbose,
421
+ clean: opts.clean
422
+ });
423
+ for (const warning of result.warnings) process.stderr.write(`warn: ${warning}\n`);
424
+ const { built, skipped, failed, canonical } = result.summary;
425
+ output({
426
+ built: result.rows.filter((r) => r.status === "built"),
427
+ skipped: result.rows.filter((r) => r.status === "skipped"),
428
+ failed: result.rows.filter((r) => r.status === "failed"),
429
+ canonical: result.rows.filter((r) => r.status === "canonical"),
430
+ summary: result.summary,
431
+ warnings: result.warnings
432
+ }, () => {
433
+ if (result.rows.length > 0) printTable(result.rows, [
434
+ {
435
+ label: "vendor",
436
+ get: (r) => r.vendor
437
+ },
438
+ {
439
+ label: "path",
440
+ get: (r) => r.path
441
+ },
442
+ {
443
+ label: "status",
444
+ get: (r) => r.status
445
+ }
446
+ ]);
447
+ const counts = `built ${built}, skipped ${skipped}, failed ${failed}`;
448
+ console.log(canonical > 0 ? `${counts}, served by plugin.json ${canonical}` : counts);
449
+ });
450
+ process.stderr.write(NEXT_STEP$2);
451
+ if (failed > 0) process.exitCode = 1;
452
+ } catch (err) {
453
+ process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
454
+ process.exit(1);
455
+ }
456
+ });
457
+ return cmd;
458
+ }
459
+ //#endregion
460
+ //#region src/pin/fs.ts
461
+ const TEXT_EXTENSIONS = new Set([
462
+ ".md",
463
+ ".mdx",
464
+ ".markdown",
465
+ ".json",
466
+ ".yaml",
467
+ ".yml",
468
+ ".txt"
469
+ ]);
470
+ /** Resolves the plugin's skills directory: the manifest's `skills` field under `root`,
471
+ * defaulting to `<root>/skills/`. */
472
+ function resolveSkillsDir(root, manifestSkills) {
473
+ return path.join(root, manifestSkills ?? "skills/");
474
+ }
475
+ function walk(dir) {
476
+ if (!fsNode.existsSync(dir)) return [];
477
+ const files = [];
478
+ for (const entry of fsNode.readdirSync(dir, { withFileTypes: true })) {
479
+ const entryPath = path.join(dir, entry.name);
480
+ if (entry.isDirectory()) files.push(...walk(entryPath));
481
+ else if (entry.isFile() && TEXT_EXTENSIONS.has(path.extname(entry.name))) files.push(entryPath);
482
+ }
483
+ return files;
484
+ }
485
+ function realPinFs(skillsDir) {
486
+ return {
487
+ listSkillFiles: () => walk(skillsDir),
488
+ readFile: (p) => fsNode.readFileSync(p, "utf8"),
489
+ writeFile: (p, c) => fsNode.writeFileSync(p, c, "utf8")
490
+ };
491
+ }
492
+ //#endregion
493
+ //#region src/pin/pin.ts
494
+ const PIN_PATTERN = /(npx|upx)\s+(?:--yes\s+|-y\s+)?([@a-z0-9/._-]+)@(\S+)/g;
495
+ /** Strips a trailing backtick, quote, or paren that isn't part of the version token. */
496
+ function stripTrailing(raw) {
497
+ return raw.replace(/[`'")]+$/, "");
498
+ }
499
+ function extractPins(text) {
500
+ const pins = [];
501
+ for (const match of text.matchAll(PIN_PATTERN)) {
502
+ const runner = match[1];
503
+ const pkg = match[2];
504
+ const current = match[3];
505
+ if (!runner || !pkg || !current) continue;
506
+ pins.push({
507
+ pkg,
508
+ current: stripTrailing(current),
509
+ file: "",
510
+ runner
511
+ });
512
+ }
513
+ return pins;
514
+ }
515
+ //#endregion
516
+ //#region src/bundle/bundle.ts
403
517
  function escapeRegExp(value) {
404
518
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
405
519
  }
406
- /** Rewrites every `npx <pkg>@<anything>` occurrence to `npx <pkg>@<next>`, regardless of the
407
- * version each occurrence currently carries — so a plugin's references to one CLI converge
408
- * (a concrete pin, a placeholder, and a stale pin all land on the same resolved value). */
409
- function rewritePin(content, pkg, next) {
410
- const pattern = new RegExp(`(npx\\s+(?:--yes\\s+|-y\\s+)?${escapeRegExp(pkg)}@)[^\\s\`'")]+`, "g");
411
- return content.replace(pattern, `$1${next}`);
412
- }
413
- /** Scans the plugin's skills for `npx <pkg>@<pin>` references and resolves each distinct
414
- * in-scope package's current version from the registry, rewriting the pins in place. */
415
- async function resolvePins(root, opts, client, pinFs) {
416
- const range = opts.range ?? "exact";
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}`);
528
+ }
529
+ const FRONTMATTER_PATTERN = /^---\r?\n([\s\S]*?)\r?\n---/;
530
+ const PIN_EXEMPT_PATTERN = /(^|\n)\s*pin-exempt:\s*true\s*(\n|$)/;
531
+ /** True when a SKILL.md's frontmatter declares `metadata.pin-exempt: true` — its version strings
532
+ * are documentation/illustration, never rewritten by `bundle`. Nesting under `metadata` is not
533
+ * enforced structurally (no YAML dependency); the key's presence anywhere in the frontmatter block
534
+ * is the marker. */
535
+ function isPinExempt(skillMdContent) {
536
+ const match = skillMdContent.match(FRONTMATTER_PATTERN);
537
+ if (!match) return false;
538
+ return PIN_EXEMPT_PATTERN.test(match[1]);
539
+ }
540
+ function dirOf(filePath) {
541
+ const idx = Math.max(filePath.lastIndexOf("/"), filePath.lastIndexOf("\\"));
542
+ return idx === -1 ? "" : filePath.slice(0, idx);
543
+ }
544
+ function isUnderExemptDir(filePath, exemptDirs) {
545
+ for (const dir of exemptDirs) if (filePath.startsWith(`${dir}/`) || filePath.startsWith(`${dir}\\`)) return true;
546
+ return false;
547
+ }
548
+ /** Scans the plugin's skills for `npx <pkg>@<pin>` references and, for each in-scope package that
549
+ * resolves against the workspace, rewrites every occurrence to that package's local version. A
550
+ * pin-exempt skill (and every file under its directory) is excluded entirely — never scanned,
551
+ * never rewritten, never reported. */
552
+ function bundlePins(pinFs, versionSource, opts = {}) {
417
553
  const files = pinFs.listSkillFiles();
418
554
  const contents = /* @__PURE__ */ new Map();
419
- const currentsByPkg = /* @__PURE__ */ new Map();
555
+ for (const file of files) contents.set(file, pinFs.readFile(file));
556
+ const exemptDirs = /* @__PURE__ */ new Set();
420
557
  for (const file of files) {
421
- const content = pinFs.readFile(file);
422
- contents.set(file, content);
423
- for (const pin of extractPins(content)) {
424
- const list = currentsByPkg.get(pin.pkg) ?? [];
425
- list.push(pin.current);
426
- currentsByPkg.set(pin.pkg, list);
427
- }
558
+ if (!file.endsWith("/SKILL.md") && !file.endsWith("\\SKILL.md")) continue;
559
+ if (isPinExempt(contents.get(file))) exemptDirs.add(dirOf(file));
560
+ }
561
+ const inScope = files.filter((file) => !isUnderExemptDir(file, exemptDirs));
562
+ const currentsByPkg = /* @__PURE__ */ new Map();
563
+ for (const file of inScope) for (const pin of extractPins(contents.get(file))) {
564
+ const list = currentsByPkg.get(pin.pkg) ?? [];
565
+ list.push(pin.current);
566
+ currentsByPkg.set(pin.pkg, list);
428
567
  }
429
- const scope = opts.packages && opts.packages.length > 0 ? new Set(opts.packages) : null;
430
- const inScope = [...currentsByPkg.entries()].filter(([pkg]) => !scope || scope.has(pkg));
431
- const results = [];
432
- results.warnings = [];
433
- for (const [pkg, currents] of inScope) {
434
- const anchor = currents.find(isConcreteVersion) ?? currents[0];
435
- const available = await client.fetchVersions(pkg);
436
- if (!available) {
437
- results.warnings.push(`registry lookup for "${pkg}" failed — skipped`);
438
- results.push({
568
+ const warnings = [];
569
+ const pins = [];
570
+ for (const [pkg, currents] of currentsByPkg) {
571
+ const current = currents[0];
572
+ const lookup = versionSource.resolve(pkg);
573
+ if (!lookup.inWorkspace) {
574
+ pins.push({
439
575
  package: pkg,
440
- current: anchor,
441
- resolved: anchor,
576
+ current,
577
+ resolved: current,
442
578
  status: "skipped"
443
579
  });
444
580
  continue;
445
581
  }
446
- const written = styleRange(pickTarget(anchor, available, { allowMajor: opts.allowMajor ?? false }), range);
582
+ if (lookup.version === void 0) {
583
+ warnings.push(`workspace package "${pkg}" has no readable package.json version — skipped`);
584
+ pins.push({
585
+ package: pkg,
586
+ current,
587
+ resolved: current,
588
+ status: "skipped"
589
+ });
590
+ continue;
591
+ }
592
+ const target = lookup.version;
447
593
  let changed = false;
448
- for (const file of files) {
594
+ for (const file of inScope) {
449
595
  const content = contents.get(file);
450
- const rewritten = rewritePin(content, pkg, written);
596
+ const rewritten = rewritePin(content, pkg, target, opts.runner);
451
597
  if (rewritten !== content) {
452
598
  changed = true;
453
599
  contents.set(file, rewritten);
454
600
  if (!opts.dryRun) pinFs.writeFile(file, rewritten);
455
601
  }
456
602
  }
457
- results.push({
603
+ pins.push({
458
604
  package: pkg,
459
- current: anchor,
460
- resolved: written,
461
- status: changed ? "updated" : "unchanged"
605
+ current,
606
+ resolved: target,
607
+ status: changed ? "pinned" : "unchanged"
462
608
  });
463
609
  }
464
- return results;
610
+ return {
611
+ pins,
612
+ warnings
613
+ };
465
614
  }
466
615
  //#endregion
467
- //#region src/build/cli.ts
468
- const DEFAULT_REGISTRY = "https://registry.npmjs.org";
469
- function collect(value, previous) {
470
- return [...previous, value];
616
+ //#region src/bundle/fs.ts
617
+ const DEFAULT_GLOBS = ["packages/*"];
618
+ /** Walks up from `start` looking for `pnpm-workspace.yaml`; falls back to `start` itself when no
619
+ * ancestor declares one (the workspace glob then resolves relative to `start`). */
620
+ function findMonorepoRoot(start) {
621
+ let dir = start;
622
+ for (;;) {
623
+ if (fsNode.existsSync(path.join(dir, "pnpm-workspace.yaml"))) return dir;
624
+ const parent = path.dirname(dir);
625
+ if (parent === dir) return start;
626
+ dir = parent;
627
+ }
471
628
  }
472
- function buildCommand() {
473
- const cmd = new Command("build").description("Generate vendor manifests from .plugin/plugin.json");
474
- 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 text (default: text)").option("--registry <url>", "Registry to resolve pinned CLI versions from", DEFAULT_REGISTRY).option("--range <style>", "Pin style to write: exact, tilde, or caret (~/^ accepted)", "exact").option("--package <name>", "Limit pin resolution to this package (repeatable)", collect, []).option("--allow-major", "Allow pin resolution to cross a major version boundary").option("--skip-pins", "Skip pin resolution entirely (manifests only)").addOption(new Option("--json").hideHelp()).addOption(ROOT_OPTION).addHelpText("after", "\nExample:\n $ universal-plugin plugin build --vendor claude-code --range tilde\n").action(async (opts) => {
629
+ /** Minimal `packages:` list extraction from a `pnpm-workspace.yaml` — no YAML dependency, just the
630
+ * flat glob-list shape pnpm-workspace files use. */
631
+ function parseWorkspaceGlobs(yamlText) {
632
+ const lines = yamlText.split(/\r?\n/);
633
+ const globs = [];
634
+ let inPackages = false;
635
+ for (const line of lines) {
636
+ if (/^packages:\s*$/.test(line)) {
637
+ inPackages = true;
638
+ continue;
639
+ }
640
+ if (!inPackages) continue;
641
+ const item = line.match(/^\s*-\s*["']?([^"'\s#]+)["']?\s*$/);
642
+ if (item) {
643
+ globs.push(item[1]);
644
+ continue;
645
+ }
646
+ if (/^\S/.test(line)) inPackages = false;
647
+ }
648
+ return globs;
649
+ }
650
+ function readWorkspaceGlobs(monorepoRoot) {
651
+ const yamlPath = path.join(monorepoRoot, "pnpm-workspace.yaml");
652
+ if (!fsNode.existsSync(yamlPath)) return DEFAULT_GLOBS;
653
+ try {
654
+ const globs = parseWorkspaceGlobs(fsNode.readFileSync(yamlPath, "utf8"));
655
+ return globs.length > 0 ? globs : DEFAULT_GLOBS;
656
+ } catch {
657
+ return DEFAULT_GLOBS;
658
+ }
659
+ }
660
+ /** Expands a workspace glob to its member directories. Only the `<dir>/*` shape pnpm-workspace
661
+ * files use is supported; a glob without a trailing `/*` is treated as a single literal path. */
662
+ function expandGlob(monorepoRoot, glob) {
663
+ if (glob.endsWith("/*")) {
664
+ const base = path.join(monorepoRoot, glob.slice(0, -2));
665
+ if (!fsNode.existsSync(base)) return [];
666
+ return fsNode.readdirSync(base, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => path.join(base, entry.name));
667
+ }
668
+ const dir = path.join(monorepoRoot, glob);
669
+ return fsNode.existsSync(dir) ? [dir] : [];
670
+ }
671
+ /** Discovers every workspace member's version, keyed by its `package.json` `name` field (falling
672
+ * back to the directory name when `package.json` is missing or unreadable — registering the
673
+ * package as an in-workspace member with an unresolved version, distinct from a package with no
674
+ * workspace entry at all). */
675
+ function discoverWorkspace(root) {
676
+ const monorepoRoot = findMonorepoRoot(root);
677
+ const globs = readWorkspaceGlobs(monorepoRoot);
678
+ const map = /* @__PURE__ */ new Map();
679
+ for (const glob of globs) for (const dir of expandGlob(monorepoRoot, glob)) {
680
+ let name = path.basename(dir);
681
+ let version;
475
682
  try {
683
+ const pkg = JSON.parse(fsNode.readFileSync(path.join(dir, "package.json"), "utf8"));
684
+ if (typeof pkg.name === "string") name = pkg.name;
685
+ if (typeof pkg.version === "string") version = pkg.version;
686
+ } catch {}
687
+ map.set(name, version);
688
+ }
689
+ return map;
690
+ }
691
+ /** Writes `<root>/.plugin/pins.json` — a flat, key-sorted `{ "<package>": "<resolvedVersion>" }`
692
+ * map of the workspace-resolved pins (`pinned` + `unchanged`), so a bundled plugin's skills can read
693
+ * the shipped version programmatically (e.g. `${CLAUDE_PLUGIN_ROOT}/.plugin/pins.json`). External /
694
+ * `skipped` packages are excluded — they have no authoritative workspace version. */
695
+ function writePinsMap(root, pins) {
696
+ const map = {};
697
+ for (const pin of pins) if (pin.status === "pinned" || pin.status === "unchanged") map[pin.package] = pin.resolved;
698
+ const sorted = {};
699
+ for (const key of Object.keys(map).sort((a, b) => a.localeCompare(b))) sorted[key] = map[key];
700
+ const dir = path.join(root, ".plugin");
701
+ fsNode.mkdirSync(dir, { recursive: true });
702
+ const pinsPath = path.join(dir, "pins.json");
703
+ const existing = fsNode.existsSync(pinsPath) ? fsNode.readFileSync(pinsPath, "utf8") : null;
704
+ const indent = existing ? detectIndent(existing) : " ";
705
+ fsNode.writeFileSync(pinsPath, `${JSON.stringify(sorted, null, indent)}\n`);
706
+ }
707
+ function realVersionSource(workspace) {
708
+ return { resolve(pkg) {
709
+ if (!workspace.has(pkg)) return { inWorkspace: false };
710
+ return {
711
+ inWorkspace: true,
712
+ version: workspace.get(pkg)
713
+ };
714
+ } };
715
+ }
716
+ //#endregion
717
+ //#region src/bundle/cli.ts
718
+ const TRUNCATE_THRESHOLD = 20;
719
+ const NEXT_STEP$1 = "→ review and commit the pinned skills\n";
720
+ function bundleCommand() {
721
+ const cmd = new Command("bundle").description("Pin the plugin's skill npx references to their workspace package.json versions (release form)");
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) => {
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)`);
476
726
  const root = resolveRoot(opts.root);
477
- const result = buildPlugin(root, {
478
- vendor: opts.vendor,
727
+ const result = bundlePins(realPinFs(resolveSkillsDir(root, universalPluginExtension(readManifest(root)).skills)), realVersionSource(discoverWorkspace(root)), {
479
728
  dryRun: opts.dryRun,
480
- verbose: opts.verbose,
481
- clean: opts.clean
729
+ runner
482
730
  });
731
+ if (!opts.dryRun) writePinsMap(root, result.pins);
483
732
  for (const warning of result.warnings) process.stderr.write(`warn: ${warning}\n`);
484
- let pins = Object.assign([], { warnings: [] });
485
- if (!opts.skipPins) {
486
- const skillsDir = resolveSkillsDir(root, readManifest(root).skills);
487
- const client = realRegistryClient(opts.registry);
488
- const pinFs = realPinFs(skillsDir);
489
- pins = await resolvePins(root, {
490
- packages: opts.package,
491
- allowMajor: opts.allowMajor,
492
- dryRun: opts.dryRun,
493
- range: normalizeRange(opts.range)
494
- }, client, pinFs);
495
- for (const warning of pins.warnings) process.stderr.write(`warn: ${warning}\n`);
733
+ const pinned = result.pins.filter((p) => p.status === "pinned").length;
734
+ const unchanged = result.pins.filter((p) => p.status === "unchanged").length;
735
+ const skipped = result.pins.filter((p) => p.status === "skipped").length;
736
+ output({ pins: result.pins }, () => {
737
+ const truncated = !opts.full && result.pins.length > TRUNCATE_THRESHOLD;
738
+ const rows = truncated ? result.pins.slice(0, TRUNCATE_THRESHOLD) : result.pins;
739
+ if (rows.length > 0) printTable(rows, [
740
+ {
741
+ label: "package",
742
+ get: (r) => r.package
743
+ },
744
+ {
745
+ label: "current",
746
+ get: (r) => r.current
747
+ },
748
+ {
749
+ label: "resolved",
750
+ get: (r) => r.resolved
751
+ },
752
+ {
753
+ label: "status",
754
+ get: (r) => r.status
755
+ }
756
+ ]);
757
+ if (truncated) console.log(`… +${result.pins.length - TRUNCATE_THRESHOLD} more — rerun with --full`);
758
+ console.log(`pinned ${pinned}, unchanged ${unchanged}, skipped ${skipped}`);
759
+ });
760
+ if (result.pins.length === 0) process.stderr.write("nothing to bundle\n");
761
+ process.stderr.write(NEXT_STEP$1);
762
+ } catch (err) {
763
+ process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
764
+ process.exit(1);
765
+ }
766
+ });
767
+ return cmd;
768
+ }
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");
496
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;
497
855
  output({
498
- ...result,
499
- pins
856
+ key: opts.key,
857
+ name: result.name,
858
+ action: result.action
500
859
  }, () => {
501
- if (result.vendors.length > 0) {
502
- printFields({ vendors: result.vendors.join(", ") });
503
- printTable(result.written.map((p) => ({ path: p })), [{
504
- label: "output",
505
- get: (r) => opts.dryRun ? `(dry-run) ${r.path}` : r.path
506
- }]);
507
- }
508
- for (const p of pins) console.log(`${p.package} ${p.current} → ${p.resolved} ${p.status}`);
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`);
509
875
  });
876
+ process.stderr.write(`→ universal-plugin config get --key ${opts.key}\n`);
510
877
  } catch (err) {
511
878
  process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
512
879
  process.exit(1);
513
880
  }
514
881
  });
515
- return cmd;
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));
516
905
  }
517
906
  //#endregion
518
907
  //#region src/governance/fs.ts
@@ -659,6 +1048,486 @@ function governanceCommand() {
659
1048
  return cmd;
660
1049
  }
661
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
662
1531
  //#region src/vendor-registry/vendor-registry.ts
663
1532
  function lookupVendor(registry, vendorId) {
664
1533
  return registry[vendorId] ?? null;
@@ -890,37 +1759,87 @@ function prepareCommand() {
890
1759
  });
891
1760
  }
892
1761
  //#endregion
893
- //#region src/publish/fs.ts
894
- const realSyncVersionFs = {
1762
+ //#region src/version/fs.ts
1763
+ const realJsonIo = {
895
1764
  exists: (p) => fsNode.existsSync(p),
896
1765
  read: (p) => fsNode.readFileSync(p, "utf8"),
897
1766
  write: (p, content) => fsNode.writeFileSync(p, content)
898
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;
899
1810
  //#endregion
900
1811
  //#region src/publish/sync-version.ts
901
- function detectIndent(json) {
902
- const match = json.match(/\n([ \t]+)/);
903
- if (!match) return " ";
904
- return match[1].startsWith(" ") ? " " : match[1].length;
905
- }
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. */
906
1817
  function syncVersion(root, syncFs) {
907
- const manifestPath = path.join(root, ".plugin", "plugin.json");
908
- 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}`);
909
1820
  const agentsConfigPath = path.join(root, ".agents", "universal-plugin.json");
910
1821
  const packagePath = (syncFs.exists(agentsConfigPath) ? JSON.parse(syncFs.read(agentsConfigPath)) : {})["packagePath"];
911
1822
  if (!packagePath || typeof packagePath !== "string") throw new Error("packagePath is required in .agents/universal-plugin.json");
912
- const raw = syncFs.read(manifestPath);
913
- const manifest = JSON.parse(raw);
1823
+ const manifest = JSON.parse(syncFs.read(manifestPath));
914
1824
  const pkgJsonPath = path.join(root, packagePath, "package.json");
915
1825
  if (!syncFs.exists(pkgJsonPath)) throw new Error(`No package.json found at ${packagePath}`);
916
1826
  const version = JSON.parse(syncFs.read(pkgJsonPath))["version"];
917
1827
  if (!version || typeof version !== "string") throw new Error(`No version found in ${packagePath}/package.json`);
918
- const indent = detectIndent(raw);
919
- const updated = {
920
- ...manifest,
921
- version
922
- };
923
- 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);
924
1843
  return {
925
1844
  version,
926
1845
  manifestPath
@@ -930,7 +1849,7 @@ function syncVersion(root, syncFs) {
930
1849
  //#region src/publish/cli.ts
931
1850
  function publishCommand() {
932
1851
  const cmd = new Command("publish").description("Prepare plugin for publishing").helpCommand(false);
933
- 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) => {
934
1853
  try {
935
1854
  const result = syncVersion(resolveRoot(opts.root), realSyncVersionFs);
936
1855
  output(result, () => {
@@ -1089,17 +2008,173 @@ function syncCommand() {
1089
2008
  return cmd;
1090
2009
  }
1091
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
1092
2162
  //#region src/cli.ts
1093
2163
  const program = new Command();
1094
2164
  program.name("universal-plugin").description("Universal AI agent plugin build tool").version("0.0.0").helpCommand(false);
1095
2165
  function pluginCommand() {
1096
- const cmd = new Command("plugin").description("Author the canonical plugin manifest (build; validate, init planned)");
2166
+ const cmd = new Command("plugin").description("Author the canonical plugin manifest (build, bundle, init, version; validate planned)");
1097
2167
  cmd.addCommand(buildCommand());
2168
+ cmd.addCommand(bundleCommand());
2169
+ cmd.addCommand(initCommand$1());
2170
+ cmd.addCommand(versionCommand());
1098
2171
  return cmd;
1099
2172
  }
1100
2173
  program.addCommand(pluginCommand());
1101
2174
  program.addCommand(cleanCommand());
2175
+ program.addCommand(configCommand());
1102
2176
  program.addCommand(governanceCommand());
2177
+ program.addCommand(marketplaceCommand());
1103
2178
  program.addCommand(prepareCommand());
1104
2179
  program.addCommand(publishCommand());
1105
2180
  program.addCommand(syncCommand());