universal-plugin 0.2.2 → 0.3.0
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/.claude-plugin/plugin.json +15 -0
- package/.codex-plugin/plugin.json +14 -0
- package/.cursor-plugin/plugin.json +14 -0
- package/agents/agentskills-specialist.md +132 -0
- package/bin/upx.mjs +6 -0
- package/dist/cli.mjs +1001 -70
- package/dist/run.mjs +271 -0
- package/governances/plugin-design.md +22 -17
- package/governances/slash-invocation.md +30 -0
- package/package.json +15 -7
- package/plugin.json +18 -0
- package/readme.md +37 -3
- package/skills/adopt-upx/README.md +38 -0
- package/skills/adopt-upx/SKILL.md +120 -0
- package/skills/adopt-upx/scripts/rewrite-upx.mjs +168 -0
- package/skills/migrate-plugin/SKILL.md +106 -0
- package/skills/migrate-plugin/evals/evals.json +11 -0
- package/skills/migrate-plugin/evals/trigger-queries.json +35 -0
- package/skills/plugin/README.md +37 -0
- package/skills/plugin/SKILL.md +105 -0
- package/skills/plugin/assets/templates/agent.md +7 -0
- package/skills/plugin/assets/templates/command.md +9 -0
- package/skills/plugin/assets/templates/hooks.json +9 -0
- package/skills/plugin/assets/templates/plugin.json +19 -0
- package/skills/plugin/assets/templates/setup-command.md +15 -0
- package/skills/plugin/assets/templates/skill.md +15 -0
- package/skills/plugin/references/adopt.md +114 -0
- package/skills/plugin/references/create.md +163 -0
- package/skills/plugin/references/delete.md +23 -0
- package/skills/plugin/references/inspect.md +21 -0
- package/skills/plugin/references/update.md +26 -0
- package/skills/plugin/references/version.md +97 -0
- package/skills/publish-plugin/SKILL.md +246 -0
- package/skills/publish-plugin/evals/evals.json +23 -0
- package/skills/publish-plugin/references/vendor-requirements.md +38 -0
- package/skills/upgrade-plugin/README.md +23 -0
- package/skills/upgrade-plugin/SKILL.md +86 -0
- package/LICENSE +0 -21
package/dist/cli.mjs
CHANGED
|
@@ -5,15 +5,12 @@ import * as os from "node:os";
|
|
|
5
5
|
import * as path from "node:path";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
7
|
import * as childProcess from "node:child_process";
|
|
8
|
+
import * as semver from "semver";
|
|
8
9
|
//#region src/cli-options.ts
|
|
9
10
|
/** Repo root; resolves to cwd when omitted. */
|
|
10
11
|
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. */
|
|
15
12
|
function resolveRoot(root) {
|
|
16
|
-
return root
|
|
13
|
+
return root ?? process.cwd();
|
|
17
14
|
}
|
|
18
15
|
//#endregion
|
|
19
16
|
//#region src/state/state.ts
|
|
@@ -200,6 +197,8 @@ function detectIndent(json) {
|
|
|
200
197
|
}
|
|
201
198
|
//#endregion
|
|
202
199
|
//#region src/build/build.ts
|
|
200
|
+
/** Where each vendor reads its manifest, relative to the project root. Shared with
|
|
201
|
+
* `plugin init --npm`, which wires exactly these paths into `package.json` `files`. */
|
|
203
202
|
const VENDOR_OUTPUT = {
|
|
204
203
|
"claude-code": ".claude-plugin/plugin.json",
|
|
205
204
|
cursor: ".cursor-plugin/plugin.json",
|
|
@@ -207,31 +206,44 @@ const VENDOR_OUTPUT = {
|
|
|
207
206
|
"copilot-cli": "plugin.json"
|
|
208
207
|
};
|
|
209
208
|
const KNOWN_VENDORS = new Set(Object.keys(VENDOR_OUTPUT));
|
|
209
|
+
/** Vendors the canonical root manifest serves as-is. The build derives no file for these — writing
|
|
210
|
+
* one would either be shadowed by root (a lower-precedence path) or clobber root itself. */
|
|
211
|
+
const CANONICAL_SERVED = new Set(["copilot-cli"]);
|
|
212
|
+
const UP_NAMESPACE$1 = "org.cyberuni.universal-plugin";
|
|
213
|
+
/** Reads universal-plugin's config block from the canonical manifest's extensions map. */
|
|
214
|
+
function universalPluginExtension(manifest) {
|
|
215
|
+
return manifest.extensions?.[UP_NAMESPACE$1] ?? {};
|
|
216
|
+
}
|
|
210
217
|
function readManifest(root) {
|
|
211
|
-
const manifestPath = path.join(root, "
|
|
212
|
-
if (!fsNode.existsSync(manifestPath)) throw new Error(`No
|
|
218
|
+
const manifestPath = path.join(root, "plugin.json");
|
|
219
|
+
if (!fsNode.existsSync(manifestPath)) throw new Error(`No plugin.json found at ${root}`);
|
|
213
220
|
return JSON.parse(fsNode.readFileSync(manifestPath, "utf8"));
|
|
214
221
|
}
|
|
215
|
-
|
|
222
|
+
/** Validates the manifest. Vendor rules apply only to the vendors actually being built: pass
|
|
223
|
+
* `targets` to scope the check (build passes its selected targets), else it defaults to the
|
|
224
|
+
* manifest's own selection (`vendors ?? harnesses` keys). */
|
|
225
|
+
function validateManifest(manifest, targets) {
|
|
216
226
|
const errors = [];
|
|
217
227
|
if (!manifest.name) errors.push("name is required");
|
|
218
|
-
|
|
219
|
-
|
|
228
|
+
const uext = universalPluginExtension(manifest);
|
|
229
|
+
const harnesses = uext.harnesses ?? {};
|
|
230
|
+
const codexTargeted = (targets ?? uext.vendors ?? Object.keys(harnesses)).includes("codex") && Boolean(harnesses["codex"]);
|
|
231
|
+
if (codexTargeted && !manifest.description) errors.push("description is required when targeting codex");
|
|
232
|
+
if (codexTargeted && !manifest.version) errors.push("version is required when targeting codex");
|
|
220
233
|
return errors;
|
|
221
234
|
}
|
|
222
235
|
function buildPlugin(root, opts = {}) {
|
|
223
|
-
const manifestPath = path.join(root, "
|
|
224
|
-
if (!fsNode.existsSync(manifestPath)) throw new Error(`No
|
|
236
|
+
const manifestPath = path.join(root, "plugin.json");
|
|
237
|
+
if (!fsNode.existsSync(manifestPath)) throw new Error(`No plugin.json found at ${root}`);
|
|
225
238
|
const indent = detectIndent(fsNode.readFileSync(manifestPath, "utf8"));
|
|
226
239
|
const manifest = readManifest(root);
|
|
227
|
-
const errors = validateManifest(manifest);
|
|
228
|
-
if (errors.length > 0) throw new Error(`plugin.json validation failed:\n${errors.map((e) => ` - ${e}`).join("\n")}`);
|
|
229
240
|
const warnings = [];
|
|
230
241
|
const rows = [];
|
|
231
|
-
const
|
|
232
|
-
|
|
242
|
+
const uext = universalPluginExtension(manifest);
|
|
243
|
+
const harnesses = uext.harnesses ?? {};
|
|
244
|
+
let vendors = (uext.vendors ?? Object.keys(harnesses)).filter((v) => {
|
|
233
245
|
if (!KNOWN_VENDORS.has(v)) {
|
|
234
|
-
warnings.push(`Unknown vendor "${v}" in
|
|
246
|
+
warnings.push(`Unknown vendor "${v}" in harnesses — skipped`);
|
|
235
247
|
rows.push({
|
|
236
248
|
vendor: v,
|
|
237
249
|
path: "-",
|
|
@@ -242,11 +254,11 @@ function buildPlugin(root, opts = {}) {
|
|
|
242
254
|
return true;
|
|
243
255
|
});
|
|
244
256
|
if (opts.vendor) {
|
|
245
|
-
if (!vendors.includes(opts.vendor)) throw new Error(`Vendor "${opts.vendor}" not declared in
|
|
257
|
+
if (!vendors.includes(opts.vendor)) throw new Error(`Vendor "${opts.vendor}" not declared in harnesses`);
|
|
246
258
|
vendors = [opts.vendor];
|
|
247
259
|
}
|
|
248
260
|
if (vendors.length === 0) {
|
|
249
|
-
warnings.push("No vendors declared in
|
|
261
|
+
warnings.push("No vendors declared in harnesses — nothing to build");
|
|
250
262
|
return {
|
|
251
263
|
vendors: [],
|
|
252
264
|
written: [],
|
|
@@ -255,20 +267,36 @@ function buildPlugin(root, opts = {}) {
|
|
|
255
267
|
summary: summarize(rows)
|
|
256
268
|
};
|
|
257
269
|
}
|
|
270
|
+
const errors = validateManifest(manifest, vendors);
|
|
271
|
+
if (errors.length > 0) throw new Error(`plugin.json validation failed:\n${errors.map((e) => ` - ${e}`).join("\n")}`);
|
|
258
272
|
const written = [];
|
|
259
|
-
const {
|
|
273
|
+
const { $schema: _schema, extensions: _extensions, ...metadata } = manifest;
|
|
274
|
+
const { vendors: _vendors, packagePath: _packagePath, harnesses: _harnesses, ...componentConfig } = uext;
|
|
275
|
+
const skills = readSkills(root, manifest);
|
|
260
276
|
for (const vendor of vendors) {
|
|
261
277
|
const relPath = VENDOR_OUTPUT[vendor];
|
|
262
278
|
const outputPath = path.join(root, relPath);
|
|
263
279
|
const outputDir = path.dirname(outputPath);
|
|
264
|
-
const vendorFields =
|
|
280
|
+
const vendorFields = harnesses[vendor] ?? {};
|
|
265
281
|
const vendorManifest = {
|
|
266
|
-
...
|
|
282
|
+
...metadata,
|
|
283
|
+
...componentConfig,
|
|
267
284
|
...vendorFields
|
|
268
285
|
};
|
|
286
|
+
if (CANONICAL_SERVED.has(vendor)) {
|
|
287
|
+
const overrides = Object.keys(vendorFields);
|
|
288
|
+
if (overrides.length > 0) warnings.push(`harnesses.${vendor} sets ${overrides.join(", ")}, but ${vendor} reads the canonical plugin.json directly — these fields are not delivered`);
|
|
289
|
+
writeSkillArtifacts(root, vendor, skills, opts, written, warnings);
|
|
290
|
+
rows.push({
|
|
291
|
+
vendor,
|
|
292
|
+
path: relPath,
|
|
293
|
+
status: "canonical"
|
|
294
|
+
});
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
269
297
|
if (opts.verbose) {
|
|
270
298
|
console.log(`[${vendor}] → ${outputPath}`);
|
|
271
|
-
for (const key of Object.keys(vendorFields)) console.log(` + ${key} (from
|
|
299
|
+
for (const key of Object.keys(vendorFields)) console.log(` + ${key} (from harnesses.${vendor})`);
|
|
272
300
|
}
|
|
273
301
|
try {
|
|
274
302
|
if (!opts.dryRun) {
|
|
@@ -277,6 +305,7 @@ function buildPlugin(root, opts = {}) {
|
|
|
277
305
|
fsNode.writeFileSync(outputPath, `${JSON.stringify(vendorManifest, null, indent)}\n`);
|
|
278
306
|
}
|
|
279
307
|
written.push(outputPath);
|
|
308
|
+
writeSkillArtifacts(root, vendor, skills, opts, written, warnings);
|
|
280
309
|
rows.push({
|
|
281
310
|
vendor,
|
|
282
311
|
path: relPath,
|
|
@@ -299,18 +328,90 @@ function buildPlugin(root, opts = {}) {
|
|
|
299
328
|
summary: summarize(rows)
|
|
300
329
|
};
|
|
301
330
|
}
|
|
331
|
+
function writeSkillArtifacts(root, vendor, skills, opts, written, warnings) {
|
|
332
|
+
for (const skill of skills) {
|
|
333
|
+
if (vendor === "claude-code") {
|
|
334
|
+
writeClaudeSkill(skill, opts, written);
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
if (skill.invocationPolicy === "model") continue;
|
|
338
|
+
if (vendor === "cursor") {
|
|
339
|
+
writeArtifact(path.join(root, ".cursor", "commands", `${skill.name}.md`), skill.body, opts, written);
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
if (vendor === "codex") try {
|
|
343
|
+
writeArtifact(path.join(os.homedir(), ".codex", "prompts", `${skill.name}.md`), skill.body, opts, written);
|
|
344
|
+
} catch (err) {
|
|
345
|
+
warnings.push(`Failed to write Codex prompt for skill "${skill.name}" (best-effort): ${err instanceof Error ? err.message : String(err)}`);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
function readSkills(root, manifest) {
|
|
350
|
+
const skillsCfg = universalPluginExtension(manifest).skills;
|
|
351
|
+
const skillsPath = typeof skillsCfg === "string" ? skillsCfg : "./skills/";
|
|
352
|
+
const skillsDir = path.resolve(root, skillsPath);
|
|
353
|
+
if (!fsNode.existsSync(skillsDir)) return [];
|
|
354
|
+
return listSkillFiles(skillsDir).map((skillPath) => parseSkill(skillPath));
|
|
355
|
+
}
|
|
356
|
+
function listSkillFiles(dir) {
|
|
357
|
+
return fsNode.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
|
|
358
|
+
const entryPath = path.join(dir, entry.name);
|
|
359
|
+
if (entry.isDirectory()) return listSkillFiles(entryPath);
|
|
360
|
+
return entry.isFile() && entry.name === "SKILL.md" ? [entryPath] : [];
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
function parseSkill(skillPath) {
|
|
364
|
+
const content = fsNode.readFileSync(skillPath, "utf8");
|
|
365
|
+
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
|
|
366
|
+
const frontmatter = match?.[1] ?? "";
|
|
367
|
+
const rawPolicy = frontmatter.match(/^invocation-policy:\s*['"]?(user|model|both)['"]?\s*(?:#.*)?$/m)?.[1];
|
|
368
|
+
const declaredPolicy = frontmatter.match(/^invocation-policy:\s*(\S+)/m)?.[1];
|
|
369
|
+
if (declaredPolicy && !rawPolicy) throw new Error(`Invalid invocation-policy "${declaredPolicy}" in ${skillPath}; expected user, model, or both`);
|
|
370
|
+
return {
|
|
371
|
+
path: skillPath,
|
|
372
|
+
name: path.basename(path.dirname(skillPath)),
|
|
373
|
+
invocationPolicy: rawPolicy ?? "both",
|
|
374
|
+
hasInvocationPolicy: rawPolicy !== void 0,
|
|
375
|
+
body: match ? content.slice(match[0].length) : content,
|
|
376
|
+
content
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
function writeClaudeSkill(skill, opts, written) {
|
|
380
|
+
if (!skill.hasInvocationPolicy) return;
|
|
381
|
+
const content = withClaudeInvocationFlags(skill);
|
|
382
|
+
if (content === skill.content) return;
|
|
383
|
+
if (!opts.dryRun) fsNode.writeFileSync(skill.path, content);
|
|
384
|
+
written.push(skill.path);
|
|
385
|
+
}
|
|
386
|
+
function withClaudeInvocationFlags(skill) {
|
|
387
|
+
const match = skill.content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
388
|
+
if (!match) return skill.content;
|
|
389
|
+
const lines = match[1].split(/\r?\n/).filter((line) => !/^(disable-model-invocation|user-invocable):\s*/.test(line));
|
|
390
|
+
if (skill.invocationPolicy === "user") lines.push("disable-model-invocation: true");
|
|
391
|
+
if (skill.invocationPolicy === "model") lines.push("user-invocable: false");
|
|
392
|
+
return `${skill.content.slice(0, match.index)}---\n${lines.join("\n")}\n---${skill.content.slice(match.index + match[0].length)}`;
|
|
393
|
+
}
|
|
394
|
+
function writeArtifact(outputPath, content, opts, written) {
|
|
395
|
+
if (!opts.dryRun) {
|
|
396
|
+
if (opts.clean && fsNode.existsSync(outputPath)) fsNode.unlinkSync(outputPath);
|
|
397
|
+
fsNode.mkdirSync(path.dirname(outputPath), { recursive: true });
|
|
398
|
+
fsNode.writeFileSync(outputPath, content);
|
|
399
|
+
}
|
|
400
|
+
written.push(outputPath);
|
|
401
|
+
}
|
|
302
402
|
function summarize(rows) {
|
|
303
403
|
return {
|
|
304
404
|
built: rows.filter((r) => r.status === "built").length,
|
|
305
405
|
skipped: rows.filter((r) => r.status === "skipped").length,
|
|
306
|
-
failed: rows.filter((r) => r.status === "failed").length
|
|
406
|
+
failed: rows.filter((r) => r.status === "failed").length,
|
|
407
|
+
canonical: rows.filter((r) => r.status === "canonical").length
|
|
307
408
|
};
|
|
308
409
|
}
|
|
309
410
|
//#endregion
|
|
310
411
|
//#region src/build/cli.ts
|
|
311
|
-
const NEXT_STEP$
|
|
412
|
+
const NEXT_STEP$2 = "→ universal-plugin plugin validate\n";
|
|
312
413
|
function buildCommand() {
|
|
313
|
-
const cmd = new Command("build").description("Generate vendor manifests from
|
|
414
|
+
const cmd = new Command("build").description("Generate vendor manifests from plugin.json");
|
|
314
415
|
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
416
|
try {
|
|
316
417
|
const result = buildPlugin(resolveRoot(opts.root), {
|
|
@@ -320,11 +421,12 @@ function buildCommand() {
|
|
|
320
421
|
clean: opts.clean
|
|
321
422
|
});
|
|
322
423
|
for (const warning of result.warnings) process.stderr.write(`warn: ${warning}\n`);
|
|
323
|
-
const { built, skipped, failed } = result.summary;
|
|
424
|
+
const { built, skipped, failed, canonical } = result.summary;
|
|
324
425
|
output({
|
|
325
426
|
built: result.rows.filter((r) => r.status === "built"),
|
|
326
427
|
skipped: result.rows.filter((r) => r.status === "skipped"),
|
|
327
428
|
failed: result.rows.filter((r) => r.status === "failed"),
|
|
429
|
+
canonical: result.rows.filter((r) => r.status === "canonical"),
|
|
328
430
|
summary: result.summary,
|
|
329
431
|
warnings: result.warnings
|
|
330
432
|
}, () => {
|
|
@@ -342,9 +444,10 @@ function buildCommand() {
|
|
|
342
444
|
get: (r) => r.status
|
|
343
445
|
}
|
|
344
446
|
]);
|
|
345
|
-
|
|
447
|
+
const counts = `built ${built}, skipped ${skipped}, failed ${failed}`;
|
|
448
|
+
console.log(canonical > 0 ? `${counts}, served by plugin.json ${canonical}` : counts);
|
|
346
449
|
});
|
|
347
|
-
process.stderr.write(NEXT_STEP$
|
|
450
|
+
process.stderr.write(NEXT_STEP$2);
|
|
348
451
|
if (failed > 0) process.exitCode = 1;
|
|
349
452
|
} catch (err) {
|
|
350
453
|
process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
|
|
@@ -388,7 +491,7 @@ function realPinFs(skillsDir) {
|
|
|
388
491
|
}
|
|
389
492
|
//#endregion
|
|
390
493
|
//#region src/pin/pin.ts
|
|
391
|
-
const PIN_PATTERN = /npx\s+(?:--yes\s+|-y\s+)?([@a-z0-9/._-]+)@(\S+)/g;
|
|
494
|
+
const PIN_PATTERN = /(npx|upx)\s+(?:--yes\s+|-y\s+)?([@a-z0-9/._-]+)@(\S+)/g;
|
|
392
495
|
/** Strips a trailing backtick, quote, or paren that isn't part of the version token. */
|
|
393
496
|
function stripTrailing(raw) {
|
|
394
497
|
return raw.replace(/[`'")]+$/, "");
|
|
@@ -396,13 +499,15 @@ function stripTrailing(raw) {
|
|
|
396
499
|
function extractPins(text) {
|
|
397
500
|
const pins = [];
|
|
398
501
|
for (const match of text.matchAll(PIN_PATTERN)) {
|
|
399
|
-
const
|
|
400
|
-
const
|
|
401
|
-
|
|
502
|
+
const runner = match[1];
|
|
503
|
+
const pkg = match[2];
|
|
504
|
+
const current = match[3];
|
|
505
|
+
if (!runner || !pkg || !current) continue;
|
|
402
506
|
pins.push({
|
|
403
507
|
pkg,
|
|
404
508
|
current: stripTrailing(current),
|
|
405
|
-
file: ""
|
|
509
|
+
file: "",
|
|
510
|
+
runner
|
|
406
511
|
});
|
|
407
512
|
}
|
|
408
513
|
return pins;
|
|
@@ -412,12 +517,14 @@ function extractPins(text) {
|
|
|
412
517
|
function escapeRegExp(value) {
|
|
413
518
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
414
519
|
}
|
|
415
|
-
/** Rewrites every `npx <pkg>@<anything>` occurrence in `content` to
|
|
416
|
-
* of whether the occurrence is a concrete version or a
|
|
417
|
-
* reference to one CLI converges on the same resolved
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
520
|
+
/** Rewrites every `npx <pkg>@<anything>` or `upx <pkg>@<anything>` occurrence in `content` to
|
|
521
|
+
* `<runner> <pkg>@<next>`, regardless of whether the occurrence is a concrete version or a
|
|
522
|
+
* placeholder like `<version>` — so every reference to one CLI converges on the same resolved
|
|
523
|
+
* value. `forceRunner` omitted preserves each occurrence's existing runner word (an `npx`
|
|
524
|
+
* reference stays `npx`, an `upx` reference stays `upx`). */
|
|
525
|
+
function rewritePin(content, pkg, next, forceRunner) {
|
|
526
|
+
const pattern = new RegExp(`(npx|upx)(\\s+(?:--yes\\s+|-y\\s+)?${escapeRegExp(pkg)}@)[^\\s\`'")]+`, "g");
|
|
527
|
+
return content.replace(pattern, (_match, runner, mid) => `${forceRunner ?? runner}${mid}${next}`);
|
|
421
528
|
}
|
|
422
529
|
const FRONTMATTER_PATTERN = /^---\r?\n([\s\S]*?)\r?\n---/;
|
|
423
530
|
const PIN_EXEMPT_PATTERN = /(^|\n)\s*pin-exempt:\s*true\s*(\n|$)/;
|
|
@@ -486,7 +593,7 @@ function bundlePins(pinFs, versionSource, opts = {}) {
|
|
|
486
593
|
let changed = false;
|
|
487
594
|
for (const file of inScope) {
|
|
488
595
|
const content = contents.get(file);
|
|
489
|
-
const rewritten = rewritePin(content, pkg, target);
|
|
596
|
+
const rewritten = rewritePin(content, pkg, target, opts.runner);
|
|
490
597
|
if (rewritten !== content) {
|
|
491
598
|
changed = true;
|
|
492
599
|
contents.set(file, rewritten);
|
|
@@ -500,7 +607,6 @@ function bundlePins(pinFs, versionSource, opts = {}) {
|
|
|
500
607
|
status: changed ? "pinned" : "unchanged"
|
|
501
608
|
});
|
|
502
609
|
}
|
|
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
610
|
return {
|
|
505
611
|
pins,
|
|
506
612
|
warnings
|
|
@@ -510,16 +616,13 @@ function bundlePins(pinFs, versionSource, opts = {}) {
|
|
|
510
616
|
//#region src/bundle/fs.ts
|
|
511
617
|
const DEFAULT_GLOBS = ["packages/*"];
|
|
512
618
|
/** 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`).
|
|
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. */
|
|
619
|
+
* ancestor declares one (the workspace glob then resolves relative to `start`). */
|
|
516
620
|
function findMonorepoRoot(start) {
|
|
517
|
-
|
|
518
|
-
let dir = from;
|
|
621
|
+
let dir = start;
|
|
519
622
|
for (;;) {
|
|
520
623
|
if (fsNode.existsSync(path.join(dir, "pnpm-workspace.yaml"))) return dir;
|
|
521
624
|
const parent = path.dirname(dir);
|
|
522
|
-
if (parent === dir) return
|
|
625
|
+
if (parent === dir) return start;
|
|
523
626
|
dir = parent;
|
|
524
627
|
}
|
|
525
628
|
}
|
|
@@ -613,16 +716,18 @@ function realVersionSource(workspace) {
|
|
|
613
716
|
//#endregion
|
|
614
717
|
//#region src/bundle/cli.ts
|
|
615
718
|
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";
|
|
719
|
+
const NEXT_STEP$1 = "→ review and commit the pinned skills\n";
|
|
620
720
|
function bundleCommand() {
|
|
621
721
|
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) => {
|
|
722
|
+
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)").option("--runner <runner>", "Runner word to emit on rewritten refs: npx or upx (default: preserve each ref's runner)").addOption(ROOT_OPTION).addHelpText("after", "\nExample:\n $ universal-plugin plugin bundle --dry-run --full\n").action((opts) => {
|
|
623
723
|
try {
|
|
724
|
+
const runner = opts.runner;
|
|
725
|
+
if (runner !== void 0 && runner !== "npx" && runner !== "upx") throw new Error(`error: unknown --runner value "${runner}" (expected npx or upx)`);
|
|
624
726
|
const root = resolveRoot(opts.root);
|
|
625
|
-
const result = bundlePins(realPinFs(resolveSkillsDir(root, readManifest(root).skills)), realVersionSource(discoverWorkspace(root)), {
|
|
727
|
+
const result = bundlePins(realPinFs(resolveSkillsDir(root, universalPluginExtension(readManifest(root)).skills)), realVersionSource(discoverWorkspace(root)), {
|
|
728
|
+
dryRun: opts.dryRun,
|
|
729
|
+
runner
|
|
730
|
+
});
|
|
626
731
|
if (!opts.dryRun) writePinsMap(root, result.pins);
|
|
627
732
|
for (const warning of result.warnings) process.stderr.write(`warn: ${warning}\n`);
|
|
628
733
|
const pinned = result.pins.filter((p) => p.status === "pinned").length;
|
|
@@ -653,8 +758,7 @@ function bundleCommand() {
|
|
|
653
758
|
console.log(`pinned ${pinned}, unchanged ${unchanged}, skipped ${skipped}`);
|
|
654
759
|
});
|
|
655
760
|
if (result.pins.length === 0) process.stderr.write("nothing to bundle\n");
|
|
656
|
-
|
|
657
|
-
process.stderr.write(noneResolved ? NOTHING_RESOLVED_STEP : NEXT_STEP);
|
|
761
|
+
process.stderr.write(NEXT_STEP$1);
|
|
658
762
|
} catch (err) {
|
|
659
763
|
process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
|
|
660
764
|
process.exit(1);
|
|
@@ -663,6 +767,143 @@ function bundleCommand() {
|
|
|
663
767
|
return cmd;
|
|
664
768
|
}
|
|
665
769
|
//#endregion
|
|
770
|
+
//#region src/config/config.ts
|
|
771
|
+
/** Pure domain for the plugin-registered config store.
|
|
772
|
+
*
|
|
773
|
+
* `.agents/universal-plugin.json` holds the CLI's own config plus, under
|
|
774
|
+
* plugin-registered keys, arrays of `{ name, … }` entry objects. This module owns
|
|
775
|
+
* the rules: which keys are reserved, how an entry merges into a key's array
|
|
776
|
+
* (append or replace-by-name, position preserved), and how a key's array is read.
|
|
777
|
+
* No I/O — the caller supplies the parsed config object. */
|
|
778
|
+
/** Keys `universal-plugin` owns for its own config — never plugin-registered arrays.
|
|
779
|
+
* `packagePath` is a string read by `publish sync-version`. */
|
|
780
|
+
const RESERVED_KEYS = ["packagePath"];
|
|
781
|
+
function isReservedKey(key) {
|
|
782
|
+
return RESERVED_KEYS.includes(key);
|
|
783
|
+
}
|
|
784
|
+
/** The array registered at `key`, or an empty array when the key is absent or non-array. */
|
|
785
|
+
function getEntries(config, key) {
|
|
786
|
+
const value = config[key];
|
|
787
|
+
return Array.isArray(value) ? value : [];
|
|
788
|
+
}
|
|
789
|
+
/** Merge `entry` into the array at `key`: append when no element shares its `name`,
|
|
790
|
+
* else replace that element in place (array position preserved). Returns a new config
|
|
791
|
+
* object; the input is not mutated. Throws when `entry` is not a JSON object or lacks a
|
|
792
|
+
* non-empty `name` (the merge dedup key). Reserved-key rejection is the caller's guard. */
|
|
793
|
+
function addEntry(config, key, entry) {
|
|
794
|
+
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) throw new Error("error: --entry must be a JSON object");
|
|
795
|
+
const name = entry.name;
|
|
796
|
+
if (typeof name !== "string" || name.length === 0) throw new Error("error: --entry must include a non-empty \"name\" field");
|
|
797
|
+
const existing = getEntries(config, key);
|
|
798
|
+
const index = existing.findIndex((e) => typeof e === "object" && e !== null && e.name === name);
|
|
799
|
+
let next;
|
|
800
|
+
let action;
|
|
801
|
+
if (index === -1) {
|
|
802
|
+
next = [...existing, entry];
|
|
803
|
+
action = "appended";
|
|
804
|
+
} else {
|
|
805
|
+
next = existing.slice();
|
|
806
|
+
next[index] = entry;
|
|
807
|
+
action = "replaced";
|
|
808
|
+
}
|
|
809
|
+
return {
|
|
810
|
+
config: {
|
|
811
|
+
...config,
|
|
812
|
+
[key]: next
|
|
813
|
+
},
|
|
814
|
+
action,
|
|
815
|
+
name
|
|
816
|
+
};
|
|
817
|
+
}
|
|
818
|
+
//#endregion
|
|
819
|
+
//#region src/config/fs.ts
|
|
820
|
+
function configPath(root) {
|
|
821
|
+
return path.join(root, ".agents", "universal-plugin.json");
|
|
822
|
+
}
|
|
823
|
+
const realConfigFs = {
|
|
824
|
+
read(root) {
|
|
825
|
+
const file = configPath(root);
|
|
826
|
+
if (!fsNode.existsSync(file)) return {};
|
|
827
|
+
return JSON.parse(fsNode.readFileSync(file, "utf8"));
|
|
828
|
+
},
|
|
829
|
+
write(root, config) {
|
|
830
|
+
const file = configPath(root);
|
|
831
|
+
const indent = fsNode.existsSync(file) ? detectIndent(fsNode.readFileSync(file, "utf8")) : " ";
|
|
832
|
+
fsNode.mkdirSync(path.dirname(file), { recursive: true });
|
|
833
|
+
fsNode.writeFileSync(file, `${JSON.stringify(config, null, indent)}\n`);
|
|
834
|
+
}
|
|
835
|
+
};
|
|
836
|
+
//#endregion
|
|
837
|
+
//#region src/config/cli.ts
|
|
838
|
+
function assertNotReserved(key) {
|
|
839
|
+
if (isReservedKey(key)) throw new Error(`error: "${key}" is a reserved key (universal-plugin's own config) — not a plugin-registered array`);
|
|
840
|
+
}
|
|
841
|
+
function addCommand(fs) {
|
|
842
|
+
return new Command("add").description("Register (append or replace-by-name) an entry in the array at a config key").requiredOption("--key <key>", "The config key whose array to write").requiredOption("--entry <json>", "The entry object as JSON (must include a \"name\" field)").option("--format <format>", "Output format: json or toon (default: toon)").addOption(ROOT_OPTION).addHelpText("after", "\nExample:\n $ universal-plugin config add --key sdd-plugins --entry '{\"name\":\"aces\",\"handles\":[\"agent evaluation\"]}'\n").action((opts) => {
|
|
843
|
+
try {
|
|
844
|
+
assertNotReserved(opts.key);
|
|
845
|
+
let parsed;
|
|
846
|
+
try {
|
|
847
|
+
parsed = JSON.parse(opts.entry);
|
|
848
|
+
} catch {
|
|
849
|
+
throw new Error("error: --entry is not valid JSON");
|
|
850
|
+
}
|
|
851
|
+
const root = resolveRoot(opts.root);
|
|
852
|
+
const result = addEntry(fs.read(root), opts.key, parsed);
|
|
853
|
+
fs.write(root, result.config);
|
|
854
|
+
const count = getEntries(result.config, opts.key).length;
|
|
855
|
+
output({
|
|
856
|
+
key: opts.key,
|
|
857
|
+
name: result.name,
|
|
858
|
+
action: result.action
|
|
859
|
+
}, () => {
|
|
860
|
+
printTable([result], [
|
|
861
|
+
{
|
|
862
|
+
label: "key",
|
|
863
|
+
get: () => opts.key
|
|
864
|
+
},
|
|
865
|
+
{
|
|
866
|
+
label: "name",
|
|
867
|
+
get: (r) => r.name
|
|
868
|
+
},
|
|
869
|
+
{
|
|
870
|
+
label: "action",
|
|
871
|
+
get: (r) => r.action
|
|
872
|
+
}
|
|
873
|
+
]);
|
|
874
|
+
console.log(`${opts.key}: ${count} entries`);
|
|
875
|
+
});
|
|
876
|
+
process.stderr.write(`→ universal-plugin config get --key ${opts.key}\n`);
|
|
877
|
+
} catch (err) {
|
|
878
|
+
process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
|
|
879
|
+
process.exit(1);
|
|
880
|
+
}
|
|
881
|
+
});
|
|
882
|
+
}
|
|
883
|
+
function getCommand(fs) {
|
|
884
|
+
return new Command("get").description("Read the array of entries registered at a config key").requiredOption("--key <key>", "The config key whose array to read").option("--format <format>", "Output format: json or toon (default: toon)").addOption(ROOT_OPTION).addHelpText("after", "\nExample:\n $ universal-plugin config get --key sdd-plugins\n").action((opts) => {
|
|
885
|
+
try {
|
|
886
|
+
assertNotReserved(opts.key);
|
|
887
|
+
const root = resolveRoot(opts.root);
|
|
888
|
+
const entries = getEntries(fs.read(root), opts.key);
|
|
889
|
+
output(entries, () => {
|
|
890
|
+
printTable(entries, [{
|
|
891
|
+
label: "name",
|
|
892
|
+
get: (e) => String(e.name ?? "")
|
|
893
|
+
}]);
|
|
894
|
+
console.log(`${opts.key}: ${entries.length} entries`);
|
|
895
|
+
});
|
|
896
|
+
process.stderr.write(`→ universal-plugin config add --key ${opts.key} --entry <json>\n`);
|
|
897
|
+
} catch (err) {
|
|
898
|
+
process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
|
|
899
|
+
process.exit(1);
|
|
900
|
+
}
|
|
901
|
+
});
|
|
902
|
+
}
|
|
903
|
+
function configCommand(fs = realConfigFs) {
|
|
904
|
+
return new Command("config").description("Read and write plugin-registered config in .agents/universal-plugin.json").addCommand(addCommand(fs)).addCommand(getCommand(fs));
|
|
905
|
+
}
|
|
906
|
+
//#endregion
|
|
666
907
|
//#region src/governance/fs.ts
|
|
667
908
|
const realGovernanceFs = {
|
|
668
909
|
exists: (p) => fsNode.existsSync(p),
|
|
@@ -807,6 +1048,486 @@ function governanceCommand() {
|
|
|
807
1048
|
return cmd;
|
|
808
1049
|
}
|
|
809
1050
|
//#endregion
|
|
1051
|
+
//#region src/init/fs.ts
|
|
1052
|
+
function manifestPath(root) {
|
|
1053
|
+
return path.join(root, "plugin.json");
|
|
1054
|
+
}
|
|
1055
|
+
function packageJsonPath(root) {
|
|
1056
|
+
return path.join(root, "package.json");
|
|
1057
|
+
}
|
|
1058
|
+
const realInitFs = {
|
|
1059
|
+
gather(root) {
|
|
1060
|
+
const pj = packageJsonPath(root);
|
|
1061
|
+
const packageJson = fsNode.existsSync(pj) ? JSON.parse(fsNode.readFileSync(pj, "utf8")) : null;
|
|
1062
|
+
return {
|
|
1063
|
+
manifestExists: fsNode.existsSync(manifestPath(root)),
|
|
1064
|
+
packageJson
|
|
1065
|
+
};
|
|
1066
|
+
},
|
|
1067
|
+
apply(root, plan) {
|
|
1068
|
+
fsNode.writeFileSync(manifestPath(root), `${JSON.stringify(plan.manifest, null, " ")}\n`);
|
|
1069
|
+
for (const dir of plan.dirs) fsNode.mkdirSync(path.join(root, dir), { recursive: true });
|
|
1070
|
+
if (plan.packageJson) {
|
|
1071
|
+
const pj = packageJsonPath(root);
|
|
1072
|
+
const indent = detectIndent(fsNode.readFileSync(pj, "utf8"));
|
|
1073
|
+
fsNode.writeFileSync(pj, `${JSON.stringify(plan.packageJson, null, indent)}\n`);
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
};
|
|
1077
|
+
//#endregion
|
|
1078
|
+
//#region src/init/init.ts
|
|
1079
|
+
/** Pure domain for `plugin init` — scaffolding the canonical manifest and (with `--npm`)
|
|
1080
|
+
* wiring an npm package to ship it.
|
|
1081
|
+
*
|
|
1082
|
+
* No I/O: the caller gathers the current filesystem state, calls `planInit`, and applies the
|
|
1083
|
+
* returned plan. `planInit` owns the rules — the guard order, the closed manifest shape, the
|
|
1084
|
+
* no-`--vendor` default (omit the `vendors` key, never write `vendors: []` — `plugin build`'s
|
|
1085
|
+
* `vendors ?? harnesses`-keys fallback engages only on an absent key), and the `--npm`
|
|
1086
|
+
* files-wiring default (`claude-code`). The vendor→derived-manifest-path map is injected so the
|
|
1087
|
+
* domain stays free of the build/registry layer. */
|
|
1088
|
+
const SCHEMA_URL = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json";
|
|
1089
|
+
const UP_NAMESPACE = "org.cyberuni.universal-plugin";
|
|
1090
|
+
const SCAFFOLD_DIRS = [
|
|
1091
|
+
"skills",
|
|
1092
|
+
"agents",
|
|
1093
|
+
"governances",
|
|
1094
|
+
"commands"
|
|
1095
|
+
];
|
|
1096
|
+
/** The closed canonical manifest init writes: `$schema` + `name`, plus the extensions namespace
|
|
1097
|
+
* carrying the `vendors` list only when `--vendor` was passed. */
|
|
1098
|
+
function buildManifest(name, vendors) {
|
|
1099
|
+
const manifest = {
|
|
1100
|
+
$schema: SCHEMA_URL,
|
|
1101
|
+
name
|
|
1102
|
+
};
|
|
1103
|
+
if (vendors.length > 0) manifest.extensions = { [UP_NAMESPACE]: { vendors } };
|
|
1104
|
+
return manifest;
|
|
1105
|
+
}
|
|
1106
|
+
/** The open-standard base every published plugin ships, whatever it targets: the canonical
|
|
1107
|
+
* Agent Plugins Spec manifest and the skills directory every runtime reads. Vendor-derived
|
|
1108
|
+
* manifests layer on top of this — they never replace it. */
|
|
1109
|
+
const STANDARD_FILES = ["plugin.json", "skills/"];
|
|
1110
|
+
/** Adds the open-standard base plus each derived manifest path to `package.json` `files`, creating
|
|
1111
|
+
* the array when absent and never duplicating an entry. Other fields and existing entries are
|
|
1112
|
+
* preserved. The base goes in regardless of `--vendor`: a package that ships only
|
|
1113
|
+
* `.claude-plugin/plugin.json` has published a Claude Code plugin, not a standard one. */
|
|
1114
|
+
function wireFiles(pkg, manifestPaths) {
|
|
1115
|
+
const files = Array.isArray(pkg.files) ? [...pkg.files] : [];
|
|
1116
|
+
for (const entry of [...STANDARD_FILES, ...manifestPaths]) if (!files.includes(entry)) files.push(entry);
|
|
1117
|
+
return {
|
|
1118
|
+
...pkg,
|
|
1119
|
+
files
|
|
1120
|
+
};
|
|
1121
|
+
}
|
|
1122
|
+
/** Plans the init run. Throws on a guard failure (an existing manifest without `--force`; `--npm`
|
|
1123
|
+
* with no `package.json`) before returning any plan, so the caller writes nothing on a guard trip. */
|
|
1124
|
+
function planInit(state, opts, rootDirName, resolveManifestPath) {
|
|
1125
|
+
if (opts.npm && state.packageJson === null) throw new Error("error: --npm requires a package.json at the project root");
|
|
1126
|
+
if (state.manifestExists && !opts.force) throw new Error("plugin.json already exists — pass --force to overwrite");
|
|
1127
|
+
const manifest = buildManifest(opts.name ?? rootDirName, opts.vendors);
|
|
1128
|
+
const dirs = opts.scaffold ? [...SCAFFOLD_DIRS] : [];
|
|
1129
|
+
const rows = [{
|
|
1130
|
+
path: "plugin.json",
|
|
1131
|
+
action: "created"
|
|
1132
|
+
}];
|
|
1133
|
+
let packageJson = null;
|
|
1134
|
+
if (opts.npm) {
|
|
1135
|
+
const manifestPaths = (opts.vendors.length > 0 ? opts.vendors : ["claude-code"]).map(resolveManifestPath).filter((p) => Boolean(p));
|
|
1136
|
+
packageJson = wireFiles(state.packageJson, manifestPaths);
|
|
1137
|
+
rows.push({
|
|
1138
|
+
path: "package.json",
|
|
1139
|
+
action: "updated"
|
|
1140
|
+
});
|
|
1141
|
+
}
|
|
1142
|
+
const created = rows.filter((r) => r.action === "created").length;
|
|
1143
|
+
const updated = rows.filter((r) => r.action === "updated").length;
|
|
1144
|
+
return {
|
|
1145
|
+
manifest,
|
|
1146
|
+
dirs,
|
|
1147
|
+
packageJson,
|
|
1148
|
+
rows,
|
|
1149
|
+
summary: {
|
|
1150
|
+
created,
|
|
1151
|
+
updated
|
|
1152
|
+
}
|
|
1153
|
+
};
|
|
1154
|
+
}
|
|
1155
|
+
//#endregion
|
|
1156
|
+
//#region src/init/cli.ts
|
|
1157
|
+
const NEXT_STEP = "→ add skills to skills/, then run universal-plugin plugin build\n";
|
|
1158
|
+
function collect(value, previous) {
|
|
1159
|
+
return [...previous, value];
|
|
1160
|
+
}
|
|
1161
|
+
function initCommand$1(deps = { fs: realInitFs }) {
|
|
1162
|
+
const cmd = new Command("init").description("Scaffold the canonical plugin.json (with --npm, wire an npm package to ship it)");
|
|
1163
|
+
cmd.option("--name <name>", "Plugin name (default: the root directory name)").option("--vendor <id>", "Target vendor; repeatable", collect, []).option("--scaffold", "Create the standard skills/ agents/ governances/ commands/ directories").option("--force", "Overwrite an existing plugin.json").option("--yes", "Non-interactive (compatibility no-op; init never prompts)").option("--npm", "Wire package.json 'files' to ship the derived vendor manifests").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 init --name my-plugin --scaffold\n").action((opts) => {
|
|
1164
|
+
try {
|
|
1165
|
+
const root = resolveRoot(opts.root);
|
|
1166
|
+
const plan = planInit(deps.fs.gather(root), {
|
|
1167
|
+
name: opts.name,
|
|
1168
|
+
vendors: opts.vendor ?? [],
|
|
1169
|
+
scaffold: Boolean(opts.scaffold),
|
|
1170
|
+
force: Boolean(opts.force),
|
|
1171
|
+
npm: Boolean(opts.npm)
|
|
1172
|
+
}, path.basename(root), (vendor) => VENDOR_OUTPUT[vendor]);
|
|
1173
|
+
deps.fs.apply(root, plan);
|
|
1174
|
+
output({
|
|
1175
|
+
created: plan.rows.filter((r) => r.action === "created").map((r) => r.path),
|
|
1176
|
+
updated: plan.rows.filter((r) => r.action === "updated").map((r) => r.path),
|
|
1177
|
+
summary: plan.summary
|
|
1178
|
+
}, () => {
|
|
1179
|
+
printTable(plan.rows, [{
|
|
1180
|
+
label: "path",
|
|
1181
|
+
get: (r) => r.path
|
|
1182
|
+
}, {
|
|
1183
|
+
label: "action",
|
|
1184
|
+
get: (r) => r.action
|
|
1185
|
+
}]);
|
|
1186
|
+
console.log(`created ${plan.summary.created}, updated ${plan.summary.updated}`);
|
|
1187
|
+
});
|
|
1188
|
+
process.stderr.write(NEXT_STEP);
|
|
1189
|
+
} catch (err) {
|
|
1190
|
+
process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
|
|
1191
|
+
process.exit(1);
|
|
1192
|
+
}
|
|
1193
|
+
});
|
|
1194
|
+
return cmd;
|
|
1195
|
+
}
|
|
1196
|
+
//#endregion
|
|
1197
|
+
//#region src/marketplace/fs.ts
|
|
1198
|
+
const realMarketplaceFs = {
|
|
1199
|
+
exists: fsNode.existsSync,
|
|
1200
|
+
isDirectory(file) {
|
|
1201
|
+
return fsNode.statSync(file).isDirectory();
|
|
1202
|
+
},
|
|
1203
|
+
realpath(file) {
|
|
1204
|
+
return fsNode.realpathSync(file);
|
|
1205
|
+
},
|
|
1206
|
+
read(file) {
|
|
1207
|
+
return fsNode.readFileSync(file, "utf8");
|
|
1208
|
+
},
|
|
1209
|
+
listEntries(dir) {
|
|
1210
|
+
return fsNode.readdirSync(dir);
|
|
1211
|
+
},
|
|
1212
|
+
writeAtomically(file, content) {
|
|
1213
|
+
fsNode.mkdirSync(path.dirname(file), { recursive: true });
|
|
1214
|
+
const temporary = `${file}.${process.pid}.${Math.random().toString(16).slice(2)}.tmp`;
|
|
1215
|
+
fsNode.writeFileSync(temporary, content);
|
|
1216
|
+
fsNode.renameSync(temporary, file);
|
|
1217
|
+
}
|
|
1218
|
+
};
|
|
1219
|
+
//#endregion
|
|
1220
|
+
//#region src/marketplace/marketplace.ts
|
|
1221
|
+
const COMMON_METADATA = [
|
|
1222
|
+
"description",
|
|
1223
|
+
"version",
|
|
1224
|
+
"homepage",
|
|
1225
|
+
"repository",
|
|
1226
|
+
"license",
|
|
1227
|
+
"keywords"
|
|
1228
|
+
];
|
|
1229
|
+
function assertMarketplaceName(value, label) {
|
|
1230
|
+
if (!/^[a-z0-9][a-z0-9._-]*$/i.test(value)) throw new Error(`error: ${label} "${value}" must contain only letters, digits, dots, underscores, or hyphens`);
|
|
1231
|
+
}
|
|
1232
|
+
function commonMetadata(plugin) {
|
|
1233
|
+
const result = {};
|
|
1234
|
+
for (const field of COMMON_METADATA) if (plugin.metadata[field] !== void 0) result[field] = plugin.metadata[field];
|
|
1235
|
+
return result;
|
|
1236
|
+
}
|
|
1237
|
+
function json(value) {
|
|
1238
|
+
return `${JSON.stringify(value, null, 2)}\n`;
|
|
1239
|
+
}
|
|
1240
|
+
function claudeArtifact(metadata, plugins) {
|
|
1241
|
+
return {
|
|
1242
|
+
path: ".claude-plugin/marketplace.json",
|
|
1243
|
+
content: json({
|
|
1244
|
+
...metadata,
|
|
1245
|
+
plugins: plugins.map((plugin) => ({
|
|
1246
|
+
name: plugin.name,
|
|
1247
|
+
source: plugin.source,
|
|
1248
|
+
...commonMetadata(plugin)
|
|
1249
|
+
}))
|
|
1250
|
+
})
|
|
1251
|
+
};
|
|
1252
|
+
}
|
|
1253
|
+
function codexArtifact(metadata, plugins) {
|
|
1254
|
+
return {
|
|
1255
|
+
path: ".agents/plugins/marketplace.json",
|
|
1256
|
+
content: json({
|
|
1257
|
+
name: metadata.name,
|
|
1258
|
+
interface: { displayName: metadata.name },
|
|
1259
|
+
plugins: plugins.map((plugin) => ({
|
|
1260
|
+
name: plugin.name,
|
|
1261
|
+
version: plugin.metadata.version,
|
|
1262
|
+
source: {
|
|
1263
|
+
source: "local",
|
|
1264
|
+
path: plugin.source
|
|
1265
|
+
},
|
|
1266
|
+
policy: {
|
|
1267
|
+
installation: "AVAILABLE",
|
|
1268
|
+
authentication: "ON_INSTALL"
|
|
1269
|
+
},
|
|
1270
|
+
category: "Productivity"
|
|
1271
|
+
}))
|
|
1272
|
+
})
|
|
1273
|
+
};
|
|
1274
|
+
}
|
|
1275
|
+
function copilotArtifact(metadata, plugins) {
|
|
1276
|
+
return {
|
|
1277
|
+
path: ".github/plugin/marketplace.json",
|
|
1278
|
+
content: json({
|
|
1279
|
+
...metadata,
|
|
1280
|
+
metadata: { displayName: metadata.name },
|
|
1281
|
+
plugins: plugins.map((plugin) => ({
|
|
1282
|
+
name: plugin.name,
|
|
1283
|
+
source: plugin.source,
|
|
1284
|
+
...commonMetadata(plugin)
|
|
1285
|
+
}))
|
|
1286
|
+
})
|
|
1287
|
+
};
|
|
1288
|
+
}
|
|
1289
|
+
function cursorArtifacts(metadata, plugins) {
|
|
1290
|
+
const sources = plugins.map((plugin) => ({
|
|
1291
|
+
name: plugin.name,
|
|
1292
|
+
source: plugin.source,
|
|
1293
|
+
...commonMetadata(plugin)
|
|
1294
|
+
}));
|
|
1295
|
+
return [{
|
|
1296
|
+
path: ".cursor-plugin/marketplace-submission.json",
|
|
1297
|
+
content: json({
|
|
1298
|
+
...metadata,
|
|
1299
|
+
plugins: sources,
|
|
1300
|
+
dashboard: "https://cursor.com/dashboard"
|
|
1301
|
+
})
|
|
1302
|
+
}, {
|
|
1303
|
+
path: "CURSOR_MARKETPLACE_SUBMISSION.md",
|
|
1304
|
+
content: `# Cursor Marketplace Submission\n\nMarketplace: ${metadata.name}\nOwner: ${metadata.owner}\n\nPlugins:\n${sources.map((plugin) => `- ${plugin.name}: ${plugin.source}`).join("\n")}\n\nSubmit this metadata through the [Cursor dashboard](https://cursor.com/dashboard). This command generated local submission metadata only; no publication or provisioning occurred.\n`
|
|
1305
|
+
}];
|
|
1306
|
+
}
|
|
1307
|
+
function serializeTarget(target, metadata, plugins) {
|
|
1308
|
+
switch (target) {
|
|
1309
|
+
case "claude": return [claudeArtifact(metadata, plugins)];
|
|
1310
|
+
case "codex": return [codexArtifact(metadata, plugins)];
|
|
1311
|
+
case "copilot": return [copilotArtifact(metadata, plugins)];
|
|
1312
|
+
case "cursor": return cursorArtifacts(metadata, plugins);
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
//#endregion
|
|
1316
|
+
//#region src/marketplace/init.ts
|
|
1317
|
+
function isInside(root, candidate) {
|
|
1318
|
+
const relative = path.relative(root, candidate);
|
|
1319
|
+
return relative === "" || !relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative);
|
|
1320
|
+
}
|
|
1321
|
+
function containingExistingPath(file, fs) {
|
|
1322
|
+
let current = file;
|
|
1323
|
+
while (!fs.exists(current)) {
|
|
1324
|
+
const parent = path.dirname(current);
|
|
1325
|
+
if (parent === current) throw new Error(`error: path "${file}" must resolve within --root`);
|
|
1326
|
+
current = parent;
|
|
1327
|
+
}
|
|
1328
|
+
return current;
|
|
1329
|
+
}
|
|
1330
|
+
function assertContained(root, candidate, fs, label) {
|
|
1331
|
+
if (!isInside(fs.realpath(root), fs.realpath(containingExistingPath(candidate, fs)))) throw new Error(`error: ${label} "${candidate}" must resolve within --root`);
|
|
1332
|
+
}
|
|
1333
|
+
function parseManifest(fs, file) {
|
|
1334
|
+
try {
|
|
1335
|
+
const parsed = JSON.parse(fs.read(file));
|
|
1336
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("not an object");
|
|
1337
|
+
return parsed;
|
|
1338
|
+
} catch {
|
|
1339
|
+
throw new Error(`error: manifest "${file}" is not valid JSON`);
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
function manifestOwner(manifest) {
|
|
1343
|
+
if (typeof manifest.author === "string") return manifest.author;
|
|
1344
|
+
if (typeof manifest.author === "object" && manifest.author !== null) {
|
|
1345
|
+
const author = manifest.author;
|
|
1346
|
+
if (typeof author.name === "string") return author.name;
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
function deriveMetadata(root, fs, opts) {
|
|
1350
|
+
const rootManifest = path.join(root, "plugin.json");
|
|
1351
|
+
if (fs.exists(rootManifest)) assertContained(root, rootManifest, fs, "root plugin.json");
|
|
1352
|
+
const manifest = fs.exists(rootManifest) ? parseManifest(fs, rootManifest) : {};
|
|
1353
|
+
const name = opts.name ?? path.basename(root);
|
|
1354
|
+
const owner = opts.owner ?? manifestOwner(manifest);
|
|
1355
|
+
assertMarketplaceName(name, "marketplace name");
|
|
1356
|
+
if (!owner || owner.trim() === "") throw new Error("error: marketplace owner is required; set --owner or root plugin.json author");
|
|
1357
|
+
return {
|
|
1358
|
+
name,
|
|
1359
|
+
owner
|
|
1360
|
+
};
|
|
1361
|
+
}
|
|
1362
|
+
function scanRoots(root, fs, scanDirs) {
|
|
1363
|
+
const roots = [...new Set((scanDirs ?? ["plugins"]).map((dir) => path.resolve(root, dir)))].sort();
|
|
1364
|
+
for (const scanRoot of roots) {
|
|
1365
|
+
if (!isInside(root, scanRoot)) throw new Error(`error: --plugin-scan-dir "${scanRoot}" must resolve within --root`);
|
|
1366
|
+
if (!fs.exists(scanRoot)) {
|
|
1367
|
+
if (scanDirs === void 0) continue;
|
|
1368
|
+
throw new Error(`error: plugin scan directory "${scanRoot}" does not exist`);
|
|
1369
|
+
}
|
|
1370
|
+
if (!fs.isDirectory(scanRoot)) throw new Error(`error: plugin scan directory "${scanRoot}" is not a directory`);
|
|
1371
|
+
assertContained(root, scanRoot, fs, "--plugin-scan-dir");
|
|
1372
|
+
}
|
|
1373
|
+
return roots.filter((scanRoot) => fs.exists(scanRoot));
|
|
1374
|
+
}
|
|
1375
|
+
function discoverPlugins(root, fs, scanDirs) {
|
|
1376
|
+
const plugins = [];
|
|
1377
|
+
const names = /* @__PURE__ */ new Set();
|
|
1378
|
+
for (const scanRoot of scanRoots(root, fs, scanDirs)) for (const entry of fs.listEntries(scanRoot).sort()) {
|
|
1379
|
+
if ([
|
|
1380
|
+
".plugin",
|
|
1381
|
+
".claude-plugin",
|
|
1382
|
+
".codex-plugin",
|
|
1383
|
+
".cursor-plugin"
|
|
1384
|
+
].includes(entry)) continue;
|
|
1385
|
+
const pluginRoot = path.join(scanRoot, entry);
|
|
1386
|
+
if (!fs.isDirectory(pluginRoot)) continue;
|
|
1387
|
+
assertContained(root, pluginRoot, fs, "plugin directory");
|
|
1388
|
+
const manifestPath = path.join(pluginRoot, "plugin.json");
|
|
1389
|
+
if (!fs.exists(manifestPath)) continue;
|
|
1390
|
+
assertContained(root, manifestPath, fs, "plugin manifest");
|
|
1391
|
+
const manifest = parseManifest(fs, manifestPath);
|
|
1392
|
+
if (typeof manifest.name !== "string" || manifest.name.trim() === "") throw new Error(`error: manifest "${manifestPath}" must contain a non-empty name`);
|
|
1393
|
+
assertMarketplaceName(manifest.name, "plugin name");
|
|
1394
|
+
if (names.has(manifest.name)) throw new Error(`error: duplicate plugin name "${manifest.name}"`);
|
|
1395
|
+
names.add(manifest.name);
|
|
1396
|
+
const source = `./${path.relative(root, pluginRoot).split(path.sep).join("/")}`;
|
|
1397
|
+
plugins.push({
|
|
1398
|
+
name: manifest.name,
|
|
1399
|
+
source,
|
|
1400
|
+
metadata: manifest
|
|
1401
|
+
});
|
|
1402
|
+
}
|
|
1403
|
+
return plugins.sort((a, b) => a.name.localeCompare(b.name));
|
|
1404
|
+
}
|
|
1405
|
+
function assertCodexPluginVersions(plugins) {
|
|
1406
|
+
for (const plugin of plugins) if (typeof plugin.metadata.version !== "string" || plugin.metadata.version.trim() === "") throw new Error(`error: canonical plugin manifest for Codex entry "${plugin.name}" requires a version`);
|
|
1407
|
+
}
|
|
1408
|
+
function writeArtifacts(fs, artifacts, root) {
|
|
1409
|
+
const changed = artifacts.filter((artifact) => !sameArtifact(fs, path.join(root, artifact.path), artifact.content));
|
|
1410
|
+
for (const artifact of changed) fs.writeAtomically(path.join(root, artifact.path), artifact.content);
|
|
1411
|
+
}
|
|
1412
|
+
function selectedTargets(targets) {
|
|
1413
|
+
return targets && targets.length > 0 ? [...new Set(targets)] : [
|
|
1414
|
+
"claude",
|
|
1415
|
+
"codex",
|
|
1416
|
+
"copilot"
|
|
1417
|
+
];
|
|
1418
|
+
}
|
|
1419
|
+
function sameArtifact(fs, file, content) {
|
|
1420
|
+
if (!fs.exists(file)) return false;
|
|
1421
|
+
const existing = fs.read(file);
|
|
1422
|
+
if (!file.endsWith(".json")) return existing === content;
|
|
1423
|
+
try {
|
|
1424
|
+
return JSON.stringify(normalizeJson(JSON.parse(existing))) === JSON.stringify(normalizeJson(JSON.parse(content)));
|
|
1425
|
+
} catch {
|
|
1426
|
+
return false;
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1429
|
+
/** JSON object key order and whitespace do not change a catalog's meaning; array order does. */
|
|
1430
|
+
function normalizeJson(value) {
|
|
1431
|
+
if (Array.isArray(value)) return value.map(normalizeJson);
|
|
1432
|
+
if (typeof value !== "object" || value === null) return value;
|
|
1433
|
+
const record = value;
|
|
1434
|
+
return Object.fromEntries(Object.keys(record).sort().map((key) => [key, normalizeJson(record[key])]));
|
|
1435
|
+
}
|
|
1436
|
+
function initializeMarketplace(rootInput, opts = {}, fs = realMarketplaceFs) {
|
|
1437
|
+
const root = path.resolve(rootInput);
|
|
1438
|
+
const metadata = deriveMetadata(root, fs, opts);
|
|
1439
|
+
const plugins = discoverPlugins(root, fs, opts.scanDirs);
|
|
1440
|
+
const targets = selectedTargets(opts.targets);
|
|
1441
|
+
if (targets.includes("codex")) assertCodexPluginVersions(plugins);
|
|
1442
|
+
const planned = targets.map((target) => ({
|
|
1443
|
+
target,
|
|
1444
|
+
artifacts: serializeTarget(target, metadata, plugins)
|
|
1445
|
+
}));
|
|
1446
|
+
for (const { artifacts } of planned) for (const artifact of artifacts) assertContained(root, path.join(root, artifact.path), fs, "selected artifact");
|
|
1447
|
+
const conflicts = [];
|
|
1448
|
+
for (const entry of planned) for (const artifact of entry.artifacts) {
|
|
1449
|
+
const output = path.join(root, artifact.path);
|
|
1450
|
+
if (fs.exists(output) && !sameArtifact(fs, output, artifact.content) && !opts.force) conflicts.push(artifact.path);
|
|
1451
|
+
}
|
|
1452
|
+
if (conflicts.length > 0) throw new Error(`error: generated artifact differs: ${conflicts.join(", ")}; rerun with --force to replace selected artifacts`);
|
|
1453
|
+
const results = planned.map(({ target, artifacts }) => {
|
|
1454
|
+
if (plugins.length === 0) return {
|
|
1455
|
+
target,
|
|
1456
|
+
status: "empty",
|
|
1457
|
+
paths: [],
|
|
1458
|
+
plugins: [],
|
|
1459
|
+
reason: "no plugins discovered"
|
|
1460
|
+
};
|
|
1461
|
+
const unchanged = artifacts.every((artifact) => sameArtifact(fs, path.join(root, artifact.path), artifact.content));
|
|
1462
|
+
return {
|
|
1463
|
+
target,
|
|
1464
|
+
status: opts.dryRun ? "planned" : unchanged ? "unchanged" : "generated",
|
|
1465
|
+
paths: artifacts.map((artifact) => artifact.path),
|
|
1466
|
+
plugins: plugins.map((plugin) => plugin.name)
|
|
1467
|
+
};
|
|
1468
|
+
});
|
|
1469
|
+
if (!opts.dryRun && plugins.length > 0) writeArtifacts(fs, planned.flatMap((entry) => entry.artifacts), root);
|
|
1470
|
+
if (!opts.targets || opts.targets.length === 0) results.push({
|
|
1471
|
+
target: "cursor",
|
|
1472
|
+
status: "skipped-default",
|
|
1473
|
+
paths: [],
|
|
1474
|
+
plugins: [],
|
|
1475
|
+
reason: "Cursor requires an explicit submission scaffold"
|
|
1476
|
+
});
|
|
1477
|
+
return results;
|
|
1478
|
+
}
|
|
1479
|
+
//#endregion
|
|
1480
|
+
//#region src/marketplace/cli.ts
|
|
1481
|
+
function targetsFromOptions(opts) {
|
|
1482
|
+
const targets = [
|
|
1483
|
+
"claude",
|
|
1484
|
+
"codex",
|
|
1485
|
+
"copilot",
|
|
1486
|
+
"cursor"
|
|
1487
|
+
].filter((target) => opts[target]);
|
|
1488
|
+
return targets.length > 0 ? targets : void 0;
|
|
1489
|
+
}
|
|
1490
|
+
function initCommand() {
|
|
1491
|
+
return new Command("init").description("Generate local marketplace metadata without publishing or provisioning").option("--claude", "Generate the Claude marketplace catalog").option("--codex", "Generate the Codex marketplace catalog").option("--copilot", "Generate the Copilot marketplace catalog").option("--cursor", "Generate the Cursor submission scaffold").option("--plugin-scan-dir <dir>", "Directory beneath --root to scan for root-level plugin.json files (repeatable)", (value, previous = []) => [...previous, value]).option("--name <name>", "Override the marketplace name").option("--owner <name>", "Override the marketplace owner").option("--dry-run", "Preview artifacts without writing them").option("--force", "Replace differing artifacts for selected targets").option("--format <format>", "Output format: toon or json (default: toon)").addOption(new Option("--json").hideHelp()).addOption(ROOT_OPTION).addHelpText("after", "\nExample:\n $ universal-plugin marketplace init --root .\n").action((opts) => {
|
|
1492
|
+
try {
|
|
1493
|
+
if (opts.format !== void 0 && opts.format !== "toon" && opts.format !== "json") throw new Error("error: --format must be \"toon\" or \"json\"");
|
|
1494
|
+
const results = initializeMarketplace(resolveRoot(opts.root), {
|
|
1495
|
+
targets: targetsFromOptions(opts),
|
|
1496
|
+
scanDirs: opts.pluginScanDir,
|
|
1497
|
+
name: opts.name,
|
|
1498
|
+
owner: opts.owner,
|
|
1499
|
+
dryRun: opts.dryRun,
|
|
1500
|
+
force: opts.force
|
|
1501
|
+
});
|
|
1502
|
+
output(results, () => printTable(results, [
|
|
1503
|
+
{
|
|
1504
|
+
label: "target",
|
|
1505
|
+
get: (row) => row.target
|
|
1506
|
+
},
|
|
1507
|
+
{
|
|
1508
|
+
label: "status",
|
|
1509
|
+
get: (row) => row.status
|
|
1510
|
+
},
|
|
1511
|
+
{
|
|
1512
|
+
label: "paths",
|
|
1513
|
+
get: (row) => row.paths.join(", ") || "-"
|
|
1514
|
+
},
|
|
1515
|
+
{
|
|
1516
|
+
label: "plugins",
|
|
1517
|
+
get: (row) => row.plugins.join(", ") || "-"
|
|
1518
|
+
}
|
|
1519
|
+
]));
|
|
1520
|
+
process.stderr.write("Generated repository metadata only; no marketplace publication, registration, installation, authentication, or provisioning occurred.\n");
|
|
1521
|
+
} catch (err) {
|
|
1522
|
+
process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
|
|
1523
|
+
process.exitCode = 1;
|
|
1524
|
+
}
|
|
1525
|
+
});
|
|
1526
|
+
}
|
|
1527
|
+
function marketplaceCommand() {
|
|
1528
|
+
return new Command("marketplace").description("Generate repository-local marketplace metadata").addCommand(initCommand());
|
|
1529
|
+
}
|
|
1530
|
+
//#endregion
|
|
810
1531
|
//#region src/vendor-registry/vendor-registry.ts
|
|
811
1532
|
function lookupVendor(registry, vendorId) {
|
|
812
1533
|
return registry[vendorId] ?? null;
|
|
@@ -1038,32 +1759,87 @@ function prepareCommand() {
|
|
|
1038
1759
|
});
|
|
1039
1760
|
}
|
|
1040
1761
|
//#endregion
|
|
1041
|
-
//#region src/
|
|
1042
|
-
const
|
|
1762
|
+
//#region src/version/fs.ts
|
|
1763
|
+
const realJsonIo = {
|
|
1043
1764
|
exists: (p) => fsNode.existsSync(p),
|
|
1044
1765
|
read: (p) => fsNode.readFileSync(p, "utf8"),
|
|
1045
1766
|
write: (p, content) => fsNode.writeFileSync(p, content)
|
|
1046
1767
|
};
|
|
1768
|
+
/** Reads `packagePath` from `.agents/universal-plugin.json`. Absent file, absent key, or a
|
|
1769
|
+
* non-string value all mean "this plugin declares no npm package" — the manifest is then the only
|
|
1770
|
+
* authored file. (A *declared* path whose `package.json` is missing is a different case, and the
|
|
1771
|
+
* domain rejects it.) */
|
|
1772
|
+
function readPackagePath(root, io) {
|
|
1773
|
+
const configPath = path.join(root, ".agents", "universal-plugin.json");
|
|
1774
|
+
if (!io.exists(configPath)) return null;
|
|
1775
|
+
const packagePath = JSON.parse(io.read(configPath))["packagePath"];
|
|
1776
|
+
return typeof packagePath === "string" && packagePath.length > 0 ? packagePath : null;
|
|
1777
|
+
}
|
|
1778
|
+
/** Writes `value` over `filePath`, keeping whatever indentation that file already used. */
|
|
1779
|
+
function writeJson(io, filePath, value) {
|
|
1780
|
+
const indent = io.exists(filePath) ? detectIndent(io.read(filePath)) : " ";
|
|
1781
|
+
io.write(filePath, `${JSON.stringify(value, null, indent)}\n`);
|
|
1782
|
+
}
|
|
1783
|
+
/** The one applier. Writes every authored file the plan names — and nothing else: the derived
|
|
1784
|
+
* vendor manifests are re-derived by calling `plugin build`, which owns them. */
|
|
1785
|
+
function applyVersionPlan(root, plan, io) {
|
|
1786
|
+
writeJson(io, path.join(root, "plugin.json"), plan.manifest);
|
|
1787
|
+
if (plan.packageJson) writeJson(io, path.join(root, plan.packageJson.path), plan.packageJson.content);
|
|
1788
|
+
}
|
|
1789
|
+
const realVersionFs = {
|
|
1790
|
+
gather(root) {
|
|
1791
|
+
const manifestPath = path.join(root, "plugin.json");
|
|
1792
|
+
const manifestExists = realJsonIo.exists(manifestPath);
|
|
1793
|
+
const manifest = manifestExists ? JSON.parse(realJsonIo.read(manifestPath)) : null;
|
|
1794
|
+
const packagePath = readPackagePath(root, realJsonIo);
|
|
1795
|
+
const pkgJsonPath = packagePath === null ? null : path.join(root, packagePath, "package.json");
|
|
1796
|
+
return {
|
|
1797
|
+
manifestExists,
|
|
1798
|
+
manifest,
|
|
1799
|
+
packagePath,
|
|
1800
|
+
packageJson: pkgJsonPath !== null && realJsonIo.exists(pkgJsonPath) ? JSON.parse(realJsonIo.read(pkgJsonPath)) : null
|
|
1801
|
+
};
|
|
1802
|
+
},
|
|
1803
|
+
apply(root, plan) {
|
|
1804
|
+
applyVersionPlan(root, plan, realJsonIo);
|
|
1805
|
+
}
|
|
1806
|
+
};
|
|
1807
|
+
//#endregion
|
|
1808
|
+
//#region src/publish/fs.ts
|
|
1809
|
+
const realSyncVersionFs = realJsonIo;
|
|
1047
1810
|
//#endregion
|
|
1048
1811
|
//#region src/publish/sync-version.ts
|
|
1812
|
+
/** The changesets-driven direction of the version flow: the number is decided by
|
|
1813
|
+
* `changeset version` in `<packagePath>/package.json`, and this copies it into the canonical
|
|
1814
|
+
* manifest. `plugin version` is the other direction — the number decided here, flowing out to
|
|
1815
|
+
* `package.json`. The two differ **only** in where the version comes from, so they share
|
|
1816
|
+
* `applyVersionPlan` and cannot drift; `package.json` is the source here, never rewritten. */
|
|
1049
1817
|
function syncVersion(root, syncFs) {
|
|
1050
|
-
const manifestPath = path.join(root, "
|
|
1051
|
-
if (!syncFs.exists(manifestPath)) throw new Error(`No
|
|
1818
|
+
const manifestPath = path.join(root, "plugin.json");
|
|
1819
|
+
if (!syncFs.exists(manifestPath)) throw new Error(`No plugin.json found at ${root}`);
|
|
1052
1820
|
const agentsConfigPath = path.join(root, ".agents", "universal-plugin.json");
|
|
1053
1821
|
const packagePath = (syncFs.exists(agentsConfigPath) ? JSON.parse(syncFs.read(agentsConfigPath)) : {})["packagePath"];
|
|
1054
1822
|
if (!packagePath || typeof packagePath !== "string") throw new Error("packagePath is required in .agents/universal-plugin.json");
|
|
1055
|
-
const
|
|
1056
|
-
const manifest = JSON.parse(raw);
|
|
1823
|
+
const manifest = JSON.parse(syncFs.read(manifestPath));
|
|
1057
1824
|
const pkgJsonPath = path.join(root, packagePath, "package.json");
|
|
1058
1825
|
if (!syncFs.exists(pkgJsonPath)) throw new Error(`No package.json found at ${packagePath}`);
|
|
1059
1826
|
const version = JSON.parse(syncFs.read(pkgJsonPath))["version"];
|
|
1060
1827
|
if (!version || typeof version !== "string") throw new Error(`No version found in ${packagePath}/package.json`);
|
|
1061
|
-
const
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
version
|
|
1065
|
-
|
|
1066
|
-
|
|
1828
|
+
const current = manifest["version"];
|
|
1829
|
+
applyVersionPlan(root, {
|
|
1830
|
+
from: typeof current === "string" ? current : null,
|
|
1831
|
+
to: version,
|
|
1832
|
+
manifest: {
|
|
1833
|
+
...manifest,
|
|
1834
|
+
version
|
|
1835
|
+
},
|
|
1836
|
+
packageJson: null,
|
|
1837
|
+
rows: [{
|
|
1838
|
+
path: "plugin.json",
|
|
1839
|
+
action: "updated"
|
|
1840
|
+
}],
|
|
1841
|
+
summary: { updated: 1 }
|
|
1842
|
+
}, syncFs);
|
|
1067
1843
|
return {
|
|
1068
1844
|
version,
|
|
1069
1845
|
manifestPath
|
|
@@ -1073,7 +1849,7 @@ function syncVersion(root, syncFs) {
|
|
|
1073
1849
|
//#region src/publish/cli.ts
|
|
1074
1850
|
function publishCommand() {
|
|
1075
1851
|
const cmd = new Command("publish").description("Prepare plugin for publishing").helpCommand(false);
|
|
1076
|
-
cmd.command("sync-version").description("Sync version from packagePath/package.json into
|
|
1852
|
+
cmd.command("sync-version").description("Sync version from packagePath/package.json into plugin.json").addOption(ROOT_OPTION).action((opts) => {
|
|
1077
1853
|
try {
|
|
1078
1854
|
const result = syncVersion(resolveRoot(opts.root), realSyncVersionFs);
|
|
1079
1855
|
output(result, () => {
|
|
@@ -1232,18 +2008,173 @@ function syncCommand() {
|
|
|
1232
2008
|
return cmd;
|
|
1233
2009
|
}
|
|
1234
2010
|
//#endregion
|
|
2011
|
+
//#region src/version/version.ts
|
|
2012
|
+
/** Pure domain for `plugin version` — moving a plugin's version.
|
|
2013
|
+
*
|
|
2014
|
+
* No I/O: the caller gathers the current filesystem state, calls `planVersion`, and applies the
|
|
2015
|
+
* returned plan. `planVersion` owns the rules — the guard order (every guard resolves before the
|
|
2016
|
+
* first write, so a failing run leaves the tree untouched), the bump arithmetic, and the fact that
|
|
2017
|
+
* only the **authored** files are planned.
|
|
2018
|
+
*
|
|
2019
|
+
* A version lives in five places, but only two are authored: the canonical `plugin.json` and, when
|
|
2020
|
+
* the project declares a `packagePath`, that `package.json`. The per-vendor manifests
|
|
2021
|
+
* (`plugin build`), the local marketplace catalogs (`marketplace init`), and the `npx`/`upx`
|
|
2022
|
+
* pins in `skills/**` (`plugin bundle`) are all **derived** — re-derived by calling the command
|
|
2023
|
+
* that owns them, never written a second time here. */
|
|
2024
|
+
const RELEASE_TYPES = [
|
|
2025
|
+
"major",
|
|
2026
|
+
"minor",
|
|
2027
|
+
"patch",
|
|
2028
|
+
"premajor",
|
|
2029
|
+
"preminor",
|
|
2030
|
+
"prepatch",
|
|
2031
|
+
"prerelease"
|
|
2032
|
+
];
|
|
2033
|
+
function isReleaseType(value) {
|
|
2034
|
+
return RELEASE_TYPES.includes(value);
|
|
2035
|
+
}
|
|
2036
|
+
/** Reads the canonical manifest's current version, or `null` when it carries none. */
|
|
2037
|
+
function currentVersion(manifest) {
|
|
2038
|
+
const version = manifest?.["version"];
|
|
2039
|
+
return typeof version === "string" && version.length > 0 ? version : null;
|
|
2040
|
+
}
|
|
2041
|
+
/** Resolves the bump argument against the current version. An explicit version is used as given;
|
|
2042
|
+
* a release type is applied to the current one, which must therefore exist. */
|
|
2043
|
+
function resolveTarget(current, opts) {
|
|
2044
|
+
if (semver.valid(opts.bump)) return semver.valid(opts.bump);
|
|
2045
|
+
if (!isReleaseType(opts.bump)) throw new Error(`Unknown version or release type "${opts.bump}" — expected an explicit version (1.4.0) or one of: ${RELEASE_TYPES.join(", ")}`);
|
|
2046
|
+
if (current === null) throw new Error("plugin.json has no version to bump from — pass an explicit version (e.g. 0.1.0) to set the first one");
|
|
2047
|
+
const next = opts.preid === void 0 ? semver.inc(current, opts.bump) : semver.inc(current, opts.bump, opts.preid);
|
|
2048
|
+
if (next === null) throw new Error(`Current version "${current}" in plugin.json is not valid semver`);
|
|
2049
|
+
return next;
|
|
2050
|
+
}
|
|
2051
|
+
/** Plans the version move. Throws on any guard failure before returning a plan, so the caller
|
|
2052
|
+
* writes nothing on a guard trip. */
|
|
2053
|
+
function planVersion(state, opts) {
|
|
2054
|
+
if (!state.manifestExists || state.manifest === null) throw new Error("No plugin.json found at the project root");
|
|
2055
|
+
const from = currentVersion(state.manifest);
|
|
2056
|
+
const to = resolveTarget(from, opts);
|
|
2057
|
+
if (from !== null && !semver.gt(to, from) && !opts.force) throw new Error(`Target version "${to}" does not advance on the current version "${from}" — pass --force to set it anyway`);
|
|
2058
|
+
if (state.packagePath !== null && state.packageJson === null) throw new Error(`No package.json found at packagePath "${state.packagePath}"`);
|
|
2059
|
+
const rows = [{
|
|
2060
|
+
path: "plugin.json",
|
|
2061
|
+
action: "updated"
|
|
2062
|
+
}];
|
|
2063
|
+
let packageJson = null;
|
|
2064
|
+
if (state.packagePath !== null && state.packageJson !== null) {
|
|
2065
|
+
const relPath = joinRelative(state.packagePath, "package.json");
|
|
2066
|
+
packageJson = {
|
|
2067
|
+
path: relPath,
|
|
2068
|
+
content: {
|
|
2069
|
+
...state.packageJson,
|
|
2070
|
+
version: to
|
|
2071
|
+
}
|
|
2072
|
+
};
|
|
2073
|
+
rows.push({
|
|
2074
|
+
path: relPath,
|
|
2075
|
+
action: "updated"
|
|
2076
|
+
});
|
|
2077
|
+
}
|
|
2078
|
+
return {
|
|
2079
|
+
from,
|
|
2080
|
+
to,
|
|
2081
|
+
manifest: {
|
|
2082
|
+
...state.manifest,
|
|
2083
|
+
version: to
|
|
2084
|
+
},
|
|
2085
|
+
packageJson,
|
|
2086
|
+
rows,
|
|
2087
|
+
summary: { updated: rows.length }
|
|
2088
|
+
};
|
|
2089
|
+
}
|
|
2090
|
+
/** Root-relative POSIX join — the domain reports paths as the user typed `packagePath`, so the
|
|
2091
|
+
* result stays stable across platforms and readable in the TOON output. */
|
|
2092
|
+
function joinRelative(dir, file) {
|
|
2093
|
+
const normalized = dir.replace(/\\/g, "/").replace(/\/+$/, "");
|
|
2094
|
+
return normalized === "" || normalized === "." ? file : `${normalized}/${file}`;
|
|
2095
|
+
}
|
|
2096
|
+
//#endregion
|
|
2097
|
+
//#region src/version/cli.ts
|
|
2098
|
+
function versionCommand(deps = { fs: realVersionFs }) {
|
|
2099
|
+
const cmd = new Command("version").description("Move the plugin version across every file that carries one").argument("<bump>", `Release type (${RELEASE_TYPES.join(", ")}) or an explicit version (1.4.0)`);
|
|
2100
|
+
cmd.option("--preid <id>", "Prerelease identifier for the pre* release types").option("--force", "Allow a version that does not advance on the current one").option("--no-build", "Skip re-deriving the vendor manifests").option("--dry-run", "Print what would be written without writing").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 version minor\n").action((bump, opts) => {
|
|
2101
|
+
try {
|
|
2102
|
+
const root = resolveRoot(opts.root);
|
|
2103
|
+
const plan = planVersion(deps.fs.gather(root), {
|
|
2104
|
+
bump,
|
|
2105
|
+
preid: opts.preid,
|
|
2106
|
+
force: Boolean(opts.force)
|
|
2107
|
+
});
|
|
2108
|
+
const dryRun = Boolean(opts.dryRun);
|
|
2109
|
+
const rows = plan.rows.map((r) => ({
|
|
2110
|
+
path: r.path,
|
|
2111
|
+
action: dryRun ? "planned" : r.action
|
|
2112
|
+
}));
|
|
2113
|
+
if (!dryRun) {
|
|
2114
|
+
deps.fs.apply(root, plan);
|
|
2115
|
+
if (opts.build !== false) {
|
|
2116
|
+
const result = buildPlugin(root, {});
|
|
2117
|
+
for (const warning of result.warnings) process.stderr.write(`warn: ${warning}\n`);
|
|
2118
|
+
for (const written of result.written) rows.push({
|
|
2119
|
+
path: path.relative(root, written).split(path.sep).join("/"),
|
|
2120
|
+
action: "derived"
|
|
2121
|
+
});
|
|
2122
|
+
}
|
|
2123
|
+
}
|
|
2124
|
+
const jsonResult = {
|
|
2125
|
+
from: plan.from,
|
|
2126
|
+
to: plan.to,
|
|
2127
|
+
dryRun,
|
|
2128
|
+
written: dryRun ? [] : rows.map((r) => r.path),
|
|
2129
|
+
planned: rows.filter((r) => r.action === "planned").map((r) => r.path),
|
|
2130
|
+
summary: {
|
|
2131
|
+
updated: plan.summary.updated,
|
|
2132
|
+
derived: rows.filter((r) => r.action === "derived").length
|
|
2133
|
+
}
|
|
2134
|
+
};
|
|
2135
|
+
output(jsonResult, () => {
|
|
2136
|
+
printFields({
|
|
2137
|
+
from: plan.from ?? "(none)",
|
|
2138
|
+
to: plan.to
|
|
2139
|
+
});
|
|
2140
|
+
printTable(rows, [{
|
|
2141
|
+
label: "path",
|
|
2142
|
+
get: (r) => r.path
|
|
2143
|
+
}, {
|
|
2144
|
+
label: "action",
|
|
2145
|
+
get: (r) => r.action
|
|
2146
|
+
}]);
|
|
2147
|
+
console.log(dryRun ? `planned ${plan.summary.updated}, updated 0 (dry run)` : `updated ${plan.summary.updated}, derived ${jsonResult.summary.derived}`);
|
|
2148
|
+
});
|
|
2149
|
+
process.stderr.write(nextStep(dryRun, opts.build !== false));
|
|
2150
|
+
} catch (err) {
|
|
2151
|
+
process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
|
|
2152
|
+
process.exit(1);
|
|
2153
|
+
}
|
|
2154
|
+
});
|
|
2155
|
+
return cmd;
|
|
2156
|
+
}
|
|
2157
|
+
function nextStep(dryRun, built) {
|
|
2158
|
+
if (dryRun) return "→ re-run without --dry-run to apply\n";
|
|
2159
|
+
return built ? "→ universal-plugin plugin bundle\n" : "→ universal-plugin plugin build\n";
|
|
2160
|
+
}
|
|
2161
|
+
//#endregion
|
|
1235
2162
|
//#region src/cli.ts
|
|
1236
2163
|
const program = new Command();
|
|
1237
2164
|
program.name("universal-plugin").description("Universal AI agent plugin build tool").version("0.0.0").helpCommand(false);
|
|
1238
2165
|
function pluginCommand() {
|
|
1239
|
-
const cmd = new Command("plugin").description("Author the canonical plugin manifest (build, bundle; validate
|
|
2166
|
+
const cmd = new Command("plugin").description("Author the canonical plugin manifest (build, bundle, init, version; validate planned)");
|
|
1240
2167
|
cmd.addCommand(buildCommand());
|
|
1241
2168
|
cmd.addCommand(bundleCommand());
|
|
2169
|
+
cmd.addCommand(initCommand$1());
|
|
2170
|
+
cmd.addCommand(versionCommand());
|
|
1242
2171
|
return cmd;
|
|
1243
2172
|
}
|
|
1244
2173
|
program.addCommand(pluginCommand());
|
|
1245
2174
|
program.addCommand(cleanCommand());
|
|
2175
|
+
program.addCommand(configCommand());
|
|
1246
2176
|
program.addCommand(governanceCommand());
|
|
2177
|
+
program.addCommand(marketplaceCommand());
|
|
1247
2178
|
program.addCommand(prepareCommand());
|
|
1248
2179
|
program.addCommand(publishCommand());
|
|
1249
2180
|
program.addCommand(syncCommand());
|