universal-plugin 0.2.0 → 0.2.2

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 (4) hide show
  1. package/LICENSE +21 -0
  2. package/dist/cli.mjs +410 -25
  3. package/package.json +4 -3
  4. package/readme.md +70 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 unional
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/dist/cli.mjs CHANGED
@@ -8,8 +8,12 @@ import * as childProcess from "node:child_process";
8
8
  //#region src/cli-options.ts
9
9
  /** Repo root; resolves to cwd when omitted. */
10
10
  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. */
11
15
  function resolveRoot(root) {
12
- return root ?? process.cwd();
16
+ return root === void 0 ? process.cwd() : path.resolve(root);
13
17
  }
14
18
  //#endregion
15
19
  //#region src/state/state.ts
@@ -186,6 +190,15 @@ function output(data, readable) {
186
190
  else readable();
187
191
  }
188
192
  //#endregion
193
+ //#region src/json.ts
194
+ /** Detects indentation style from JSON text. Returns `'\t'` for tabs or a number for space count.
195
+ * Falls back to `'\t'` when no indentation is detected. */
196
+ function detectIndent(json) {
197
+ const match = json.match(/\n([ \t]+)/);
198
+ if (!match) return " ";
199
+ return match[1].startsWith(" ") ? " " : match[1].length;
200
+ }
201
+ //#endregion
189
202
  //#region src/build/build.ts
