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.
- package/LICENSE +21 -0
- package/dist/cli.mjs +410 -25
- package/package.json +4 -3
- 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
|
|
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,
|
|
259
|
+
const { vendorExtensions: _ext, $schema: _schema, ...canonical } = manifest;
|
|
236
260
|
for (const vendor of vendors) {
|
|
237
|
-
const
|
|
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
|
-
|
|
249
|
-
if (opts.
|
|
250
|
-
|
|
251
|
-
|
|
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
|
|
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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
|
677
|
-
const packagePath =
|
|
678
|
-
if (!packagePath || typeof packagePath !== "string") throw new Error("packagePath is required in .
|
|
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,
|
|
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
|
-
|
|
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.
|
|
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/
|
|
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": "
|
|
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
|
+
[](https://www.npmjs.com/package/universal-plugin)
|
|
4
|
+
[](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
|