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/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) if (plugin.metadata[field] !== void 0) result[field] = plugin.metadata[field];
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 file for these — writing
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} — 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`);
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
- 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
+ } : {
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
- 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) {
895
1364
  const skillsCfg = universalPluginExtension(manifest).skills;
896
- const skillsPath = typeof skillsCfg === "string" ? skillsCfg : "./skills/";
897
- const skillsDir = path.resolve(root, skillsPath);
898
- if (!fs.existsSync(skillsDir)) return [];
899
- 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;
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, manifestPaths) {
2302
+ function wireFiles(pkg, vendorPaths) {
1713
2303
  const files = Array.isArray(pkg.files) ? [...pkg.files] : [];
1714
- 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);
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, resolveManifestPath) {
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 manifestPaths = (opts.vendors.length > 0 ? opts.vendors : ["claude-code"]).map(resolveManifestPath).filter((p) => Boolean(p));
1809
- packageJson = wireFiles(state.packageJson, manifestPaths);
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) => VENDOR_OUTPUT[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) assertContained(root, path.join(root, artifact.path), fs, "selected artifact");
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