universal-plugin 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/dist/cli.mjs +259 -18
  3. package/package.json +2 -2
  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
@@ -186,6 +186,140 @@ function output(data, readable) {
186
186
  else readable();
187
187
  }
188
188
  //#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
+ } };
321
+ }
322
+ //#endregion
189
323
  //#region src/build/build.ts
190
324
  const VENDOR_OUTPUT = {
191
325
  "claude-code": ".claude-plugin/plugin.json",
@@ -194,6 +328,11 @@ const VENDOR_OUTPUT = {
194
328
  "copilot-cli": "plugin.json"
195
329
  };
196
330
  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;
335
+ }
197
336
  function readManifest(root) {
198
337
  const manifestPath = path.join(root, ".plugin", "plugin.json");
199
338
  if (!fsNode.existsSync(manifestPath)) throw new Error(`No .plugin/plugin.json found at ${root}`);
@@ -207,6 +346,9 @@ function validateManifest(manifest) {
207
346
  return errors;
208
347
  }
209
348
  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"));
210
352
  const manifest = readManifest(root);
211
353
  const errors = validateManifest(manifest);
212
354
  if (errors.length > 0) throw new Error(`plugin.json validation failed:\n${errors.map((e) => ` - ${e}`).join("\n")}`);
@@ -232,7 +374,7 @@ function buildPlugin(root, opts = {}) {
232
374
  };
233
375
  }
234
376
  const written = [];
235
- const { vendorExtensions: _ext, $schema: _schema, packagePath: _pkg, ...canonical } = manifest;
377
+ const { vendorExtensions: _ext, $schema: _schema, ...canonical } = manifest;
236
378
  for (const vendor of vendors) {
237
379
  const outputPath = path.join(root, VENDOR_OUTPUT[vendor]);
238
380
  const outputDir = path.dirname(outputPath);
@@ -248,7 +390,7 @@ function buildPlugin(root, opts = {}) {
248
390
  if (!opts.dryRun) {
249
391
  if (opts.clean && fsNode.existsSync(outputPath)) fsNode.unlinkSync(outputPath);
250
392
  fsNode.mkdirSync(outputDir, { recursive: true });
251
- fsNode.writeFileSync(outputPath, `${JSON.stringify(vendorManifest, null, 2)}\n`);
393
+ fsNode.writeFileSync(outputPath, `${JSON.stringify(vendorManifest, null, indent)}\n`);
252
394
  }
253
395
  written.push(outputPath);
254
396
  }
@@ -258,26 +400,112 @@ function buildPlugin(root, opts = {}) {
258
400
  warnings
259
401
  };
260
402
  }
403
+ function escapeRegExp(value) {
404
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
405
+ }
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";
417
+ const files = pinFs.listSkillFiles();
418
+ const contents = /* @__PURE__ */ new Map();
419
+ const currentsByPkg = /* @__PURE__ */ new Map();
420
+ 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
+ }
428
+ }
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({
439
+ package: pkg,
440
+ current: anchor,
441
+ resolved: anchor,
442
+ status: "skipped"
443
+ });
444
+ continue;
445
+ }
446
+ const written = styleRange(pickTarget(anchor, available, { allowMajor: opts.allowMajor ?? false }), range);
447
+ let changed = false;
448
+ for (const file of files) {
449
+ const content = contents.get(file);
450
+ const rewritten = rewritePin(content, pkg, written);
451
+ if (rewritten !== content) {
452
+ changed = true;
453
+ contents.set(file, rewritten);
454
+ if (!opts.dryRun) pinFs.writeFile(file, rewritten);
455
+ }
456
+ }
457
+ results.push({
458
+ package: pkg,
459
+ current: anchor,
460
+ resolved: written,
461
+ status: changed ? "updated" : "unchanged"
462
+ });
463
+ }
464
+ return results;
465
+ }
261
466
  //#endregion
262
467
  //#region src/build/cli.ts
468
+ const DEFAULT_REGISTRY = "https://registry.npmjs.org";
469
+ function collect(value, previous) {
470
+ return [...previous, value];
471
+ }
263
472
  function buildCommand() {
264
473
  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) => {
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) => {
266
475
  try {
267
- const result = buildPlugin(resolveRoot(opts.root), {
476
+ const root = resolveRoot(opts.root);
477
+ const result = buildPlugin(root, {
268
478
  vendor: opts.vendor,
269
479
  dryRun: opts.dryRun,
270
480
  verbose: opts.verbose,
271
481
  clean: opts.clean
272
482
  });
273
483
  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
- }]);
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`);
496
+ }
497
+ output({
498
+ ...result,
499
+ pins
500
+ }, () => {
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}`);
281
509
  });
282
510
  } catch (err) {
283
511
  process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
@@ -403,7 +631,7 @@ function readGlobalState() {
403
631
  }
404
632
  function governanceCommand() {
405
633
  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) => {
634
+ 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
635
  const result = showGovernance(name, resolveRoot(opts.root), realGovernanceFs, {
408
636
  state: readGlobalState(),
409
637
  globalStorePath: globalStorePath()
@@ -416,7 +644,7 @@ function governanceCommand() {
416
644
  process.stdout.write(result.content);
417
645
  });
418
646
  });
419
- cmd.command("list").description("List available governances").addOption(ROOT_OPTION).addOption(new Option("--json").hideHelp()).action((opts) => {
647
+ 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
648
  const entries = listGovernances(resolveRoot(opts.root), realGovernanceFs);
421
649
  output(entries, () => {
422
650
  printTable(entries, [{
@@ -670,21 +898,29 @@ const realSyncVersionFs = {
670
898
  };
671
899
  //#endregion
672
900
  //#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
+ }
673
906
  function syncVersion(root, syncFs) {
674
907
  const manifestPath = path.join(root, ".plugin", "plugin.json");
675
908
  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");
909
+ const agentsConfigPath = path.join(root, ".agents", "universal-plugin.json");
910
+ const packagePath = (syncFs.exists(agentsConfigPath) ? JSON.parse(syncFs.read(agentsConfigPath)) : {})["packagePath"];
911
+ 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);
679
914
  const pkgJsonPath = path.join(root, packagePath, "package.json");
680
915
  if (!syncFs.exists(pkgJsonPath)) throw new Error(`No package.json found at ${packagePath}`);
681
916
  const version = JSON.parse(syncFs.read(pkgJsonPath))["version"];
682
917
  if (!version || typeof version !== "string") throw new Error(`No version found in ${packagePath}/package.json`);
918
+ const indent = detectIndent(raw);
683
919
  const updated = {
684
920
  ...manifest,
685
921
  version
686
922
  };
687
- syncFs.write(manifestPath, `${JSON.stringify(updated, null, " ")}\n`);
923
+ syncFs.write(manifestPath, `${JSON.stringify(updated, null, indent)}\n`);
688
924
  return {
689
925
  version,
690
926
  manifestPath
@@ -856,7 +1092,12 @@ function syncCommand() {
856
1092
  //#region src/cli.ts
857
1093
  const program = new Command();
858
1094
  program.name("universal-plugin").description("Universal AI agent plugin build tool").version("0.0.0").helpCommand(false);
859
- program.addCommand(buildCommand());
1095
+ function pluginCommand() {
1096
+ const cmd = new Command("plugin").description("Author the canonical plugin manifest (build; validate, init planned)");
1097
+ cmd.addCommand(buildCommand());
1098
+ return cmd;
1099
+ }
1100
+ program.addCommand(pluginCommand());
860
1101
  program.addCommand(cleanCommand());
861
1102
  program.addCommand(governanceCommand());
862
1103
  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.1",
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",
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