Files
youxi798 4dab24cc4c docs: simplify copy and localize the Kami website
Explain product capabilities and privacy boundaries directly across locales. Align writing guidance and schemas so documents do not require filler captions, metaphors, or unsupported metrics.
2026-09-08 14:34:19 +08:00

688 lines
24 KiB
Python

#!/usr/bin/env python3
"""Generate Kami plugin metadata and mirror files.
Source of truth:
- skills/kami/VERSION
- skills/kami/SKILL.md
- skills/kami/CHEATSHEET.md
- skills/kami/references/
- skills/kami/scripts/
- skills/kami/assets/templates/
- skills/kami/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/
- site/.well-known/agent-skills/index.json
- site/.well-known/mcp/server-card.json
- site/feeds/catalog.jsonld
- site/schemamap.xml
- site/kami-skill.md
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 ast
import difflib
import hashlib
import json
import shutil
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
SKILL_ROOT = ROOT / "skills" / "kami"
SITE_ROOT = ROOT / "site"
sys.path.insert(0, str(SKILL_ROOT / "scripts"))
from shared import (
DIAGRAM_TEMPLATES,
HTML_TEMPLATES,
PUBLIC_DOCUMENT_TEMPLATE_KINDS,
public_template_kind,
)
PLUGIN_NAME = "kami"
CODEX_CATEGORY = "Productivity"
HOMEPAGE = "https://github.com/tw93/kami"
REPOSITORY = "https://github.com/tw93/kami"
SITE = "https://kami.tw93.fun"
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_ALLOWED_FONT_FILES = {
"JetBrainsMono.woff2",
"LICENSE-SourceHanSerifK.txt",
}
SKILL_MIRROR_IGNORED_DIRS = {
"examples",
"__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 provides document templates and layout rules 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,
}
],
}
# --- Public site machine-readable surfaces -------------------------------
#
# The site is a static Vercel deploy of this repository, so every agent-facing
# discovery document is a committed file. They are generated here (rather than
# hand-edited) because each one restates the version, the tool list, or the
# template registry, and a stale copy is worse than no copy: agents read these
# instead of the HTML.
SKILL_WHEN_TO_USE = (
"Use when a user asks for a finished document whose appearance matters: a "
"resume, one-pager, letter, portfolio, long report, slide deck, equity "
"report, changelog, or a landing page. Kami fills an HTML "
"template and exports PDF or PNG, with editable PPTX for slides, then reviews "
"the result with deterministic and perceptual checks. Skip it when the "
"user only wants the text."
)
# Public document kinds, in the order the site presents them, with the
# one-line purpose used in the catalog feed.
DOCUMENT_TEMPLATE_NOTES = {
"one-pager": ("One-Pager", "Single-page brief for a product, project, or pitch."),
"letter": ("Letter", "Formal correspondence: offers, notices, cover letters."),
"long-doc": ("Long Document", "Multi-page report or spec with running headers."),
"portfolio": ("Portfolio", "Case-study layout for work samples."),
"resume": ("Resume", "One or two page CV with a fixed density budget."),
"slides": ("Slides", "Deck rendered to PDF, with an editable PPTX fallback."),
"equity-report": ("Equity Report", "Company or stock analysis with data tables."),
"changelog": ("Changelog", "Release notes grouped by version."),
}
DIAGRAM_NOTES = {
"diagram-architecture": ("Architecture", "System components and the connections between them."),
"diagram-architecture-board": ("Architecture Board", "Wide board view of a system, sized for slides."),
"diagram-flowchart": ("Flowchart", "Process and decision flow."),
"diagram-quadrant": ("Quadrant", "Two-axis positioning of options or competitors."),
"diagram-bar-chart": ("Bar Chart", "Comparison across categories."),
"diagram-line-chart": ("Line Chart", "Trend across an ordered axis."),
"diagram-donut-chart": ("Donut Chart", "Share of a whole."),
"diagram-state-machine": ("State Machine", "States and the transitions between them."),
"diagram-timeline": ("Timeline", "Milestones along a time axis."),
"diagram-swimlane": ("Swimlane", "Process split across owners or teams."),
"diagram-tree": ("Tree", "Hierarchy or breakdown structure."),
"diagram-layer-stack": ("Layer Stack", "Stacked layers of a system."),
"diagram-venn": ("Venn", "Overlap between sets."),
"diagram-candlestick": ("Candlestick", "Open-high-low-close price movement."),
"diagram-waterfall": ("Waterfall", "Cumulative contribution to a total."),
"diagram-sequence": ("Sequence", "Messages exchanged between participants over time."),
"diagram-class": ("Class", "Class structure and relationships."),
"diagram-er": ("ER", "Entities and their relationships."),
}
_TEMPLATE_LOCALES = {"": "zh-CN", "-en": "en", "-ko": "ko"}
def _module_constants(source: str) -> dict:
"""Return top-level string constants of a module without importing it.
scripts/mcp_server.py imports the render and check stack, which pulls in
optional third-party dependencies. The generator only needs its declared
tool names, descriptions, and protocol version, so it reads the syntax
tree instead of the module.
"""
tree = ast.parse(source)
constants: dict = {}
for node in tree.body:
if not isinstance(node, ast.Assign) or len(node.targets) != 1:
continue
target = node.targets[0]
if not isinstance(target, ast.Name):
continue
if isinstance(node.value, ast.Constant):
constants[target.id] = node.value.value
elif isinstance(node.value, ast.List):
constants[target.id] = node.value
return constants
def read_mcp_surface(root: Path) -> tuple[str, list[dict]]:
"""Return (protocol version, [{name, description}]) declared by the server."""
server_file = root / "scripts" / "mcp_server.py"
if not server_file.exists():
raise SystemExit(f"ERROR: missing MCP server at {server_file}")
constants = _module_constants(server_file.read_text(encoding="utf-8"))
protocol = constants.get("PROTOCOL_VERSION")
if not isinstance(protocol, str):
raise SystemExit("ERROR: could not read PROTOCOL_VERSION from mcp_server.py")
tools_node = constants.get("TOOLS")
if not isinstance(tools_node, ast.List):
raise SystemExit("ERROR: could not read TOOLS from mcp_server.py")
tools: list[dict] = []
for element in tools_node.elts:
if not isinstance(element, ast.Dict):
continue
entry = {}
for key, value in zip(element.keys, element.values):
if (
isinstance(key, ast.Constant)
and key.value in ("name", "description")
and isinstance(value, ast.Constant)
):
entry[key.value] = value.value
if "name" in entry and "description" in entry:
tools.append(entry)
if not tools:
raise SystemExit("ERROR: no MCP tools parsed from mcp_server.py")
return protocol, tools
def template_locales(kind: str) -> list[str]:
"""Return the languages a public document kind ships in."""
locales = []
for name in HTML_TEMPLATES:
if public_template_kind(name) != kind:
continue
suffix = ""
for candidate in ("-en", "-ko"):
if name.endswith(candidate):
suffix = candidate
break
locale = _TEMPLATE_LOCALES[suffix]
if locale not in locales:
locales.append(locale)
return locales
def build_agent_skills_index(version: str, skill_digest: str) -> dict:
return {
"$schema": "https://schemas.agentskills.io/discovery/0.2.0/schema.json",
"skills": [
{
"name": PLUGIN_NAME,
"type": "skill-md",
"description": SKILL_WHEN_TO_USE,
"url": "/SKILL.md",
"digest": skill_digest,
"version": version,
"license": "MIT",
"homepage": SITE,
"repository": REPOSITORY,
"documentation": f"{SITE}/developers.md",
}
],
}
def build_mcp_server_card(version: str, protocol: str, tools: list[dict]) -> dict:
return {
"name": PLUGIN_NAME,
"title": "Kami",
"description": (
"Render and verify Kami documents locally: list templates and "
"content schemas, render filled HTML to PDF, run the deterministic "
"checks, and rasterize pages for a perceptual review pass."
),
"version": version,
"protocolVersion": protocol,
"license": "MIT",
"homepage": SITE,
"repository": REPOSITORY,
"documentation": f"{SITE}/developers.md",
# Kami runs on the user's machine and reads and writes local files, so
# there is no hosted endpoint to advertise. Agents install the package
# and speak stdio; `remotes` stays empty on purpose.
"transports": ["stdio"],
"remotes": [],
"packages": [
{
"registryType": "github",
"identifier": "tw93/kami",
"version": version,
"transport": {"type": "stdio"},
"runtimeHint": "python3",
"install": f"claude mcp add {PLUGIN_NAME} -- python3 <checkout>/scripts/mcp_server.py",
}
],
"tools": tools,
}
def build_catalog_feed(root: Path) -> str:
"""Return the JSON-LD catalog of every public template and diagram type."""
documents = []
for position, kind in enumerate(
[k for k in DOCUMENT_TEMPLATE_NOTES if k in PUBLIC_DOCUMENT_TEMPLATE_KINDS], start=1
):
name, description = DOCUMENT_TEMPLATE_NOTES[kind]
encoding_formats = ["text/html", "application/pdf"]
if kind == "slides":
encoding_formats.append(
"application/vnd.openxmlformats-officedocument.presentationml.presentation"
)
documents.append(
{
"@type": "ListItem",
"position": position,
"item": {
"@type": "CreativeWork",
"@id": f"{SITE}/#template-{kind}",
"identifier": kind,
"name": name,
"description": description,
"genre": "document template",
"inLanguage": template_locales(kind),
"encodingFormat": encoding_formats,
},
}
)
diagrams = []
for position, key in enumerate(DIAGRAM_TEMPLATES, start=1):
name, description = DIAGRAM_NOTES[key]
diagrams.append(
{
"@type": "ListItem",
"position": position,
"item": {
"@type": "CreativeWork",
"@id": f"{SITE}/#{key}",
"identifier": key,
"name": f"{name} Diagram",
"description": description,
"genre": "diagram template",
"encodingFormat": ["image/svg+xml", "text/html"],
},
}
)
schemas = sorted(p.stem for p in (root / "references" / "schemas").glob("*.json"))
graph = {
"@context": "https://schema.org",
"@id": f"{SITE}/feeds/catalog.jsonld",
"@type": "ItemList",
"name": "Kami template catalog",
"description": (
"Every document template and diagram type Kami ships, plus the "
"content schema types an agent can validate against before layout."
),
"url": f"{SITE}/developers",
"numberOfItems": len(documents) + len(diagrams),
"itemListElement": documents + diagrams,
"about": {
"@type": "SoftwareApplication",
"@id": f"{SITE}/#kami",
"name": "Kami",
"url": SITE,
},
"mainEntityOfPage": f"{SITE}/developers",
"keywords": [f"content-schema:{name}" for name in schemas],
}
return render_json(graph)
def build_schemamap() -> str:
"""Return the schema map: which JSON-LD endpoint describes which page."""
resources = [
(f"{SITE}/developers", f"{SITE}/feeds/catalog.jsonld", ["ItemList", "CreativeWork"]),
]
lines = [
'<?xml version="1.0" encoding="UTF-8"?>',
'<schemamap xmlns="https://specification.website/schemas/schemamap/0.1">',
]
for loc, jsonld, types in resources:
lines.append(" <resource>")
lines.append(f" <loc>{loc}</loc>")
lines.append(f" <jsonld>{jsonld}</jsonld>")
for type_name in types:
lines.append(f" <type>{type_name}</type>")
lines.append(" </resource>")
lines.append("</schemamap>")
return "\n".join(lines) + "\n"
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, so plugins/kami carries the two manifests plus a
byte-for-byte copy of skills/kami. The copy exists so a plugin install does
not drag the website, fonts, and tests that share the repository.
"""
generated = {
"plugins/kami/.claude-plugin/plugin.json": claude_manifest_rendered.encode(),
"plugins/kami/.codex-plugin/plugin.json": codex_manifest_rendered.encode(),
}
skill_root = root / "skills" / "kami"
if not (skill_root / "SKILL.md").exists():
raise SystemExit(f"ERROR: missing skill source at {skill_root}")
for path in sorted(skill_root.rglob("*")):
if not path.is_file():
continue
source_rel = path.relative_to(skill_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_files: list[tuple[Path, str]], plugin_tree: dict[str, bytes]) -> int:
drift = False
for generated_path, expected in generated_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_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_files: list[tuple[Path, str]], plugin_tree: dict[str, bytes]) -> int:
for generated_path, expected in generated_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()
skill_root = root / "skills" / "kami"
site_root = root / "site"
version = read_version(skill_root)
codex_plugin_rendered = render_json(build_codex_plugin(version, read_token_value(skill_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)
skill_source = (skill_root / "SKILL.md").read_bytes()
skill_digest = "sha256:" + hashlib.sha256(skill_source).hexdigest()
protocol, tools = read_mcp_surface(skill_root)
generated_files = [
(root / ".claude-plugin" / "marketplace.json", claude_marketplace_rendered),
(root / ".agents" / "plugins" / "marketplace.json", codex_marketplace_rendered),
(
site_root / ".well-known" / "agent-skills" / "index.json",
render_json(build_agent_skills_index(version, skill_digest)),
),
(
site_root / ".well-known" / "mcp" / "server-card.json",
render_json(build_mcp_server_card(version, protocol, tools)),
),
(site_root / "feeds" / "catalog.jsonld", build_catalog_feed(skill_root)),
(site_root / "schemamap.xml", build_schemamap()),
# The website serves the skill definition at /SKILL.md through a rewrite
# to this copy. It is deliberately not named SKILL.md: the skills CLI
# treats any SKILL.md one level below the repo root as an installable
# skill, and site/SKILL.md would win over skills/kami.
(site_root / "kami-skill.md", skill_source.decode("utf-8")),
]
if args.check:
return check_generated(root, generated_files, plugin_tree)
return write_generated(root, generated_files, plugin_tree)
if __name__ == "__main__":
raise SystemExit(main())