← Files Marketing CouncilARCHIVED FILE

scripts/validate_distribution.py

7.82 KB · Oct 2, 2026 · 00:31 UTC

↓ Download file

#!/usr/bin/env python3
"""Validate Marketing Council host-distribution metadata and component counts."""
from __future__ import annotations

import argparse
import json
import re
import sys
from pathlib import Path

EXPECTED_NAME = "marketing-council"
EXPECTED_SKILLS = 29
EXPECTED_AGENTS = 24
HEX = re.compile(r"^#[0-9A-Fa-f]{6}$")


def fail(errors: list[str], condition: bool, message: str) -> None:
    if not condition:
        errors.append(message)


def frontmatter_text(path: Path) -> str:
    text = path.read_text(encoding="utf-8")
    if not text.startswith("---\n") or text.count("---") < 2:
        return ""
    return text.split("---", 2)[1]


def openai_interface(path: Path) -> dict[str, str]:
    data: dict[str, str] = {}
    for line in path.read_text(encoding="utf-8").splitlines():
        stripped = line.strip()
        if stripped.startswith(("display_name:", "short_description:", "default_prompt:")):
            key, value = stripped.split(":", 1)
            data[key] = value.strip().strip('"')
    return data


def validate(root: Path) -> dict:
    root = root.resolve()
    errors: list[str] = []
    warnings: list[str] = []

    manifest_path = root / "manifest.json"
    fail(errors, manifest_path.exists(), "missing manifest.json")
    if not manifest_path.exists():
        return {"valid": False, "errors": errors, "warnings": warnings, "skill_count": 0, "agent_count": 0}

    manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
    version = manifest.get("version", "")
    fail(errors, manifest.get("name") == EXPECTED_NAME, "manifest name must be marketing-council")
    fail(errors, bool(re.fullmatch(r"\d+\.\d+\.\d+", version)), "manifest version must be strict semver")

    skills = sorted((root / "skills").glob("*/SKILL.md"))
    agents = sorted((root / "agents").glob("*.md"))
    fail(errors, len(skills) == EXPECTED_SKILLS, f"expected {EXPECTED_SKILLS} skills, found {len(skills)}")
    fail(errors, len(agents) == EXPECTED_AGENTS, f"expected {EXPECTED_AGENTS} agents, found {len(agents)}")

    for skill in skills:
        fm = frontmatter_text(skill)
        top_keys = []
        for line in fm.splitlines():
            if line and not line.startswith(" ") and ":" in line:
                top_keys.append(line.split(":", 1)[0].strip())
        fail(errors, set(top_keys) == {"name", "description"}, f"unsupported SKILL.md frontmatter: {skill.parent.name}: {top_keys}")

        metadata = skill.parent / "agents" / "openai.yaml"
        fail(errors, metadata.exists(), f"missing OpenAI metadata: {skill.parent.name}")
        if metadata.exists():
            iface = openai_interface(metadata)
            fail(errors, bool(iface.get("display_name")), f"missing OpenAI display_name: {skill.parent.name}")
            short = iface.get("short_description", "")
            fail(errors, 25 <= len(short) <= 64, f"OpenAI short_description must be 25..64 chars: {skill.parent.name}")
            prompt = iface.get("default_prompt", "")
            fail(errors, f"${skill.parent.name}" in prompt, f"OpenAI default_prompt must mention ${skill.parent.name}")

    for agent in agents:
        fm = frontmatter_text(agent)
        fail(errors, f"name: {agent.stem}" in fm, f"agent name mismatch: {agent.name}")
        fail(errors, "description: Use when" in fm, f"agent trigger description missing: {agent.name}")

    openai_path = root / ".codex-plugin" / "plugin.json"
    claude_path = root / ".claude-plugin" / "plugin.json"
    claude_market_path = root / ".claude-plugin" / "marketplace.json"
    openai_market_path = root / ".agents" / "plugins" / "marketplace.json"
    for path in (openai_path, claude_path, claude_market_path, openai_market_path):
        fail(errors, path.exists(), f"missing distribution metadata: {path.relative_to(root)}")

    if openai_path.exists():
        data = json.loads(openai_path.read_text(encoding="utf-8"))
        iface = data.get("interface", {})
        fail(errors, data.get("name") == EXPECTED_NAME, "OpenAI plugin name mismatch")
        fail(errors, data.get("version") == version, "OpenAI plugin version mismatch")
        fail(errors, data.get("skills") == "./skills/", "OpenAI plugin skills path must be ./skills/")
        fail(errors, len(iface.get("displayName", "")) <= 30, "OpenAI displayName exceeds 30 chars")
        fail(errors, 0 < len(iface.get("shortDescription", "")) <= 30, "OpenAI shortDescription must be 1..30 chars")
        fail(errors, len(iface.get("capabilities", [])) <= 20, "OpenAI capabilities exceeds 20")
        fail(errors, len(iface.get("defaultPrompt", [])) <= 3, "OpenAI defaultPrompt exceeds 3")
        for prompt in iface.get("defaultPrompt", []):
            fail(errors, len(prompt) <= 128 and "@" not in prompt and "\n" not in prompt, f"invalid OpenAI default prompt: {prompt}")
        for key in ("logo", "composerIcon"):
            value = iface.get(key, "")
            fail(errors, value.startswith("./"), f"OpenAI {key} must be ./-relative")
            fail(errors, (root / value.removeprefix("./")).exists(), f"missing OpenAI {key} asset")
        for key in ("brandColor", "brandColorDark"):
            if key in iface:
                fail(errors, bool(HEX.fullmatch(iface[key])), f"invalid OpenAI {key}")

    if claude_path.exists():
        data = json.loads(claude_path.read_text(encoding="utf-8"))
        fail(errors, data.get("name") == EXPECTED_NAME, "Claude plugin name mismatch")
        fail(errors, data.get("version") == version, "Claude plugin version mismatch")
        fail(errors, data.get("skills") == "./skills/", "Claude skills path mismatch")
        fail(errors, data.get("agents") == "./agents/", "Claude agents path mismatch")

    if claude_market_path.exists():
        data = json.loads(claude_market_path.read_text(encoding="utf-8"))
        fail(errors, data.get("name") == EXPECTED_NAME, "Claude marketplace name mismatch")
        entries = data.get("plugins", [])
        fail(errors, len(entries) == 1 and entries[0].get("name") == EXPECTED_NAME, "Claude marketplace plugin entry mismatch")

    if openai_market_path.exists():
        data = json.loads(openai_market_path.read_text(encoding="utf-8"))
        entries = data.get("plugins", [])
        fail(errors, len(entries) == 1 and entries[0].get("name") == EXPECTED_NAME, "OpenAI marketplace plugin entry mismatch")
        if entries:
            policy = entries[0].get("policy", {})
            fail(errors, bool(policy.get("installation")), "OpenAI marketplace installation policy missing")
            fail(errors, bool(policy.get("authentication")), "OpenAI marketplace authentication policy missing")
            fail(errors, bool(entries[0].get("category")), "OpenAI marketplace category missing")

    fail(errors, not (root / "hooks" / "hooks.json").exists(), "unexpected executable lifecycle hooks in public skills-only package")
    fail(errors, not (root / ".mcp.json").exists(), "unexpected MCP server in skills-only package")

    return {
        "valid": not errors,
        "name": manifest.get("name"),
        "version": version,
        "skill_count": len(skills),
        "agent_count": len(agents),
        "errors": errors,
        "warnings": warnings,
    }


def main() -> None:
    parser = argparse.ArgumentParser(description="Validate Marketing Council distribution metadata.")
    parser.add_argument("root", nargs="?", type=Path, default=Path(__file__).resolve().parents[1])
    parser.add_argument("--json", action="store_true")
    args = parser.parse_args()
    result = validate(args.root)
    if args.json:
        print(json.dumps(result, indent=2))
    else:
        print(f"valid={result['valid']} skills={result['skill_count']} agents={result['agent_count']}")
        for error in result["errors"]:
            print(f"ERROR: {error}")
        for warning in result["warnings"]:
            print(f"WARN: {warning}")
    raise SystemExit(0 if result["valid"] else 1)


if __name__ == "__main__":
    main()

SHA-256: 854aa0d9aa77e40a5b47888dc90dc4111be6f135d4079b9e2ba68c1d4a5442d2