mirror of
https://github.com/tw93/Waza.git
synced 2026-09-14 19:54:25 +08:00
4fa775568d
Both repos now read the same way: one skills CLI command for Claude Code, Codex, Cursor, and every agent that reads ~/.agents/skills, then the host plugins with their update commands inline, then Claude Desktop and Pi. The pinned install string drops antigravity-cli from the default agent list; private-directory agents add their id after -a.
419 lines
18 KiB
Python
419 lines
18 KiB
Python
"""Distribution-surface checks: marketplaces, Codex plugin mirror, packaging, npm, shared assets.
|
|
|
|
Split from skill_checks.py; import through skill_checks (the facade) so the
|
|
check inventory stays visible in one place.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
|
|
from skill_frontmatter import (
|
|
fail,
|
|
should_include_codex_mirror_file,
|
|
)
|
|
|
|
|
|
def check_marketplace(root: Path, expected_version: str, skill_names: set[str], skill_descriptions: dict[str, str]):
|
|
"""Validate generated Claude Code and Codex marketplace metadata."""
|
|
check_claude_marketplace(root, expected_version, skill_names, skill_descriptions)
|
|
check_codex_plugin(root, expected_version)
|
|
check_codex_marketplace(root)
|
|
|
|
|
|
def check_claude_marketplace(root: Path, expected_version: str, skill_names: set[str], skill_descriptions: dict[str, str]):
|
|
"""Validate Claude Code marketplace.json shape:
|
|
|
|
- One bundle entry: name == "waza", source == "./".
|
|
- Per-skill entries: name == "waza-<skill>", source == "./skills/<skill>".
|
|
- All versions in marketplace march in lock-step with the top-level VERSION
|
|
file. Source of truth is VERSION; per-skill SKILL.md no longer carries a
|
|
version field (codegen + this check guarantee marketplace stays correct).
|
|
"""
|
|
market_path = root / ".claude-plugin" / "marketplace.json"
|
|
marketplace = json.loads(market_path.read_text())
|
|
plugins = marketplace.get("plugins")
|
|
if not isinstance(plugins, list):
|
|
fail("INVALID MARKETPLACE: plugins must be a list")
|
|
|
|
market_versions: dict[str, str] = {}
|
|
market_descriptions: dict[str, str] = {}
|
|
seen_names: set[str] = set()
|
|
bundle_version = ""
|
|
for entry in plugins:
|
|
if not isinstance(entry, dict):
|
|
fail("INVALID MARKETPLACE: plugin entry must be an object")
|
|
name = entry.get("name")
|
|
version = entry.get("version")
|
|
source = entry.get("source")
|
|
description = (entry.get("description") or "").strip().strip('"')
|
|
if not name or not version:
|
|
fail("INVALID MARKETPLACE: every plugin needs name and version")
|
|
if not description:
|
|
fail(f"MISSING DESCRIPTION: marketplace plugin {name}")
|
|
if name in seen_names:
|
|
fail(f"DUPLICATE MARKETPLACE ENTRY: {name}")
|
|
seen_names.add(name)
|
|
|
|
if name == "waza":
|
|
if source != "./":
|
|
fail(f"WRONG BUNDLE SOURCE: source={source!r} expected='./'")
|
|
bundle_version = version
|
|
continue
|
|
|
|
if not name.startswith("waza-"):
|
|
fail(
|
|
f"INVALID PLUGIN NAME: {name!r} must be 'waza' (bundle) or "
|
|
f"'waza-<skill>' (per-skill entry)"
|
|
)
|
|
skill_name = name.removeprefix("waza-")
|
|
if not skill_name:
|
|
fail(
|
|
f"INVALID PLUGIN NAME: {name!r} has an empty <skill> suffix; "
|
|
f"per-skill entries must be named 'waza-<skill>' with a non-empty skill name"
|
|
)
|
|
expected_source = f"./skills/{skill_name}"
|
|
if source != expected_source:
|
|
fail(f"WRONG SOURCE: {name} source={source!r} expected={expected_source!r}")
|
|
market_versions[skill_name] = version
|
|
market_descriptions[skill_name] = description
|
|
|
|
if "waza" not in seen_names:
|
|
fail(
|
|
"MISSING BUNDLE ENTRY: marketplace.json must include a 'waza' bundle entry "
|
|
"(name=\"waza\", source=\"./\") so /plugin install waza@waza registers "
|
|
"all skills under the waza namespace"
|
|
)
|
|
|
|
missing_from_market = sorted(skill_names - set(market_versions))
|
|
if missing_from_market:
|
|
fail("NOT IN MARKETPLACE: " + ", ".join(missing_from_market))
|
|
extra_in_market = sorted(set(market_versions) - skill_names)
|
|
if extra_in_market:
|
|
fail("MISSING SKILL DIRECTORY: " + ", ".join(extra_in_market))
|
|
|
|
for skill in sorted(skill_names):
|
|
market_version = market_versions[skill]
|
|
if market_version != expected_version:
|
|
fail(
|
|
f"VERSION DRIFT: marketplace waza-{skill} version={market_version!r} "
|
|
f"!= VERSION file {expected_version!r}.\n"
|
|
f" All marketplace entries march in lock-step. "
|
|
f"Update .claude-plugin/marketplace.json to match VERSION."
|
|
)
|
|
if not market_descriptions[skill].startswith(skill_descriptions[skill]):
|
|
fail(
|
|
f"DESCRIPTION MISMATCH: {skill}\n"
|
|
f" SKILL.md: {skill_descriptions[skill]}\n"
|
|
f" marketplace: {market_descriptions[skill]}\n"
|
|
f" marketplace description must start with the SKILL.md description"
|
|
)
|
|
print(f"ok: marketplace waza-{skill} pinned to {market_version}")
|
|
|
|
if bundle_version and bundle_version != expected_version:
|
|
fail(
|
|
f"VERSION DRIFT: waza bundle version={bundle_version!r} "
|
|
f"!= VERSION file {expected_version!r}.\n"
|
|
f" Update the 'waza' entry in .claude-plugin/marketplace.json to match VERSION."
|
|
)
|
|
print(f"ok: all versions in lock-step with VERSION={expected_version}")
|
|
|
|
|
|
def check_codex_plugin(root: Path, expected_version: str):
|
|
"""Validate the Codex plugin surface: manifest shape, then mirror integrity."""
|
|
check_codex_plugin_manifest(root, expected_version)
|
|
check_codex_plugin_mirror(root)
|
|
|
|
|
|
def check_codex_plugin_manifest(root: Path, expected_version: str):
|
|
"""Validate Codex plugin manifest shape."""
|
|
plugin_root = root / "plugins" / "waza"
|
|
manifest_path = plugin_root / ".codex-plugin" / "plugin.json"
|
|
if not manifest_path.exists():
|
|
fail(
|
|
"MISSING CODEX PLUGIN MANIFEST: expected "
|
|
"plugins/waza/.codex-plugin/plugin.json "
|
|
"so Codex can install Waza as a plugin from the repo marketplace"
|
|
)
|
|
manifest = json.loads(manifest_path.read_text())
|
|
required = {
|
|
"name": "waza",
|
|
"version": expected_version,
|
|
"skills": "./skills/",
|
|
"license": "MIT",
|
|
"homepage": "https://github.com/tw93/Waza",
|
|
"repository": "https://github.com/tw93/Waza",
|
|
}
|
|
for key, expected in required.items():
|
|
actual = manifest.get(key)
|
|
if actual != expected:
|
|
fail(
|
|
f"CODEX PLUGIN FIELD DRIFT: plugins/waza/.codex-plugin/plugin.json {key}="
|
|
f"{actual!r} expected {expected!r}"
|
|
)
|
|
if not (manifest.get("description") or "").strip():
|
|
fail("CODEX PLUGIN DESCRIPTION: plugins/waza/.codex-plugin/plugin.json needs description")
|
|
author = manifest.get("author")
|
|
if not isinstance(author, dict) or not author.get("name"):
|
|
fail("CODEX PLUGIN AUTHOR: plugins/waza/.codex-plugin/plugin.json needs author.name")
|
|
interface = manifest.get("interface")
|
|
if not isinstance(interface, dict):
|
|
fail("CODEX PLUGIN INTERFACE: plugins/waza/.codex-plugin/plugin.json needs interface object")
|
|
interface_required = {
|
|
"displayName": "Waza",
|
|
"developerName": "Tw93",
|
|
"category": "Developer Tools",
|
|
"websiteURL": "https://github.com/tw93/Waza",
|
|
}
|
|
for key, expected in interface_required.items():
|
|
actual = interface.get(key)
|
|
if actual != expected:
|
|
fail(
|
|
f"CODEX PLUGIN INTERFACE DRIFT: plugins/waza/.codex-plugin/plugin.json "
|
|
f"interface.{key}={actual!r} expected {expected!r}"
|
|
)
|
|
default_prompt = interface.get("defaultPrompt")
|
|
if (
|
|
not isinstance(default_prompt, list)
|
|
or not default_prompt
|
|
or len(default_prompt) > 3
|
|
or any(not isinstance(item, str) or len(item) > 128 for item in default_prompt)
|
|
):
|
|
fail(
|
|
"CODEX PLUGIN DEFAULT PROMPTS: interface.defaultPrompt must contain "
|
|
"1-3 strings, each <=128 chars"
|
|
)
|
|
if not (plugin_root / "skills").is_dir():
|
|
fail(
|
|
"CODEX PLUGIN SKILLS PATH: plugins/waza/.codex-plugin/plugin.json "
|
|
"points at missing plugins/waza/skills/"
|
|
)
|
|
print(f"ok: Codex plugin manifest pinned to {expected_version}")
|
|
|
|
|
|
def check_codex_plugin_mirror(root: Path):
|
|
"""Byte-compare skills/ and rules/ against the generated plugin mirror.
|
|
|
|
The verifier owns its traversal independently from codegen, while both
|
|
share only the declarative cache/noise filter. A broken generator walk
|
|
must not make its oracle agree on an incomplete tree."""
|
|
plugin_root = root / "plugins" / "waza"
|
|
for source_name in ("skills", "rules"):
|
|
source_root = root / source_name
|
|
for source_path in sorted(source_root.rglob("*")):
|
|
if not source_path.is_file():
|
|
continue
|
|
source_rel = source_path.relative_to(source_root)
|
|
if not should_include_codex_mirror_file(source_rel):
|
|
continue
|
|
mirror_path = plugin_root / source_name / source_rel
|
|
if not mirror_path.exists():
|
|
fail(
|
|
f"CODEX PLUGIN MIRROR MISSING: {mirror_path.relative_to(root)} "
|
|
f"must mirror {source_path.relative_to(root)}"
|
|
)
|
|
if mirror_path.read_bytes() != source_path.read_bytes():
|
|
fail(
|
|
f"CODEX PLUGIN MIRROR DRIFT: {mirror_path.relative_to(root)} "
|
|
f"differs from {source_path.relative_to(root)}"
|
|
)
|
|
# Reverse direction: a mirror file with no source counterpart is stale
|
|
# output that regeneration would delete.
|
|
for source_name in ("skills", "rules"):
|
|
mirror_root = plugin_root / source_name
|
|
if not mirror_root.exists():
|
|
continue
|
|
for mirror_path in sorted(mirror_root.rglob("*")):
|
|
if not mirror_path.is_file():
|
|
continue
|
|
mirror_rel = mirror_path.relative_to(mirror_root)
|
|
if not should_include_codex_mirror_file(mirror_rel):
|
|
continue
|
|
if not (root / source_name / mirror_rel).exists():
|
|
fail(
|
|
f"CODEX PLUGIN MIRROR EXTRA: {mirror_path.relative_to(root)} "
|
|
f"has no source under {source_name}/. "
|
|
f"Run scripts/build_metadata.py (no flags) to regenerate."
|
|
)
|
|
print("ok: Codex plugin mirror matches skills/ and rules/")
|
|
|
|
|
|
def check_codex_marketplace(root: Path):
|
|
"""Validate repo-local Codex marketplace shape."""
|
|
marketplace_path = root / ".agents" / "plugins" / "marketplace.json"
|
|
if not marketplace_path.exists():
|
|
fail(
|
|
"MISSING CODEX MARKETPLACE: expected .agents/plugins/marketplace.json "
|
|
"so `codex plugin marketplace add tw93/Waza` can discover Waza"
|
|
)
|
|
marketplace = json.loads(marketplace_path.read_text())
|
|
if marketplace.get("name") != "waza":
|
|
fail("CODEX MARKETPLACE NAME: .agents/plugins/marketplace.json name must be 'waza'")
|
|
interface = marketplace.get("interface")
|
|
if not isinstance(interface, dict) or interface.get("displayName") != "Waza":
|
|
fail(
|
|
"CODEX MARKETPLACE DISPLAY NAME: .agents/plugins/marketplace.json "
|
|
"must set interface.displayName to 'Waza'"
|
|
)
|
|
plugins = marketplace.get("plugins")
|
|
if not isinstance(plugins, list) or len(plugins) != 1:
|
|
fail("CODEX MARKETPLACE PLUGINS: expected exactly one Waza plugin entry")
|
|
entry = plugins[0]
|
|
expected_entry = {
|
|
"name": "waza",
|
|
"source": {
|
|
"source": "local",
|
|
"path": "./plugins/waza",
|
|
},
|
|
"policy": {
|
|
"installation": "AVAILABLE",
|
|
"authentication": "ON_INSTALL",
|
|
},
|
|
"category": "Developer Tools",
|
|
}
|
|
if entry != expected_entry:
|
|
fail(
|
|
"CODEX MARKETPLACE ENTRY DRIFT: .agents/plugins/marketplace.json "
|
|
f"plugins[0]={entry!r} expected {expected_entry!r}"
|
|
)
|
|
if not (root / "plugins" / "waza" / ".codex-plugin" / "plugin.json").exists():
|
|
fail(
|
|
"CODEX MARKETPLACE SOURCE: source.path './plugins/waza' must resolve to a plugin "
|
|
"root containing .codex-plugin/plugin.json"
|
|
)
|
|
print("ok: Codex marketplace exposes waza plugin")
|
|
|
|
|
|
def check_rules_files_present(root: Path):
|
|
"""Required shared rule files outside skills/ that the per-skill ref check
|
|
doesn't cover."""
|
|
required = [
|
|
"english.md",
|
|
"chinese.md",
|
|
"anti-patterns.md",
|
|
"durable-context.md",
|
|
"waza-routing.md",
|
|
]
|
|
for name in required:
|
|
path = root / "rules" / name
|
|
if not path.exists():
|
|
fail(f"MISSING RULE FILE: {path}")
|
|
print(f"ok: rules/ files present ({', '.join(required)})")
|
|
|
|
|
|
AUTOMATIC_UPDATE_CHECK_FRAGMENT = "scripts/check-update.sh"
|
|
|
|
|
|
def check_no_automatic_update_checks(root: Path, skill_names: set[str]):
|
|
"""Updates are explicit maintenance, never work every skill invocation."""
|
|
forbidden_files = [root / AUTOMATIC_UPDATE_CHECK_FRAGMENT]
|
|
forbidden_files.extend(
|
|
root / "skills" / skill / AUTOMATIC_UPDATE_CHECK_FRAGMENT
|
|
for skill in sorted(skill_names)
|
|
)
|
|
present = [path.relative_to(root) for path in forbidden_files if path.exists()]
|
|
if present:
|
|
fail(
|
|
"AUTOMATIC UPDATE CHECKER PRESENT: updates must remain explicit; "
|
|
f"remove {', '.join(map(str, present))}"
|
|
)
|
|
|
|
surfaces = [root / "scripts" / "dispatcher-template.md"]
|
|
surfaces.extend(root / "skills" / skill / "SKILL.md" for skill in skill_names)
|
|
referenced = [
|
|
path.relative_to(root)
|
|
for path in surfaces
|
|
if path.exists() and AUTOMATIC_UPDATE_CHECK_FRAGMENT in path.read_text()
|
|
]
|
|
if referenced:
|
|
fail(
|
|
"AUTOMATIC UPDATE INSTRUCTION PRESENT: updates must remain explicit; "
|
|
f"remove the checker invocation from {', '.join(map(str, referenced))}"
|
|
)
|
|
print("ok: skill invocations do not perform automatic update checks")
|
|
|
|
|
|
def check_readme_install_command(root: Path):
|
|
"""README must show the default install command users can copy-paste."""
|
|
readme = root / "README.md"
|
|
if not readme.exists():
|
|
fail(f"MISSING README.md at {readme}")
|
|
text = readme.read_text()
|
|
# One command installs every skill to an explicit agent list, so the README
|
|
# documents a single copy-paste line. The list is intentional: `-a '*'` (or
|
|
# the no-`-a` auto-detect path) sweeps in agents like PromptScript and Eve
|
|
# that lack global-install support and prints a "Failed to install" block.
|
|
# Naming agents installs cleanly; codex and cursor share `~/.agents/skills`,
|
|
# so other universal agents pick the skills up without being listed.
|
|
expected = "npx skills add tw93/Waza -a claude-code codex cursor -g -y"
|
|
if expected not in text:
|
|
fail(
|
|
f"README INSTALL COMMAND: README.md must include {expected!r}\n"
|
|
f" Waza's public install path depends on this exact string."
|
|
)
|
|
expected_codex_marketplace = "codex plugin marketplace add tw93/Waza"
|
|
if expected_codex_marketplace not in text:
|
|
fail(
|
|
"README CODEX MARKETPLACE COMMAND: README.md must include "
|
|
f"{expected_codex_marketplace!r}\n"
|
|
f" Codex plugin installs should use the repo marketplace so users can "
|
|
f"upgrade without rerunning npx skills add."
|
|
)
|
|
expected_codex_install = "codex plugin add waza@waza"
|
|
if expected_codex_install not in text:
|
|
fail(
|
|
"README CODEX PLUGIN COMMAND: README.md must include "
|
|
f"{expected_codex_install!r}\n"
|
|
f" The Codex marketplace entry must document the plugin install selector."
|
|
)
|
|
expected_pi = "pi install npm:@tw93/waza"
|
|
if expected_pi not in text:
|
|
fail(
|
|
f"README PI INSTALL COMMAND: README.md must include {expected_pi!r}\n"
|
|
f" The Pi package install path depends on this exact string."
|
|
)
|
|
expected_installers = {
|
|
"setup-rule": "https://github.com/tw93/Waza/releases/latest/download/setup-rule.sh",
|
|
"setup-statusline": "https://github.com/tw93/Waza/releases/latest/download/setup-statusline.sh",
|
|
}
|
|
for label, url in expected_installers.items():
|
|
if url not in text:
|
|
fail(
|
|
f"README {label.upper()} URL: README.md must include {url!r}\n"
|
|
f" Installer snippets should follow the latest release asset "
|
|
f"without per-release README churn."
|
|
)
|
|
print(
|
|
"ok: README documents the universal skills install, Codex plugin "
|
|
"marketplace, Pi package, and latest installer assets"
|
|
)
|
|
|
|
|
|
def check_release_workflow_npm_surface(root: Path):
|
|
"""GitHub releases must publish the npm package that Pi consumes."""
|
|
workflow = root / ".github" / "workflows" / "release.yml"
|
|
if not workflow.exists():
|
|
fail(f"MISSING RELEASE WORKFLOW: expected {workflow}")
|
|
text = workflow.read_text()
|
|
required = {
|
|
"npm publish": "publishes @tw93/waza during release",
|
|
"npm view @tw93/waza": "re-reads the npm registry after publish",
|
|
"id-token: write": "allows npm trusted publishing through GitHub OIDC",
|
|
"node-version: 24": "uses a Node/npm runtime that supports trusted publishing",
|
|
"package-manager-cache: false": "keeps release publish jobs from caching credentials or package state",
|
|
"github.event.release.tag_name": "checks the GitHub release tag",
|
|
"package.json').pi.skills[0]": "checks Pi package metadata",
|
|
"dist-tags.latest": "confirms the npm latest dist-tag",
|
|
"scripts/setup-rule.sh": "uploads the rule installer as a latest release asset",
|
|
"scripts/setup-statusline.sh": "uploads the statusline installer as a latest release asset",
|
|
}
|
|
missing = [label for label, reason in required.items() if label not in text]
|
|
if missing:
|
|
fail(
|
|
"RELEASE WORKFLOW NPM SURFACE: .github/workflows/release.yml "
|
|
"must publish and verify @tw93/waza for Pi installs.\n"
|
|
+ "\n".join(f" missing {label!r}: {required[label]}" for label in missing)
|
|
)
|
|
print("ok: release workflow publishes npm package and installer assets")
|