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.
- package/dist/cli.mjs +370 -226
- 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
|
|
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/
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
"
|
|
195
|
-
".
|
|
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
|
|
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
|
|
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
|
-
|
|
391
|
-
if (opts.
|
|
392
|
-
|
|
393
|
-
|
|
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
|
|
407
|
-
*
|
|
408
|
-
*
|
|
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
|
-
|
|
414
|
-
*
|
|
415
|
-
|
|
416
|
-
|
|
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
|
|
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
|
-
|
|
422
|
-
contents.
|
|
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
|
|
430
|
-
const
|
|
431
|
-
const
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
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
|
|
441
|
-
resolved:
|
|
469
|
+
current,
|
|
470
|
+
resolved: current,
|
|
442
471
|
status: "skipped"
|
|
443
472
|
});
|
|
444
473
|
continue;
|
|
445
474
|
}
|
|
446
|
-
|
|
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
|
|
487
|
+
for (const file of inScope) {
|
|
449
488
|
const content = contents.get(file);
|
|
450
|
-
const rewritten = rewritePin(content, pkg,
|
|
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
|
-
|
|
496
|
+
pins.push({
|
|
458
497
|
package: pkg,
|
|
459
|
-
current
|
|
460
|
-
resolved:
|
|
461
|
-
status: changed ? "
|
|
498
|
+
current,
|
|
499
|
+
resolved: target,
|
|
500
|
+
status: changed ? "pinned" : "unchanged"
|
|
462
501
|
});
|
|
463
502
|
}
|
|
464
|
-
|
|
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/
|
|
468
|
-
const
|
|
469
|
-
|
|
470
|
-
|
|
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
|
-
|
|
473
|
-
|
|
474
|
-
|
|
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 =
|
|
478
|
-
|
|
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
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
const
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
label: "
|
|
505
|
-
get: (r) =>
|
|
506
|
-
}
|
|
507
|
-
|
|
508
|
-
|
|
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.
|
|
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": "
|
|
55
|
+
"test": "vitest run src",
|
|
55
56
|
"test:watch": "vitest",
|
|
56
57
|
"typecheck": "tsc --noEmit",
|
|
57
58
|
"verify": "pnpm typecheck && pnpm lint && pnpm test"
|