← Files Creative ProductionARCHIVED FILE

skills/produce/helpers/styles/scripts/create_style_explorer.py

5.44 KB · Oct 4, 2026 · 12:04 UTC

↓ Download file

#!/usr/bin/env python3
"""Create a local adaptive style explorer app from a JSON spec."""

from __future__ import annotations

import argparse
import json
import re
import shutil
from pathlib import Path

ROOT = Path(__file__).resolve().parents[1]
PLUGIN_ROOT = ROOT.parents[3]
TEMPLATE = ROOT / "assets" / "style-explorer-app"


def slugify(value: str) -> str:
    slug = re.sub(r"[^a-zA-Z0-9]+", "-", value.strip().lower()).strip("-")
    return slug or "style-route"


def normalize_list(value: object) -> list[str]:
    if isinstance(value, list):
        return [str(item) for item in value if str(item).strip()]
    if isinstance(value, str) and value.strip():
        return [value.strip()]
    return []


def validate_spec(spec: dict) -> None:
    meta = spec.setdefault("meta", {})
    meta.setdefault("title", "Style Explorer")
    meta.setdefault("stage", "Polish exploration")
    meta.setdefault("anchor", meta.get("title", "Style anchor"))
    meta.setdefault("summary", "Compare style systems and select one for final production.")

    constraints = spec.setdefault("constraints", {})
    constraints["preserve"] = normalize_list(constraints.get("preserve"))
    constraints["avoid"] = normalize_list(constraints.get("avoid"))

    routes = spec.get("routes") or spec.get("families") or spec.get("items")
    if not isinstance(routes, list) or not routes:
        raise ValueError("Spec must include a non-empty routes array.")

    normalized_routes = []
    for index, route in enumerate(routes, start=1):
        if not isinstance(route, dict):
            raise ValueError(f"Route {index} must be an object.")
        label = (
            route.get("label") or route.get("title") or route.get("id") or f"Style system {index}"
        )
        route["id"] = route.get("id") or slugify(label)
        route["label"] = label
        route.setdefault("family", route.get("tone") or "style")
        route.setdefault("rationale", route.get("caption") or "")
        route.setdefault(
            "final_owner",
            spec.get("handoff", {}).get("default_owner", "produce"),
        )
        route["next_prompt_hints"] = normalize_list(route.get("next_prompt_hints"))
        if not route.get("prompt"):
            raise ValueError(f"Route {index} ({route['id']}) is missing prompt.")
        normalized_routes.append(route)

    spec["routes"] = normalized_routes
    spec.setdefault("handoff", {})
    spec["handoff"].setdefault("default_owner", "produce")
    spec["handoff"]["next_prompt_hints"] = normalize_list(spec["handoff"].get("next_prompt_hints"))


def copy_base_asset(spec: dict, output: Path) -> None:
    meta = spec.get("meta", {})
    base_asset = meta.get("base_asset") or meta.get("base_asset_path")
    if not base_asset:
        return

    source = Path(base_asset).expanduser()
    if not source.is_absolute():
        source = Path.cwd() / source
    if not source.exists():
        raise FileNotFoundError(f"Base asset not found: {source}")

    suffix = source.suffix.lower() or ".png"
    target = output / "data" / f"base_asset{suffix}"
    shutil.copy2(source, target)
    meta["base_asset"] = str(source)
    meta["base_asset_url"] = f"/data/{target.name}"


def plugin_version_label() -> str:
    manifest_path = PLUGIN_ROOT / ".codex-plugin" / "plugin.json"
    manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
    return f"{manifest['name']}@{manifest['version']}"


def write_runtime_config(output: Path) -> None:
    (output / "data" / "runtime-config.json").write_text(
        json.dumps(
            {
                "createdByPluginVersion": plugin_version_label(),
            },
            indent=2,
        )
        + "\n",
        encoding="utf-8",
    )


def main() -> None:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument(
        "--spec",
        required=True,
        type=Path,
        help="JSON file with meta, constraints, and routes.",
    )
    parser.add_argument("--output", required=True, type=Path, help="Output app directory.")
    parser.add_argument(
        "--force",
        action="store_true",
        help="Replace output directory if it already exists.",
    )
    args = parser.parse_args()

    spec = json.loads(args.spec.read_text(encoding="utf-8"))
    validate_spec(spec)

    if args.output.exists():
        if not args.force:
            raise FileExistsError(f"{args.output} already exists. Use --force to replace it.")
        shutil.rmtree(args.output)

    shutil.copytree(TEMPLATE, args.output)
    data_dir = args.output / "data"
    data_dir.mkdir(exist_ok=True)
    (args.output / "generated").mkdir(exist_ok=True)
    copy_base_asset(spec, args.output)
    write_runtime_config(args.output)

    (data_dir / "style-spec.json").write_text(
        json.dumps(spec, indent=2, ensure_ascii=False) + "\n",
        encoding="utf-8",
    )

    print(
        json.dumps(
            {
                "output": str(args.output),
                "routes": len(spec["routes"]),
                "handoff_json": str(data_dir / "selected-style-route.json"),
                "handoff_markdown": str(data_dir / "handoff.md"),
                "reviewSurface": "creative_production_board",
                "handoff": (
                    "Use the inline MCP mood-board review surface for generated style imagery. "
                    "Use the local HTML/server surface only for debug or inspection requests."
                ),
            },
            indent=2,
        )
    )


if __name__ == "__main__":
    main()

SHA-256: 4ac76de1e97c3aae00cc43d19c8cf1358a71f1e3f79e6b077545eabd0b628f1d