Files
2026-07-13 13:05:52 +08:00

394 lines
12 KiB
Python

#!/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())