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.
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/dist/cli.mjs +345 -3
- package/package.json +2 -1
- package/plugin.json +1 -1
- package/readme.md +4 -0
- package/skills/doctor/README.md +3 -1
- package/skills/doctor/SKILL.md +11 -0
- package/skills/doctor/scripts/doctor.mjs +28 -0
- package/skills/marketplace/README.md +14 -2
- package/skills/marketplace/SKILL.md +45 -6
- package/skills/marketplace/scripts/validate.mjs +11 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "universal-plugin",
|
|
3
|
-
"version": "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.
|
|
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.
|
|
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)
|
|
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)
|
|
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.
|
|
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.
|
|
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
|
package/skills/doctor/README.md
CHANGED
|
@@ -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,
|
|
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
|
|
package/skills/doctor/SKILL.md
CHANGED
|
@@ -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,
|
|
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.
|
|
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
|
-
###
|
|
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'))
|