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/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/com.github.copilot/agents/agentskills-specialist.agent.md +132 -0
- package/dist/cli.mjs +382 -40
- package/governances/plugin-design.md +14 -0
- package/package.json +3 -1
- package/plugin.json +1 -1
- package/schema/README.md +10 -0
- package/schema/claude-code-marketplace.json +1939 -0
- package/schema/extension.schema.json +846 -0
- package/skills/doctor/SKILL.md +16 -6
- package/skills/doctor/scripts/doctor.mjs +62 -0
- package/skills/init/README.md +4 -1
- package/skills/init/SKILL.md +35 -10
- package/skills/init/references/adopt.md +101 -13
- package/skills/init/references/detection.md +8 -2
- package/skills/init/references/vendors/copilot-cli.md +159 -11
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
|
|
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} —
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
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,
|
|
2302
|
+
function wireFiles(pkg, vendorPaths) {
|
|
1961
2303
|
const files = Array.isArray(pkg.files) ? [...pkg.files] : [];
|
|
1962
|
-
for (const entry of [...STANDARD_FILES, ...
|
|
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,
|
|
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
|
|
2059
|
-
packageJson = wireFiles(state.packageJson,
|
|
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) =>
|
|
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.
|
|
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.
|
|
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"
|
package/schema/README.md
ADDED
|
@@ -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.
|