Files
EapilSkillMarket/scripts/validate_marketplace.py
T
AreChen 64f469cfce Keep external sync as script-managed automation
Constraint: The maintainer wants periodic upstream sync handled by script logic rather than Gitea workflow automation.

Rejected: Repository-owned Gitea Actions schedule | The remote Gitea runner setup is unnecessary for the current direction.

Confidence: high

Scope-risk: narrow

Directive: Treat scripts/sync_external_plugins.ps1 as the synchronization entry point unless a new scheduler is explicitly requested.

Tested: python scripts\\validate_marketplace.py; rg found no remaining Gitea Actions sync references.

Not-tested: External scheduler/container execution.
2026-06-10 15:48:42 +08:00

117 lines
5.4 KiB
Python

#!/usr/bin/env python3
"""Validate this repository's Codex marketplace layout.
This is intentionally narrower than Codex's internal plugin validator. It checks
the structure this repo generates so sync automation can fail before
committing malformed marketplace data.
"""
from __future__ import annotations
import json
import re
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
MARKETPLACES = [
REPO_ROOT / ".agents" / "plugins" / "marketplace.json",
REPO_ROOT / "plugins" / "codex" / ".agents" / "plugins" / "marketplace.json",
]
SEMVER_RE = re.compile(r"^\d+\.\d+\.\d+([-.+][0-9A-Za-z.-]+)?$")
def load_json(path: Path) -> object:
try:
return json.loads(path.read_text(encoding="utf-8"))
except UnicodeDecodeError as exc:
raise AssertionError(f"{path} is not valid UTF-8: {exc}") from exc
except json.JSONDecodeError as exc:
raise AssertionError(f"{path} is not valid JSON: {exc}") from exc
def require(condition: bool, message: str) -> None:
if not condition:
raise AssertionError(message)
def validate_manifest(plugin_dir: Path, expected_name: str) -> None:
manifest_path = plugin_dir / ".codex-plugin" / "plugin.json"
require(manifest_path.exists(), f"Missing manifest: {manifest_path}")
manifest = load_json(manifest_path)
require(isinstance(manifest, dict), f"Manifest must be an object: {manifest_path}")
require(manifest.get("name") == expected_name, f"{manifest_path} name must be {expected_name}")
require(SEMVER_RE.match(str(manifest.get("version", ""))) is not None, f"{manifest_path} version must be semver-like")
require(bool(manifest.get("description")), f"{manifest_path} missing description")
require("hooks" not in manifest, f"{manifest_path} must not declare unsupported hooks field")
author = manifest.get("author")
require(isinstance(author, dict) and bool(author.get("name")), f"{manifest_path} missing author.name")
skills_rel = manifest.get("skills")
require(isinstance(skills_rel, str) and skills_rel.startswith("./"), f"{manifest_path} skills path must be relative ./...")
skills_dir = plugin_dir / skills_rel[2:]
require(skills_dir.exists(), f"{manifest_path} skills directory does not exist: {skills_dir}")
skill_dirs = [path for path in skills_dir.iterdir() if path.is_dir()]
require(bool(skill_dirs), f"{manifest_path} must contain at least one skill directory")
for skill_dir in skill_dirs:
require((skill_dir / "SKILL.md").exists(), f"Missing SKILL.md in {skill_dir}")
interface = manifest.get("interface")
require(isinstance(interface, dict), f"{manifest_path} missing interface object")
for key in ["displayName", "shortDescription", "longDescription", "developerName", "category"]:
require(bool(interface.get(key)), f"{manifest_path} missing interface.{key}")
capabilities = interface.get("capabilities")
require(isinstance(capabilities, list), f"{manifest_path} interface.capabilities must be a list")
def resolve_marketplace_plugin_path(marketplace_path: Path, source_path: str) -> Path:
require(source_path.startswith("./"), f"{marketplace_path} plugin source.path must start with ./")
return (marketplace_path.parent.parent.parent / source_path[2:]).resolve()
def validate_marketplace(marketplace_path: Path) -> None:
require(marketplace_path.exists(), f"Missing marketplace: {marketplace_path}")
marketplace = load_json(marketplace_path)
require(isinstance(marketplace, dict), f"Marketplace must be an object: {marketplace_path}")
require(bool(marketplace.get("name")), f"{marketplace_path} missing name")
require(isinstance(marketplace.get("plugins"), list), f"{marketplace_path} plugins must be a list")
seen: set[str] = set()
for entry in marketplace["plugins"]:
require(isinstance(entry, dict), f"{marketplace_path} plugin entry must be an object")
name = entry.get("name")
require(isinstance(name, str) and name, f"{marketplace_path} plugin entry missing name")
require(name not in seen, f"{marketplace_path} duplicate plugin name: {name}")
seen.add(name)
source = entry.get("source")
require(isinstance(source, dict), f"{marketplace_path} {name} missing source")
require(source.get("source") == "local", f"{marketplace_path} {name} source.source must be local")
plugin_dir = resolve_marketplace_plugin_path(marketplace_path, str(source.get("path", "")))
require(plugin_dir.exists(), f"{marketplace_path} {name} path does not exist: {plugin_dir}")
policy = entry.get("policy")
require(isinstance(policy, dict), f"{marketplace_path} {name} missing policy")
require(policy.get("installation") in {"AVAILABLE", "INSTALLED_BY_DEFAULT", "NOT_AVAILABLE"}, f"{marketplace_path} {name} invalid installation policy")
require(policy.get("authentication") in {"ON_INSTALL", "ON_USE"}, f"{marketplace_path} {name} invalid authentication policy")
require(bool(entry.get("category")), f"{marketplace_path} {name} missing category")
validate_manifest(plugin_dir, name)
def main() -> int:
try:
for marketplace in MARKETPLACES:
validate_marketplace(marketplace)
except AssertionError as exc:
print(f"Validation failed: {exc}", file=sys.stderr)
return 1
print("Marketplace validation passed")
return 0
if __name__ == "__main__":
raise SystemExit(main())