universal-plugin 0.5.0 → 0.6.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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "universal-plugin",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Research and design toolkit for building universal AI coding agent plugins that work across Claude Code, Cursor, Codex, and GitHub Copilot CLI.",
5
5
  "author": {
6
6
  "name": "unional"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "universal-plugin",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Research and design toolkit for building universal AI coding agent plugins that work across Claude Code, Cursor, Codex, and GitHub Copilot CLI.",
5
5
  "author": {
6
6
  "name": "unional"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "universal-plugin",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Research and design toolkit for building universal AI coding agent plugins that work across Claude Code, Cursor, Codex, and GitHub Copilot CLI.",
5
5
  "author": {
6
6
  "name": "unional"
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,235 @@ 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
668
914
  //#region src/build/build.ts
669
915
  /** Where each vendor reads its manifest, relative to the project root. Shared with
670
916
  * `plugin init --npm`, which wires exactly these paths into `package.json` `files`. */
@@ -848,6 +1094,8 @@ function refreshCatalogs(root, manifest, vendors, opts, written, warnings) {
848
1094
  if (existing === void 0) continue;
849
1095
  try {
850
1096
  const artifact = refreshCatalogEntry(target, plugin, existing);
1097
+ const issues = validateCatalogContent(target, artifact.content);
1098
+ if (issues.length > 0) warnings.push(formatCatalogIssues(relative, issues));
851
1099
  if (sameCatalogContent(artifact.content, existing)) {
852
1100
  rows.push({
853
1101
  path: relative,
@@ -1775,6 +2023,8 @@ function planCatalogs(state, opts, manifest, notes) {
1775
2023
  const target = VENDOR_TARGETS[vendor];
1776
2024
  if (!target) continue;
1777
2025
  const artifact = mergeCatalogEntry(target, metadata, plugin, (path) => repo.catalogs[path]);
2026
+ const issues = validateCatalogContent(target, artifact.content);
2027
+ if (issues.length > 0) notes.push(formatCatalogIssues(artifact.path, issues));
1778
2028
  catalogs.push({
1779
2029
  path: `${toPluginRoot}${artifact.path}`,
1780
2030
  content: artifact.content
@@ -2378,7 +2628,11 @@ function initializeMarketplace(rootInput, opts = {}, fs = realMarketplaceFs) {
2378
2628
  target,
2379
2629
  artifacts: serializeTarget(target, metadata, plugins)
2380
2630
  }));
2381
- for (const { artifacts } of planned) for (const artifact of artifacts) assertContained(root, path.join(root, artifact.path), fs, "selected artifact");
2631
+ for (const { target, artifacts } of planned) for (const artifact of artifacts) {
2632
+ assertContained(root, path.join(root, artifact.path), fs, "selected artifact");
2633
+ const issues = validateCatalogContent(target, artifact.content);
2634
+ if (issues.length > 0) throw new Error(formatCatalogIssues(artifact.path, issues));
2635
+ }
2382
2636
  const conflicts = [];
2383
2637
  for (const entry of planned) for (const artifact of entry.artifacts) {
2384
2638
  const output = path.join(root, artifact.path);
@@ -2405,6 +2659,66 @@ function initializeMarketplace(rootInput, opts = {}, fs = realMarketplaceFs) {
2405
2659
  return results;
2406
2660
  }
2407
2661
  //#endregion
2662
+ //#region src/marketplace/validate.ts
2663
+ /** A `./`-prefixed source names a directory inside the repository, and Claude Code resolves it
2664
+ * against the directory holding `.claude-plugin/`. A source pointing nowhere passes every schema
2665
+ * check and still installs nothing, so the on-disk check belongs here rather than in the rules. */
2666
+ function checkSources(root, fs, catalog) {
2667
+ if (typeof catalog !== "object" || catalog === null) return [];
2668
+ const plugins = catalog.plugins;
2669
+ if (!Array.isArray(plugins)) return [];
2670
+ const issues = [];
2671
+ plugins.forEach((entry, index) => {
2672
+ if (typeof entry !== "object" || entry === null) return;
2673
+ const source = entry.source;
2674
+ const location = typeof source === "string" ? source : typeof source === "object" && source !== null ? source.path : void 0;
2675
+ if (typeof location !== "string" || !location.startsWith("./")) return;
2676
+ if (!fs.exists(path.join(root, location))) issues.push({
2677
+ path: `plugins[${index}].source`,
2678
+ message: `points at "${location}", which does not exist`
2679
+ });
2680
+ });
2681
+ return issues;
2682
+ }
2683
+ /** Checks the catalogs a repository carries against the shape each runtime loads. Reads only; it
2684
+ * repairs nothing, because a catalog someone hand-edited is theirs to correct. */
2685
+ function validateMarketplace(rootInput, opts = {}, fs = realMarketplaceFs) {
2686
+ const root = path.resolve(rootInput);
2687
+ return (opts.targets && opts.targets.length > 0 ? [...new Set(opts.targets)] : [
2688
+ "claude",
2689
+ "codex",
2690
+ "copilot",
2691
+ "cursor"
2692
+ ]).map((target) => {
2693
+ const relative = TARGET_CATALOG_PATHS[target];
2694
+ const file = path.join(root, relative);
2695
+ if (!fs.exists(file)) return {
2696
+ target,
2697
+ path: relative,
2698
+ status: opts.required ? "invalid" : "missing",
2699
+ issues: opts.required ? [{
2700
+ path: "",
2701
+ message: "no catalog at this path"
2702
+ }] : []
2703
+ };
2704
+ const content = fs.read(file);
2705
+ const issues = [...validateCatalogContent(target, content), ...parseAndCheckSources(root, fs, content)];
2706
+ return {
2707
+ target,
2708
+ path: relative,
2709
+ status: issues.length === 0 ? "valid" : "invalid",
2710
+ issues
2711
+ };
2712
+ });
2713
+ }
2714
+ function parseAndCheckSources(root, fs, content) {
2715
+ try {
2716
+ return checkSources(root, fs, JSON.parse(content));
2717
+ } catch {
2718
+ return [];
2719
+ }
2720
+ }
2721
+ //#endregion
2408
2722
  //#region src/marketplace/cli.ts
2409
2723
  function targetsFromOptions(opts) {
2410
2724
  const targets = [
@@ -2443,8 +2757,36 @@ function initCommand() {
2443
2757
  }
2444
2758
  });
2445
2759
  }
2760
+ function validateCommand() {
2761
+ 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) => {
2762
+ try {
2763
+ if (opts.format !== void 0 && opts.format !== "toon" && opts.format !== "json") throw new Error("error: --format must be \"toon\" or \"json\"");
2764
+ const results = validateMarketplace(resolveRoot(opts.root), {
2765
+ targets: targetsFromOptions(opts),
2766
+ required: opts.required
2767
+ });
2768
+ output(results, {
2769
+ targets: results.map((row) => ({
2770
+ target: row.target,
2771
+ status: row.status,
2772
+ path: row.path,
2773
+ issues: row.issues.length
2774
+ })),
2775
+ summary: `${results.filter((row) => row.status === "invalid").length} invalid of ${results.length}`
2776
+ });
2777
+ for (const row of results.filter((row) => row.status === "invalid")) {
2778
+ process.stderr.write(formatCatalogIssues(row.path, row.issues));
2779
+ process.stderr.write("\n");
2780
+ }
2781
+ if (results.some((row) => row.status === "invalid")) process.exitCode = 1;
2782
+ } catch (err) {
2783
+ process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
2784
+ process.exitCode = 1;
2785
+ }
2786
+ });
2787
+ }
2446
2788
  function marketplaceCommand() {
2447
- return new Command("marketplace").description("Generate repository-local marketplace metadata").addCommand(initCommand());
2789
+ return new Command("marketplace").description("Generate repository-local marketplace metadata").addCommand(initCommand()).addCommand(validateCommand());
2448
2790
  }
2449
2791
  //#endregion
2450
2792
  //#region src/prepare/fs.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "universal-plugin",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Universal AI agent plugin build tool",
5
5
  "keywords": [
6
6
  "agent-plugin",
@@ -44,6 +44,7 @@
44
44
  "devDependencies": {
45
45
  "@types/node": "^24.10.1",
46
46
  "@types/semver": "^7.7.1",
47
+ "ajv": "^8.20.0",
47
48
  "knip": "^6.14.1",
48
49
  "tsdown": "^0.22.0",
49
50
  "tsx": "^4.22.3",
package/plugin.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
3
3
  "name": "universal-plugin",
4
- "version": "0.5.0",
4
+ "version": "0.6.0",
5
5
  "description": "Research and design toolkit for building universal AI coding agent plugins that work across Claude Code, Cursor, Codex, and GitHub Copilot CLI.",
6
6
  "author": {
7
7
  "name": "unional"
package/readme.md CHANGED
@@ -79,8 +79,12 @@ keep their previous version.
79
79
 
80
80
  ```sh
81
81
  npx universal-plugin marketplace init --codex --root .
82
+ npx universal-plugin marketplace validate --root .
82
83
  ```
83
84
 
85
+ `validate` checks each catalog against the schema its runtime loads and names the key at fault, so a
86
+ catalog that would be refused at install time is caught in the repository.
87
+
84
88
  Codex caches a local plugin install by its marketplace entry version. After you change packaged
85
89
  plugin files: update the canonical `plugin.json` version, regenerate the catalog (add `--force` to
86
90
  replace an existing one), reinstall the plugin, then start a new Codex session. The installed copy
@@ -8,7 +8,9 @@ disk still matches it for Claude Code, Cursor, Codex, and GitHub Copilot CLI.
8
8
  `scripts/doctor.mjs` composes the shipped CLI's `plugin build --dry-run --format json` with the
9
9
  filesystem facts that build cannot see — whether each derived manifest exists, whether it predates
10
10
  the canonical manifest, whether a stale or shadowing manifest is lying around, whether the two
11
- authored version numbers still agree, and whether shipped content has moved since the version did. It emits one JSON object: `vendors`, `findings`, `ok`.
11
+ authored version numbers still agree, whether shipped content has moved since the version did, and
12
+ whether every marketplace catalog at the repository root is a shape its runtime loads. It emits one
13
+ JSON object: `vendors`, `findings`, `ok`.
12
14
 
13
15
  The skill supplies the judgment around it: which finding matters, and which skill owns its repair.
14
16
 
@@ -76,6 +76,17 @@ Each `code` below is what the script emits.
76
76
  | `no-vendors` | no vendor is declared, so the build writes nothing and no runtime reads the plugin | `/universal-plugin:init`, update route |
77
77
  | `package-path-missing` | `packagePath` names a directory with no readable `package.json` | fix `packagePath`, or create the package |
78
78
  | `unparsable-manifest` | root `plugin.json` is not valid JSON | fix the syntax error |
79
+ | `invalid-catalog` | a marketplace catalog at the repository root is not a shape its runtime loads — it is found, read, and refused at install time, in the user's terminal | `/universal-plugin:marketplace` |
80
+
81
+ ## Catalogs are checked at the repository root
82
+
83
+ The marketplace catalogs sit above the plugin in a monorepo, so the catalog check runs against the
84
+ repository root rather than `--root`. It reports only a catalog that would be **refused**: a missing
85
+ one is not a fault, and nothing here has an opinion on which catalogs a repository ought to carry.
86
+
87
+ The detail names the key at fault, so hand it to `/universal-plugin:marketplace` as it stands. An
88
+ entry's fields are derived from the plugin's `plugin.json`, and the catalog's own `name` and `owner`
89
+ are authored in the catalog — which half is at fault decides where the repair goes.
79
90
 
80
91
  ## Checking staleness properly
81
92
 
@@ -199,8 +199,36 @@ if (manifest.version !== undefined && packagePath === null) {
199
199
  }
200
200
  }
201
201
 
202
+ // The marketplace catalogs a user installs from. They sit at the *repository* root, above a plugin in
203
+ // a monorepo, and each is read by its runtime at install time — a catalog whose shape that runtime
204
+ // refuses fails in the user's terminal, not here. The shipped CLI owns the rules; this only asks.
205
+ for (const row of invalidCatalogs()) {
206
+ add(
207
+ 'invalid-catalog',
208
+ 'high',
209
+ `${row.path} is not a shape its runtime loads: ${row.issues.map((issue) => `${issue.path} ${issue.message}`).join('; ')}`,
210
+ '/universal-plugin:marketplace',
211
+ )
212
+ }
213
+
202
214
  report({ vendors })
203
215
 
216
+ /** Every catalog the repository carries that its runtime would refuse. Empty when there is nothing to
217
+ * read, when the CLI is too old to answer, or when every catalog is fine — a missing catalog is not a
218
+ * fault, and this reports no opinion on which ones a repository ought to carry. */
219
+ function invalidCatalogs() {
220
+ const catalogRoot = git('rev-parse', '--show-toplevel') ?? root
221
+ const result = fs.existsSync(bin)
222
+ ? spawnSync(process.execPath, [bin, 'marketplace', 'validate', '--format', 'json', '--root', catalogRoot], {
223
+ encoding: 'utf8',
224
+ })
225
+ : spawnSync('npx', ['universal-plugin', 'marketplace', 'validate', '--format', 'json', '--root', catalogRoot], {
226
+ encoding: 'utf8',
227
+ })
228
+ const rows = readJson_stdout(result.stdout)
229
+ return Array.isArray(rows) ? rows.filter((row) => row.status === 'invalid') : []
230
+ }
231
+
204
232
  /** Runs git inside `root`, returning its stdout or `null` — a non-zero status, a missing git, and a
205
233
  * directory outside any repository are all the same answer here: no history to read. */
206
234
  function git(...args) {
@@ -6,8 +6,8 @@ then write the README section that tells users what to type.
6
6
  ## What it does
7
7
 
8
8
  `marketplace init` discovers the plugins under `plugins/` and derives one catalog per selected
9
- runtime. This skill picks the targets with the user, runs the generation, verifies it, and offers
10
- the install documentation that goes with it.
9
+ runtime. This skill picks the targets with the user, runs the generation, validates every catalog
10
+ against the schema its runtime loads, and offers the install documentation that goes with it.
11
11
 
12
12
  Nothing is published. The catalogs sit in the repository until someone adds it as a marketplace.
13
13
 
@@ -25,6 +25,16 @@ evidence ID. That constraint exists because the obvious way to write an install
25
25
  one from another project's README, and two of the four commands in the README that prompted this
26
26
  skill are not in any vendor documentation.
27
27
 
28
+ ## The validation half
29
+
30
+ `scripts/validate.mjs` checks each catalog against the shape its runtime actually loads and names the
31
+ key at fault. It exists because a broken catalog fails silently here and loudly at install time, in
32
+ someone else's terminal. Two shapes reach a repository unnoticed, both of them what `package.json`
33
+ carries: `owner` as a `"Name <email>"` string, and `repository` as a `{ type, url }` object. Claude
34
+ Code refuses the catalog for either. Generation now reduces what it can (an npm `repository` becomes
35
+ its URL) and validation catches the rest, including a `./` source pointing at a directory that is not
36
+ there.
37
+
28
38
  ## The README half
29
39
 
30
40
  `scripts/install-docs.mjs` reads the catalogs on disk and emits the section as JSON, so the
@@ -36,3 +46,5 @@ document.
36
46
 
37
47
  - [Research: local marketplaces](https://github.com/cyberuni/universal-plugin/blob/main/.research/local-marketplaces/conclusion.md)
38
48
  - [`marketplace init` spec](https://github.com/cyberuni/universal-plugin/blob/main/packages/universal-plugin/.agents/spec/marketplace/init/README.md)
49
+ - [`marketplace validate` spec](https://github.com/cyberuni/universal-plugin/blob/main/packages/universal-plugin/.agents/spec/marketplace/validate/README.md)
50
+ - [Official Claude Code marketplace schema](https://json.schemastore.org/claude-code-marketplace.json)
@@ -77,7 +77,43 @@ A selected artifact that differs from what would be generated stops the whole ru
77
77
  command protecting a hand-edited catalog. Read the difference, then re-run with `--force` only once
78
78
  you know what it discards.
79
79
 
80
- ### 4. Offer the README section
80
+ ### 4. Validate every catalog
81
+
82
+ A catalog the runtime rejects is worse than no catalog: it is found, read, and refused at install
83
+ time, far from here. Never conclude this skill without running the check.
84
+
85
+ ```bash
86
+ node scripts/validate.mjs
87
+ ```
88
+
89
+ Resolve that path against this skill's own directory; `npx universal-plugin marketplace validate` is
90
+ the fallback. It reads each catalog and checks it against the schema its runtime loads. Exit status is
91
+ 1 when any selected catalog is invalid, and every issue names the key and the value to write instead,
92
+ on stderr:
93
+
94
+ ```
95
+ error: catalog ".claude-plugin/marketplace.json" does not match the marketplace schema:
96
+ owner must be an object with a name, not string — write { "name": "Ari Vance" }
97
+ plugins[0].repository must be a string, not object — write "https://github.com/o/r.git"
98
+ ```
99
+
100
+ | Status | Means |
101
+ | --- | --- |
102
+ | `valid` | loads in that runtime |
103
+ | `invalid` | the runtime would refuse it; the issues say why |
104
+ | `missing` | no catalog at that path, which is only a failure under `--required` |
105
+
106
+ Fix an issue in the source it comes from, then regenerate: an entry's fields are derived from the
107
+ plugin's `plugin.json`, so an npm-style `repository` object belongs fixed there. The top-level `name`
108
+ and `owner` live in the catalog itself, and `owner` must be an object — `{ "name": "…" }`, never the
109
+ `"Name <email>"` string `package.json` uses.
110
+
111
+ The two checks the schema cannot make: `--required` for a target the user asked for, and sources on
112
+ disk. Every `./` source is checked for existence by `validate`; sources resolve against the directory
113
+ containing `.claude-plugin/`, and they do not resolve at all for a user who adds the marketplace by
114
+ direct URL to the JSON file.
115
+
116
+ ### 5. Offer the README section
81
117
 
82
118
  Ask before writing. A README is the user's document, and this is an edit to it, not a new file.
83
119
 
@@ -95,16 +131,12 @@ rather than leaving a placeholder in their README.
95
131
  If the README already has an install section, show the difference and let the user choose. Do not
96
132
  append a second one.
97
133
 
98
- ### 5. Verify
134
+ ### 6. Verify
99
135
 
100
136
  Re-run the generator and confirm every selected target reports `unchanged`. Confirm each catalog
101
137
  path exists. State plainly that nothing was published: these files sit in the repository until a
102
138
  user adds it as a marketplace.
103
139
 
104
- For Claude Code, check that each plugin `source` is a `./`-prefixed path that exists. Sources resolve
105
- against the directory containing `.claude-plugin/`, and they do not resolve at all for a user who
106
- adds the marketplace by direct URL to the JSON file.
107
-
108
140
  A local path is the cheapest end-to-end proof:
109
141
 
110
142
  ```bash
@@ -147,6 +179,11 @@ a version the plugin does not have.
147
179
  - **Do not write a Cursor install command.** Cursor has no command that adds a repository catalog;
148
180
  a developer tests through `~/.cursor/plugins/local/<name>` and users get the plugin through a team
149
181
  marketplace an admin imports.
182
+ - **Never hand-author or hand-patch a catalog.** Generate it, then validate it. A catalog written by
183
+ copying fields out of `package.json` carries `owner` as a string and `repository` as an object, and
184
+ Claude Code refuses it for either one.
185
+ - **Report the validation result, not just the generation result.** A run that generated four files
186
+ and validated none has not been verified.
150
187
  - **Ask before editing the README**, and before `--force` replaces a catalog the user may have
151
188
  hand-edited.
152
189
  - This command publishes nothing and registers nothing. Say so in the report; a user who believes
@@ -166,5 +203,7 @@ a version the plugin does not have.
166
203
  ## References
167
204
 
168
205
  - `references/runtimes.md` — per-runtime install commands and their sources
206
+ - The official Claude Code marketplace schema, which `validate` checks against:
207
+ <https://json.schemastore.org/claude-code-marketplace.json>
169
208
  - [Research conclusion](https://github.com/cyberuni/universal-plugin/blob/main/.research/local-marketplaces/conclusion.md)
170
209
  - [`marketplace init` spec](https://github.com/cyberuni/universal-plugin/blob/main/packages/universal-plugin/.agents/spec/marketplace/init/README.md)
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env node
2
+ // Runs `universal-plugin marketplace validate` from the CLI that ships beside this skill, so the
3
+ // check never depends on a network fetch or on which version `npx` happens to resolve.
4
+ import { dirname, join } from 'node:path'
5
+ import { fileURLToPath } from 'node:url'
6
+
7
+ // <package>/skills/<skill>/scripts/validate.mjs: four levels up is the package root.
8
+ const packageRoot = dirname(dirname(dirname(dirname(fileURLToPath(import.meta.url)))))
9
+
10
+ process.argv.splice(2, 0, 'marketplace', 'validate')
11
+ await import(join(packageRoot, 'bin', 'universal-plugin.mjs'))