Files
tw93__waza/scripts/build_metadata.py
T
2026-07-24 23:32:53 +08:00

630 lines
24 KiB
Python
Executable File

#!/usr/bin/env python3
"""Generate Waza distribution metadata from a single source of truth.
Source of truth:
- VERSION top-level version string (single source)
- skills/<name>/SKILL.md frontmatter name, description, dispatch_intent
Generated files:
- .claude-plugin/marketplace.json full plugin manifest
- plugins/waza/.codex-plugin/plugin.json
Codex plugin manifest
- plugins/waza/skills/ Codex plugin skill mirror
- plugins/waza/rules/ Codex plugin rule mirror
- .agents/plugins/marketplace.json Codex repo marketplace
- README.md install URLs pinned to VERSION
- package.json npm/Pi package metadata pinned to VERSION
- skills/*/references/durable-context.md
direct-install copies of the shared
durable-context preamble (only skills
whose SKILL.md links it)
- scripts/setup-rule.sh default WAZA_REF pinned to VERSION
- scripts/setup-statusline.sh default WAZA_REF pinned to VERSION
Modes:
--write (default) regenerate every file from source
--check compare generated bytes against committed files;
exit non-zero with a diff on mismatch
Run as: python3 scripts/build_metadata.py [--check] [--root PATH]
"""
from __future__ import annotations
import argparse
import difflib
import json
import re
import shutil
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "scripts"))
from skill_frontmatter import ( # noqa: E402
iter_codex_plugin_files,
iter_codex_source_files,
parse_frontmatter,
)
# Hand-maintained marketplace/plugin constants. Kept here (not in frontmatter)
# because they describe the Waza project itself, not any single skill.
CLAUDE_MARKETPLACE_TOP = {
"$schema": "https://anthropic.com/claude-code/marketplace.schema.json",
"name": "waza",
"description": (
"Personal skill collection for Claude Code, Codex, Antigravity, "
"OpenCode, and Pi: think, check, hunt, ui, read, write, learn, "
"and health for agent config and AI maintainability audits."
),
"owner": {
"name": "Tw93",
"email": "hitw93@gmail.com",
},
}
BUNDLE_DESCRIPTION = (
"Installs the full Waza toolkit. Registers all eight skills under the "
"waza namespace, callable as /waza:think, /waza:check, /waza:hunt, "
"/waza:ui, /waza:read, /waza:write, /waza:learn, and /waza:health. "
"For one skill on Claude Code v2.1.143 or newer, use /plugin install "
"waza-<name>@waza."
)
CATEGORY = "development"
CODEX_CATEGORY = "Developer Tools"
HOMEPAGE = "https://github.com/tw93/Waza"
REPOSITORY = "https://github.com/tw93/Waza"
AUTHOR = {
"name": "Tw93",
"email": "hitw93@gmail.com",
"url": "https://github.com/tw93",
}
CODEX_DESCRIPTION = (
"Engineering workflow skills for Codex: think, check, hunt, ui, read, "
"write, learn, and health."
)
# Shared durable-context preamble: source of truth in rules/, copied into each
# referencing skill's references/ so direct installs resolve the link locally.
DURABLE_CONTEXT_RULE = Path("rules/durable-context.md")
DURABLE_CONTEXT_COPY = Path("references/durable-context.md")
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 collect_skill_metadata(root: Path) -> list[dict]:
skill_files = sorted((root / "skills").glob("*/SKILL.md"))
if not skill_files:
raise SystemExit("ERROR: no SKILL.md files found under skills/")
skills: list[dict] = []
for path in skill_files:
fields = parse_frontmatter(path)
if fields["name"] != path.parent.name:
raise SystemExit(
f"ERROR: {path} frontmatter name={fields['name']!r} "
f"!= directory {path.parent.name!r}"
)
skills.append(fields)
return skills
def build_marketplace(version: str, skills: list[dict]) -> dict:
"""Build the Claude Code plugin marketplace metadata."""
plugins = [
{
"name": "waza",
"description": BUNDLE_DESCRIPTION,
"version": version,
"category": CATEGORY,
"source": "./",
"homepage": HOMEPAGE,
}
]
for skill in sorted(skills, key=lambda s: s["name"]):
plugins.append(
{
"name": f"waza-{skill['name']}",
"description": skill["description"],
"version": version,
"category": CATEGORY,
"source": f"./skills/{skill['name']}",
"homepage": HOMEPAGE,
# `skills` field declares which subdirectories under `source`
# contain SKILL.md entrypoints. "./" means the source dir itself
# is the skill root. Present on every per-skill entry; absent
# on the bundle (the bundle uses Claude Code's auto-discovery).
"skills": ["./"],
"strict": False,
}
)
return {**CLAUDE_MARKETPLACE_TOP, "plugins": plugins}
def render_json(data: dict) -> str:
return json.dumps(data, indent=2, ensure_ascii=False) + "\n"
def build_codex_plugin(version: str) -> dict:
return {
"name": "waza",
"version": version,
"description": CODEX_DESCRIPTION,
"author": AUTHOR,
"homepage": HOMEPAGE,
"repository": REPOSITORY,
"license": "MIT",
"keywords": [
"codex",
"skills",
"engineering-workflow",
"code-review",
"debugging",
"planning",
"writing",
],
"skills": "./skills/",
"interface": {
"displayName": "Waza",
"shortDescription": "Engineering workflow skills for Codex",
"longDescription": (
"Waza packages eight engineering habits as Codex skills: "
"think for planning, check for review, hunt for debugging, "
"ui for frontend work, read for source intake, write for "
"prose, learn for domain research, and health for agent "
"configuration audits."
),
"developerName": "Tw93",
"category": CODEX_CATEGORY,
"capabilities": [
"Interactive",
"Write",
],
"websiteURL": HOMEPAGE,
"defaultPrompt": [
"Use Waza think to plan this change",
"Use Waza check to review this diff",
"Use Waza hunt to debug this failure",
],
"brandColor": "#111827",
},
}
def build_agy_plugin() -> dict:
return {
"$schema": "https://antigravity.google/schemas/v1/plugin.json",
"name": "waza",
"description": CODEX_DESCRIPTION.replace("Codex", "Antigravity CLI"),
}
def build_codex_marketplace() -> dict:
return {
"name": "waza",
"interface": {
"displayName": "Waza",
},
"plugins": [
{
"name": "waza",
"source": {
"source": "local",
"path": "./plugins/waza",
},
"policy": {
"installation": "AVAILABLE",
"authentication": "ON_INSTALL",
},
"category": CODEX_CATEGORY,
}
],
}
def build_package_json(version: str) -> str:
package = {
"name": "@tw93/waza",
"version": version,
"description": (
"Waza engineering skills for Claude Code, Codex, Antigravity, "
"OpenCode, Pi, and compatible coding agents."
),
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/tw93/Waza.git",
},
"homepage": "https://github.com/tw93/Waza#readme",
"keywords": [
"pi-package",
"agent-skills",
"waza",
"claude-code",
"codex",
"antigravity",
"opencode",
"antigravity-cli"
],
"files": [
"LICENSE",
"README.md",
"rules",
"scripts/setup-rule.sh",
"scripts/setup-statusline.sh",
"scripts/statusline.sh",
"skills",
"!**/__pycache__/**",
"!**/*.pyc",
],
"publishConfig": {
"access": "public",
},
"pi": {
# Pi scans a skills directory for both recursive SKILL.md and
# stray root-level *.md, so exclude the human routing index;
# it has no frontmatter description and is not a skill.
"skills": [
"./skills",
"!skills/RESOLVER.md",
],
},
}
return json.dumps(package, indent=2, ensure_ascii=False) + "\n"
ROUTING_TABLE_START = "<!-- routing-table:start -->"
ROUTING_TABLE_END = "<!-- routing-table:end -->"
def render_dispatcher(template: str, skills: list[dict]) -> str:
rows = ["| Intent | Skill | File |", "|--------|-------|------|"]
for skill in sorted(skills, key=lambda s: s["name"]):
intent = skill.get("dispatch_intent") or ""
if not intent:
raise SystemExit(
f"ERROR: skill {skill['name']} missing dispatch_intent in frontmatter"
)
rows.append(f"| {intent} | {skill['name']} | `skills/{skill['name']}/SKILL.md` |")
table = "\n".join(rows)
block = f"{ROUTING_TABLE_START}\n{table}\n{ROUTING_TABLE_END}"
if ROUTING_TABLE_START not in template or ROUTING_TABLE_END not in template:
raise SystemExit(
"ERROR: dispatcher template is missing routing-table markers"
)
pattern = re.compile(
re.escape(ROUTING_TABLE_START) + r".*?" + re.escape(ROUTING_TABLE_END),
re.DOTALL,
)
return pattern.sub(block, template)
# README installer entrypoints should follow the latest GitHub release asset.
# The downloaded scripts themselves still default WAZA_REF to a release tag, so
# users get a stable install without README churn on every version bump.
README_INSTALL_URL_RE = re.compile(
r"https://raw\.githubusercontent\.com/tw93/Waza/"
r"(?:main|v\d+\.\d+\.\d+)/scripts/(setup-(?:rule|statusline)\.sh)"
)
README_SWAP_TAG_RE = re.compile(
r"Curl URLs are pinned to the current release tag for reproducibility; "
r"swap `v\d+\.\d+\.\d+` for `main` if you want bleeding-edge scripts\."
)
WAZA_REF_RE = re.compile(r'WAZA_REF="\$\{WAZA_REF:-(?:main|v\d+\.\d+\.\d+)\}"')
def render_readme(current: str) -> str:
current = README_INSTALL_URL_RE.sub(
r"https://github.com/tw93/Waza/releases/latest/download/\1", current
)
return README_SWAP_TAG_RE.sub(
"Curl URLs use the latest GitHub release asset. Set `WAZA_REF=main` "
"before the command if you want bleeding-edge scripts.",
current,
)
def render_script_ref(current: str, version: str) -> str:
return WAZA_REF_RE.sub(f'WAZA_REF="${{WAZA_REF:-v{version}}}"', current)
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 collect_skill_shared_assets(root: Path) -> dict[str, bytes]:
"""Per-skill copies of shared assets that direct installs need locally.
`npx skills add` copies only each skill directory, so every skill whose
SKILL.md links the shared durable-context preamble carries a references/
copy of it.
"""
generated: dict[str, bytes] = {}
durable_source = root / DURABLE_CONTEXT_RULE
durable_bytes = durable_source.read_bytes() if durable_source.exists() else None
for skill_file in sorted((root / "skills").glob("*/SKILL.md")):
skill_dir = skill_file.parent.relative_to(root)
if DURABLE_CONTEXT_COPY.as_posix() in skill_file.read_text():
if durable_bytes is None:
raise SystemExit(
f"ERROR: {skill_file} links {DURABLE_CONTEXT_COPY} but "
f"{DURABLE_CONTEXT_RULE} does not exist"
)
generated[(skill_dir / DURABLE_CONTEXT_COPY).as_posix()] = durable_bytes
return generated
def shared_asset_source(rel: str) -> str:
return DURABLE_CONTEXT_RULE.as_posix()
def collect_codex_plugin_tree(
root: Path,
codex_plugin_rendered: str,
agy_plugin_rendered: str,
generated_skill_files: dict[str, bytes],
) -> dict[str, bytes]:
"""Build the generated file set for the Codex and Antigravity plugin directory.
Codex installs only the directory referenced by marketplace source.path, so
the plugin tree contains real copies of the skill and rule files instead of
symlinks or references back to the repository root.
"""
generated = {
"plugins/waza/.codex-plugin/plugin.json": codex_plugin_rendered.encode(),
"plugins/waza/plugin.json": agy_plugin_rendered.encode(),
}
for source_name in ("skills", "rules"):
if not (root / source_name).exists():
raise SystemExit(
f"ERROR: missing required Codex plugin source tree {root / source_name}"
)
for _source_name, _source_rel, path in iter_codex_source_files(root):
rel = path.relative_to(root).as_posix()
generated[f"plugins/waza/{rel}"] = path.read_bytes()
for rel, content in generated_skill_files.items():
generated[f"plugins/waza/{rel}"] = content
return generated
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)
skills = collect_skill_metadata(root)
marketplace = build_marketplace(version, skills)
rendered = render_json(marketplace)
codex_plugin_rendered = render_json(build_codex_plugin(version))
agy_plugin_rendered = render_json(build_agy_plugin())
codex_marketplace_rendered = render_json(build_codex_marketplace())
package_rendered = build_package_json(version)
target = root / ".claude-plugin" / "marketplace.json"
codex_marketplace_target = root / ".agents" / "plugins" / "marketplace.json"
generated_json_files = [
(target, rendered, "Claude Code marketplace"),
(codex_marketplace_target, codex_marketplace_rendered, "Codex marketplace"),
]
package_json = root / "package.json"
package_actual = package_json.read_text() if package_json.exists() else ""
readme = root / "README.md"
readme_actual = readme.read_text() if readme.exists() else ""
readme_rendered = render_readme(readme_actual)
pinned_scripts = [
(
root / "scripts" / "setup-rule.sh",
"default WAZA_REF",
lambda actual: render_script_ref(actual, version),
),
(
root / "scripts" / "setup-statusline.sh",
"default WAZA_REF",
lambda actual: render_script_ref(actual, version),
),
]
script_pairs = []
for script, field_label, renderer in pinned_scripts:
actual = script.read_text() if script.exists() else ""
script_pairs.append((script, field_label, actual, renderer(actual)))
skill_shared_assets = collect_skill_shared_assets(root)
codex_plugin_tree = collect_codex_plugin_tree(
root,
codex_plugin_rendered,
agy_plugin_rendered,
skill_shared_assets,
)
dispatcher_template = root / "scripts" / "dispatcher-template.md"
dispatcher_target = root / "scripts" / "dispatcher.md"
if not dispatcher_template.exists():
raise SystemExit(f"ERROR: missing dispatcher template at {dispatcher_template}")
dispatcher_actual = (
dispatcher_target.read_text() if dispatcher_target.exists() else ""
)
dispatcher_rendered = render_dispatcher(dispatcher_template.read_text(), skills)
if args.check:
drift = False
for generated_path, expected, label 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 VERSION + "
f"SKILL.md frontmatter.\n"
f"Run scripts/build_metadata.py (no flags) to regenerate.",
file=sys.stderr,
)
sys.stderr.write(diff(rel, expected, actual))
drift = True
for rel, expected in codex_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 repository skills/rules "
f"and Codex plugin metadata.\n"
f"Run scripts/build_metadata.py (no flags) to regenerate.",
file=sys.stderr,
)
sys.stderr.write(bytes_diff(rel, expected, actual))
drift = True
codex_plugin_root = root / "plugins" / "waza"
if codex_plugin_root.exists():
expected_paths = set(codex_plugin_tree)
for _plugin_rel, path in iter_codex_plugin_files(codex_plugin_root):
rel = path.relative_to(root).as_posix()
if rel not in expected_paths:
print(
f"DRIFT: {rel} is an extra file in the generated "
"Codex plugin tree.\n"
f"Run scripts/build_metadata.py (no flags) to regenerate.",
file=sys.stderr,
)
drift = True
if readme_actual != readme_rendered:
print(
"DRIFT: README.md installer URLs must use latest release assets.\n"
f"Run scripts/build_metadata.py (no flags) to regenerate.",
file=sys.stderr,
)
sys.stderr.write(diff("README.md", readme_rendered, readme_actual))
drift = True
if package_actual != package_rendered:
print(
f"DRIFT: package.json is out of sync with VERSION v{version} "
f"and Pi package metadata.\n"
f"Run scripts/build_metadata.py (no flags) to regenerate.",
file=sys.stderr,
)
sys.stderr.write(diff("package.json", package_rendered, package_actual))
drift = True
for rel, expected in skill_shared_assets.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 {shared_asset_source(rel)}.\n"
f"Run scripts/build_metadata.py (no flags) to regenerate.",
file=sys.stderr,
)
sys.stderr.write(bytes_diff(rel, expected, actual))
drift = True
for script, field_label, actual, rendered_script in script_pairs:
if actual != rendered_script:
label = script.relative_to(root).as_posix()
print(
f"DRIFT: {label} {field_label} is not pinned to v{version}.\n"
f"Run scripts/build_metadata.py (no flags) to regenerate.",
file=sys.stderr,
)
sys.stderr.write(diff(label, rendered_script, actual))
drift = True
if dispatcher_actual != dispatcher_rendered:
label = dispatcher_target.relative_to(root).as_posix()
print(
f"DRIFT: {label} routing table is out of sync with "
f"SKILL.md dispatch_intent frontmatter.\n"
f"Run scripts/build_metadata.py (no flags) to regenerate.",
file=sys.stderr,
)
sys.stderr.write(diff(label, dispatcher_rendered, dispatcher_actual))
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/waza Codex plugin tree matches generator")
print("ok: README.md install URLs use latest release assets")
print(f"ok: package.json pinned to v{version}")
print(f"ok: installer defaults pinned to v{version}")
print("ok: skill-local shared assets match generator")
print(f"ok: {dispatcher_target.relative_to(root)} matches generator")
return 0
for generated_path, expected, _ in generated_json_files:
generated_path.parent.mkdir(parents=True, exist_ok=True)
generated_path.write_text(expected)
print(f"wrote: {generated_path.relative_to(root)} ({len(expected)} bytes)")
codex_plugin_root = root / "plugins" / "waza"
shutil.rmtree(codex_plugin_root, ignore_errors=True)
for rel, expected in codex_plugin_tree.items():
path = root / rel
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(expected)
print(f"wrote: plugins/waza ({len(codex_plugin_tree)} generated files)")
if package_actual != package_rendered:
package_json.write_text(package_rendered)
print(f"wrote: package.json (pinned version to v{version})")
else:
print(f"ok: package.json already pinned to v{version}")
if readme_actual != readme_rendered:
readme.write_text(readme_rendered)
print("wrote: README.md (installer URLs use latest release assets)")
else:
print("ok: README.md install URLs already use latest release assets")
for script, field_label, actual, rendered_script in script_pairs:
if actual != rendered_script:
script.write_text(rendered_script)
print(f"wrote: {script.relative_to(root)} ({field_label}=v{version})")
else:
print(f"ok: {script.relative_to(root)} {field_label} already pinned")
for rel, expected in skill_shared_assets.items():
path = root / rel
path.parent.mkdir(parents=True, exist_ok=True)
if not path.exists() or path.read_bytes() != expected:
path.write_bytes(expected)
print(f"wrote: {rel} (copied from {shared_asset_source(rel)})")
if dispatcher_actual != dispatcher_rendered:
dispatcher_target.write_text(dispatcher_rendered)
print(
f"wrote: {dispatcher_target.relative_to(root)} "
f"({len(dispatcher_rendered)} bytes)"
)
else:
print(f"ok: {dispatcher_target.relative_to(root)} already matches generator")
return 0
if __name__ == "__main__":
sys.exit(main())