universal-plugin 0.2.1 → 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 +1330 -255
- package/dist/run.mjs +271 -0
- package/governances/plugin-design.md +22 -17
- package/governances/slash-invocation.md +30 -0
- package/package.json +14 -5
- 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,6 +5,7 @@ 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");
|
|
@@ -186,141 +187,18 @@ function output(data, readable) {
|
|
|
186
187
|
else readable();
|
|
187
188
|
}
|
|
188
189
|
//#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
|
-
} };
|
|
190
|
+
//#region src/json.ts
|
|
191
|
+
/** Detects indentation style from JSON text. Returns `'\t'` for tabs or a number for space count.
|
|
192
|
+
* Falls back to `'\t'` when no indentation is detected. */
|
|
193
|
+
function detectIndent(json) {
|
|
194
|
+
const match = json.match(/\n([ \t]+)/);
|
|
195
|
+
if (!match) return " ";
|
|
196
|
+
return match[1].startsWith(" ") ? " " : match[1].length;
|
|
321
197
|
}
|
|
322
198
|
//#endregion
|
|
323
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`. */
|
|
324
202
|
const VENDOR_OUTPUT = {
|
|
325
203
|
"claude-code": ".claude-plugin/plugin.json",
|
|
326
204
|
cursor: ".cursor-plugin/plugin.json",
|
|
@@ -328,191 +206,702 @@ const VENDOR_OUTPUT = {
|
|
|
328
206
|
"copilot-cli": "plugin.json"
|
|
329
207
|
};
|
|
330
208
|
const KNOWN_VENDORS = new Set(Object.keys(VENDOR_OUTPUT));
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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] ?? {};
|
|
335
216
|
}
|
|
336
217
|
function readManifest(root) {
|
|
337
|
-
const manifestPath = path.join(root, "
|
|
338
|
-
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}`);
|
|
339
220
|
return JSON.parse(fsNode.readFileSync(manifestPath, "utf8"));
|
|
340
221
|
}
|
|
341
|
-
|
|
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) {
|
|
342
226
|
const errors = [];
|
|
343
227
|
if (!manifest.name) errors.push("name is required");
|
|
344
|
-
|
|
345
|
-
|
|
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");
|
|
346
233
|
return errors;
|
|
347
234
|
}
|
|
348
235
|
function buildPlugin(root, opts = {}) {
|
|
349
|
-
const manifestPath = path.join(root, "
|
|
350
|
-
if (!fsNode.existsSync(manifestPath)) throw new Error(`No
|
|
351
|
-
const indent = detectIndent
|
|
236
|
+
const manifestPath = path.join(root, "plugin.json");
|
|
237
|
+
if (!fsNode.existsSync(manifestPath)) throw new Error(`No plugin.json found at ${root}`);
|
|
238
|
+
const indent = detectIndent(fsNode.readFileSync(manifestPath, "utf8"));
|
|
352
239
|
const manifest = readManifest(root);
|
|
353
|
-
const errors = validateManifest(manifest);
|
|
354
|
-
if (errors.length > 0) throw new Error(`plugin.json validation failed:\n${errors.map((e) => ` - ${e}`).join("\n")}`);
|
|
355
240
|
const warnings = [];
|
|
356
|
-
const
|
|
357
|
-
|
|
241
|
+
const rows = [];
|
|
242
|
+
const uext = universalPluginExtension(manifest);
|
|
243
|
+
const harnesses = uext.harnesses ?? {};
|
|
244
|
+
let vendors = (uext.vendors ?? Object.keys(harnesses)).filter((v) => {
|
|
358
245
|
if (!KNOWN_VENDORS.has(v)) {
|
|
359
|
-
warnings.push(`Unknown vendor "${v}" in
|
|
246
|
+
warnings.push(`Unknown vendor "${v}" in harnesses — skipped`);
|
|
247
|
+
rows.push({
|
|
248
|
+
vendor: v,
|
|
249
|
+
path: "-",
|
|
250
|
+
status: "skipped"
|
|
251
|
+
});
|
|
360
252
|
return false;
|
|
361
253
|
}
|
|
362
254
|
return true;
|
|
363
255
|
});
|
|
364
256
|
if (opts.vendor) {
|
|
365
|
-
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`);
|
|
366
258
|
vendors = [opts.vendor];
|
|
367
259
|
}
|
|
368
260
|
if (vendors.length === 0) {
|
|
369
|
-
warnings.push("No vendors declared in
|
|
261
|
+
warnings.push("No vendors declared in harnesses — nothing to build");
|
|
370
262
|
return {
|
|
371
263
|
vendors: [],
|
|
372
264
|
written: [],
|
|
373
|
-
warnings
|
|
265
|
+
warnings,
|
|
266
|
+
rows,
|
|
267
|
+
summary: summarize(rows)
|
|
374
268
|
};
|
|
375
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")}`);
|
|
376
272
|
const written = [];
|
|
377
|
-
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);
|
|
378
276
|
for (const vendor of vendors) {
|
|
379
|
-
const
|
|
277
|
+
const relPath = VENDOR_OUTPUT[vendor];
|
|
278
|
+
const outputPath = path.join(root, relPath);
|
|
380
279
|
const outputDir = path.dirname(outputPath);
|
|
381
|
-
const vendorFields =
|
|
280
|
+
const vendorFields = harnesses[vendor] ?? {};
|
|
382
281
|
const vendorManifest = {
|
|
383
|
-
...
|
|
282
|
+
...metadata,
|
|
283
|
+
...componentConfig,
|
|
384
284
|
...vendorFields
|
|
385
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
|
+
}
|
|
386
297
|
if (opts.verbose) {
|
|
387
298
|
console.log(`[${vendor}] → ${outputPath}`);
|
|
388
|
-
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})`);
|
|
389
300
|
}
|
|
390
|
-
|
|
391
|
-
if (opts.
|
|
392
|
-
|
|
393
|
-
|
|
301
|
+
try {
|
|
302
|
+
if (!opts.dryRun) {
|
|
303
|
+
if (opts.clean && fsNode.existsSync(outputPath)) fsNode.unlinkSync(outputPath);
|
|
304
|
+
fsNode.mkdirSync(outputDir, { recursive: true });
|
|
305
|
+
fsNode.writeFileSync(outputPath, `${JSON.stringify(vendorManifest, null, indent)}\n`);
|
|
306
|
+
}
|
|
307
|
+
written.push(outputPath);
|
|
308
|
+
writeSkillArtifacts(root, vendor, skills, opts, written, warnings);
|
|
309
|
+
rows.push({
|
|
310
|
+
vendor,
|
|
311
|
+
path: relPath,
|
|
312
|
+
status: "built"
|
|
313
|
+
});
|
|
314
|
+
} catch (err) {
|
|
315
|
+
warnings.push(`Failed to write "${vendor}" → ${relPath}: ${err instanceof Error ? err.message : String(err)}`);
|
|
316
|
+
rows.push({
|
|
317
|
+
vendor,
|
|
318
|
+
path: relPath,
|
|
319
|
+
status: "failed"
|
|
320
|
+
});
|
|
394
321
|
}
|
|
395
|
-
written.push(outputPath);
|
|
396
322
|
}
|
|
397
323
|
return {
|
|
398
324
|
vendors,
|
|
399
325
|
written,
|
|
400
|
-
warnings
|
|
326
|
+
warnings,
|
|
327
|
+
rows,
|
|
328
|
+
summary: summarize(rows)
|
|
401
329
|
};
|
|
402
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
|
+
}
|
|
402
|
+
function summarize(rows) {
|
|
403
|
+
return {
|
|
404
|
+
built: rows.filter((r) => r.status === "built").length,
|
|
405
|
+
skipped: rows.filter((r) => r.status === "skipped").length,
|
|
406
|
+
failed: rows.filter((r) => r.status === "failed").length,
|
|
407
|
+
canonical: rows.filter((r) => r.status === "canonical").length
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
//#endregion
|
|
411
|
+
//#region src/build/cli.ts
|
|
412
|
+
const NEXT_STEP$2 = "→ universal-plugin plugin validate\n";
|
|
413
|
+
function buildCommand() {
|
|
414
|
+
const cmd = new Command("build").description("Generate vendor manifests from plugin.json");
|
|
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) => {
|
|
416
|
+
try {
|
|
417
|
+
const result = buildPlugin(resolveRoot(opts.root), {
|
|
418
|
+
vendor: opts.vendor,
|
|
419
|
+
dryRun: opts.dryRun,
|
|
420
|
+
verbose: opts.verbose,
|
|
421
|
+
clean: opts.clean
|
|
422
|
+
});
|
|
423
|
+
for (const warning of result.warnings) process.stderr.write(`warn: ${warning}\n`);
|
|
424
|
+
const { built, skipped, failed, canonical } = result.summary;
|
|
425
|
+
output({
|
|
426
|
+
built: result.rows.filter((r) => r.status === "built"),
|
|
427
|
+
skipped: result.rows.filter((r) => r.status === "skipped"),
|
|
428
|
+
failed: result.rows.filter((r) => r.status === "failed"),
|
|
429
|
+
canonical: result.rows.filter((r) => r.status === "canonical"),
|
|
430
|
+
summary: result.summary,
|
|
431
|
+
warnings: result.warnings
|
|
432
|
+
}, () => {
|
|
433
|
+
if (result.rows.length > 0) printTable(result.rows, [
|
|
434
|
+
{
|
|
435
|
+
label: "vendor",
|
|
436
|
+
get: (r) => r.vendor
|
|
437
|
+
},
|
|
438
|
+
{
|
|
439
|
+
label: "path",
|
|
440
|
+
get: (r) => r.path
|
|
441
|
+
},
|
|
442
|
+
{
|
|
443
|
+
label: "status",
|
|
444
|
+
get: (r) => r.status
|
|
445
|
+
}
|
|
446
|
+
]);
|
|
447
|
+
const counts = `built ${built}, skipped ${skipped}, failed ${failed}`;
|
|
448
|
+
console.log(canonical > 0 ? `${counts}, served by plugin.json ${canonical}` : counts);
|
|
449
|
+
});
|
|
450
|
+
process.stderr.write(NEXT_STEP$2);
|
|
451
|
+
if (failed > 0) process.exitCode = 1;
|
|
452
|
+
} catch (err) {
|
|
453
|
+
process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
|
|
454
|
+
process.exit(1);
|
|
455
|
+
}
|
|
456
|
+
});
|
|
457
|
+
return cmd;
|
|
458
|
+
}
|
|
459
|
+
//#endregion
|
|
460
|
+
//#region src/pin/fs.ts
|
|
461
|
+
const TEXT_EXTENSIONS = new Set([
|
|
462
|
+
".md",
|
|
463
|
+
".mdx",
|
|
464
|
+
".markdown",
|
|
465
|
+
".json",
|
|
466
|
+
".yaml",
|
|
467
|
+
".yml",
|
|
468
|
+
".txt"
|
|
469
|
+
]);
|
|
470
|
+
/** Resolves the plugin's skills directory: the manifest's `skills` field under `root`,
|
|
471
|
+
* defaulting to `<root>/skills/`. */
|
|
472
|
+
function resolveSkillsDir(root, manifestSkills) {
|
|
473
|
+
return path.join(root, manifestSkills ?? "skills/");
|
|
474
|
+
}
|
|
475
|
+
function walk(dir) {
|
|
476
|
+
if (!fsNode.existsSync(dir)) return [];
|
|
477
|
+
const files = [];
|
|
478
|
+
for (const entry of fsNode.readdirSync(dir, { withFileTypes: true })) {
|
|
479
|
+
const entryPath = path.join(dir, entry.name);
|
|
480
|
+
if (entry.isDirectory()) files.push(...walk(entryPath));
|
|
481
|
+
else if (entry.isFile() && TEXT_EXTENSIONS.has(path.extname(entry.name))) files.push(entryPath);
|
|
482
|
+
}
|
|
483
|
+
return files;
|
|
484
|
+
}
|
|
485
|
+
function realPinFs(skillsDir) {
|
|
486
|
+
return {
|
|
487
|
+
listSkillFiles: () => walk(skillsDir),
|
|
488
|
+
readFile: (p) => fsNode.readFileSync(p, "utf8"),
|
|
489
|
+
writeFile: (p, c) => fsNode.writeFileSync(p, c, "utf8")
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
//#endregion
|
|
493
|
+
//#region src/pin/pin.ts
|
|
494
|
+
const PIN_PATTERN = /(npx|upx)\s+(?:--yes\s+|-y\s+)?([@a-z0-9/._-]+)@(\S+)/g;
|
|
495
|
+
/** Strips a trailing backtick, quote, or paren that isn't part of the version token. */
|
|
496
|
+
function stripTrailing(raw) {
|
|
497
|
+
return raw.replace(/[`'")]+$/, "");
|
|
498
|
+
}
|
|
499
|
+
function extractPins(text) {
|
|
500
|
+
const pins = [];
|
|
501
|
+
for (const match of text.matchAll(PIN_PATTERN)) {
|
|
502
|
+
const runner = match[1];
|
|
503
|
+
const pkg = match[2];
|
|
504
|
+
const current = match[3];
|
|
505
|
+
if (!runner || !pkg || !current) continue;
|
|
506
|
+
pins.push({
|
|
507
|
+
pkg,
|
|
508
|
+
current: stripTrailing(current),
|
|
509
|
+
file: "",
|
|
510
|
+
runner
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
return pins;
|
|
514
|
+
}
|
|
515
|
+
//#endregion
|
|
516
|
+
//#region src/bundle/bundle.ts
|
|
403
517
|
function escapeRegExp(value) {
|
|
404
518
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
405
519
|
}
|
|
406
|
-
/** Rewrites every `npx <pkg>@<anything>`
|
|
407
|
-
*
|
|
408
|
-
*
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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}`);
|
|
528
|
+
}
|
|
529
|
+
const FRONTMATTER_PATTERN = /^---\r?\n([\s\S]*?)\r?\n---/;
|
|
530
|
+
const PIN_EXEMPT_PATTERN = /(^|\n)\s*pin-exempt:\s*true\s*(\n|$)/;
|
|
531
|
+
/** True when a SKILL.md's frontmatter declares `metadata.pin-exempt: true` — its version strings
|
|
532
|
+
* are documentation/illustration, never rewritten by `bundle`. Nesting under `metadata` is not
|
|
533
|
+
* enforced structurally (no YAML dependency); the key's presence anywhere in the frontmatter block
|
|
534
|
+
* is the marker. */
|
|
535
|
+
function isPinExempt(skillMdContent) {
|
|
536
|
+
const match = skillMdContent.match(FRONTMATTER_PATTERN);
|
|
537
|
+
if (!match) return false;
|
|
538
|
+
return PIN_EXEMPT_PATTERN.test(match[1]);
|
|
539
|
+
}
|
|
540
|
+
function dirOf(filePath) {
|
|
541
|
+
const idx = Math.max(filePath.lastIndexOf("/"), filePath.lastIndexOf("\\"));
|
|
542
|
+
return idx === -1 ? "" : filePath.slice(0, idx);
|
|
543
|
+
}
|
|
544
|
+
function isUnderExemptDir(filePath, exemptDirs) {
|
|
545
|
+
for (const dir of exemptDirs) if (filePath.startsWith(`${dir}/`) || filePath.startsWith(`${dir}\\`)) return true;
|
|
546
|
+
return false;
|
|
547
|
+
}
|
|
548
|
+
/** Scans the plugin's skills for `npx <pkg>@<pin>` references and, for each in-scope package that
|
|
549
|
+
* resolves against the workspace, rewrites every occurrence to that package's local version. A
|
|
550
|
+
* pin-exempt skill (and every file under its directory) is excluded entirely — never scanned,
|
|
551
|
+
* never rewritten, never reported. */
|
|
552
|
+
function bundlePins(pinFs, versionSource, opts = {}) {
|
|
417
553
|
const files = pinFs.listSkillFiles();
|
|
418
554
|
const contents = /* @__PURE__ */ new Map();
|
|
419
|
-
const
|
|
555
|
+
for (const file of files) contents.set(file, pinFs.readFile(file));
|
|
556
|
+
const exemptDirs = /* @__PURE__ */ new Set();
|
|
420
557
|
for (const file of files) {
|
|
421
|
-
|
|
422
|
-
contents.
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
558
|
+
if (!file.endsWith("/SKILL.md") && !file.endsWith("\\SKILL.md")) continue;
|
|
559
|
+
if (isPinExempt(contents.get(file))) exemptDirs.add(dirOf(file));
|
|
560
|
+
}
|
|
561
|
+
const inScope = files.filter((file) => !isUnderExemptDir(file, exemptDirs));
|
|
562
|
+
const currentsByPkg = /* @__PURE__ */ new Map();
|
|
563
|
+
for (const file of inScope) for (const pin of extractPins(contents.get(file))) {
|
|
564
|
+
const list = currentsByPkg.get(pin.pkg) ?? [];
|
|
565
|
+
list.push(pin.current);
|
|
566
|
+
currentsByPkg.set(pin.pkg, list);
|
|
428
567
|
}
|
|
429
|
-
const
|
|
430
|
-
const
|
|
431
|
-
const
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
if (!available) {
|
|
437
|
-
results.warnings.push(`registry lookup for "${pkg}" failed — skipped`);
|
|
438
|
-
results.push({
|
|
568
|
+
const warnings = [];
|
|
569
|
+
const pins = [];
|
|
570
|
+
for (const [pkg, currents] of currentsByPkg) {
|
|
571
|
+
const current = currents[0];
|
|
572
|
+
const lookup = versionSource.resolve(pkg);
|
|
573
|
+
if (!lookup.inWorkspace) {
|
|
574
|
+
pins.push({
|
|
439
575
|
package: pkg,
|
|
440
|
-
current
|
|
441
|
-
resolved:
|
|
576
|
+
current,
|
|
577
|
+
resolved: current,
|
|
442
578
|
status: "skipped"
|
|
443
579
|
});
|
|
444
580
|
continue;
|
|
445
581
|
}
|
|
446
|
-
|
|
582
|
+
if (lookup.version === void 0) {
|
|
583
|
+
warnings.push(`workspace package "${pkg}" has no readable package.json version — skipped`);
|
|
584
|
+
pins.push({
|
|
585
|
+
package: pkg,
|
|
586
|
+
current,
|
|
587
|
+
resolved: current,
|
|
588
|
+
status: "skipped"
|
|
589
|
+
});
|
|
590
|
+
continue;
|
|
591
|
+
}
|
|
592
|
+
const target = lookup.version;
|
|
447
593
|
let changed = false;
|
|
448
|
-
for (const file of
|
|
594
|
+
for (const file of inScope) {
|
|
449
595
|
const content = contents.get(file);
|
|
450
|
-
const rewritten = rewritePin(content, pkg,
|
|
596
|
+
const rewritten = rewritePin(content, pkg, target, opts.runner);
|
|
451
597
|
if (rewritten !== content) {
|
|
452
598
|
changed = true;
|
|
453
599
|
contents.set(file, rewritten);
|
|
454
600
|
if (!opts.dryRun) pinFs.writeFile(file, rewritten);
|
|
455
601
|
}
|
|
456
602
|
}
|
|
457
|
-
|
|
603
|
+
pins.push({
|
|
458
604
|
package: pkg,
|
|
459
|
-
current
|
|
460
|
-
resolved:
|
|
461
|
-
status: changed ? "
|
|
605
|
+
current,
|
|
606
|
+
resolved: target,
|
|
607
|
+
status: changed ? "pinned" : "unchanged"
|
|
462
608
|
});
|
|
463
609
|
}
|
|
464
|
-
return
|
|
610
|
+
return {
|
|
611
|
+
pins,
|
|
612
|
+
warnings
|
|
613
|
+
};
|
|
465
614
|
}
|
|
466
615
|
//#endregion
|
|
467
|
-
//#region src/
|
|
468
|
-
const
|
|
469
|
-
|
|
470
|
-
|
|
616
|
+
//#region src/bundle/fs.ts
|
|
617
|
+
const DEFAULT_GLOBS = ["packages/*"];
|
|
618
|
+
/** Walks up from `start` looking for `pnpm-workspace.yaml`; falls back to `start` itself when no
|
|
619
|
+
* ancestor declares one (the workspace glob then resolves relative to `start`). */
|
|
620
|
+
function findMonorepoRoot(start) {
|
|
621
|
+
let dir = start;
|
|
622
|
+
for (;;) {
|
|
623
|
+
if (fsNode.existsSync(path.join(dir, "pnpm-workspace.yaml"))) return dir;
|
|
624
|
+
const parent = path.dirname(dir);
|
|
625
|
+
if (parent === dir) return start;
|
|
626
|
+
dir = parent;
|
|
627
|
+
}
|
|
471
628
|
}
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
629
|
+
/** Minimal `packages:` list extraction from a `pnpm-workspace.yaml` — no YAML dependency, just the
|
|
630
|
+
* flat glob-list shape pnpm-workspace files use. */
|
|
631
|
+
function parseWorkspaceGlobs(yamlText) {
|
|
632
|
+
const lines = yamlText.split(/\r?\n/);
|
|
633
|
+
const globs = [];
|
|
634
|
+
let inPackages = false;
|
|
635
|
+
for (const line of lines) {
|
|
636
|
+
if (/^packages:\s*$/.test(line)) {
|
|
637
|
+
inPackages = true;
|
|
638
|
+
continue;
|
|
639
|
+
}
|
|
640
|
+
if (!inPackages) continue;
|
|
641
|
+
const item = line.match(/^\s*-\s*["']?([^"'\s#]+)["']?\s*$/);
|
|
642
|
+
if (item) {
|
|
643
|
+
globs.push(item[1]);
|
|
644
|
+
continue;
|
|
645
|
+
}
|
|
646
|
+
if (/^\S/.test(line)) inPackages = false;
|
|
647
|
+
}
|
|
648
|
+
return globs;
|
|
649
|
+
}
|
|
650
|
+
function readWorkspaceGlobs(monorepoRoot) {
|
|
651
|
+
const yamlPath = path.join(monorepoRoot, "pnpm-workspace.yaml");
|
|
652
|
+
if (!fsNode.existsSync(yamlPath)) return DEFAULT_GLOBS;
|
|
653
|
+
try {
|
|
654
|
+
const globs = parseWorkspaceGlobs(fsNode.readFileSync(yamlPath, "utf8"));
|
|
655
|
+
return globs.length > 0 ? globs : DEFAULT_GLOBS;
|
|
656
|
+
} catch {
|
|
657
|
+
return DEFAULT_GLOBS;
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
/** Expands a workspace glob to its member directories. Only the `<dir>/*` shape pnpm-workspace
|
|
661
|
+
* files use is supported; a glob without a trailing `/*` is treated as a single literal path. */
|
|
662
|
+
function expandGlob(monorepoRoot, glob) {
|
|
663
|
+
if (glob.endsWith("/*")) {
|
|
664
|
+
const base = path.join(monorepoRoot, glob.slice(0, -2));
|
|
665
|
+
if (!fsNode.existsSync(base)) return [];
|
|
666
|
+
return fsNode.readdirSync(base, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => path.join(base, entry.name));
|
|
667
|
+
}
|
|
668
|
+
const dir = path.join(monorepoRoot, glob);
|
|
669
|
+
return fsNode.existsSync(dir) ? [dir] : [];
|
|
670
|
+
}
|
|
671
|
+
/** Discovers every workspace member's version, keyed by its `package.json` `name` field (falling
|
|
672
|
+
* back to the directory name when `package.json` is missing or unreadable — registering the
|
|
673
|
+
* package as an in-workspace member with an unresolved version, distinct from a package with no
|
|
674
|
+
* workspace entry at all). */
|
|
675
|
+
function discoverWorkspace(root) {
|
|
676
|
+
const monorepoRoot = findMonorepoRoot(root);
|
|
677
|
+
const globs = readWorkspaceGlobs(monorepoRoot);
|
|
678
|
+
const map = /* @__PURE__ */ new Map();
|
|
679
|
+
for (const glob of globs) for (const dir of expandGlob(monorepoRoot, glob)) {
|
|
680
|
+
let name = path.basename(dir);
|
|
681
|
+
let version;
|
|
475
682
|
try {
|
|
683
|
+
const pkg = JSON.parse(fsNode.readFileSync(path.join(dir, "package.json"), "utf8"));
|
|
684
|
+
if (typeof pkg.name === "string") name = pkg.name;
|
|
685
|
+
if (typeof pkg.version === "string") version = pkg.version;
|
|
686
|
+
} catch {}
|
|
687
|
+
map.set(name, version);
|
|
688
|
+
}
|
|
689
|
+
return map;
|
|
690
|
+
}
|
|
691
|
+
/** Writes `<root>/.plugin/pins.json` — a flat, key-sorted `{ "<package>": "<resolvedVersion>" }`
|
|
692
|
+
* map of the workspace-resolved pins (`pinned` + `unchanged`), so a bundled plugin's skills can read
|
|
693
|
+
* the shipped version programmatically (e.g. `${CLAUDE_PLUGIN_ROOT}/.plugin/pins.json`). External /
|
|
694
|
+
* `skipped` packages are excluded — they have no authoritative workspace version. */
|
|
695
|
+
function writePinsMap(root, pins) {
|
|
696
|
+
const map = {};
|
|
697
|
+
for (const pin of pins) if (pin.status === "pinned" || pin.status === "unchanged") map[pin.package] = pin.resolved;
|
|
698
|
+
const sorted = {};
|
|
699
|
+
for (const key of Object.keys(map).sort((a, b) => a.localeCompare(b))) sorted[key] = map[key];
|
|
700
|
+
const dir = path.join(root, ".plugin");
|
|
701
|
+
fsNode.mkdirSync(dir, { recursive: true });
|
|
702
|
+
const pinsPath = path.join(dir, "pins.json");
|
|
703
|
+
const existing = fsNode.existsSync(pinsPath) ? fsNode.readFileSync(pinsPath, "utf8") : null;
|
|
704
|
+
const indent = existing ? detectIndent(existing) : " ";
|
|
705
|
+
fsNode.writeFileSync(pinsPath, `${JSON.stringify(sorted, null, indent)}\n`);
|
|
706
|
+
}
|
|
707
|
+
function realVersionSource(workspace) {
|
|
708
|
+
return { resolve(pkg) {
|
|
709
|
+
if (!workspace.has(pkg)) return { inWorkspace: false };
|
|
710
|
+
return {
|
|
711
|
+
inWorkspace: true,
|
|
712
|
+
version: workspace.get(pkg)
|
|
713
|
+
};
|
|
714
|
+
} };
|
|
715
|
+
}
|
|
716
|
+
//#endregion
|
|
717
|
+
//#region src/bundle/cli.ts
|
|
718
|
+
const TRUNCATE_THRESHOLD = 20;
|
|
719
|
+
const NEXT_STEP$1 = "→ review and commit the pinned skills\n";
|
|
720
|
+
function bundleCommand() {
|
|
721
|
+
const cmd = new Command("bundle").description("Pin the plugin's skill npx references to their workspace package.json versions (release form)");
|
|
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) => {
|
|
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)`);
|
|
476
726
|
const root = resolveRoot(opts.root);
|
|
477
|
-
const result =
|
|
478
|
-
vendor: opts.vendor,
|
|
727
|
+
const result = bundlePins(realPinFs(resolveSkillsDir(root, universalPluginExtension(readManifest(root)).skills)), realVersionSource(discoverWorkspace(root)), {
|
|
479
728
|
dryRun: opts.dryRun,
|
|
480
|
-
|
|
481
|
-
clean: opts.clean
|
|
729
|
+
runner
|
|
482
730
|
});
|
|
731
|
+
if (!opts.dryRun) writePinsMap(root, result.pins);
|
|
483
732
|
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
|
-
|
|
733
|
+
const pinned = result.pins.filter((p) => p.status === "pinned").length;
|
|
734
|
+
const unchanged = result.pins.filter((p) => p.status === "unchanged").length;
|
|
735
|
+
const skipped = result.pins.filter((p) => p.status === "skipped").length;
|
|
736
|
+
output({ pins: result.pins }, () => {
|
|
737
|
+
const truncated = !opts.full && result.pins.length > TRUNCATE_THRESHOLD;
|
|
738
|
+
const rows = truncated ? result.pins.slice(0, TRUNCATE_THRESHOLD) : result.pins;
|
|
739
|
+
if (rows.length > 0) printTable(rows, [
|
|
740
|
+
{
|
|
741
|
+
label: "package",
|
|
742
|
+
get: (r) => r.package
|
|
743
|
+
},
|
|
744
|
+
{
|
|
745
|
+
label: "current",
|
|
746
|
+
get: (r) => r.current
|
|
747
|
+
},
|
|
748
|
+
{
|
|
749
|
+
label: "resolved",
|
|
750
|
+
get: (r) => r.resolved
|
|
751
|
+
},
|
|
752
|
+
{
|
|
753
|
+
label: "status",
|
|
754
|
+
get: (r) => r.status
|
|
755
|
+
}
|
|
756
|
+
]);
|
|
757
|
+
if (truncated) console.log(`… +${result.pins.length - TRUNCATE_THRESHOLD} more — rerun with --full`);
|
|
758
|
+
console.log(`pinned ${pinned}, unchanged ${unchanged}, skipped ${skipped}`);
|
|
759
|
+
});
|
|
760
|
+
if (result.pins.length === 0) process.stderr.write("nothing to bundle\n");
|
|
761
|
+
process.stderr.write(NEXT_STEP$1);
|
|
762
|
+
} catch (err) {
|
|
763
|
+
process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
|
|
764
|
+
process.exit(1);
|
|
765
|
+
}
|
|
766
|
+
});
|
|
767
|
+
return cmd;
|
|
768
|
+
}
|
|
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");
|
|
496
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;
|
|
497
855
|
output({
|
|
498
|
-
|
|
499
|
-
|
|
856
|
+
key: opts.key,
|
|
857
|
+
name: result.name,
|
|
858
|
+
action: result.action
|
|
500
859
|
}, () => {
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
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`);
|
|
509
875
|
});
|
|
876
|
+
process.stderr.write(`→ universal-plugin config get --key ${opts.key}\n`);
|
|
510
877
|
} catch (err) {
|
|
511
878
|
process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
|
|
512
879
|
process.exit(1);
|
|
513
880
|
}
|
|
514
881
|
});
|
|
515
|
-
|
|
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));
|
|
516
905
|
}
|
|
517
906
|
//#endregion
|
|
518
907
|
//#region src/governance/fs.ts
|
|
@@ -659,6 +1048,486 @@ function governanceCommand() {
|
|
|
659
1048
|
return cmd;
|
|
660
1049
|
}
|
|
661
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
|
|
662
1531
|
//#region src/vendor-registry/vendor-registry.ts
|
|
663
1532
|
function lookupVendor(registry, vendorId) {
|
|
664
1533
|
return registry[vendorId] ?? null;
|
|
@@ -890,37 +1759,87 @@ function prepareCommand() {
|
|
|
890
1759
|
});
|
|
891
1760
|
}
|
|
892
1761
|
//#endregion
|
|
893
|
-
//#region src/
|
|
894
|
-
const
|
|
1762
|
+
//#region src/version/fs.ts
|
|
1763
|
+
const realJsonIo = {
|
|
895
1764
|
exists: (p) => fsNode.existsSync(p),
|
|
896
1765
|
read: (p) => fsNode.readFileSync(p, "utf8"),
|
|
897
1766
|
write: (p, content) => fsNode.writeFileSync(p, content)
|
|
898
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;
|
|
899
1810
|
//#endregion
|
|
900
1811
|
//#region src/publish/sync-version.ts
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
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. */
|
|
906
1817
|
function syncVersion(root, syncFs) {
|
|
907
|
-
const manifestPath = path.join(root, "
|
|
908
|
-
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}`);
|
|
909
1820
|
const agentsConfigPath = path.join(root, ".agents", "universal-plugin.json");
|
|
910
1821
|
const packagePath = (syncFs.exists(agentsConfigPath) ? JSON.parse(syncFs.read(agentsConfigPath)) : {})["packagePath"];
|
|
911
1822
|
if (!packagePath || typeof packagePath !== "string") throw new Error("packagePath is required in .agents/universal-plugin.json");
|
|
912
|
-
const
|
|
913
|
-
const manifest = JSON.parse(raw);
|
|
1823
|
+
const manifest = JSON.parse(syncFs.read(manifestPath));
|
|
914
1824
|
const pkgJsonPath = path.join(root, packagePath, "package.json");
|
|
915
1825
|
if (!syncFs.exists(pkgJsonPath)) throw new Error(`No package.json found at ${packagePath}`);
|
|
916
1826
|
const version = JSON.parse(syncFs.read(pkgJsonPath))["version"];
|
|
917
1827
|
if (!version || typeof version !== "string") throw new Error(`No version found in ${packagePath}/package.json`);
|
|
918
|
-
const
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
version
|
|
922
|
-
|
|
923
|
-
|
|
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);
|
|
924
1843
|
return {
|
|
925
1844
|
version,
|
|
926
1845
|
manifestPath
|
|
@@ -930,7 +1849,7 @@ function syncVersion(root, syncFs) {
|
|
|
930
1849
|
//#region src/publish/cli.ts
|
|
931
1850
|
function publishCommand() {
|
|
932
1851
|
const cmd = new Command("publish").description("Prepare plugin for publishing").helpCommand(false);
|
|
933
|
-
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) => {
|
|
934
1853
|
try {
|
|
935
1854
|
const result = syncVersion(resolveRoot(opts.root), realSyncVersionFs);
|
|
936
1855
|
output(result, () => {
|
|
@@ -1089,17 +2008,173 @@ function syncCommand() {
|
|
|
1089
2008
|
return cmd;
|
|
1090
2009
|
}
|
|
1091
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
|
|
1092
2162
|
//#region src/cli.ts
|
|
1093
2163
|
const program = new Command();
|
|
1094
2164
|
program.name("universal-plugin").description("Universal AI agent plugin build tool").version("0.0.0").helpCommand(false);
|
|
1095
2165
|
function pluginCommand() {
|
|
1096
|
-
const cmd = new Command("plugin").description("Author the canonical plugin manifest (build
|
|
2166
|
+
const cmd = new Command("plugin").description("Author the canonical plugin manifest (build, bundle, init, version; validate planned)");
|
|
1097
2167
|
cmd.addCommand(buildCommand());
|
|
2168
|
+
cmd.addCommand(bundleCommand());
|
|
2169
|
+
cmd.addCommand(initCommand$1());
|
|
2170
|
+
cmd.addCommand(versionCommand());
|
|
1098
2171
|
return cmd;
|
|
1099
2172
|
}
|
|
1100
2173
|
program.addCommand(pluginCommand());
|
|
1101
2174
|
program.addCommand(cleanCommand());
|
|
2175
|
+
program.addCommand(configCommand());
|
|
1102
2176
|
program.addCommand(governanceCommand());
|
|
2177
|
+
program.addCommand(marketplaceCommand());
|
|
1103
2178
|
program.addCommand(prepareCommand());
|
|
1104
2179
|
program.addCommand(publishCommand());
|
|
1105
2180
|
program.addCommand(syncCommand());
|