Files
EapilSkillMarket/scripts/validate_marketplace.py
KeyInfo Bot bfe3759cdd Allow empty Superpowers hooks manifest
Publish the completed third-party source refresh after Superpowers 6.1.1 changed its Codex manifest to use an empty no-op hooks object instead of a hooks file path.

Constraint: Current Codex CLI installs superpowers@eapil-skill-market with hooks: {}, while the marketplace validator still rejected empty inline hooks objects.
Rejected: Restore the old hooks directory by hand | Upstream no longer declares those Codex hook files, and hand-restoring generated content would drift from source.
Confidence: high
Scope-risk: moderate
Directive: Empty hooks objects are placeholders; allow them but do not count them as the plugin's only effective component.
Tested: /Users/eapil/git/keyinfo/.venv/bin/python3 scripts/validate_marketplace.py; rg public-secret scan; git diff --check; Codex temp CODEX_HOME marketplace add/list/add superpowers@eapil-skill-market.
Not-tested: Interactive Superpowers skill invocation inside a live Codex thread.
2026-07-06 09:53:32 +08:00

223 lines
10 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 os
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.-]+)?$")
PLUGIN_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$")
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 resolve_manifest_path(plugin_dir: Path, manifest_path: Path, value: str, field: str) -> Path:
require(value.startswith("./"), f"{manifest_path} {field} path must start with ./")
target = (plugin_dir / value[2:]).resolve()
plugin_root = plugin_dir.resolve()
require(
os.path.commonpath([str(plugin_root), str(target)]) == str(plugin_root),
f"{manifest_path} {field} path must stay inside plugin root: {value}",
)
return target
def validate_manifest_path(plugin_dir: Path, manifest_path: Path, value: str, field: str) -> Path:
target = resolve_manifest_path(plugin_dir, manifest_path, value, field)
require(target.exists(), f"{manifest_path} {field} target does not exist: {target}")
return target
def validate_hooks_field(plugin_dir: Path, manifest_path: Path, hooks: object) -> None:
"""Validate Codex-supported plugin hook declarations.
Codex supports a hook path string, a list of path strings, an inline hooks
object, an empty no-op hooks object, or a list containing hook paths and
inline hooks objects. The marketplace only verifies path safety and basic
type shape; Codex owns hook schema details.
"""
if isinstance(hooks, str):
validate_manifest_path(plugin_dir, manifest_path, hooks, "hooks")
return
if isinstance(hooks, dict):
return
if isinstance(hooks, list):
for index, item in enumerate(hooks):
if isinstance(item, str):
validate_manifest_path(plugin_dir, manifest_path, item, f"hooks[{index}]")
elif isinstance(item, dict):
continue
else:
raise AssertionError(f"{manifest_path} hooks[{index}] must be a path or inline object")
return
raise AssertionError(f"{manifest_path} hooks must be a path, path list, inline object, or inline object list")
def is_declared_component(manifest: dict[str, object], key: str) -> bool:
if key not in manifest:
return False
value = manifest.get(key)
if key != "hooks":
return True
if isinstance(value, str):
return bool(value.strip())
if isinstance(value, dict):
return bool(value)
if isinstance(value, list):
return any(
(isinstance(item, str) and bool(item.strip())) or (isinstance(item, dict) and bool(item))
for item in value
)
return False
def validate_skills_component(plugin_dir: Path, manifest_path: Path, skills_rel: object) -> None:
require(isinstance(skills_rel, str) and skills_rel.startswith("./"), f"{manifest_path} skills path must be relative ./...")
skills_dir = validate_manifest_path(plugin_dir, manifest_path, skills_rel, "skills")
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} skills component 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}")
def validate_json_component(
plugin_dir: Path,
manifest_path: Path,
value: object,
field: str,
required_top_level: str,
) -> None:
require(isinstance(value, str), f"{manifest_path} {field} must be a path string")
target = validate_manifest_path(plugin_dir, manifest_path, value, field)
payload = load_json(target)
require(isinstance(payload, dict), f"{target} must be a JSON object")
require(isinstance(payload.get(required_top_level), dict), f"{target} missing object {required_top_level}")
def validate_interface_asset(plugin_dir: Path, manifest_path: Path, value: object, field: str) -> None:
if value is None or value == "":
return
require(isinstance(value, str), f"{manifest_path} interface.{field} must be a string")
if value.startswith(("http://", "https://")):
return
validate_manifest_path(plugin_dir, manifest_path, value, f"interface.{field}")
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(PLUGIN_NAME_RE.match(expected_name) is not None, f"{manifest_path} plugin name must be ASCII kebab-case")
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")
author = manifest.get("author")
require(isinstance(author, dict) and bool(author.get("name")), f"{manifest_path} missing author.name")
declared_components = [key for key in ["skills", "mcpServers", "apps", "hooks"] if is_declared_component(manifest, key)]
require(bool(declared_components), f"{manifest_path} must declare at least one component: skills, mcpServers, apps, or hooks")
if "skills" in manifest:
validate_skills_component(plugin_dir, manifest_path, manifest.get("skills"))
if "mcpServers" in manifest:
validate_json_component(plugin_dir, manifest_path, manifest.get("mcpServers"), "mcpServers", "mcpServers")
if "apps" in manifest:
validate_json_component(plugin_dir, manifest_path, manifest.get("apps"), "apps", "apps")
if "hooks" in manifest:
validate_hooks_field(plugin_dir, manifest_path, manifest["hooks"])
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")
validate_interface_asset(plugin_dir, manifest_path, interface.get("logo"), "logo")
validate_interface_asset(plugin_dir, manifest_path, interface.get("composerIcon"), "composerIcon")
screenshots = interface.get("screenshots")
if screenshots is not None:
require(isinstance(screenshots, list), f"{manifest_path} interface.screenshots must be a list")
for index, screenshot in enumerate(screenshots):
validate_interface_asset(plugin_dir, manifest_path, screenshot, f"screenshots[{index}]")
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(PLUGIN_NAME_RE.match(name) is not None, f"{marketplace_path} plugin name must be ASCII kebab-case: {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())