feat: publish agent discovery files from the template registry

The site is a static deploy of this repo, so every agent-facing discovery
document has to be a committed file. Generating them keeps the version, the
MCP tool list, and the template catalog tied to their single source instead
of a hand-copied duplicate that rots on the next release.
This commit is contained in:
Tw93
2026-07-25 20:12:40 +08:00
parent 73fbb33b7f
commit 507dab0e80
11 changed files with 1184 additions and 22 deletions
+17
View File
@@ -0,0 +1,17 @@
{
"$schema": "https://schemas.agentskills.io/discovery/0.2.0/schema.json",
"skills": [
{
"name": "kami",
"type": "skill-md",
"description": "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 a constrained parchment template and exports HTML to PDF, PNG, or editable PPTX, then verifies the result with deterministic and perceptual checks. Skip it when the user only wants the text.",
"url": "/SKILL.md",
"digest": "sha256:53b0dbf660a26a75d7ce6aabd5aa6efb0a0deb920bf95bdb672b99a86e47a01d",
"version": "1.10.0",
"license": "MIT",
"homepage": "https://kami.tw93.fun",
"repository": "https://github.com/tw93/kami",
"documentation": "https://kami.tw93.fun/developers.md"
}
]
}
+45
View File
@@ -0,0 +1,45 @@
{
"name": "kami",
"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": "1.10.0",
"protocolVersion": "2025-06-18",
"license": "MIT",
"homepage": "https://kami.tw93.fun",
"repository": "https://github.com/tw93/kami",
"documentation": "https://kami.tw93.fun/developers.md",
"transports": [
"stdio"
],
"remotes": [],
"packages": [
{
"registryType": "github",
"identifier": "tw93/kami",
"version": "1.10.0",
"transport": {
"type": "stdio"
},
"runtimeHint": "python3",
"install": "claude mcp add kami -- python3 <checkout>/scripts/mcp_server.py"
}
],
"tools": [
{
"name": "kami_templates",
"description": "List Kami document templates, browser-only templates, the diagram library, and content schema types, with the reference docs to read before filling."
},
{
"name": "kami_render",
"description": "Render trusted local Kami HTML to PDF via WeasyPrint, with build-time code highlighting. Referenced file, HTTP, and HTTPS resources load with this process's permissions. Returns the PDF path and page count."
},
{
"name": "kami_check",
"description": "Run Kami's deterministic checks for a file. HTML: placeholders + markdown residue (+ content coverage when a content IR JSON is given). PDF: markdown residue + orphans + density. JSON: content IR schema validation. Returns the full report text."
},
{
"name": "kami_screenshot",
"description": "Rasterize every PDF page to PNG for a perceptual review pass. Writes or replaces <pdf-stem>-visual/page-*.png, then returns the image paths and fixed review checklist; view every image against the checklist before shipping."
}
]
}
BIN
View File
Binary file not shown.
+485
View File
@@ -0,0 +1,485 @@
{
"@context": "https://schema.org",
"@id": "https://kami.tw93.fun/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": "https://kami.tw93.fun/developers",
"numberOfItems": 26,
"itemListElement": [
{
"@type": "ListItem",
"position": 1,
"item": {
"@type": "CreativeWork",
"@id": "https://kami.tw93.fun/#template-one-pager",
"identifier": "one-pager",
"name": "One-Pager",
"description": "Single-page brief for a product, project, or pitch.",
"genre": "document template",
"inLanguage": [
"zh-CN",
"en",
"ko"
],
"encodingFormat": [
"text/html",
"application/pdf"
]
}
},
{
"@type": "ListItem",
"position": 2,
"item": {
"@type": "CreativeWork",
"@id": "https://kami.tw93.fun/#template-letter",
"identifier": "letter",
"name": "Letter",
"description": "Formal correspondence: offers, notices, cover letters.",
"genre": "document template",
"inLanguage": [
"zh-CN",
"en",
"ko"
],
"encodingFormat": [
"text/html",
"application/pdf"
]
}
},
{
"@type": "ListItem",
"position": 3,
"item": {
"@type": "CreativeWork",
"@id": "https://kami.tw93.fun/#template-long-doc",
"identifier": "long-doc",
"name": "Long Document",
"description": "Multi-page report or spec with running headers.",
"genre": "document template",
"inLanguage": [
"zh-CN",
"en",
"ko"
],
"encodingFormat": [
"text/html",
"application/pdf"
]
}
},
{
"@type": "ListItem",
"position": 4,
"item": {
"@type": "CreativeWork",
"@id": "https://kami.tw93.fun/#template-portfolio",
"identifier": "portfolio",
"name": "Portfolio",
"description": "Case-study layout for work samples.",
"genre": "document template",
"inLanguage": [
"zh-CN",
"en",
"ko"
],
"encodingFormat": [
"text/html",
"application/pdf"
]
}
},
{
"@type": "ListItem",
"position": 5,
"item": {
"@type": "CreativeWork",
"@id": "https://kami.tw93.fun/#template-resume",
"identifier": "resume",
"name": "Resume",
"description": "One or two page CV with a fixed density budget.",
"genre": "document template",
"inLanguage": [
"zh-CN",
"en",
"ko"
],
"encodingFormat": [
"text/html",
"application/pdf"
]
}
},
{
"@type": "ListItem",
"position": 6,
"item": {
"@type": "CreativeWork",
"@id": "https://kami.tw93.fun/#template-slides",
"identifier": "slides",
"name": "Slides",
"description": "Deck rendered to PDF, with an editable PPTX fallback.",
"genre": "document template",
"inLanguage": [
"ko",
"zh-CN",
"en"
],
"encodingFormat": [
"text/html",
"application/pdf"
]
}
},
{
"@type": "ListItem",
"position": 7,
"item": {
"@type": "CreativeWork",
"@id": "https://kami.tw93.fun/#template-equity-report",
"identifier": "equity-report",
"name": "Equity Report",
"description": "Company or stock analysis with data tables.",
"genre": "document template",
"inLanguage": [
"ko",
"zh-CN",
"en"
],
"encodingFormat": [
"text/html",
"application/pdf"
]
}
},
{
"@type": "ListItem",
"position": 8,
"item": {
"@type": "CreativeWork",
"@id": "https://kami.tw93.fun/#template-changelog",
"identifier": "changelog",
"name": "Changelog",
"description": "Release notes grouped by version.",
"genre": "document template",
"inLanguage": [
"ko",
"zh-CN",
"en"
],
"encodingFormat": [
"text/html",
"application/pdf"
]
}
},
{
"@type": "ListItem",
"position": 1,
"item": {
"@type": "CreativeWork",
"@id": "https://kami.tw93.fun/#diagram-architecture",
"identifier": "diagram-architecture",
"name": "Architecture Diagram",
"description": "System components and the connections between them.",
"genre": "diagram template",
"encodingFormat": [
"image/svg+xml",
"text/html"
]
}
},
{
"@type": "ListItem",
"position": 2,
"item": {
"@type": "CreativeWork",
"@id": "https://kami.tw93.fun/#diagram-architecture-board",
"identifier": "diagram-architecture-board",
"name": "Architecture Board Diagram",
"description": "Wide board view of a system, sized for slides.",
"genre": "diagram template",
"encodingFormat": [
"image/svg+xml",
"text/html"
]
}
},
{
"@type": "ListItem",
"position": 3,
"item": {
"@type": "CreativeWork",
"@id": "https://kami.tw93.fun/#diagram-flowchart",
"identifier": "diagram-flowchart",
"name": "Flowchart Diagram",
"description": "Process and decision flow.",
"genre": "diagram template",
"encodingFormat": [
"image/svg+xml",
"text/html"
]
}
},
{
"@type": "ListItem",
"position": 4,
"item": {
"@type": "CreativeWork",
"@id": "https://kami.tw93.fun/#diagram-quadrant",
"identifier": "diagram-quadrant",
"name": "Quadrant Diagram",
"description": "Two-axis positioning of options or competitors.",
"genre": "diagram template",
"encodingFormat": [
"image/svg+xml",
"text/html"
]
}
},
{
"@type": "ListItem",
"position": 5,
"item": {
"@type": "CreativeWork",
"@id": "https://kami.tw93.fun/#diagram-bar-chart",
"identifier": "diagram-bar-chart",
"name": "Bar Chart Diagram",
"description": "Comparison across categories.",
"genre": "diagram template",
"encodingFormat": [
"image/svg+xml",
"text/html"
]
}
},
{
"@type": "ListItem",
"position": 6,
"item": {
"@type": "CreativeWork",
"@id": "https://kami.tw93.fun/#diagram-line-chart",
"identifier": "diagram-line-chart",
"name": "Line Chart Diagram",
"description": "Trend across an ordered axis.",
"genre": "diagram template",
"encodingFormat": [
"image/svg+xml",
"text/html"
]
}
},
{
"@type": "ListItem",
"position": 7,
"item": {
"@type": "CreativeWork",
"@id": "https://kami.tw93.fun/#diagram-donut-chart",
"identifier": "diagram-donut-chart",
"name": "Donut Chart Diagram",
"description": "Share of a whole.",
"genre": "diagram template",
"encodingFormat": [
"image/svg+xml",
"text/html"
]
}
},
{
"@type": "ListItem",
"position": 8,
"item": {
"@type": "CreativeWork",
"@id": "https://kami.tw93.fun/#diagram-state-machine",
"identifier": "diagram-state-machine",
"name": "State Machine Diagram",
"description": "States and the transitions between them.",
"genre": "diagram template",
"encodingFormat": [
"image/svg+xml",
"text/html"
]
}
},
{
"@type": "ListItem",
"position": 9,
"item": {
"@type": "CreativeWork",
"@id": "https://kami.tw93.fun/#diagram-timeline",
"identifier": "diagram-timeline",
"name": "Timeline Diagram",
"description": "Milestones along a time axis.",
"genre": "diagram template",
"encodingFormat": [
"image/svg+xml",
"text/html"
]
}
},
{
"@type": "ListItem",
"position": 10,
"item": {
"@type": "CreativeWork",
"@id": "https://kami.tw93.fun/#diagram-swimlane",
"identifier": "diagram-swimlane",
"name": "Swimlane Diagram",
"description": "Process split across owners or teams.",
"genre": "diagram template",
"encodingFormat": [
"image/svg+xml",
"text/html"
]
}
},
{
"@type": "ListItem",
"position": 11,
"item": {
"@type": "CreativeWork",
"@id": "https://kami.tw93.fun/#diagram-tree",
"identifier": "diagram-tree",
"name": "Tree Diagram",
"description": "Hierarchy or breakdown structure.",
"genre": "diagram template",
"encodingFormat": [
"image/svg+xml",
"text/html"
]
}
},
{
"@type": "ListItem",
"position": 12,
"item": {
"@type": "CreativeWork",
"@id": "https://kami.tw93.fun/#diagram-layer-stack",
"identifier": "diagram-layer-stack",
"name": "Layer Stack Diagram",
"description": "Stacked layers of a system.",
"genre": "diagram template",
"encodingFormat": [
"image/svg+xml",
"text/html"
]
}
},
{
"@type": "ListItem",
"position": 13,
"item": {
"@type": "CreativeWork",
"@id": "https://kami.tw93.fun/#diagram-venn",
"identifier": "diagram-venn",
"name": "Venn Diagram",
"description": "Overlap between sets.",
"genre": "diagram template",
"encodingFormat": [
"image/svg+xml",
"text/html"
]
}
},
{
"@type": "ListItem",
"position": 14,
"item": {
"@type": "CreativeWork",
"@id": "https://kami.tw93.fun/#diagram-candlestick",
"identifier": "diagram-candlestick",
"name": "Candlestick Diagram",
"description": "Open-high-low-close price movement.",
"genre": "diagram template",
"encodingFormat": [
"image/svg+xml",
"text/html"
]
}
},
{
"@type": "ListItem",
"position": 15,
"item": {
"@type": "CreativeWork",
"@id": "https://kami.tw93.fun/#diagram-waterfall",
"identifier": "diagram-waterfall",
"name": "Waterfall Diagram",
"description": "Cumulative contribution to a total.",
"genre": "diagram template",
"encodingFormat": [
"image/svg+xml",
"text/html"
]
}
},
{
"@type": "ListItem",
"position": 16,
"item": {
"@type": "CreativeWork",
"@id": "https://kami.tw93.fun/#diagram-sequence",
"identifier": "diagram-sequence",
"name": "Sequence Diagram",
"description": "Messages exchanged between participants over time.",
"genre": "diagram template",
"encodingFormat": [
"image/svg+xml",
"text/html"
]
}
},
{
"@type": "ListItem",
"position": 17,
"item": {
"@type": "CreativeWork",
"@id": "https://kami.tw93.fun/#diagram-class",
"identifier": "diagram-class",
"name": "Class Diagram",
"description": "Class structure and relationships.",
"genre": "diagram template",
"encodingFormat": [
"image/svg+xml",
"text/html"
]
}
},
{
"@type": "ListItem",
"position": 18,
"item": {
"@type": "CreativeWork",
"@id": "https://kami.tw93.fun/#diagram-er",
"identifier": "diagram-er",
"name": "ER Diagram",
"description": "Entities and their relationships.",
"genre": "diagram template",
"encodingFormat": [
"image/svg+xml",
"text/html"
]
}
}
],
"about": {
"@type": "SoftwareApplication",
"@id": "https://kami.tw93.fun/#kami",
"name": "Kami",
"url": "https://kami.tw93.fun"
},
"mainEntityOfPage": "https://kami.tw93.fun/developers",
"keywords": [
"content-schema:changelog",
"content-schema:equity-report",
"content-schema:landing-page",
"content-schema:letter",
"content-schema:long-doc",
"content-schema:one-pager",
"content-schema:portfolio",
"content-schema:resume",
"content-schema:slides"
]
}
@@ -17,6 +17,10 @@ Generated files:
- plugins/kami/.claude-plugin/plugin.json
- plugins/kami/.codex-plugin/plugin.json
- plugins/kami/skills/kami/
- .well-known/agent-skills/index.json
- .well-known/mcp/server-card.json
- feeds/catalog.jsonld
- schemamap.xml
Modes:
--write (default) regenerate plugin files from source
@@ -28,17 +32,27 @@ 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
from pathlib import Path
from shared import (
DIAGRAM_TEMPLATES,
HTML_TEMPLATES,
PUBLIC_DOCUMENT_TEMPLATE_KINDS,
public_template_kind,
)
ROOT = Path(__file__).resolve().parent.parent
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",
@@ -223,6 +237,277 @@ def build_claude_marketplace(version: str) -> dict:
}
# --- 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 a constrained parchment "
"template and exports HTML to PDF, PNG, or editable PPTX, then verifies "
"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]
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": ["text/html", "application/pdf"],
},
}
)
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
@@ -289,10 +574,10 @@ def bytes_diff(label: str, expected: bytes, actual: bytes) -> str:
)
def check_generated(root: Path, generated_json_files: list[tuple[Path, str]], plugin_tree: dict[str, bytes]) -> int:
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_json_files:
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()
@@ -335,14 +620,14 @@ def check_generated(root: Path, generated_json_files: list[tuple[Path, str]], pl
if drift:
return 1
for generated_path, _ in generated_json_files:
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_json_files: list[tuple[Path, str]], plugin_tree: dict[str, bytes]) -> int:
for generated_path, expected in generated_json_files:
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)")
@@ -379,14 +664,28 @@ def main() -> int:
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 = [
skill_digest = "sha256:" + hashlib.sha256((root / "SKILL.md").read_bytes()).hexdigest()
protocol, tools = read_mcp_surface(root)
generated_files = [
(root / ".claude-plugin" / "marketplace.json", claude_marketplace_rendered),
(root / ".agents" / "plugins" / "marketplace.json", codex_marketplace_rendered),
(
root / ".well-known" / "agent-skills" / "index.json",
render_json(build_agent_skills_index(version, skill_digest)),
),
(
root / ".well-known" / "mcp" / "server-card.json",
render_json(build_mcp_server_card(version, protocol, tools)),
),
(root / "feeds" / "catalog.jsonld", build_catalog_feed(root)),
(root / "schemamap.xml", build_schemamap()),
]
if args.check:
return check_generated(root, generated_json_files, plugin_tree)
return write_generated(root, generated_json_files, plugin_tree)
return check_generated(root, generated_files, plugin_tree)
return write_generated(root, generated_files, plugin_tree)
if __name__ == "__main__":
+4 -3
View File
@@ -186,7 +186,8 @@ PUBLIC_DOCUMENT_TEMPLATE_KINDS = {
}
def _public_template_kind(name: str) -> str:
def public_template_kind(name: str) -> str:
"""Map a registry template name to the public kind it belongs to."""
for suffix in ("-en", "-ko"):
if name.endswith(suffix):
name = name[: -len(suffix)]
@@ -199,9 +200,9 @@ def _public_template_kind(name: str) -> str:
def public_document_template_kinds() -> set[str]:
"""Return public document-template kinds represented by HTML_TEMPLATES."""
return {
_public_template_kind(name)
public_template_kind(name)
for name in HTML_TEMPLATES
if _public_template_kind(name) in PUBLIC_DOCUMENT_TEMPLATE_KINDS
if public_template_kind(name) in PUBLIC_DOCUMENT_TEMPLATE_KINDS
}
@@ -39,9 +39,12 @@ SITE_LOCALE_PAGES = (
# Every surface that must carry the full public fact set. Derived from the
# locale-page tuple so adding a locale automatically joins both checks.
# index.md is the Markdown twin agents read instead of the homepage, so it
# carries the same install and product facts.
FULL_PUBLIC_FACT_FILES = (
"README.md",
"llms.txt",
"index.md",
SITE_BASE_PAGE,
*SITE_LOCALE_PAGES,
)
+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<schemamap xmlns="https://specification.website/schemas/schemamap/0.1">
<resource>
<loc>https://kami.tw93.fun/developers</loc>
<jsonld>https://kami.tw93.fun/feeds/catalog.jsonld</jsonld>
<type>ItemList</type>
<type>CreativeWork</type>
</resource>
</schemamap>
+307 -8
View File
@@ -17,6 +17,10 @@ Generated files:
- plugins/kami/.claude-plugin/plugin.json
- plugins/kami/.codex-plugin/plugin.json
- plugins/kami/skills/kami/
- .well-known/agent-skills/index.json
- .well-known/mcp/server-card.json
- feeds/catalog.jsonld
- schemamap.xml
Modes:
--write (default) regenerate plugin files from source
@@ -28,17 +32,27 @@ 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
from pathlib import Path
from shared import (
DIAGRAM_TEMPLATES,
HTML_TEMPLATES,
PUBLIC_DOCUMENT_TEMPLATE_KINDS,
public_template_kind,
)
ROOT = Path(__file__).resolve().parent.parent
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",
@@ -223,6 +237,277 @@ def build_claude_marketplace(version: str) -> dict:
}
# --- 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 a constrained parchment "
"template and exports HTML to PDF, PNG, or editable PPTX, then verifies "
"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]
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": ["text/html", "application/pdf"],
},
}
)
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
@@ -289,10 +574,10 @@ def bytes_diff(label: str, expected: bytes, actual: bytes) -> str:
)
def check_generated(root: Path, generated_json_files: list[tuple[Path, str]], plugin_tree: dict[str, bytes]) -> int:
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_json_files:
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()
@@ -335,14 +620,14 @@ def check_generated(root: Path, generated_json_files: list[tuple[Path, str]], pl
if drift:
return 1
for generated_path, _ in generated_json_files:
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_json_files: list[tuple[Path, str]], plugin_tree: dict[str, bytes]) -> int:
for generated_path, expected in generated_json_files:
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)")
@@ -379,14 +664,28 @@ def main() -> int:
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 = [
skill_digest = "sha256:" + hashlib.sha256((root / "SKILL.md").read_bytes()).hexdigest()
protocol, tools = read_mcp_surface(root)
generated_files = [
(root / ".claude-plugin" / "marketplace.json", claude_marketplace_rendered),
(root / ".agents" / "plugins" / "marketplace.json", codex_marketplace_rendered),
(
root / ".well-known" / "agent-skills" / "index.json",
render_json(build_agent_skills_index(version, skill_digest)),
),
(
root / ".well-known" / "mcp" / "server-card.json",
render_json(build_mcp_server_card(version, protocol, tools)),
),
(root / "feeds" / "catalog.jsonld", build_catalog_feed(root)),
(root / "schemamap.xml", build_schemamap()),
]
if args.check:
return check_generated(root, generated_json_files, plugin_tree)
return write_generated(root, generated_json_files, plugin_tree)
return check_generated(root, generated_files, plugin_tree)
return write_generated(root, generated_files, plugin_tree)
if __name__ == "__main__":
+4 -3
View File
@@ -186,7 +186,8 @@ PUBLIC_DOCUMENT_TEMPLATE_KINDS = {
}
def _public_template_kind(name: str) -> str:
def public_template_kind(name: str) -> str:
"""Map a registry template name to the public kind it belongs to."""
for suffix in ("-en", "-ko"):
if name.endswith(suffix):
name = name[: -len(suffix)]
@@ -199,9 +200,9 @@ def _public_template_kind(name: str) -> str:
def public_document_template_kinds() -> set[str]:
"""Return public document-template kinds represented by HTML_TEMPLATES."""
return {
_public_template_kind(name)
public_template_kind(name)
for name in HTML_TEMPLATES
if _public_template_kind(name) in PUBLIC_DOCUMENT_TEMPLATE_KINDS
if public_template_kind(name) in PUBLIC_DOCUMENT_TEMPLATE_KINDS
}
+3
View File
@@ -39,9 +39,12 @@ SITE_LOCALE_PAGES = (
# Every surface that must carry the full public fact set. Derived from the
# locale-page tuple so adding a locale automatically joins both checks.
# index.md is the Markdown twin agents read instead of the homepage, so it
# carries the same install and product facts.
FULL_PUBLIC_FACT_FILES = (
"README.md",
"llms.txt",
"index.md",
SITE_BASE_PAGE,
*SITE_LOCALE_PAGES,
)