190
203
  const VENDOR_OUTPUT = {
191
204
  "claude-code": ".claude-plugin/plugin.json",
@@ -207,14 +220,23 @@ function validateManifest(manifest) {
207
220
  return errors;
208
221
  }
209
222
  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}`);
225
+ const indent = detectIndent(fsNode.readFileSync(manifestPath, "utf8"));
210
226
  const manifest = readManifest(root);
211
227
  const errors = validateManifest(manifest);
212
228
  if (errors.length > 0) throw new Error(`plugin.json validation failed:\n${errors.map((e) => ` - ${e}`).join("\n")}`);
213
229
  const warnings = [];
230
+ const rows = [];
214
231
  const vendorExtensions = manifest.vendorExtensions ?? {};
215
232
  let vendors = Object.keys(vendorExtensions).filter((v) => {
216
233
  if (!KNOWN_VENDORS.has(v)) {
217
234
  warnings.push(`Unknown vendor "${v}" in vendorExtensions — skipped`);
235
+ rows.push({
236
+ vendor: v,
237
+ path: "-",
238
+ status: "skipped"
239
+ });
218
240
  return false;
219
241
  }
220
242
  return true;
@@ -228,13 +250,16 @@ function buildPlugin(root, opts = {}) {
228
250
  return {
229
251
  vendors: [],
230
252
  written: [],
231
- warnings
253
+ warnings,
254
+ rows,
255
+ summary: summarize(rows)
232
256
  };
233
257
  }
234
258
  const written = [];
235
- const { vendorExtensions: _ext, $schema: _schema, packagePath: _pkg, ...canonical } = manifest;
259
+ const { vendorExtensions: _ext, $schema: _schema, ...canonical } = manifest;
236
260
  for (const vendor of vendors) {
237
- const outputPath = path.join(root, VENDOR_OUTPUT[vendor]);
261
+ const relPath = VENDOR_OUTPUT[vendor];
262
+ const outputPath = path.join(root, relPath);
238
263
  const outputDir = path.dirname(outputPath);
239
264
  const vendorFields = vendorExtensions[vendor] ?? {};
240
265
  const vendorManifest = {
@@ -245,24 +270,48 @@ function buildPlugin(root, opts = {}) {
245
270
  console.log(`[${vendor}] → ${outputPath}`);
246
271
  for (const key of Object.keys(vendorFields)) console.log(` + ${key} (from vendorExtensions)`);
247
272
  }
248
- if (!opts.dryRun) {
249
- if (opts.clean && fsNode.existsSync(outputPath)) fsNode.unlinkSync(outputPath);
250
- fsNode.mkdirSync(outputDir, { recursive: true });
251
- fsNode.writeFileSync(outputPath, `${JSON.stringify(vendorManifest, null, 2)}\n`);
273
+ try {
274
+ if (!opts.dryRun) {
275
+ if (opts.clean && fsNode.existsSync(outputPath)) fsNode.unlinkSync(outputPath);
276
+ fsNode.mkdirSync(outputDir, { recursive: true });
277
+ fsNode.writeFileSync(outputPath, `${JSON.stringify(vendorManifest, null, indent)}\n`);
278
+ }
279
+ written.push(outputPath);
280
+ rows.push({
281
+ vendor,
282
+ path: relPath,
283
+ status: "built"
284
+ });
285
+ } catch (err) {
286
+ warnings.push(`Failed to write "${vendor}" → ${relPath}: ${err instanceof Error ? err.message : String(err)}`);
287
+ rows.push({
288
+ vendor,
289
+ path: relPath,
290
+ status: "failed"
291
+ });
252
292
  }
253
- written.push(outputPath);
254
293
  }
255
294
  return {
256
295
  vendors,
257
296
  written,
258
- warnings
297
+ warnings,
298
+ rows,
299
+ summary: summarize(rows)
300
+ };
301
+ }
302
+ function summarize(rows) {
303
+ return {
304
+ built: rows.filter((r) => r.status === "built").length,
305
+ skipped: rows.filter((r) => r.status === "skipped").length,
306
+ failed: rows.filter((r) => r.status === "failed").length
259
307
  };
260
308
  }
261
309
  //#endregion
262
310
  //#region src/build/cli.ts
311
+ const NEXT_STEP$1 = "→ universal-plugin plugin validate\n";
263
312
  function buildCommand() {
264
313
  const cmd = new Command("build").description("Generate vendor manifests from .plugin/plugin.json");
265
- 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)").addOption(new Option("--json").hideHelp()).addOption(ROOT_OPTION).action((opts) => {
314
+ 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) => {
266
315
  try {
267
316
  const result = buildPlugin(resolveRoot(opts.root), {
268
317
  vendor: opts.vendor,
@@ -271,14 +320,341 @@ function buildCommand() {
271
320
  clean: opts.clean
272
321
  });
273
322
  for (const warning of result.warnings) process.stderr.write(`warn: ${warning}\n`);
274
- output(result, () => {
275
- if (result.vendors.length === 0) return;
276
- printFields({ vendors: result.vendors.join(", ") });
277
- printTable(result.written.map((p) => ({ path: p })), [{
278
- label: "output",
279
- get: (r) => opts.dryRun ? `(dry-run) ${r.path}` : r.path
280
- }]);
323
+ const { built, skipped, failed } = result.summary;
324
+ output({
325
+ built: result.rows.filter((r) => r.status === "built"),
326
+ skipped: result.rows.filter((r) => r.status === "skipped"),
327
+ failed: result.rows.filter((r) => r.status === "failed"),
328
+ summary: result.summary,
329
+ warnings: result.warnings
330
+ }, () => {
331
+ if (result.rows.length > 0) printTable(result.rows, [
332
+ {
333
+ label: "vendor",
334
+ get: (r) => r.vendor
335
+ },
336
+ {
337
+ label: "path",
338
+ get: (r) => r.path
339
+ },
340
+ {
341
+ label: "status",
342
+ get: (r) => r.status
343
+ }
344
+ ]);
345
+ console.log(`built ${built}, skipped ${skipped}, failed ${failed}`);
281
346
  });
347
+ process.stderr.write(NEXT_STEP$1);
348
+ if (failed > 0) process.exitCode = 1;
349
+ } catch (err) {
350
+ process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
351
+ process.exit(1);
352
+ }
353
+ });
354
+ return cmd;
355
+ }
356
+ //#endregion
357
+ //#region src/pin/fs.ts
358
+ const TEXT_EXTENSIONS = new Set([
359
+ ".md",
360
+ ".mdx",
361
+ ".markdown",
362
+ ".json",
363
+ ".yaml",
364
+ ".yml",
365
+ ".txt"
366
+ ]);
367
+ /** Resolves the plugin's skills directory: the manifest's `skills` field under `root`,
368
+ * defaulting to `<root>/skills/`. */
369
+ function resolveSkillsDir(root, manifestSkills) {
370
+ return path.join(root, manifestSkills ?? "skills/");
371
+ }
372
+ function walk(dir) {
373
+ if (!fsNode.existsSync(dir)) return [];
374
+ const files = [];
375
+ for (const entry of fsNode.readdirSync(dir, { withFileTypes: true })) {
376
+ const entryPath = path.join(dir, entry.name);
377
+ if (entry.isDirectory()) files.push(...walk(entryPath));
378
+ else if (entry.isFile() && TEXT_EXTENSIONS.has(path.extname(entry.name))) files.push(entryPath);
379
+ }
380
+ return files;
381
+ }
382
+ function realPinFs(skillsDir) {
383
+ return {
384
+ listSkillFiles: () => walk(skillsDir),
385
+ readFile: (p) => fsNode.readFileSync(p, "utf8"),
386
+ writeFile: (p, c) => fsNode.writeFileSync(p, c, "utf8")
387
+ };
388
+ }
389
+ //#endregion
390
+ //#region src/pin/pin.ts
391
+ const PIN_PATTERN = /npx\s+(?:--yes\s+|-y\s+)?([@a-z0-9/._-]+)@(\S+)/g;
392
+ /** Strips a trailing backtick, quote, or paren that isn't part of the version token. */
393
+ function stripTrailing(raw) {
394
+ return raw.replace(/[`'")]+$/, "");
395
+ }
396
+ function extractPins(text) {
397
+ const pins = [];
398
+ for (const match of text.matchAll(PIN_PATTERN)) {
399
+ const pkg = match[1];
400
+ const current = match[2];
401
+ if (!pkg || !current) continue;
402
+ pins.push({
403
+ pkg,
404
+ current: stripTrailing(current),
405
+ file: ""
406
+ });
407
+ }
408
+ return pins;
409
+ }
410
+ //#endregion
411
+ //#region src/bundle/bundle.ts
412
+ function escapeRegExp(value) {
413
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
414
+ }
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}`);
421
+ }
422
+ const FRONTMATTER_PATTERN = /^---\r?\n([\s\S]*?)\r?\n---/;
423
+ const PIN_EXEMPT_PATTERN = /(^|\n)\s*pin-exempt:\s*true\s*(\n|$)/;
424
+ /** True when a SKILL.md's frontmatter declares `metadata.pin-exempt: true` — its version strings
425
+ * are documentation/illustration, never rewritten by `bundle`. Nesting under `metadata` is not
426
+ * enforced structurally (no YAML dependency); the key's presence anywhere in the frontmatter block
427
+ * is the marker. */
428
+ function isPinExempt(skillMdContent) {
429
+ const match = skillMdContent.match(FRONTMATTER_PATTERN);
430
+ if (!match) return false;
431
+ return PIN_EXEMPT_PATTERN.test(match[1]);
432
+ }
433
+ function dirOf(filePath) {
434
+ const idx = Math.max(filePath.lastIndexOf("/"), filePath.lastIndexOf("\\"));
435
+ return idx === -1 ? "" : filePath.slice(0, idx);
436
+ }
437
+ function isUnderExemptDir(filePath, exemptDirs) {
438
+ for (const dir of exemptDirs) if (filePath.startsWith(`${dir}/`) || filePath.startsWith(`${dir}\\`)) return true;
439
+ return false;
440
+ }
441
+ /** Scans the plugin's skills for `npx <pkg>@<pin>` references and, for each in-scope package that
442
+ * resolves against the workspace, rewrites every occurrence to that package's local version. A
443
+ * pin-exempt skill (and every file under its directory) is excluded entirely — never scanned,
444
+ * never rewritten, never reported. */
445
+ function bundlePins(pinFs, versionSource, opts = {}) {
446
+ const files = pinFs.listSkillFiles();
447
+ const contents = /* @__PURE__ */ new Map();
448
+ for (const file of files) contents.set(file, pinFs.readFile(file));
449
+ const exemptDirs = /* @__PURE__ */ new Set();
450
+ for (const file of files) {
451
+ if (!file.endsWith("/SKILL.md") && !file.endsWith("\\SKILL.md")) continue;
452
+ if (isPinExempt(contents.get(file))) exemptDirs.add(dirOf(file));
453
+ }
454
+ const inScope = files.filter((file) => !isUnderExemptDir(file, exemptDirs));
455
+ const currentsByPkg = /* @__PURE__ */ new Map();
456
+ for (const file of inScope) for (const pin of extractPins(contents.get(file))) {
457
+ const list = currentsByPkg.get(pin.pkg) ?? [];
458
+ list.push(pin.current);
459
+ currentsByPkg.set(pin.pkg, list);
460
+ }
461
+ const warnings = [];
462
+ const pins = [];
463
+ for (const [pkg, currents] of currentsByPkg) {
464
+ const current = currents[0];
465
+ const lookup = versionSource.resolve(pkg);
466
+ if (!lookup.inWorkspace) {
467
+ pins.push({
468
+ package: pkg,
469
+ current,
470
+ resolved: current,
471
+ status: "skipped"
472
+ });
473
+ continue;
474
+ }
475
+ if (lookup.version === void 0) {
476
+ warnings.push(`workspace package "${pkg}" has no readable package.json version — skipped`);
477
+ pins.push({
478
+ package: pkg,
479
+ current,
480
+ resolved: current,
481
+ status: "skipped"
482
+ });
483
+ continue;
484
+ }
485
+ const target = lookup.version;
486
+ let changed = false;
487
+ for (const file of inScope) {
488
+ const content = contents.get(file);
489
+ const rewritten = rewritePin(content, pkg, target);
490
+ if (rewritten !== content) {
491
+ changed = true;
492
+ contents.set(file, rewritten);
493
+ if (!opts.dryRun) pinFs.writeFile(file, rewritten);
494
+ }
495
+ }
496
+ pins.push({
497
+ package: pkg,
498
+ current,
499
+ resolved: target,
500
+ status: changed ? "pinned" : "unchanged"
501
+ });
502
+ }
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
+ return {
505
+ pins,
506
+ warnings
507
+ };
508
+ }
509
+ //#endregion
510
+ //#region src/bundle/fs.ts
511
+ const DEFAULT_GLOBS = ["packages/*"];
512
+ /** 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. */
516
+ function findMonorepoRoot(start) {
517
+ const from = path.resolve(start);
518
+ let dir = from;
519
+ for (;;) {
520
+ if (fsNode.existsSync(path.join(dir, "pnpm-workspace.yaml"))) return dir;
521
+ const parent = path.dirname(dir);
522
+ if (parent === dir) return from;
523
+ dir = parent;
524
+ }
525
+ }
526
+ /** Minimal `packages:` list extraction from a `pnpm-workspace.yaml` — no YAML dependency, just the
527
+ * flat glob-list shape pnpm-workspace files use. */
528
+ function parseWorkspaceGlobs(yamlText) {
529
+ const lines = yamlText.split(/\r?\n/);
530
+ const globs = [];
531
+ let inPackages = false;
532
+ for (const line of lines) {
533
+ if (/^packages:\s*$/.test(line)) {
534
+ inPackages = true;
535
+ continue;
536
+ }
537
+ if (!inPackages) continue;
538
+ const item = line.match(/^\s*-\s*["']?([^"'\s#]+)["']?\s*$/);
539
+ if (item) {
540
+ globs.push(item[1]);
541
+ continue;
542
+ }
543
+ if (/^\S/.test(line)) inPackages = false;
544
+ }
545
+ return globs;
546
+ }
547
+ function readWorkspaceGlobs(monorepoRoot) {
548
+ const yamlPath = path.join(monorepoRoot, "pnpm-workspace.yaml");
549
+ if (!fsNode.existsSync(yamlPath)) return DEFAULT_GLOBS;
550
+ try {
551
+ const globs = parseWorkspaceGlobs(fsNode.readFileSync(yamlPath, "utf8"));
552
+ return globs.length > 0 ? globs : DEFAULT_GLOBS;
553
+ } catch {
554
+ return DEFAULT_GLOBS;
555
+ }
556
+ }
557
+ /** Expands a workspace glob to its member directories. Only the `<dir>/*` shape pnpm-workspace
558
+ * files use is supported; a glob without a trailing `/*` is treated as a single literal path. */
559
+ function expandGlob(monorepoRoot, glob) {
560
+ if (glob.endsWith("/*")) {
561
+ const base = path.join(monorepoRoot, glob.slice(0, -2));
562
+ if (!fsNode.existsSync(base)) return [];
563
+ return fsNode.readdirSync(base, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => path.join(base, entry.name));
564
+ }
565
+ const dir = path.join(monorepoRoot, glob);
566
+ return fsNode.existsSync(dir) ? [dir] : [];
567
+ }
568
+ /** Discovers every workspace member's version, keyed by its `package.json` `name` field (falling
569
+ * back to the directory name when `package.json` is missing or unreadable — registering the
570
+ * package as an in-workspace member with an unresolved version, distinct from a package with no
571
+ * workspace entry at all). */
572
+ function discoverWorkspace(root) {
573
+ const monorepoRoot = findMonorepoRoot(root);
574
+ const globs = readWorkspaceGlobs(monorepoRoot);
575
+ const map = /* @__PURE__ */ new Map();
576
+ for (const glob of globs) for (const dir of expandGlob(monorepoRoot, glob)) {
577
+ let name = path.basename(dir);
578
+ let version;
579
+ try {
580
+ const pkg = JSON.parse(fsNode.readFileSync(path.join(dir, "package.json"), "utf8"));
581
+ if (typeof pkg.name === "string") name = pkg.name;
582
+ if (typeof pkg.version === "string") version = pkg.version;
583
+ } catch {}
584
+ map.set(name, version);
585
+ }
586
+ return map;
587
+ }
588
+ /** Writes `<root>/.plugin/pins.json` — a flat, key-sorted `{ "<package>": "<resolvedVersion>" }`
589
+ * map of the workspace-resolved pins (`pinned` + `unchanged`), so a bundled plugin's skills can read
590
+ * the shipped version programmatically (e.g. `${CLAUDE_PLUGIN_ROOT}/.plugin/pins.json`). External /
591
+ * `skipped` packages are excluded — they have no authoritative workspace version. */
592
+ function writePinsMap(root, pins) {
593
+ const map = {};
594
+ for (const pin of pins) if (pin.status === "pinned" || pin.status === "unchanged") map[pin.package] = pin.resolved;
595
+ const sorted = {};
596
+ for (const key of Object.keys(map).sort((a, b) => a.localeCompare(b))) sorted[key] = map[key];
597
+ const dir = path.join(root, ".plugin");
598
+ fsNode.mkdirSync(dir, { recursive: true });
599
+ const pinsPath = path.join(dir, "pins.json");
600
+ const existing = fsNode.existsSync(pinsPath) ? fsNode.readFileSync(pinsPath, "utf8") : null;
601
+ const indent = existing ? detectIndent(existing) : " ";
602
+ fsNode.writeFileSync(pinsPath, `${JSON.stringify(sorted, null, indent)}\n`);
603
+ }
604
+ function realVersionSource(workspace) {
605
+ return { resolve(pkg) {
606
+ if (!workspace.has(pkg)) return { inWorkspace: false };
607
+ return {
608
+ inWorkspace: true,
609
+ version: workspace.get(pkg)
610
+ };
611
+ } };
612
+ }
613
+ //#endregion
614
+ //#region src/bundle/cli.ts
615
+ 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";
620
+ function bundleCommand() {
621
+ 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) => {
623
+ try {
624
+ const root = resolveRoot(opts.root);
625
+ const result = bundlePins(realPinFs(resolveSkillsDir(root, readManifest(root).skills)), realVersionSource(discoverWorkspace(root)), { dryRun: opts.dryRun });
626
+ if (!opts.dryRun) writePinsMap(root, result.pins);
627
+ for (const warning of result.warnings) process.stderr.write(`warn: ${warning}\n`);
628
+ const pinned = result.pins.filter((p) => p.status === "pinned").length;
629
+ const unchanged = result.pins.filter((p) => p.status === "unchanged").length;
630
+ const skipped = result.pins.filter((p) => p.status === "skipped").length;
631
+ output({ pins: result.pins }, () => {
632
+ const truncated = !opts.full && result.pins.length > TRUNCATE_THRESHOLD;
633
+ const rows = truncated ? result.pins.slice(0, TRUNCATE_THRESHOLD) : result.pins;
634
+ if (rows.length > 0) printTable(rows, [
635
+ {
636
+ label: "package",
637
+ get: (r) => r.package
638
+ },
639
+ {
640
+ label: "current",
641
+ get: (r) => r.current
642
+ },
643
+ {
644
+ label: "resolved",
645
+ get: (r) => r.resolved
646
+ },
647
+ {
648
+ label: "status",
649
+ get: (r) => r.status
650
+ }
651
+ ]);
652
+ if (truncated) console.log(`… +${result.pins.length - TRUNCATE_THRESHOLD} more — rerun with --full`);
653
+ console.log(`pinned ${pinned}, unchanged ${unchanged}, skipped ${skipped}`);
654
+ });
655
+ 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);
282
658
  } catch (err) {
283
659
  process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
284
660
  process.exit(1);
@@ -403,7 +779,7 @@ function readGlobalState() {
403
779
  }
404
780
  function governanceCommand() {
405
781
  const cmd = new Command("governance").description("Manage plugin governances").helpCommand(false);
406
- cmd.command("show <name>").description("Show a governance by name").addOption(ROOT_OPTION).addOption(new Option("--json").hideHelp()).action((name, opts) => {
782
+ cmd.command("show <name>").description("Show a governance by name").option("--format <format>", "Output format: json or text (default: text)").addOption(ROOT_OPTION).addOption(new Option("--json").hideHelp()).action((name, opts) => {
407
783
  const result = showGovernance(name, resolveRoot(opts.root), realGovernanceFs, {
408
784
  state: readGlobalState(),
409
785
  globalStorePath: globalStorePath()
@@ -416,7 +792,7 @@ function governanceCommand() {
416
792
  process.stdout.write(result.content);
417
793
  });
418
794
  });
419
- cmd.command("list").description("List available governances").addOption(ROOT_OPTION).addOption(new Option("--json").hideHelp()).action((opts) => {
795
+ cmd.command("list").description("List available governances").option("--format <format>", "Output format: json or text (default: text)").addOption(ROOT_OPTION).addOption(new Option("--json").hideHelp()).action((opts) => {
420
796
  const entries = listGovernances(resolveRoot(opts.root), realGovernanceFs);
421
797
  output(entries, () => {
422
798
  printTable(entries, [{
@@ -673,18 +1049,21 @@ const realSyncVersionFs = {
673
1049
  function syncVersion(root, syncFs) {
674
1050
  const manifestPath = path.join(root, ".plugin", "plugin.json");
675
1051
  if (!syncFs.exists(manifestPath)) throw new Error(`No .plugin/plugin.json found at ${root}`);
676
- const manifest = JSON.parse(syncFs.read(manifestPath));
677
- const packagePath = manifest["packagePath"];
678
- if (!packagePath || typeof packagePath !== "string") throw new Error("packagePath is required in .plugin/plugin.json");
1052
+ const agentsConfigPath = path.join(root, ".agents", "universal-plugin.json");
1053
+ const packagePath = (syncFs.exists(agentsConfigPath) ? JSON.parse(syncFs.read(agentsConfigPath)) : {})["packagePath"];
1054
+ 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);
679
1057
  const pkgJsonPath = path.join(root, packagePath, "package.json");
680
1058
  if (!syncFs.exists(pkgJsonPath)) throw new Error(`No package.json found at ${packagePath}`);
681
1059
  const version = JSON.parse(syncFs.read(pkgJsonPath))["version"];
682
1060
  if (!version || typeof version !== "string") throw new Error(`No version found in ${packagePath}/package.json`);
1061
+ const indent = detectIndent(raw);
683
1062
  const updated = {
684
1063
  ...manifest,
685
1064
  version
686
1065
  };
687
- syncFs.write(manifestPath, `${JSON.stringify(updated, null, " ")}\n`);
1066
+ syncFs.write(manifestPath, `${JSON.stringify(updated, null, indent)}\n`);
688
1067
  return {
689
1068
  version,
690
1069
  manifestPath
@@ -856,7 +1235,13 @@ function syncCommand() {
856
1235
  //#region src/cli.ts
857
1236
  const program = new Command();
858
1237
  program.name("universal-plugin").description("Universal AI agent plugin build tool").version("0.0.0").helpCommand(false);
859
- program.addCommand(buildCommand());
1238
+ function pluginCommand() {
1239
+ const cmd = new Command("plugin").description("Author the canonical plugin manifest (build, bundle; validate, init planned)");
1240
+ cmd.addCommand(buildCommand());
1241
+ cmd.addCommand(bundleCommand());
1242
+ return cmd;
1243
+ }
1244
+ program.addCommand(pluginCommand());
860
1245
  program.addCommand(cleanCommand());
861
1246
  program.addCommand(governanceCommand());
862
1247
  program.addCommand(prepareCommand());
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "universal-plugin",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Universal AI agent plugin build tool",
5
5
  "keywords": [
6
6
  "agent-plugin",
@@ -11,7 +11,7 @@
11
11
  ],
12
12
  "repository": {
13
13
  "type": "git",
14
- "url": "git+https://github.com/cyberuni/universal-plugin.git",
14
+ "url": "git+https://github.com/cyberuni/cyberplace.git",
15
15
  "directory": "packages/universal-plugin"
16
16
  },
17
17
  "license": "MIT",
@@ -48,10 +48,11 @@
48
48
  },
49
49
  "scripts": {
50
50
  "build": "tsdown",
51
+ "check:spec": "sdd-check-specs",
51
52
  "dev": "tsx src/cli.ts",
52
53
  "knip": "knip",
53
54
  "lint": "biome check .",
54
- "test": "pnpm build && vitest run src",
55
+ "test": "vitest run src",
55
56
  "test:watch": "vitest",
56
57
  "typecheck": "tsc --noEmit",
57
58
  "verify": "pnpm typecheck && pnpm lint && pnpm test"
package/readme.md ADDED
@@ -0,0 +1,70 @@
1
+ # universal-plugin
2
+
3
+ [![npm version](https://img.shields.io/npm/v/universal-plugin.svg)](https://www.npmjs.com/package/universal-plugin)
4
+ [![license: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
5
+
6
+ Universal AI agent plugin build tool. Author one canonical plugin manifest (`.plugin/plugin.json`) and generate vendor-specific manifests for Claude Code, Cursor, Codex, and GitHub Copilot CLI.
7
+
8
+ ## Usage
9
+
10
+ No install required — run with `npx`:
11
+
12
+ ```sh
13
+ npx universal-plugin <command>
14
+ ```
15
+
16
+ Or pin to an exact version for reproducible builds:
17
+
18
+ ```sh
19
+ npx universal-plugin@0.2.0 <command>
20
+ ```
21
+
22
+ ## Commands
23
+
24
+ ### plugin — author the canonical manifest
25
+
26
+ ```sh
27
+ # Generate vendor manifests from .plugin/plugin.json
28
+ npx universal-plugin plugin build
29
+ ```
30
+
31
+ `validate` and `init` are specified but implementation is deferred.
32
+
33
+ ### sync — cross-vendor plugin sync
34
+
35
+ ```sh
36
+ # Detect cross-vendor sync actions from a vendor's manifest
37
+ npx universal-plugin prepare <vendor-id> # e.g. claude-code
38
+ npx universal-plugin prepare <vendor-id> --scope project --root <path>
39
+ npx universal-plugin prepare <vendor-id> --dry-run # print action count without writing state
40
+
41
+ # Apply a pending sync action
42
+ npx universal-plugin sync apply <action-id>
43
+ ```
44
+
45
+ ### publish
46
+
47
+ ```sh
48
+ # Sync version from packagePath/package.json into .plugin/plugin.json
49
+ npx universal-plugin publish sync-version
50
+ ```
51
+
52
+ ### governance
53
+
54
+ Version-pinned agent-tool contracts, read at runtime.
55
+
56
+ ```sh
57
+ npx universal-plugin governance list
58
+ npx universal-plugin governance show plugin-design
59
+ ```
60
+
61
+ ### Housekeeping
62
+
63
+ ```sh
64
+ npx universal-plugin clean # remove the asset store
65
+ npx universal-plugin self-update <version> # update the version pin in hook files
66
+ ```
67
+
68
+ ## License
69
+
70
+ MIT