universal-plugin 0.6.0 → 0.7.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/dist/cli.mjs CHANGED
@@ -911,6 +911,145 @@ function formatCatalogIssues(file, issues) {
911
911
  return `error: catalog "${file}" does not match the marketplace schema:\n${issues.map((issue) => ` ${issue.path === "" ? file : `${issue.path}`} ${issue.message}`).join("\n")}`;
912
912
  }
913
913
  //#endregion
914
+ //#region src/pin/pin.ts
915
+ /** The words that mean "fetch and run this package". Stated once: the skill-prose matcher below and
916
+ * the argv matcher used for `mcpServers` invocations must agree on what a runner is. */
917
+ const RUNNERS = "npx|upx";
918
+ /** The `-y` spelling is the one ad-hoc regexes miss, so it lives here with `--yes` rather than in
919
+ * each caller. */
920
+ const RUNNER_FLAG = "--yes|-y";
921
+ /** Characters a package specifier may carry, scope included. */
922
+ const PACKAGE_CHARS = "@a-z0-9/._-";
923
+ const PIN_PATTERN = new RegExp(`(${RUNNERS})\\s+(?:(?:${RUNNER_FLAG})\\s+)?([${PACKAGE_CHARS}]+)@(\\S+)`, "g");
924
+ const RUNNER_WORD = new RegExp(`^(?:${RUNNERS})$`);
925
+ const RUNNER_FLAG_ARG = new RegExp(`^(?:${RUNNER_FLAG})$`);
926
+ const PACKAGE_TOKEN = new RegExp(`^[${PACKAGE_CHARS}]+$`);
927
+ /** Strips a trailing backtick, quote, or paren that isn't part of the version token. */
928
+ function stripTrailing(raw) {
929
+ return raw.replace(/[`'")]+$/, "");
930
+ }
931
+ function extractPins(text) {
932
+ const pins = [];
933
+ for (const match of text.matchAll(PIN_PATTERN)) {
934
+ const runner = match[1];
935
+ const pkg = match[2];
936
+ const current = match[3];
937
+ if (!runner || !pkg || !current) continue;
938
+ pins.push({
939
+ pkg,
940
+ current: stripTrailing(current),
941
+ file: "",
942
+ runner
943
+ });
944
+ }
945
+ return pins;
946
+ }
947
+ /** True when `command` invokes a package runner, and so carries a package specifier in its argv. */
948
+ function isPackageRunner(command) {
949
+ return typeof command === "string" && RUNNER_WORD.test(command);
950
+ }
951
+ /** Splits a package specifier token into its name and, when present, its version. A leading `@` is
952
+ * the scope separator, never the version one. */
953
+ function splitSpecifier(token) {
954
+ const at = token.lastIndexOf("@");
955
+ if (at <= 0) return { pkg: token };
956
+ return {
957
+ pkg: token.slice(0, at),
958
+ version: token.slice(at + 1)
959
+ };
960
+ }
961
+ /** Rewrites the package specifier in a runner's argv to `<pkg>@<version>`. The specifier is the
962
+ * first argument that is not a runner flag. Returns null when the argv carries none. Every other
963
+ * argument is carried through exactly as authored — this rewrites one slot, not the argv. */
964
+ function pinRunnerArgs(args, version) {
965
+ for (let i = 0; i < args.length; i++) {
966
+ const arg = args[i];
967
+ if (typeof arg !== "string") continue;
968
+ if (RUNNER_FLAG_ARG.test(arg)) continue;
969
+ if (!PACKAGE_TOKEN.test(arg)) return null;
970
+ const { pkg, version: previous } = splitSpecifier(arg);
971
+ const next = [...args];
972
+ next[i] = `${pkg}@${version}`;
973
+ return previous === void 0 ? {
974
+ args: next,
975
+ pkg
976
+ } : {
977
+ args: next,
978
+ pkg,
979
+ previous
980
+ };
981
+ }
982
+ return null;
983
+ }
984
+ /** The canonical opt-in marker on an `mcpServers` entry: "this package is the plugin's own, stamp
985
+ * the plugin's version onto it". A build directive — never part of a vendor's schema, so it is
986
+ * stripped from everything derived. */
987
+ const PIN_MARKER = "pinToPluginVersion";
988
+ /** Stamps the plugin's version onto every `mcpServers` entry that opts in with {@link PIN_MARKER},
989
+ * and strips the marker from all of them. Pure: the caller decides where the result is delivered.
990
+ *
991
+ * Opt-in is required and a name match is never used — a plugin may publish its server under a
992
+ * package name that is not the plugin's, and an unrelated `npx -y widget-cli` must never be
993
+ * stamped with this plugin's version. */
994
+ function pinMcpServers(servers, version) {
995
+ const out = {};
996
+ const notes = [];
997
+ let changed = false;
998
+ for (const [server, rawEntry] of Object.entries(servers)) {
999
+ if (!rawEntry || typeof rawEntry !== "object" || Array.isArray(rawEntry)) {
1000
+ out[server] = rawEntry;
1001
+ continue;
1002
+ }
1003
+ const { [PIN_MARKER]: marker, ...entry } = rawEntry;
1004
+ if (marker !== void 0) changed = true;
1005
+ if (marker !== true) {
1006
+ out[server] = entry;
1007
+ continue;
1008
+ }
1009
+ if (!version) {
1010
+ notes.push({
1011
+ server,
1012
+ kind: "no-manifest-version"
1013
+ });
1014
+ out[server] = entry;
1015
+ continue;
1016
+ }
1017
+ if (!isPackageRunner(entry["command"])) {
1018
+ notes.push({
1019
+ server,
1020
+ kind: "not-a-runner"
1021
+ });
1022
+ out[server] = entry;
1023
+ continue;
1024
+ }
1025
+ const args = entry["args"];
1026
+ const pinned = Array.isArray(args) ? pinRunnerArgs(args, version) : null;
1027
+ if (!pinned) {
1028
+ notes.push({
1029
+ server,
1030
+ kind: "no-specifier"
1031
+ });
1032
+ out[server] = entry;
1033
+ continue;
1034
+ }
1035
+ if (pinned.previous !== void 0 && pinned.previous !== version) notes.push({
1036
+ server,
1037
+ kind: "repinned",
1038
+ previous: pinned.previous
1039
+ });
1040
+ out[server] = {
1041
+ ...entry,
1042
+ args: pinned.args
1043
+ };
1044
+ changed = true;
1045
+ }
1046
+ return {
1047
+ servers: out,
1048
+ changed,
1049
+ notes
1050
+ };
1051
+ }
1052
+ //#endregion
914
1053
  //#region src/build/build.ts
915
1054
  /** Where each vendor reads its manifest, relative to the project root. Shared with
916
1055
  * `plugin init --npm`, which wires exactly these paths into `package.json` `files`. */
@@ -921,17 +1060,68 @@ const VENDOR_OUTPUT = {
921
1060
  "copilot-cli": "plugin.json"
922
1061
  };
923
1062
  const KNOWN_VENDORS = new Set(Object.keys(VENDOR_OUTPUT));
924
- /** Vendors the canonical root manifest serves as-is. The build derives no file for these — writing
925
- * one would either be shadowed by root (a lower-precedence path) or clobber root itself. */
1063
+ /** Vendors the canonical root manifest serves as-is. The build derives no *manifest* for these —
1064
+ * writing one would either be shadowed by root (a lower-precedence path) or clobber root itself.
1065
+ * Their components are a separate question: see COPILOT_NAMESPACE. */
926
1066
  const CANONICAL_SERVED = new Set(["copilot-cli"]);
1067
+ /** The reverse-domain directory Copilot CLI reads its native components from once a plugin declares
1068
+ * the canonical `$schema` (ADR-0015). It REPLACES the plugin root for these kinds — a spec-mode
1069
+ * runtime does not read `agents/` at all — so the build derives the tree rather than relying on the
1070
+ * authored paths. `skills/` and `mcp.json` do not move.
1071
+ * Evidence: `.research/copilot-spec-mode-namespace/`. */
1072
+ const COPILOT_NAMESPACE = "com.github.copilot";
1073
+ /** Component kinds whose directories the namespace took over, copied file-for-file, each with the
1074
+ * schema's default location for the field. The default is what an *undeclared* field means, never a
1075
+ * stand-in for a declared path that did not resolve — the same asymmetry `readSkills` follows. */
1076
+ const COPILOT_COPIED_KINDS = {
1077
+ agents: "./agents/",
1078
+ commands: "./commands/",
1079
+ rules: "./rules/"
1080
+ };
1081
+ /** Where the namespace reads hooks and LSP config from. Fixed paths — Copilot CLI has no derived
1082
+ * manifest that could repoint them. */
1083
+ const COPILOT_HOOKS_PATH = "hooks/hooks.json";
1084
+ const COPILOT_LSP_PATH = "lsp.json";
1085
+ /** Authored under the namespace, never derived: Copilot's canvas extensions have no canonical root
1086
+ * location to derive from, so `--clean` must leave this subtree alone. */
1087
+ const COPILOT_AUTHORED_DIR = "extensions";
1088
+ /** What each vendor's build output occupies in the published package, for `plugin init --npm`'s
1089
+ * `package.json` `files` wiring. */
1090
+ const VENDOR_SHIPPED_PATHS = {
1091
+ "claude-code": [VENDOR_OUTPUT["claude-code"]],
1092
+ cursor: [VENDOR_OUTPUT.cursor],
1093
+ codex: [VENDOR_OUTPUT.codex],
1094
+ "copilot-cli": [`${COPILOT_NAMESPACE}/`]
1095
+ };
927
1096
  /** Where every vendor looks for a plugin's hooks when the manifest declares none
928
1097
  * (`.research/hook-event-survey/conclusion.md`). */
929
1098
  const DEFAULT_HOOKS_PATH = "./hooks/hooks.json";
1099
+ /** Where skills live when the extension namespace declares no `skills` path. */
1100
+ const DEFAULT_SKILLS_PATH = "./skills/";
1101
+ /** The Agent Plugins Specification's fixed location for a plugin's MCP config (ADR-0007 §6.1). */
1102
+ const DEFAULT_MCP_PATH = "./mcp.json";
930
1103
  const UP_NAMESPACE$1 = "org.cyberuni.universal-plugin";
931
1104
  /** Reads universal-plugin's config block from the canonical manifest's extensions map. */
932
1105
  function universalPluginExtension(manifest) {
933
1106
  return manifest.extensions?.[UP_NAMESPACE$1] ?? {};
934
1107
  }
1108
+ /** Copilot CLI searches `.plugin/plugin.json` first, so a leftover one there outranks the canonical
1109
+ * root manifest — the pre-0.6 layout's other half. */
1110
+ const SHADOWING_MANIFEST = ".plugin/plugin.json";
1111
+ /** The pre-0.6 layout signals, if any, that explain a build deriving nothing (issue #61). Pure: the
1112
+ * one filesystem fact it needs is passed in.
1113
+ *
1114
+ * Scoped to the two signals that are unambiguously the old layout. A manifest that merely omits the
1115
+ * `extensions` block is not one of them — that is as likely a manifest nobody has configured yet as
1116
+ * one left behind by an upgrade, and erroring on it would fail builds this change has no quarrel
1117
+ * with. `doctor` still reports it as `legacy-manifest`, which is why the empty-state path points
1118
+ * there. */
1119
+ function legacyLayoutSignals(manifest, hasShadowingManifest) {
1120
+ const signals = [];
1121
+ if ("vendorExtensions" in manifest) signals.push("plugin.json carries a top-level \"vendorExtensions\" block — harness fields moved under extensions[\"org.cyberuni.universal-plugin\"].harnesses");
1122
+ if (hasShadowingManifest) signals.push(`${SHADOWING_MANIFEST} shadows the canonical root plugin.json`);
1123
+ return signals;
1124
+ }
935
1125
  function readManifest(root) {
936
1126
  const manifestPath = path.join(root, "plugin.json");
937
1127
  if (!fs.existsSync(manifestPath)) throw new Error(`No plugin.json found at ${root}`);
@@ -977,6 +1167,8 @@ function buildPlugin(root, opts = {}) {
977
1167
  vendors = [opts.vendor];
978
1168
  }
979
1169
  if (vendors.length === 0) {
1170
+ const signals = legacyLayoutSignals(manifest, fs.existsSync(path.join(root, SHADOWING_MANIFEST)));
1171
+ if (signals.length > 0) throw new Error(`Nothing was derived, and this project is still on the pre-0.6 manifest layout:\n${signals.map((s) => ` - ${s}`).join("\n")}\nRun /universal-plugin:doctor for the full diagnosis and the skill that owns each repair.`);
980
1172
  warnings.push("No vendors declared in harnesses — nothing to build");
981
1173
  return {
982
1174
  vendors: [],
@@ -993,8 +1185,11 @@ function buildPlugin(root, opts = {}) {
993
1185
  const { $schema: _schema, extensions: _extensions, ...metadata } = manifest;
994
1186
  const { vendors: _vendors, packagePath: _packagePath, harnesses: _harnesses, dependencies: declaredDependencies, ...componentConfig } = uext;
995
1187
  warnings.push(...validateDependencies(declaredDependencies).warnings);
996
- const skills = readSkills(root, manifest);
1188
+ const skills = readSkills(root, manifest, warnings);
997
1189
  const canonicalHooks = readCanonicalHooks(root, componentConfig["hooks"], warnings);
1190
+ const declaredMcp = readCanonicalMcpServers(root, componentConfig["mcpServers"], warnings);
1191
+ const mcp = declaredMcp ? pinMcpServers(declaredMcp.servers, manifest.version) : null;
1192
+ warnings.push(...(mcp?.notes ?? []).map(formatMcpNote));
998
1193
  for (const vendor of vendors) {
999
1194
  const relPath = VENDOR_OUTPUT[vendor];
1000
1195
  const outputPath = path.join(root, relPath);
@@ -1010,11 +1205,17 @@ function buildPlugin(root, opts = {}) {
1010
1205
  warnings.push(...dependencies.warnings);
1011
1206
  if (dependencies.dependencies) vendorManifest["dependencies"] = dependencies.dependencies;
1012
1207
  if (CANONICAL_SERVED.has(vendor)) {
1013
- for (const drop of dedupeDrops(hooks?.drops ?? [])) warnings.push(`${vendor} cannot run the "${drop.type}" hook handler on ${drop.event} — it is ignored at runtime`);
1208
+ for (const drop of dedupeDrops(hooks?.drops ?? [])) warnings.push(`${vendor} cannot run the "${drop.type}" hook handler on ${drop.event} — dropped from the derived hooks file`);
1209
+ if (mcp?.changed) warnings.push(`${vendor} reads the canonical plugin.json directly — the pinned mcpServers is not delivered to it`);
1014
1210
  const overrides = Object.keys(vendorFields);
1015
1211
  if (overrides.length > 0) warnings.push(`harnesses.${vendor} sets ${overrides.join(", ")}, but ${vendor} reads the canonical plugin.json directly — these fields are not delivered`);
1016
1212
  writeSkillArtifacts(vendor, skills, opts, written, warnings);
1017
- rows.push({
1213
+ const derived = deriveCopilotNamespace(root, componentConfig, hooks, indent, opts, written, warnings);
1214
+ rows.push(derived ? {
1215
+ vendor,
1216
+ path: `${COPILOT_NAMESPACE}/`,
1217
+ status: "built"
1218
+ } : {
1018
1219
  vendor,
1019
1220
  path: relPath,
1020
1221
  status: "canonical"
@@ -1025,6 +1226,9 @@ function buildPlugin(root, opts = {}) {
1025
1226
  const derivedHooksPath = path.join(outputDir, "hooks.json");
1026
1227
  if (hooks?.changed) if (hooks.hooks) vendorManifest["hooks"] = `./${path.dirname(relPath).split(path.sep).join("/")}/hooks.json`;
1027
1228
  else delete vendorManifest["hooks"];
1229
+ const derivedMcpPath = path.join(outputDir, "mcp.json");
1230
+ if (mcp?.changed && declaredMcp) if (declaredMcp.inline) vendorManifest["mcpServers"] = mcp.servers;
1231
+ else vendorManifest["mcpServers"] = `./${path.dirname(relPath).split(path.sep).join("/")}/mcp.json`;
1028
1232
  if (opts.verbose) {
1029
1233
  console.log(`[${vendor}] → ${outputPath}`);
1030
1234
  for (const key of Object.keys(vendorFields)) console.log(` + ${key} (from harnesses.${vendor})`);
@@ -1040,6 +1244,7 @@ function buildPlugin(root, opts = {}) {
1040
1244
  if (hooks.hooks) writeArtifact(derivedHooksPath, `${JSON.stringify(hooks.hooks, null, indent)}\n`, opts, written);
1041
1245
  else if (!opts.dryRun && fs.existsSync(derivedHooksPath)) fs.unlinkSync(derivedHooksPath);
1042
1246
  }
1247
+ if (mcp?.changed && declaredMcp && !declaredMcp.inline) writeArtifact(derivedMcpPath, `${JSON.stringify({ mcpServers: mcp.servers }, null, indent)}\n`, opts, written);
1043
1248
  writeSkillArtifacts(vendor, skills, opts, written, warnings);
1044
1249
  rows.push({
1045
1250
  vendor,
@@ -1139,12 +1344,40 @@ function writeSkillArtifacts(vendor, skills, opts, written, warnings) {
1139
1344
  }
1140
1345
  }
1141
1346
  }
1142
- function readSkills(root, manifest) {
1347
+ /** Resolves a `pathValue` declaration — a single "./" path, an array of them, or a { paths: [...] }
1348
+ * object — into the list of declared paths. Returns null when the field is absent or malformed, so
1349
+ * the caller can tell "declared nothing" from "declared these", and never silently substitute a
1350
+ * default for a form it failed to read. */
1351
+ function resolvePathValue(declaration) {
1352
+ if (typeof declaration === "string") return [declaration];
1353
+ if (Array.isArray(declaration)) {
1354
+ const paths = declaration.filter((entry) => typeof entry === "string");
1355
+ return paths.length === declaration.length ? paths : null;
1356
+ }
1357
+ if (declaration && typeof declaration === "object") {
1358
+ const paths = declaration.paths;
1359
+ if (Array.isArray(paths) && paths.every((entry) => typeof entry === "string")) return paths;
1360
+ }
1361
+ return null;
1362
+ }
1363
+ function readSkills(root, manifest, warnings) {
1143
1364
  const skillsCfg = universalPluginExtension(manifest).skills;
1144
- const skillsPath = typeof skillsCfg === "string" ? skillsCfg : "./skills/";
1145
- const skillsDir = path.resolve(root, skillsPath);
1146
- if (!fs.existsSync(skillsDir)) return [];
1147
- return listSkillFiles(skillsDir).map((skillPath) => parseSkill(skillPath));
1365
+ const declared = skillsCfg === void 0 ? null : resolvePathValue(skillsCfg);
1366
+ if (skillsCfg !== void 0 && declared === null) {
1367
+ warnings.push("skills declaration is not a path, a path list, or a { paths } object — no skills were read");
1368
+ return [];
1369
+ }
1370
+ const paths = declared ?? [DEFAULT_SKILLS_PATH];
1371
+ const skills = [];
1372
+ for (const relPath of paths) {
1373
+ const skillsDir = path.resolve(root, relPath);
1374
+ if (!fs.existsSync(skillsDir)) {
1375
+ if (declared) warnings.push(`skills path "${relPath}" not found — no skills read from it`);
1376
+ continue;
1377
+ }
1378
+ skills.push(...listSkillFiles(skillsDir).map((skillPath) => parseSkill(skillPath)));
1379
+ }
1380
+ return skills;
1148
1381
  }
1149
1382
  function listSkillFiles(dir) {
1150
1383
  return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
@@ -1184,6 +1417,87 @@ function withClaudeInvocationFlags(skill) {
1184
1417
  if (skill.invocationPolicy === "model") lines.push("user-invocable: false");
1185
1418
  return `${skill.content.slice(0, match.index)}---\n${lines.join("\n")}\n---${skill.content.slice(match.index + match[0].length)}`;
1186
1419
  }
1420
+ /** Derives the `com.github.copilot/` tree Copilot CLI reads its native components from in spec mode
1421
+ * (ADR-0015). Returns whether anything was derived — a plugin that declares none of the moved kinds
1422
+ * has nothing here and keeps its `canonical` row.
1423
+ *
1424
+ * The moved directory kinds are copied file-for-file: their content is vendor-neutral, and the
1425
+ * namespace is a location change, not a format change. Hooks are the exception — they are translated
1426
+ * first, exactly as for every other vendor. `skills/` and `mcp.json` stay at the plugin root and are
1427
+ * deliberately not copied: a second copy under the namespace is one nothing reads. */
1428
+ function deriveCopilotNamespace(root, componentConfig, hooks, indent, opts, written, warnings) {
1429
+ const nsDir = path.join(root, COPILOT_NAMESPACE);
1430
+ if (opts.clean && !opts.dryRun) cleanCopilotNamespace(nsDir);
1431
+ let derived = false;
1432
+ for (const [kind, defaultPath] of Object.entries(COPILOT_COPIED_KINDS)) {
1433
+ const declaration = componentConfig[kind];
1434
+ const declared = declaration === void 0 ? null : resolvePathValue(declaration);
1435
+ if (declaration !== void 0 && declared === null) {
1436
+ warnings.push(`${kind} declaration is not a path, a path list, or a { paths } object — nothing copied to ${COPILOT_NAMESPACE}/${kind}/`);
1437
+ continue;
1438
+ }
1439
+ for (const relPath of declared ?? [defaultPath]) {
1440
+ const sourceDir = path.resolve(root, relPath);
1441
+ if (!fs.existsSync(sourceDir)) {
1442
+ if (declared) warnings.push(`${kind} path "${relPath}" not found — nothing copied to ${COPILOT_NAMESPACE}/${kind}/`);
1443
+ continue;
1444
+ }
1445
+ for (const file of listFilesRecursive(sourceDir)) {
1446
+ const relative = path.relative(sourceDir, file);
1447
+ const target = kind === "agents" ? copilotAgentName(relative) : relative;
1448
+ writeArtifact(path.join(nsDir, kind, target), fs.readFileSync(file), opts, written);
1449
+ derived = true;
1450
+ }
1451
+ }
1452
+ }
1453
+ const hooksPath = path.join(nsDir, COPILOT_HOOKS_PATH);
1454
+ if (hooks?.hooks) {
1455
+ writeArtifact(hooksPath, `${JSON.stringify(hooks.hooks, null, indent)}\n`, opts, written);
1456
+ derived = true;
1457
+ } else if (hooks && !opts.dryRun && fs.existsSync(hooksPath)) fs.unlinkSync(hooksPath);
1458
+ const lspDeclaration = componentConfig["lspServers"];
1459
+ if (lspDeclaration !== void 0) {
1460
+ const lspPaths = resolvePathValue(lspDeclaration);
1461
+ if (lspPaths === null) warnings.push("copilot-cli reads lspServers from com.github.copilot/lsp.json, and an inline map has no documented file shape to write — not delivered");
1462
+ else for (const relPath of lspPaths) {
1463
+ const sourceFile = path.resolve(root, relPath);
1464
+ if (!fs.existsSync(sourceFile)) {
1465
+ warnings.push(`lspServers path "${relPath}" not found — nothing copied to ${COPILOT_NAMESPACE}/${COPILOT_LSP_PATH}`);
1466
+ continue;
1467
+ }
1468
+ writeArtifact(path.join(nsDir, COPILOT_LSP_PATH), fs.readFileSync(sourceFile), opts, written);
1469
+ derived = true;
1470
+ }
1471
+ }
1472
+ return derived;
1473
+ }
1474
+ /** Copilot CLI reads `agents/` as `.agent.md` files, while the canonical `agents/` is the Claude
1475
+ * Code-shaped `*.md`. Copying the authored name would land a file the runtime ignores — the same
1476
+ * silent loss ADR-0015 exists to end, one directory over. Commands and rules are copied verbatim:
1477
+ * the runtime documents no extension for either, and inventing one would be a guess. */
1478
+ function copilotAgentName(relative) {
1479
+ if (!relative.endsWith(".md") || relative.endsWith(".agent.md")) return relative;
1480
+ return `${relative.slice(0, -3)}.agent.md`;
1481
+ }
1482
+ /** Removes what the build derives under the namespace, and only that. `extensions/` is authored
1483
+ * there — deleting it would destroy the one thing in the tree nothing can regenerate. */
1484
+ function cleanCopilotNamespace(nsDir) {
1485
+ if (!fs.existsSync(nsDir)) return;
1486
+ for (const entry of fs.readdirSync(nsDir, { withFileTypes: true })) {
1487
+ if (entry.name === COPILOT_AUTHORED_DIR) continue;
1488
+ fs.rmSync(path.join(nsDir, entry.name), {
1489
+ recursive: true,
1490
+ force: true
1491
+ });
1492
+ }
1493
+ }
1494
+ function listFilesRecursive(dir) {
1495
+ return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
1496
+ const entryPath = path.join(dir, entry.name);
1497
+ if (entry.isDirectory()) return listFilesRecursive(entryPath);
1498
+ return entry.isFile() ? [entryPath] : [];
1499
+ });
1500
+ }
1187
1501
  function writeArtifact(outputPath, content, opts, written) {
1188
1502
  if (!opts.dryRun) {
1189
1503
  if (opts.clean && fs.existsSync(outputPath)) fs.unlinkSync(outputPath);
@@ -1238,9 +1552,60 @@ function dedupeDrops(drops) {
1238
1552
  return true;
1239
1553
  });
1240
1554
  }
1555
+ /** Resolves the canonical MCP declaration — an inline block, a path, or a list of paths — the same
1556
+ * way hooks are resolved. Returns null when there is nothing to read; an unreadable declaration
1557
+ * warns and leaves the declaration to pass through untouched. */
1558
+ function readCanonicalMcpServers(root, declaration, warnings) {
1559
+ if (declaration && typeof declaration === "object" && !Array.isArray(declaration)) {
1560
+ const block = declaration;
1561
+ if (Array.isArray(block["paths"])) return fromPaths(root, block["paths"], warnings);
1562
+ const inner = block["mcpServers"];
1563
+ return {
1564
+ servers: inner && typeof inner === "object" && !Array.isArray(inner) ? inner : block,
1565
+ inline: true
1566
+ };
1567
+ }
1568
+ const paths = (typeof declaration === "string" ? [declaration] : Array.isArray(declaration) ? declaration : null) ?? (fs.existsSync(path.resolve(root, DEFAULT_MCP_PATH)) ? [DEFAULT_MCP_PATH] : []);
1569
+ if (paths.length === 0) return null;
1570
+ return fromPaths(root, paths, warnings);
1571
+ }
1572
+ function fromPaths(root, paths, warnings) {
1573
+ const merged = {};
1574
+ let read = 0;
1575
+ for (const relPath of paths) {
1576
+ const mcpPath = path.resolve(root, relPath);
1577
+ if (!fs.existsSync(mcpPath)) {
1578
+ warnings.push(`mcp file "${relPath}" not found — left untranslated`);
1579
+ continue;
1580
+ }
1581
+ try {
1582
+ const parsed = JSON.parse(fs.readFileSync(mcpPath, "utf8"));
1583
+ const inner = parsed["mcpServers"];
1584
+ Object.assign(merged, inner && typeof inner === "object" && !Array.isArray(inner) ? inner : parsed);
1585
+ read++;
1586
+ } catch (err) {
1587
+ warnings.push(`mcp file "${relPath}" could not be read — left untranslated: ${err instanceof Error ? err.message : String(err)}`);
1588
+ }
1589
+ }
1590
+ return read === 0 ? null : {
1591
+ servers: merged,
1592
+ inline: false
1593
+ };
1594
+ }
1595
+ /** A marked entry that could not be pinned is a silent loss of the guarantee the marker asked for,
1596
+ * so each one is named. None of them fails the build — the manifest still derives. */
1597
+ function formatMcpNote(note) {
1598
+ switch (note.kind) {
1599
+ case "no-manifest-version": return `mcpServers "${note.server}" asks to be pinned to the plugin version, but the manifest declares no version — left unpinned`;
1600
+ case "not-a-runner": return `mcpServers "${note.server}" asks to be pinned to the plugin version, but its command is not "npx" or "upx" — left unpinned`;
1601
+ case "no-specifier": return `mcpServers "${note.server}" asks to be pinned to the plugin version, but its args carry no package specifier — left unpinned`;
1602
+ case "repinned": return `mcpServers "${note.server}" was authored at "${note.previous}" — overwritten with the plugin version`;
1603
+ }
1604
+ }
1241
1605
  //#endregion
1242
1606
  //#region src/build/cli.ts
1243
1607
  const NEXT_STEP$2 = "→ universal-plugin plugin validate\n";
1608
+ const NEXT_STEP_NOTHING_BUILT = "→ /universal-plugin:doctor — diagnose why nothing is declared\n";
1244
1609
  function buildCommand() {
1245
1610
  const cmd = new Command("build").description("Generate vendor manifests from plugin.json");
1246
1611
  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) => {
@@ -1276,7 +1641,7 @@ function buildCommand() {
1276
1641
  })),
1277
1642
  summary: (canonical > 0 ? `${counts}, served by plugin.json ${canonical}` : counts) + catalogSummary
1278
1643
  });
1279
- process.stderr.write(NEXT_STEP$2);
1644
+ process.stderr.write(result.vendors.length === 0 ? NEXT_STEP_NOTHING_BUILT : NEXT_STEP$2);
1280
1645
  if (failed > 0) process.exitCode = 1;
1281
1646
  } catch (err) {
1282
1647
  process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
@@ -1319,29 +1684,6 @@ function realPinFs(skillsDir) {
1319
1684
  };
1320
1685
  }
1321
1686
  //#endregion
1322
- //#region src/pin/pin.ts
1323
- const PIN_PATTERN = /(npx|upx)\s+(?:--yes\s+|-y\s+)?([@a-z0-9/._-]+)@(\S+)/g;
1324
- /** Strips a trailing backtick, quote, or paren that isn't part of the version token. */
1325
- function stripTrailing(raw) {
1326
- return raw.replace(/[`'")]+$/, "");
1327
- }
1328
- function extractPins(text) {
1329
- const pins = [];
1330
- for (const match of text.matchAll(PIN_PATTERN)) {
1331
- const runner = match[1];
1332
- const pkg = match[2];
1333
- const current = match[3];
1334
- if (!runner || !pkg || !current) continue;
1335
- pins.push({
1336
- pkg,
1337
- current: stripTrailing(current),
1338
- file: "",
1339
- runner
1340
- });
1341
- }
1342
- return pins;
1343
- }
1344
- //#endregion
1345
1687
  //#region src/bundle/bundle.ts
1346
1688
  function escapeRegExp(value) {
1347
1689
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -1957,9 +2299,9 @@ const STANDARD_FILES = ["plugin.json", "skills/"];
1957
2299
  * the array when absent and never duplicating an entry. Other fields and existing entries are
1958
2300
  * preserved. The base goes in regardless of `--vendor`: a package that ships only
1959
2301
  * `.claude-plugin/plugin.json` has published a Claude Code plugin, not a standard one. */
1960
- function wireFiles(pkg, manifestPaths) {
2302
+ function wireFiles(pkg, vendorPaths) {
1961
2303
  const files = Array.isArray(pkg.files) ? [...pkg.files] : [];
1962
- for (const entry of [...STANDARD_FILES, ...manifestPaths]) if (!files.includes(entry)) files.push(entry);
2304
+ for (const entry of [...STANDARD_FILES, ...vendorPaths]) if (!files.includes(entry)) files.push(entry);
1963
2305
  return {
1964
2306
  ...pkg,
1965
2307
  files
@@ -2034,7 +2376,7 @@ function planCatalogs(state, opts, manifest, notes) {
2034
2376
  }
2035
2377
  /** Plans the init run. Throws on a guard failure (an existing manifest without `--force`; `--npm`
2036
2378
  * with no `package.json`) before returning any plan, so the caller writes nothing on a guard trip. */
2037
- function planInit(state, opts, rootDirName, resolveManifestPath) {
2379
+ function planInit(state, opts, rootDirName, resolveShippedPaths) {
2038
2380
  if (opts.npm && state.packageJson === null) throw new Error("error: --npm requires a package.json at the project root");
2039
2381
  if (state.manifestExists && !opts.force) throw new Error("plugin.json already exists — pass --force to overwrite");
2040
2382
  const manifest = buildManifest(opts.name ?? rootDirName, opts.vendors);
@@ -2055,8 +2397,8 @@ function planInit(state, opts, rootDirName, resolveManifestPath) {
2055
2397
  }
2056
2398
  let packageJson = null;
2057
2399
  if (opts.npm) {
2058
- const manifestPaths = (opts.vendors.length > 0 ? opts.vendors : ["claude-code"]).map(resolveManifestPath).filter((p) => Boolean(p));
2059
- packageJson = wireFiles(state.packageJson, manifestPaths);
2400
+ const wireVendors = opts.vendors.length > 0 ? opts.vendors : ["claude-code"];
2401
+ packageJson = wireFiles(state.packageJson, wireVendors.flatMap(resolveShippedPaths));
2060
2402
  rows.push({
2061
2403
  path: "package.json",
2062
2404
  action: "updated"
@@ -2097,7 +2439,7 @@ function initCommand$1(deps = { fs: realInitFs }) {
2097
2439
  force: Boolean(opts.force),
2098
2440
  npm: Boolean(opts.npm),
2099
2441
  marketplace: opts.marketplace !== false
2100
- }, path.basename(root), (vendor) => VENDOR_OUTPUT[vendor]);
2442
+ }, path.basename(root), (vendor) => VENDOR_SHIPPED_PATHS[vendor] ?? []);
2101
2443
  deps.fs.apply(root, plan);
2102
2444
  output({
2103
2445
  created: plan.rows.filter((r) => r.action === "created").map((r) => r.path),
@@ -245,6 +245,20 @@ ln -sf .mcp.json mcp.json
245
245
 
246
246
  If the repo needs explicit symlink tracking: `mcp.json symlink` in `.gitattributes`. MCP server startup failures are non-fatal.
247
247
 
248
+ ### Pinning a self-published server to the plugin version
249
+
250
+ A plugin whose MCP server is its own npm package writes an unpinned invocation — `{"command": "npx", "args": ["-y", "my-server", "mcp"]}` — and a consumer on plugin 1.4.0 then gets 1.4.0's skills alongside whatever `npx` resolves as latest for the server. Mark the entry and `build` stamps the version on:
251
+
252
+ ```json
253
+ { "command": "npx", "args": ["-y", "my-server", "mcp"], "pinToPluginVersion": true }
254
+ ```
255
+
256
+ - **The marker is required.** `build` never name-matches — a plugin may publish its server under a package name that is not the plugin's, and an unrelated `npx -y widget-cli` must never be stamped with this plugin's version.
257
+ - **The marker never reaches a runtime.** It is a build directive, not part of any vendor's schema, so `build` strips it from every derived manifest and derived `mcp.json`. The authored files are left as written.
258
+ - An already-pinned specifier is **overwritten** with the manifest version, and the build warns naming the version it replaced. Do not hand-pin a marked entry.
259
+ - A marked entry the build cannot pin — the `command` is not `npx`/`upx`, the manifest declares no `version`, or `args` carry no package specifier — is warned about and left alone; the build stays green.
260
+ - `copilot-cli` reads the canonical manifest directly, so it has no derived manifest to receive the pin; the build warns rather than pretending otherwise.
261
+
248
262
  ## Environment Variable Mapping
249
263
 
250
264
  | Canonical | Claude Code | Cursor | Codex | Copilot CLI |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "universal-plugin",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Universal AI agent plugin build tool",
5
5
  "keywords": [
6
6
  "agent-plugin",
@@ -30,9 +30,11 @@
30
30
  "dist",
31
31
  "governances",
32
32
  "plugin.json",
33
+ "schema",
33
34
  ".claude-plugin",
34
35
  ".cursor-plugin",
35
36
  ".codex-plugin",
37
+ "com.github.copilot",
36
38
  "skills",
37
39
  "agents"
38
40
  ],
package/plugin.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
3
3
  "name": "universal-plugin",
4
- "version": "0.6.0",
4
+ "version": "0.7.0",
5
5
  "description": "Research and design toolkit for building universal AI coding agent plugins that work across Claude Code, Cursor, Codex, and GitHub Copilot CLI.",
6
6
  "author": {
7
7
  "name": "unional"
@@ -0,0 +1,10 @@
1
+ # Vendored schemas
2
+
3
+ `claude-code-marketplace.json` is the official Claude Code marketplace schema, fetched verbatim from
4
+ <https://json.schemastore.org/claude-code-marketplace.json> (the copy generated 2026-04-23) on
5
+ 2026-08-19.
6
+
7
+ It ships nowhere: `package.json` `files` does not list this directory. It exists so
8
+ `src/marketplace/validation.schema.test.ts` can hold this project's catalog rules against the
9
+ runtime's own schema without reaching the network in a test, and so a re-fetch is a reviewable diff.
10
+ Re-fetch it when Claude Code's catalog shape moves, then run the tests.