universal-plugin 0.5.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 +727 -43
- package/governances/plugin-design.md +14 -0
- package/package.json +4 -1
- package/plugin.json +1 -1
- package/readme.md +4 -0
- package/schema/README.md +10 -0
- package/schema/claude-code-marketplace.json +1939 -0
- package/schema/extension.schema.json +846 -0
- package/skills/doctor/README.md +3 -1
- package/skills/doctor/SKILL.md +27 -6
- package/skills/doctor/scripts/doctor.mjs +90 -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/skills/marketplace/README.md +14 -2
- package/skills/marketplace/SKILL.md +45 -6
- package/skills/marketplace/scripts/validate.mjs +11 -0
package/dist/cli.mjs
CHANGED
|
@@ -423,9 +423,26 @@ const COMMON_METADATA = [
|
|
|
423
423
|
function assertMarketplaceName(value, label) {
|
|
424
424
|
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`);
|
|
425
425
|
}
|
|
426
|
+
/** A manifest field carried into a catalog entry, in the shape the catalog schema states. A manifest
|
|
427
|
+
* written from a `package.json` carries `repository` as `{ type, url }`, and every catalog wants the
|
|
428
|
+
* URL alone — Claude Code rejects the object (`plugins[].repository: expected string`). A value that
|
|
429
|
+
* cannot be reduced to the right type is dropped rather than written: an entry missing an optional
|
|
430
|
+
* field still installs, an entry with the wrong type installs nowhere. */
|
|
431
|
+
function catalogValue(field, value) {
|
|
432
|
+
if (field === "keywords") return Array.isArray(value) && value.every((item) => typeof item === "string") ? value : void 0;
|
|
433
|
+
if (field === "repository" && typeof value === "object" && value !== null && !Array.isArray(value)) {
|
|
434
|
+
const url = value.url;
|
|
435
|
+
return typeof url === "string" ? url.replace(/^git\+/, "") : void 0;
|
|
436
|
+
}
|
|
437
|
+
return typeof value === "string" ? value : void 0;
|
|
438
|
+
}
|
|
426
439
|
function commonMetadata(plugin) {
|
|
427
440
|
const result = {};
|
|
428
|
-
for (const field of COMMON_METADATA)
|
|
441
|
+
for (const field of COMMON_METADATA) {
|
|
442
|
+
if (plugin.metadata[field] === void 0) continue;
|
|
443
|
+
const value = catalogValue(field, plugin.metadata[field]);
|
|
444
|
+
if (value !== void 0) result[field] = value;
|
|
445
|
+
}
|
|
429
446
|
return result;
|
|
430
447
|
}
|
|
431
448
|
function json(value) {
|
|
@@ -665,6 +682,374 @@ function gatherCatalogRepo(root) {
|
|
|
665
682
|
};
|
|
666
683
|
}
|
|
667
684
|
//#endregion
|
|
685
|
+
//#region src/marketplace/validation.ts
|
|
686
|
+
function isObject(value) {
|
|
687
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
688
|
+
}
|
|
689
|
+
function typeName(value) {
|
|
690
|
+
if (value === null) return "null";
|
|
691
|
+
if (Array.isArray(value)) return "array";
|
|
692
|
+
return typeof value;
|
|
693
|
+
}
|
|
694
|
+
function checkString(issues, path, value, { required = false } = {}) {
|
|
695
|
+
if (value === void 0) {
|
|
696
|
+
if (required) issues.push({
|
|
697
|
+
path,
|
|
698
|
+
message: "is required"
|
|
699
|
+
});
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
if (typeof value !== "string") {
|
|
703
|
+
issues.push({
|
|
704
|
+
path,
|
|
705
|
+
message: `must be a string, not ${typeName(value)}`
|
|
706
|
+
});
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
if (required && value.trim() === "") issues.push({
|
|
710
|
+
path,
|
|
711
|
+
message: "must not be empty"
|
|
712
|
+
});
|
|
713
|
+
}
|
|
714
|
+
function checkStringArray(issues, path, value) {
|
|
715
|
+
if (value === void 0) return;
|
|
716
|
+
if (!Array.isArray(value)) {
|
|
717
|
+
issues.push({
|
|
718
|
+
path,
|
|
719
|
+
message: `must be an array of strings, not ${typeName(value)}`
|
|
720
|
+
});
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
723
|
+
value.forEach((item, index) => {
|
|
724
|
+
checkString(issues, `${path}[${index}]`, item);
|
|
725
|
+
});
|
|
726
|
+
}
|
|
727
|
+
/** `owner` and a plugin's `author` share one shape: an object carrying a required `name`. A string
|
|
728
|
+
* there is the mistake `package.json`'s `"author": "Name <email>"` invites; Claude Code reports
|
|
729
|
+
* `expected object, received string` and refuses the catalog. */
|
|
730
|
+
function checkPerson(issues, path, value, { required = false } = {}) {
|
|
731
|
+
if (value === void 0) {
|
|
732
|
+
if (required) issues.push({
|
|
733
|
+
path,
|
|
734
|
+
message: "is required"
|
|
735
|
+
});
|
|
736
|
+
return;
|
|
737
|
+
}
|
|
738
|
+
if (!isObject(value)) {
|
|
739
|
+
const remedy = typeof value === "string" ? ` — write { "name": ${JSON.stringify(value)} }` : "";
|
|
740
|
+
issues.push({
|
|
741
|
+
path,
|
|
742
|
+
message: `must be an object with a name, not ${typeName(value)}${remedy}`
|
|
743
|
+
});
|
|
744
|
+
return;
|
|
745
|
+
}
|
|
746
|
+
checkString(issues, `${path}.name`, value.name, { required: true });
|
|
747
|
+
checkString(issues, `${path}.email`, value.email);
|
|
748
|
+
checkString(issues, `${path}.url`, value.url);
|
|
749
|
+
}
|
|
750
|
+
/** The source forms the official schema accepts: a `./`-prefixed repository-relative path, or one of
|
|
751
|
+
* the tagged remote objects. */
|
|
752
|
+
const REMOTE_SOURCE_KEYS = {
|
|
753
|
+
npm: ["package"],
|
|
754
|
+
url: ["url"],
|
|
755
|
+
github: ["repo"],
|
|
756
|
+
"git-subdir": ["url", "path"]
|
|
757
|
+
};
|
|
758
|
+
function checkClaudeSource(issues, path, value) {
|
|
759
|
+
if (value === void 0) {
|
|
760
|
+
issues.push({
|
|
761
|
+
path,
|
|
762
|
+
message: "is required"
|
|
763
|
+
});
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
766
|
+
if (typeof value === "string") {
|
|
767
|
+
if (!value.startsWith("./")) issues.push({
|
|
768
|
+
path,
|
|
769
|
+
message: `must be a "./"-prefixed repository-relative path, not ${JSON.stringify(value)}`
|
|
770
|
+
});
|
|
771
|
+
return;
|
|
772
|
+
}
|
|
773
|
+
if (!isObject(value)) {
|
|
774
|
+
issues.push({
|
|
775
|
+
path,
|
|
776
|
+
message: `must be a "./" path or a source object, not ${typeName(value)}`
|
|
777
|
+
});
|
|
778
|
+
return;
|
|
779
|
+
}
|
|
780
|
+
const kind = value.source;
|
|
781
|
+
if (typeof kind !== "string" || !(kind in REMOTE_SOURCE_KEYS)) {
|
|
782
|
+
issues.push({
|
|
783
|
+
path: `${path}.source`,
|
|
784
|
+
message: `must be one of ${Object.keys(REMOTE_SOURCE_KEYS).join(", ")}`
|
|
785
|
+
});
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
for (const key of REMOTE_SOURCE_KEYS[kind]) checkString(issues, `${path}.${key}`, value[key], { required: true });
|
|
789
|
+
}
|
|
790
|
+
function checkClaudeEntry(issues, path, entry) {
|
|
791
|
+
if (!isObject(entry)) {
|
|
792
|
+
issues.push({
|
|
793
|
+
path,
|
|
794
|
+
message: `must be an object, not ${typeName(entry)}`
|
|
795
|
+
});
|
|
796
|
+
return;
|
|
797
|
+
}
|
|
798
|
+
checkString(issues, `${path}.name`, entry.name, { required: true });
|
|
799
|
+
checkClaudeSource(issues, `${path}.source`, entry.source);
|
|
800
|
+
checkString(issues, `${path}.version`, entry.version);
|
|
801
|
+
checkString(issues, `${path}.description`, entry.description);
|
|
802
|
+
checkString(issues, `${path}.homepage`, entry.homepage);
|
|
803
|
+
if (isObject(entry.repository) && typeof entry.repository.url === "string") issues.push({
|
|
804
|
+
path: `${path}.repository`,
|
|
805
|
+
message: `must be a string, not object — write ${JSON.stringify(entry.repository.url)}`
|
|
806
|
+
});
|
|
807
|
+
else checkString(issues, `${path}.repository`, entry.repository);
|
|
808
|
+
checkString(issues, `${path}.license`, entry.license);
|
|
809
|
+
checkString(issues, `${path}.category`, entry.category);
|
|
810
|
+
checkPerson(issues, `${path}.author`, entry.author);
|
|
811
|
+
checkStringArray(issues, `${path}.keywords`, entry.keywords);
|
|
812
|
+
checkStringArray(issues, `${path}.tags`, entry.tags);
|
|
813
|
+
}
|
|
814
|
+
function validateClaudeShaped(catalog) {
|
|
815
|
+
const issues = [];
|
|
816
|
+
checkString(issues, "name", catalog.name, { required: true });
|
|
817
|
+
checkPerson(issues, "owner", catalog.owner, { required: true });
|
|
818
|
+
checkString(issues, "description", catalog.description);
|
|
819
|
+
checkString(issues, "version", catalog.version);
|
|
820
|
+
if (catalog.plugins === void 0) {
|
|
821
|
+
issues.push({
|
|
822
|
+
path: "plugins",
|
|
823
|
+
message: "is required"
|
|
824
|
+
});
|
|
825
|
+
return issues;
|
|
826
|
+
}
|
|
827
|
+
if (!Array.isArray(catalog.plugins)) {
|
|
828
|
+
issues.push({
|
|
829
|
+
path: "plugins",
|
|
830
|
+
message: `must be an array, not ${typeName(catalog.plugins)}`
|
|
831
|
+
});
|
|
832
|
+
return issues;
|
|
833
|
+
}
|
|
834
|
+
catalog.plugins.forEach((entry, index) => {
|
|
835
|
+
checkClaudeEntry(issues, `plugins[${index}]`, entry);
|
|
836
|
+
});
|
|
837
|
+
return issues;
|
|
838
|
+
}
|
|
839
|
+
/** Codex reads a document of its own: no `owner`, a display name under `interface`, and an object
|
|
840
|
+
* `source` naming a local path (`.research/local-marketplaces`, E-CODEX-M11). It tolerates extra
|
|
841
|
+
* keys and requires no entry `version`. */
|
|
842
|
+
function validateCodex(catalog) {
|
|
843
|
+
const issues = [];
|
|
844
|
+
checkString(issues, "name", catalog.name, { required: true });
|
|
845
|
+
if (catalog.interface !== void 0 && !isObject(catalog.interface)) issues.push({
|
|
846
|
+
path: "interface",
|
|
847
|
+
message: `must be an object, not ${typeName(catalog.interface)}`
|
|
848
|
+
});
|
|
849
|
+
else if (isObject(catalog.interface)) checkString(issues, "interface.displayName", catalog.interface.displayName);
|
|
850
|
+
if (catalog.plugins === void 0) {
|
|
851
|
+
issues.push({
|
|
852
|
+
path: "plugins",
|
|
853
|
+
message: "is required"
|
|
854
|
+
});
|
|
855
|
+
return issues;
|
|
856
|
+
}
|
|
857
|
+
if (!Array.isArray(catalog.plugins)) {
|
|
858
|
+
issues.push({
|
|
859
|
+
path: "plugins",
|
|
860
|
+
message: `must be an array, not ${typeName(catalog.plugins)}`
|
|
861
|
+
});
|
|
862
|
+
return issues;
|
|
863
|
+
}
|
|
864
|
+
catalog.plugins.forEach((entry, index) => {
|
|
865
|
+
const path = `plugins[${index}]`;
|
|
866
|
+
if (!isObject(entry)) {
|
|
867
|
+
issues.push({
|
|
868
|
+
path,
|
|
869
|
+
message: `must be an object, not ${typeName(entry)}`
|
|
870
|
+
});
|
|
871
|
+
return;
|
|
872
|
+
}
|
|
873
|
+
checkString(issues, `${path}.name`, entry.name, { required: true });
|
|
874
|
+
checkString(issues, `${path}.version`, entry.version);
|
|
875
|
+
checkString(issues, `${path}.category`, entry.category);
|
|
876
|
+
if (typeof entry.source === "string") checkClaudeSource(issues, `${path}.source`, entry.source);
|
|
877
|
+
else if (isObject(entry.source)) {
|
|
878
|
+
checkString(issues, `${path}.source.source`, entry.source.source, { required: true });
|
|
879
|
+
if (entry.source.source === "local") checkClaudeSource(issues, `${path}.source.path`, entry.source.path);
|
|
880
|
+
} else issues.push({
|
|
881
|
+
path: `${path}.source`,
|
|
882
|
+
message: "is required"
|
|
883
|
+
});
|
|
884
|
+
});
|
|
885
|
+
return issues;
|
|
886
|
+
}
|
|
887
|
+
/** Every issue a target's runtime would raise against this catalog, empty when it loads. */
|
|
888
|
+
function validateCatalog(target, catalog) {
|
|
889
|
+
if (!isObject(catalog)) return [{
|
|
890
|
+
path: "",
|
|
891
|
+
message: `catalog must be a JSON object, not ${typeName(catalog)}`
|
|
892
|
+
}];
|
|
893
|
+
return target === "codex" ? validateCodex(catalog) : validateClaudeShaped(catalog);
|
|
894
|
+
}
|
|
895
|
+
/** The same check against a catalog still in its serialized form. Text that does not parse is one
|
|
896
|
+
* issue rather than a thrown error, so a caller checking four files reports all four. */
|
|
897
|
+
function validateCatalogContent(target, content) {
|
|
898
|
+
let parsed;
|
|
899
|
+
try {
|
|
900
|
+
parsed = JSON.parse(content);
|
|
901
|
+
} catch (err) {
|
|
902
|
+
return [{
|
|
903
|
+
path: "",
|
|
904
|
+
message: `is not valid JSON: ${err instanceof Error ? err.message : String(err)}`
|
|
905
|
+
}];
|
|
906
|
+
}
|
|
907
|
+
return validateCatalog(target, parsed);
|
|
908
|
+
}
|
|
909
|
+
/** One error message naming the file and every issue in it, for a caller that fails loud. */
|
|
910
|
+
function formatCatalogIssues(file, issues) {
|
|
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
|
+
}
|
|
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
|
|
668
1053
|
//#region src/build/build.ts
|
|
669
1054
|
/** Where each vendor reads its manifest, relative to the project root. Shared with
|
|
670
1055
|
* `plugin init --npm`, which wires exactly these paths into `package.json` `files`. */
|
|
@@ -675,17 +1060,68 @@ const VENDOR_OUTPUT = {
|
|
|
675
1060
|
"copilot-cli": "plugin.json"
|
|
676
1061
|
};
|
|
677
1062
|
const KNOWN_VENDORS = new Set(Object.keys(VENDOR_OUTPUT));
|
|
678
|
-
/** Vendors the canonical root manifest serves as-is. The build derives no
|
|
679
|
-
* 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. */
|
|
680
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
|
+
};
|
|
681
1096
|
/** Where every vendor looks for a plugin's hooks when the manifest declares none
|
|
682
1097
|
* (`.research/hook-event-survey/conclusion.md`). */
|
|
683
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";
|
|
684
1103
|
const UP_NAMESPACE$1 = "org.cyberuni.universal-plugin";
|
|
685
1104
|
/** Reads universal-plugin's config block from the canonical manifest's extensions map. */
|
|
686
1105
|
function universalPluginExtension(manifest) {
|
|
687
1106
|
return manifest.extensions?.[UP_NAMESPACE$1] ?? {};
|
|
688
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
|
+
}
|
|
689
1125
|
function readManifest(root) {
|
|
690
1126
|
const manifestPath = path.join(root, "plugin.json");
|
|
691
1127
|
if (!fs.existsSync(manifestPath)) throw new Error(`No plugin.json found at ${root}`);
|
|
@@ -731,6 +1167,8 @@ function buildPlugin(root, opts = {}) {
|
|
|
731
1167
|
vendors = [opts.vendor];
|
|
732
1168
|
}
|
|
733
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.`);
|
|
734
1172
|
warnings.push("No vendors declared in harnesses — nothing to build");
|
|
735
1173
|
return {
|
|
736
1174
|
vendors: [],
|
|
@@ -747,8 +1185,11 @@ function buildPlugin(root, opts = {}) {
|
|
|
747
1185
|
const { $schema: _schema, extensions: _extensions, ...metadata } = manifest;
|
|
748
1186
|
const { vendors: _vendors, packagePath: _packagePath, harnesses: _harnesses, dependencies: declaredDependencies, ...componentConfig } = uext;
|
|
749
1187
|
warnings.push(...validateDependencies(declaredDependencies).warnings);
|
|
750
|
-
const skills = readSkills(root, manifest);
|
|
1188
|
+
const skills = readSkills(root, manifest, warnings);
|
|
751
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));
|
|
752
1193
|
for (const vendor of vendors) {
|
|
753
1194
|
const relPath = VENDOR_OUTPUT[vendor];
|
|
754
1195
|
const outputPath = path.join(root, relPath);
|
|
@@ -764,11 +1205,17 @@ function buildPlugin(root, opts = {}) {
|
|
|
764
1205
|
warnings.push(...dependencies.warnings);
|
|
765
1206
|
if (dependencies.dependencies) vendorManifest["dependencies"] = dependencies.dependencies;
|
|
766
1207
|
if (CANONICAL_SERVED.has(vendor)) {
|
|
767
|
-
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`);
|
|
768
1210
|
const overrides = Object.keys(vendorFields);
|
|
769
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`);
|
|
770
1212
|
writeSkillArtifacts(vendor, skills, opts, written, warnings);
|
|
771
|
-
|
|
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
|
+
} : {
|
|
772
1219
|
vendor,
|
|
773
1220
|
path: relPath,
|
|
774
1221
|
status: "canonical"
|
|
@@ -779,6 +1226,9 @@ function buildPlugin(root, opts = {}) {
|
|
|
779
1226
|
const derivedHooksPath = path.join(outputDir, "hooks.json");
|
|
780
1227
|
if (hooks?.changed) if (hooks.hooks) vendorManifest["hooks"] = `./${path.dirname(relPath).split(path.sep).join("/")}/hooks.json`;
|
|
781
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`;
|
|
782
1232
|
if (opts.verbose) {
|
|
783
1233
|
console.log(`[${vendor}] → ${outputPath}`);
|
|
784
1234
|
for (const key of Object.keys(vendorFields)) console.log(` + ${key} (from harnesses.${vendor})`);
|
|
@@ -794,6 +1244,7 @@ function buildPlugin(root, opts = {}) {
|
|
|
794
1244
|
if (hooks.hooks) writeArtifact(derivedHooksPath, `${JSON.stringify(hooks.hooks, null, indent)}\n`, opts, written);
|
|
795
1245
|
else if (!opts.dryRun && fs.existsSync(derivedHooksPath)) fs.unlinkSync(derivedHooksPath);
|
|
796
1246
|
}
|
|
1247
|
+
if (mcp?.changed && declaredMcp && !declaredMcp.inline) writeArtifact(derivedMcpPath, `${JSON.stringify({ mcpServers: mcp.servers }, null, indent)}\n`, opts, written);
|
|
797
1248
|
writeSkillArtifacts(vendor, skills, opts, written, warnings);
|
|
798
1249
|
rows.push({
|
|
799
1250
|
vendor,
|
|
@@ -848,6 +1299,8 @@ function refreshCatalogs(root, manifest, vendors, opts, written, warnings) {
|
|
|
848
1299
|
if (existing === void 0) continue;
|
|
849
1300
|
try {
|
|
850
1301
|
const artifact = refreshCatalogEntry(target, plugin, existing);
|
|
1302
|
+
const issues = validateCatalogContent(target, artifact.content);
|
|
1303
|
+
if (issues.length > 0) warnings.push(formatCatalogIssues(relative, issues));
|
|
851
1304
|
if (sameCatalogContent(artifact.content, existing)) {
|
|
852
1305
|
rows.push({
|
|
853
1306
|
path: relative,
|
|
@@ -891,12 +1344,40 @@ function writeSkillArtifacts(vendor, skills, opts, written, warnings) {
|
|
|
891
1344
|
}
|
|
892
1345
|
}
|
|
893
1346
|
}
|
|
894
|
-
|
|
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) {
|
|
895
1364
|
const skillsCfg = universalPluginExtension(manifest).skills;
|
|
896
|
-
const
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
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;
|
|
900
1381
|
}
|
|
901
1382
|
function listSkillFiles(dir) {
|
|
902
1383
|
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
|
|
@@ -936,6 +1417,87 @@ function withClaudeInvocationFlags(skill) {
|
|
|
936
1417
|
if (skill.invocationPolicy === "model") lines.push("user-invocable: false");
|
|
937
1418
|
return `${skill.content.slice(0, match.index)}---\n${lines.join("\n")}\n---${skill.content.slice(match.index + match[0].length)}`;
|
|
938
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
|
+
}
|
|
939
1501
|
function writeArtifact(outputPath, content, opts, written) {
|
|
940
1502
|
if (!opts.dryRun) {
|
|
941
1503
|
if (opts.clean && fs.existsSync(outputPath)) fs.unlinkSync(outputPath);
|
|
@@ -990,9 +1552,60 @@ function dedupeDrops(drops) {
|
|
|
990
1552
|
return true;
|
|
991
1553
|
});
|
|
992
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
|
+
}
|
|
993
1605
|
//#endregion
|
|
994
1606
|
//#region src/build/cli.ts
|
|
995
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";
|
|
996
1609
|
function buildCommand() {
|
|
997
1610
|
const cmd = new Command("build").description("Generate vendor manifests from plugin.json");
|
|
998
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) => {
|
|
@@ -1028,7 +1641,7 @@ function buildCommand() {
|
|
|
1028
1641
|
})),
|
|
1029
1642
|
summary: (canonical > 0 ? `${counts}, served by plugin.json ${canonical}` : counts) + catalogSummary
|
|
1030
1643
|
});
|
|
1031
|
-
process.stderr.write(NEXT_STEP$2);
|
|
1644
|
+
process.stderr.write(result.vendors.length === 0 ? NEXT_STEP_NOTHING_BUILT : NEXT_STEP$2);
|
|
1032
1645
|
if (failed > 0) process.exitCode = 1;
|
|
1033
1646
|
} catch (err) {
|
|
1034
1647
|
process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
|
|
@@ -1071,29 +1684,6 @@ function realPinFs(skillsDir) {
|
|
|
1071
1684
|
};
|
|
1072
1685
|
}
|
|
1073
1686
|
//#endregion
|
|
1074
|
-
//#region src/pin/pin.ts
|
|
1075
|
-
const PIN_PATTERN = /(npx|upx)\s+(?:--yes\s+|-y\s+)?([@a-z0-9/._-]+)@(\S+)/g;
|
|
1076
|
-
/** Strips a trailing backtick, quote, or paren that isn't part of the version token. */
|
|
1077
|
-
function stripTrailing(raw) {
|
|
1078
|
-
return raw.replace(/[`'")]+$/, "");
|
|
1079
|
-
}
|
|
1080
|
-
function extractPins(text) {
|
|
1081
|
-
const pins = [];
|
|
1082
|
-
for (const match of text.matchAll(PIN_PATTERN)) {
|
|
1083
|
-
const runner = match[1];
|
|
1084
|
-
const pkg = match[2];
|
|
1085
|
-
const current = match[3];
|
|
1086
|
-
if (!runner || !pkg || !current) continue;
|
|
1087
|
-
pins.push({
|
|
1088
|
-
pkg,
|
|
1089
|
-
current: stripTrailing(current),
|
|
1090
|
-
file: "",
|
|
1091
|
-
runner
|
|
1092
|
-
});
|
|
1093
|
-
}
|
|
1094
|
-
return pins;
|
|
1095
|
-
}
|
|
1096
|
-
//#endregion
|
|
1097
1687
|
//#region src/bundle/bundle.ts
|
|
1098
1688
|
function escapeRegExp(value) {
|
|
1099
1689
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
@@ -1709,9 +2299,9 @@ const STANDARD_FILES = ["plugin.json", "skills/"];
|
|
|
1709
2299
|
* the array when absent and never duplicating an entry. Other fields and existing entries are
|
|
1710
2300
|
* preserved. The base goes in regardless of `--vendor`: a package that ships only
|
|
1711
2301
|
* `.claude-plugin/plugin.json` has published a Claude Code plugin, not a standard one. */
|
|
1712
|
-
function wireFiles(pkg,
|
|
2302
|
+
function wireFiles(pkg, vendorPaths) {
|
|
1713
2303
|
const files = Array.isArray(pkg.files) ? [...pkg.files] : [];
|
|
1714
|
-
for (const entry of [...STANDARD_FILES, ...
|
|
2304
|
+
for (const entry of [...STANDARD_FILES, ...vendorPaths]) if (!files.includes(entry)) files.push(entry);
|
|
1715
2305
|
return {
|
|
1716
2306
|
...pkg,
|
|
1717
2307
|
files
|
|
@@ -1775,6 +2365,8 @@ function planCatalogs(state, opts, manifest, notes) {
|
|
|
1775
2365
|
const target = VENDOR_TARGETS[vendor];
|
|
1776
2366
|
if (!target) continue;
|
|
1777
2367
|
const artifact = mergeCatalogEntry(target, metadata, plugin, (path) => repo.catalogs[path]);
|
|
2368
|
+
const issues = validateCatalogContent(target, artifact.content);
|
|
2369
|
+
if (issues.length > 0) notes.push(formatCatalogIssues(artifact.path, issues));
|
|
1778
2370
|
catalogs.push({
|
|
1779
2371
|
path: `${toPluginRoot}${artifact.path}`,
|
|
1780
2372
|
content: artifact.content
|
|
@@ -1784,7 +2376,7 @@ function planCatalogs(state, opts, manifest, notes) {
|
|
|
1784
2376
|
}
|
|
1785
2377
|
/** Plans the init run. Throws on a guard failure (an existing manifest without `--force`; `--npm`
|
|
1786
2378
|
* with no `package.json`) before returning any plan, so the caller writes nothing on a guard trip. */
|
|
1787
|
-
function planInit(state, opts, rootDirName,
|
|
2379
|
+
function planInit(state, opts, rootDirName, resolveShippedPaths) {
|
|
1788
2380
|
if (opts.npm && state.packageJson === null) throw new Error("error: --npm requires a package.json at the project root");
|
|
1789
2381
|
if (state.manifestExists && !opts.force) throw new Error("plugin.json already exists — pass --force to overwrite");
|
|
1790
2382
|
const manifest = buildManifest(opts.name ?? rootDirName, opts.vendors);
|
|
@@ -1805,8 +2397,8 @@ function planInit(state, opts, rootDirName, resolveManifestPath) {
|
|
|
1805
2397
|
}
|
|
1806
2398
|
let packageJson = null;
|
|
1807
2399
|
if (opts.npm) {
|
|
1808
|
-
const
|
|
1809
|
-
packageJson = wireFiles(state.packageJson,
|
|
2400
|
+
const wireVendors = opts.vendors.length > 0 ? opts.vendors : ["claude-code"];
|
|
2401
|
+
packageJson = wireFiles(state.packageJson, wireVendors.flatMap(resolveShippedPaths));
|
|
1810
2402
|
rows.push({
|
|
1811
2403
|
path: "package.json",
|
|
1812
2404
|
action: "updated"
|
|
@@ -1847,7 +2439,7 @@ function initCommand$1(deps = { fs: realInitFs }) {
|
|
|
1847
2439
|
force: Boolean(opts.force),
|
|
1848
2440
|
npm: Boolean(opts.npm),
|
|
1849
2441
|
marketplace: opts.marketplace !== false
|
|
1850
|
-
}, path.basename(root), (vendor) =>
|
|
2442
|
+
}, path.basename(root), (vendor) => VENDOR_SHIPPED_PATHS[vendor] ?? []);
|
|
1851
2443
|
deps.fs.apply(root, plan);
|
|
1852
2444
|
output({
|
|
1853
2445
|
created: plan.rows.filter((r) => r.action === "created").map((r) => r.path),
|
|
@@ -2378,7 +2970,11 @@ function initializeMarketplace(rootInput, opts = {}, fs = realMarketplaceFs) {
|
|
|
2378
2970
|
target,
|
|
2379
2971
|
artifacts: serializeTarget(target, metadata, plugins)
|
|
2380
2972
|
}));
|
|
2381
|
-
for (const { artifacts } of planned) for (const artifact of artifacts)
|
|
2973
|
+
for (const { target, artifacts } of planned) for (const artifact of artifacts) {
|
|
2974
|
+
assertContained(root, path.join(root, artifact.path), fs, "selected artifact");
|
|
2975
|
+
const issues = validateCatalogContent(target, artifact.content);
|
|
2976
|
+
if (issues.length > 0) throw new Error(formatCatalogIssues(artifact.path, issues));
|
|
2977
|
+
}
|
|
2382
2978
|
const conflicts = [];
|
|
2383
2979
|
for (const entry of planned) for (const artifact of entry.artifacts) {
|
|
2384
2980
|
const output = path.join(root, artifact.path);
|
|
@@ -2405,6 +3001,66 @@ function initializeMarketplace(rootInput, opts = {}, fs = realMarketplaceFs) {
|
|
|
2405
3001
|
return results;
|
|
2406
3002
|
}
|
|
2407
3003
|
//#endregion
|
|
3004
|
+
//#region src/marketplace/validate.ts
|
|
3005
|
+
/** A `./`-prefixed source names a directory inside the repository, and Claude Code resolves it
|
|
3006
|
+
* against the directory holding `.claude-plugin/`. A source pointing nowhere passes every schema
|
|
3007
|
+
* check and still installs nothing, so the on-disk check belongs here rather than in the rules. */
|
|
3008
|
+
function checkSources(root, fs, catalog) {
|
|
3009
|
+
if (typeof catalog !== "object" || catalog === null) return [];
|
|
3010
|
+
const plugins = catalog.plugins;
|
|
3011
|
+
if (!Array.isArray(plugins)) return [];
|
|
3012
|
+
const issues = [];
|
|
3013
|
+
plugins.forEach((entry, index) => {
|
|
3014
|
+
if (typeof entry !== "object" || entry === null) return;
|
|
3015
|
+
const source = entry.source;
|
|
3016
|
+
const location = typeof source === "string" ? source : typeof source === "object" && source !== null ? source.path : void 0;
|
|
3017
|
+
if (typeof location !== "string" || !location.startsWith("./")) return;
|
|
3018
|
+
if (!fs.exists(path.join(root, location))) issues.push({
|
|
3019
|
+
path: `plugins[${index}].source`,
|
|
3020
|
+
message: `points at "${location}", which does not exist`
|
|
3021
|
+
});
|
|
3022
|
+
});
|
|
3023
|
+
return issues;
|
|
3024
|
+
}
|
|
3025
|
+
/** Checks the catalogs a repository carries against the shape each runtime loads. Reads only; it
|
|
3026
|
+
* repairs nothing, because a catalog someone hand-edited is theirs to correct. */
|
|
3027
|
+
function validateMarketplace(rootInput, opts = {}, fs = realMarketplaceFs) {
|
|
3028
|
+
const root = path.resolve(rootInput);
|
|
3029
|
+
return (opts.targets && opts.targets.length > 0 ? [...new Set(opts.targets)] : [
|
|
3030
|
+
"claude",
|
|
3031
|
+
"codex",
|
|
3032
|
+
"copilot",
|
|
3033
|
+
"cursor"
|
|
3034
|
+
]).map((target) => {
|
|
3035
|
+
const relative = TARGET_CATALOG_PATHS[target];
|
|
3036
|
+
const file = path.join(root, relative);
|
|
3037
|
+
if (!fs.exists(file)) return {
|
|
3038
|
+
target,
|
|
3039
|
+
path: relative,
|
|
3040
|
+
status: opts.required ? "invalid" : "missing",
|
|
3041
|
+
issues: opts.required ? [{
|
|
3042
|
+
path: "",
|
|
3043
|
+
message: "no catalog at this path"
|
|
3044
|
+
}] : []
|
|
3045
|
+
};
|
|
3046
|
+
const content = fs.read(file);
|
|
3047
|
+
const issues = [...validateCatalogContent(target, content), ...parseAndCheckSources(root, fs, content)];
|
|
3048
|
+
return {
|
|
3049
|
+
target,
|
|
3050
|
+
path: relative,
|
|
3051
|
+
status: issues.length === 0 ? "valid" : "invalid",
|
|
3052
|
+
issues
|
|
3053
|
+
};
|
|
3054
|
+
});
|
|
3055
|
+
}
|
|
3056
|
+
function parseAndCheckSources(root, fs, content) {
|
|
3057
|
+
try {
|
|
3058
|
+
return checkSources(root, fs, JSON.parse(content));
|
|
3059
|
+
} catch {
|
|
3060
|
+
return [];
|
|
3061
|
+
}
|
|
3062
|
+
}
|
|
3063
|
+
//#endregion
|
|
2408
3064
|
//#region src/marketplace/cli.ts
|
|
2409
3065
|
function targetsFromOptions(opts) {
|
|
2410
3066
|
const targets = [
|
|
@@ -2443,8 +3099,36 @@ function initCommand() {
|
|
|
2443
3099
|
}
|
|
2444
3100
|
});
|
|
2445
3101
|
}
|
|
3102
|
+
function validateCommand() {
|
|
3103
|
+
return new Command("validate").description("Check the repository-local marketplace catalogs against the schema each runtime loads").option("--claude", "Validate the Claude marketplace catalog").option("--codex", "Validate the Codex marketplace catalog").option("--copilot", "Validate the Copilot marketplace catalog").option("--cursor", "Validate the Cursor marketplace catalog").option("--required", "Treat a selected target with no catalog as a failure").option("--format <format>", "Output format: toon or json (default: toon)").addOption(ROOT_OPTION).addHelpText("after", "\nExample:\n $ universal-plugin marketplace validate --claude\n").action((opts) => {
|
|
3104
|
+
try {
|
|
3105
|
+
if (opts.format !== void 0 && opts.format !== "toon" && opts.format !== "json") throw new Error("error: --format must be \"toon\" or \"json\"");
|
|
3106
|
+
const results = validateMarketplace(resolveRoot(opts.root), {
|
|
3107
|
+
targets: targetsFromOptions(opts),
|
|
3108
|
+
required: opts.required
|
|
3109
|
+
});
|
|
3110
|
+
output(results, {
|
|
3111
|
+
targets: results.map((row) => ({
|
|
3112
|
+
target: row.target,
|
|
3113
|
+
status: row.status,
|
|
3114
|
+
path: row.path,
|
|
3115
|
+
issues: row.issues.length
|
|
3116
|
+
})),
|
|
3117
|
+
summary: `${results.filter((row) => row.status === "invalid").length} invalid of ${results.length}`
|
|
3118
|
+
});
|
|
3119
|
+
for (const row of results.filter((row) => row.status === "invalid")) {
|
|
3120
|
+
process.stderr.write(formatCatalogIssues(row.path, row.issues));
|
|
3121
|
+
process.stderr.write("\n");
|
|
3122
|
+
}
|
|
3123
|
+
if (results.some((row) => row.status === "invalid")) process.exitCode = 1;
|
|
3124
|
+
} catch (err) {
|
|
3125
|
+
process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
|
|
3126
|
+
process.exitCode = 1;
|
|
3127
|
+
}
|
|
3128
|
+
});
|
|
3129
|
+
}
|
|
2446
3130
|
function marketplaceCommand() {
|
|
2447
|
-
return new Command("marketplace").description("Generate repository-local marketplace metadata").addCommand(initCommand());
|
|
3131
|
+
return new Command("marketplace").description("Generate repository-local marketplace metadata").addCommand(initCommand()).addCommand(validateCommand());
|
|
2448
3132
|
}
|
|
2449
3133
|
//#endregion
|
|
2450
3134
|
//#region src/prepare/fs.ts
|