#!/usr/bin/env python3 """Generate Kami plugin metadata and mirror files. Source of truth: - VERSION - root SKILL.md - CHEATSHEET.md - references/ - scripts/ - assets/templates/ - assets/diagrams/ - selected lightweight assets Generated files: - .claude-plugin/marketplace.json - .agents/plugins/marketplace.json - plugins/kami/.claude-plugin/plugin.json - plugins/kami/.codex-plugin/plugin.json - plugins/kami/skills/kami/ Modes: --write (default) regenerate plugin files from source --check compare generated bytes against committed files Run as: python3 scripts/build_metadata.py [--check] [--root PATH] """ from __future__ import annotations import argparse import difflib import json import shutil from pathlib import Path ROOT = Path(__file__).resolve().parent.parent PLUGIN_NAME = "kami" CODEX_CATEGORY = "Productivity" HOMEPAGE = "https://github.com/tw93/kami" REPOSITORY = "https://github.com/tw93/kami" AUTHOR = { "name": "Tw93", "email": "hitw93@gmail.com", "url": "https://github.com/tw93", } CODEX_DESCRIPTION = ( "Professional document and landing-page typesetting skill for Codex: " "resumes, one-pagers, reports, letters, portfolios, slides, and more." ) CLAUDE_MARKETPLACE_DESCRIPTION = "Document typesetting skill for Claude Code." CLAUDE_PLUGIN_DESCRIPTION = ( "Typeset professional documents and landing pages with the Kami design system." ) SKILL_MIRROR_ROOT = Path("plugins/kami/skills/kami") SKILL_MIRROR_FILES = ( "CHEATSHEET.md", "LICENSE", "SKILL.md", "VERSION", ) SKILL_MIRROR_DIRS = ( "assets/diagrams", "assets/fonts", "assets/images", "assets/templates", "references", "scripts", ) SKILL_MIRROR_ALLOWED_FONT_FILES = { "JetBrainsMono.woff2", "LICENSE-SourceHanSerifK.txt", } SKILL_MIRROR_IGNORED_DIRS = { "__pycache__", ".mypy_cache", ".pytest_cache", ".ruff_cache", } SKILL_MIRROR_IGNORED_NAMES = { ".DS_Store", } SKILL_MIRROR_IGNORED_SUFFIXES = { ".pyc", ".pyo", } def read_version(root: Path) -> str: version_file = root / "VERSION" if not version_file.exists(): raise SystemExit(f"ERROR: missing VERSION file at {version_file}") version = version_file.read_text().strip() if not version: raise SystemExit("ERROR: VERSION file is empty") return version def read_token_value(root: Path, name: str) -> str: key = name if name.startswith("--") else f"--{name}" token_file = root / "references" / "tokens.json" try: tokens = json.loads(token_file.read_text(encoding="utf-8")) except OSError as exc: raise SystemExit(f"ERROR: missing token file at {token_file}: {exc}") from exc except json.JSONDecodeError as exc: raise SystemExit(f"ERROR: tokens.json is malformed: {exc}") from exc try: return tokens[key] except KeyError as exc: raise SystemExit(f"ERROR: missing token {key} in {token_file}") from exc def render_json(data: dict) -> str: return json.dumps(data, indent=2, ensure_ascii=False) + "\n" def build_codex_plugin(version: str, brand_color: str) -> dict: return { "name": PLUGIN_NAME, "version": version, "description": CODEX_DESCRIPTION, "author": AUTHOR, "homepage": HOMEPAGE, "repository": REPOSITORY, "license": "MIT", "keywords": [ "codex", "skills", "documents", "typesetting", "resume", "slides", "landing-page", ], "skills": "./skills/", "interface": { "displayName": "Kami", "shortDescription": "Typeset polished documents and landing pages", "longDescription": ( "Kami packages a warm parchment design system for Codex. Use it " "to turn briefs and raw material into resumes, one-pagers, long " "documents, letters, portfolios, slide decks, equity reports, " "changelogs, and product landing pages." ), "developerName": "Tw93", "category": CODEX_CATEGORY, "capabilities": [ "Interactive", "Write", ], "websiteURL": HOMEPAGE, "defaultPrompt": [ "Make a polished one-pager from this brief", "Build a resume using Kami", "Turn this outline into a slide deck", ], "brandColor": brand_color, }, } def build_codex_marketplace() -> dict: return { "name": PLUGIN_NAME, "interface": { "displayName": "Kami", }, "plugins": [ { "name": PLUGIN_NAME, "source": { "source": "local", "path": "./plugins/kami", }, "policy": { "installation": "AVAILABLE", "authentication": "ON_INSTALL", }, "category": CODEX_CATEGORY, } ], } def build_claude_plugin(version: str) -> dict: return { "name": PLUGIN_NAME, "version": version, "description": CLAUDE_PLUGIN_DESCRIPTION, "author": { "name": AUTHOR["name"], "email": AUTHOR["email"], }, "homepage": HOMEPAGE, "repository": REPOSITORY, "license": "MIT", "skills": "./skills/", } def build_claude_marketplace(version: str) -> dict: return { "name": PLUGIN_NAME, "description": CLAUDE_MARKETPLACE_DESCRIPTION, "owner": { "name": AUTHOR["name"], "email": AUTHOR["email"], }, "plugins": [ { "name": PLUGIN_NAME, "version": version, "description": CLAUDE_PLUGIN_DESCRIPTION, "category": "documents", "source": "./plugins/kami", "homepage": HOMEPAGE, } ], } def should_include_skill_mirror_file(path: Path) -> bool: if any(part in SKILL_MIRROR_IGNORED_DIRS for part in path.parts): return False if path.name in SKILL_MIRROR_IGNORED_NAMES: return False if path.suffix in SKILL_MIRROR_IGNORED_SUFFIXES: return False if path.parts[:2] == ("assets", "fonts"): return path.name in SKILL_MIRROR_ALLOWED_FONT_FILES return True def collect_plugin_tree(root: Path, codex_manifest_rendered: str, claude_manifest_rendered: str) -> dict[str, bytes]: """Build the generated file set for the shared plugin directory. Claude Code and Codex install only the directory referenced by their marketplace source path. Kami's source skill lives at the repository root, so the plugin mirrors a lightweight skill root under plugins/kami/skills/kami and keeps all paths referenced by SKILL.md relative to that generated skill directory. """ generated = { "plugins/kami/.claude-plugin/plugin.json": claude_manifest_rendered.encode(), "plugins/kami/.codex-plugin/plugin.json": codex_manifest_rendered.encode(), } for source in SKILL_MIRROR_FILES: path = root / source if not path.exists(): raise SystemExit(f"ERROR: missing required plugin source file {path}") generated[(SKILL_MIRROR_ROOT / source).as_posix()] = path.read_bytes() for source in SKILL_MIRROR_DIRS: source_root = root / source if not source_root.exists(): raise SystemExit(f"ERROR: missing required plugin source tree {source_root}") for path in sorted(source_root.rglob("*")): if not path.is_file(): continue source_rel = path.relative_to(root) if not should_include_skill_mirror_file(source_rel): continue generated[(SKILL_MIRROR_ROOT / source_rel).as_posix()] = path.read_bytes() return generated def diff(label: str, expected: str, actual: str) -> str: return "".join( difflib.unified_diff( actual.splitlines(keepends=True), expected.splitlines(keepends=True), fromfile=f"committed:{label}", tofile=f"generated:{label}", ) ) def bytes_diff(label: str, expected: bytes, actual: bytes) -> str: return diff( label, expected.decode("utf-8", errors="replace"), actual.decode("utf-8", errors="replace"), ) def check_generated(root: Path, generated_json_files: list[tuple[Path, str]], plugin_tree: dict[str, bytes]) -> int: drift = False for generated_path, expected in generated_json_files: actual = generated_path.read_text() if generated_path.exists() else "" if actual != expected: rel = generated_path.relative_to(root).as_posix() print( f"DRIFT: {rel} is out of sync with plugin metadata.\n" "Run scripts/build_metadata.py (no flags) to regenerate.", ) print(diff(rel, expected, actual), end="") drift = True for rel, expected in plugin_tree.items(): path = root / rel actual = path.read_bytes() if path.exists() else b"" if actual != expected: print( f"DRIFT: {rel} is out of sync with Kami source files.\n" "Run scripts/build_metadata.py (no flags) to regenerate.", ) print(bytes_diff(rel, expected, actual), end="") drift = True codex_plugin_root = root / "plugins" / "kami" if codex_plugin_root.exists(): expected_paths = set(plugin_tree) for path in sorted(codex_plugin_root.rglob("*")): if not path.is_file(): continue rel_path = path.relative_to(root) plugin_rel = path.relative_to(codex_plugin_root) if not should_include_skill_mirror_file(plugin_rel): continue rel = rel_path.as_posix() if rel not in expected_paths: print( f"DRIFT: {rel} is an extra file in the generated plugin tree.\n" "Run scripts/build_metadata.py (no flags) to regenerate.", ) drift = True if drift: return 1 for generated_path, _ in generated_json_files: print(f"OK: {generated_path.relative_to(root)} matches generator") print("OK: plugins/kami plugin tree matches generator") return 0 def write_generated(root: Path, generated_json_files: list[tuple[Path, str]], plugin_tree: dict[str, bytes]) -> int: for generated_path, expected in generated_json_files: generated_path.parent.mkdir(parents=True, exist_ok=True) generated_path.write_text(expected) print(f"OK: wrote {generated_path.relative_to(root)} ({len(expected)} bytes)") codex_plugin_root = root / "plugins" / "kami" shutil.rmtree(codex_plugin_root, ignore_errors=True) for rel, expected in plugin_tree.items(): path = root / rel path.parent.mkdir(parents=True, exist_ok=True) path.write_bytes(expected) print(f"OK: wrote plugins/kami ({len(plugin_tree)} generated files)") return 0 def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( "--root", type=Path, default=ROOT, help="Repository root (default: parent of scripts/)", ) parser.add_argument( "--check", action="store_true", help="Compare generated bytes to committed files; exit non-zero on drift.", ) args = parser.parse_args() root = args.root.resolve() version = read_version(root) codex_plugin_rendered = render_json(build_codex_plugin(version, read_token_value(root, "brand"))) codex_marketplace_rendered = render_json(build_codex_marketplace()) claude_plugin_rendered = render_json(build_claude_plugin(version)) claude_marketplace_rendered = render_json(build_claude_marketplace(version)) plugin_tree = collect_plugin_tree(root, codex_plugin_rendered, claude_plugin_rendered) generated_json_files = [ (root / ".claude-plugin" / "marketplace.json", claude_marketplace_rendered), (root / ".agents" / "plugins" / "marketplace.json", codex_marketplace_rendered), ] if args.check: return check_generated(root, generated_json_files, plugin_tree) return write_generated(root, generated_json_files, plugin_tree) if __name__ == "__main__": raise SystemExit(main())