universal-plugin 0.2.1 → 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 (2) hide show
  1. package/dist/cli.mjs +370 -226
  2. package/package.json +3 -2
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,138 +190,13 @@ function output(data, readable) {
186
190
  else readable();
187
191
  }
188
192
  //#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
- } };
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;
321
200
  }
322
201
  //#endregion
323
202
  //#region src/build/build.ts
@@ -328,11 +207,6 @@ const VENDOR_OUTPUT = {
328
207
  "copilot-cli": "plugin.json"
329
208
  };
330
209
  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
- }
336
210
  function readManifest(root) {
337
211
  const manifestPath = path.join(root, ".plugin", "plugin.json");
338
212
  if (!fsNode.existsSync(manifestPath)) throw new Error(`No .plugin/plugin.json found at ${root}`);
@@ -348,15 +222,21 @@ function validateManifest(manifest) {
348
222
  function buildPlugin(root, opts = {}) {
349
223
  const manifestPath = path.join(root, ".plugin", "plugin.json");
350
224
  if (!fsNode.existsSync(manifestPath)) throw new Error(`No .plugin/plugin.json found at ${root}`);
351
- const indent = detectIndent$1(fsNode.readFileSync(manifestPath, "utf8"));
225
+ const indent = detectIndent(fsNode.readFileSync(manifestPath, "utf8"));
352
226
  const manifest = readManifest(root);
353
227
  const errors = validateManifest(manifest);
354
228
  if (errors.length > 0) throw new Error(`plugin.json validation failed:\n${errors.map((e) => ` - ${e}`).join("\n")}`);
355
229
  const warnings = [];
230
+ const rows = [];
356
231
  const vendorExtensions = manifest.vendorExtensions ?? {};
357
232
  let vendors = Object.keys(vendorExtensions).filter((v) => {
358
233
  if (!KNOWN_VENDORS.has(v)) {
359
234
  warnings.push(`Unknown vendor "${v}" in vendorExtensions — skipped`);
235
+ rows.push({
236
+ vendor: v,
237
+ path: "-",
238
+ status: "skipped"
239
+ });
360
240
  return false;
361
241
  }
362
242
  return true;
@@ -370,13 +250,16 @@ function buildPlugin(root, opts = {}) {
370
250
  return {
371
251
  vendors: [],
372
252
  written: [],
373
- warnings
253
+ warnings,
254
+ rows,
255
+ summary: summarize(rows)
374
256
  };
375
257
  }
376
258
  const written = [];
377
259
  const { vendorExtensions: _ext, $schema: _schema, ...canonical } = manifest;
378
260
  for (const vendor of vendors) {
379
- const outputPath = path.join(root, VENDOR_OUTPUT[vendor]);
261
+ const relPath = VENDOR_OUTPUT[vendor];
262
+ const outputPath = path.join(root, relPath);
380
263
  const outputDir = path.dirname(outputPath);
381
264
  const vendorFields = vendorExtensions[vendor] ?? {};
382
265
  const vendorManifest = {
@@ -387,126 +270,391 @@ function buildPlugin(root, opts = {}) {
387
270
  console.log(`[${vendor}] → ${outputPath}`);
388
271
  for (const key of Object.keys(vendorFields)) console.log(` + ${key} (from vendorExtensions)`);
389
272
  }
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`);
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
+ });
394
292
  }
395
- written.push(outputPath);
396
293
  }
397
294
  return {
398
295
  vendors,
399
296
  written,
400
- 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
307
+ };
308
+ }
309
+ //#endregion
310
+ //#region src/build/cli.ts
311
+ const NEXT_STEP$1 = "→ universal-plugin plugin validate\n";
312
+ function buildCommand() {
313
+ const cmd = new Command("build").description("Generate vendor manifests from .plugin/plugin.json");
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) => {
315
+ try {
316
+ const result = buildPlugin(resolveRoot(opts.root), {
317
+ vendor: opts.vendor,
318
+ dryRun: opts.dryRun,
319
+ verbose: opts.verbose,
320
+ clean: opts.clean
321
+ });
322
+ for (const warning of result.warnings) process.stderr.write(`warn: ${warning}\n`);
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}`);
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")
401
387
  };
402
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
403
412
  function escapeRegExp(value) {
404
413
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
405
414
  }
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). */
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. */
409
418
  function rewritePin(content, pkg, next) {
410
419
  const pattern = new RegExp(`(npx\\s+(?:--yes\\s+|-y\\s+)?${escapeRegExp(pkg)}@)[^\\s\`'")]+`, "g");
411
420
  return content.replace(pattern, `$1${next}`);
412
421
  }
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";
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 = {}) {
417
446
  const files = pinFs.listSkillFiles();
418
447
  const contents = /* @__PURE__ */ new Map();
419
- const currentsByPkg = /* @__PURE__ */ new Map();
448
+ for (const file of files) contents.set(file, pinFs.readFile(file));
449
+ const exemptDirs = /* @__PURE__ */ new Set();
420
450
  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
- }
451
+ if (!file.endsWith("/SKILL.md") && !file.endsWith("\\SKILL.md")) continue;
452
+ if (isPinExempt(contents.get(file))) exemptDirs.add(dirOf(file));
428
453
  }
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({
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({
439
468
  package: pkg,
440
- current: anchor,
441
- resolved: anchor,
469
+ current,
470
+ resolved: current,
442
471
  status: "skipped"
443
472
  });
444
473
  continue;
445
474
  }
446
- const written = styleRange(pickTarget(anchor, available, { allowMajor: opts.allowMajor ?? false }), range);
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;
447
486
  let changed = false;
448
- for (const file of files) {
487
+ for (const file of inScope) {
449
488
  const content = contents.get(file);
450
- const rewritten = rewritePin(content, pkg, written);
489
+ const rewritten = rewritePin(content, pkg, target);
451
490
  if (rewritten !== content) {
452
491
  changed = true;
453
492
  contents.set(file, rewritten);
454
493
  if (!opts.dryRun) pinFs.writeFile(file, rewritten);
455
494
  }
456
495
  }
457
- results.push({
496
+ pins.push({
458
497
  package: pkg,
459
- current: anchor,
460
- resolved: written,
461
- status: changed ? "updated" : "unchanged"
498
+ current,
499
+ resolved: target,
500
+ status: changed ? "pinned" : "unchanged"
462
501
  });
463
502
  }
464
- return results;
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
+ };
465
508
  }
466
509
  //#endregion
467
- //#region src/build/cli.ts
468
- const DEFAULT_REGISTRY = "https://registry.npmjs.org";
469
- function collect(value, previous) {
470
- return [...previous, value];
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
+ }
471
525
  }
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) => {
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) => {
475
623
  try {
476
624
  const root = resolveRoot(opts.root);
477
- const result = buildPlugin(root, {
478
- vendor: opts.vendor,
479
- dryRun: opts.dryRun,
480
- verbose: opts.verbose,
481
- clean: opts.clean
482
- });
625
+ const result = bundlePins(realPinFs(resolveSkillsDir(root, readManifest(root).skills)), realVersionSource(discoverWorkspace(root)), { dryRun: opts.dryRun });
626
+ if (!opts.dryRun) writePinsMap(root, result.pins);
483
627
  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`);
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}`);
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}`);
509
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);
510
658
  } catch (err) {
511
659
  process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
512
660
  process.exit(1);
@@ -898,11 +1046,6 @@ const realSyncVersionFs = {
898
1046
  };
899
1047
  //#endregion
900
1048
  //#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
- }
906
1049
  function syncVersion(root, syncFs) {
907
1050
  const manifestPath = path.join(root, ".plugin", "plugin.json");
908
1051
  if (!syncFs.exists(manifestPath)) throw new Error(`No .plugin/plugin.json found at ${root}`);
@@ -1093,8 +1236,9 @@ function syncCommand() {
1093
1236
  const program = new Command();
1094
1237
  program.name("universal-plugin").description("Universal AI agent plugin build tool").version("0.0.0").helpCommand(false);
1095
1238
  function pluginCommand() {
1096
- const cmd = new Command("plugin").description("Author the canonical plugin manifest (build; validate, init planned)");
1239
+ const cmd = new Command("plugin").description("Author the canonical plugin manifest (build, bundle; validate, init planned)");
1097
1240
  cmd.addCommand(buildCommand());
1241
+ cmd.addCommand(bundleCommand());
1098
1242
  return cmd;
1099
1243
  }
1100
1244
  program.addCommand(pluginCommand());
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "universal-plugin",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "description": "Universal AI agent plugin build tool",
5
5
  "keywords": [
6
6
  "agent-plugin",
@@ -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"