394 lines
12 KiB
Python
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())
|