mirror of
https://github.com/tw93/Kami.git
synced 2026-09-18 10:18:26 +08:00
4dab24cc4c
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.
688 lines
24 KiB
Python
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())
|