195 lines
7.0 KiB
Python
Executable File
195 lines
7.0 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
Lint all plugin + managed-agent manifests and verify cross-file references.
|
|
|
|
Checks:
|
|
1. Every *.yaml under managed-agents/ parses.
|
|
2. Every plugin.json / marketplace.json / steering-examples.json parses.
|
|
3. Every <vertical>/agents/*.md has valid YAML frontmatter with name + description.
|
|
4. Every system.file, skills[].path, callable_agents[].manifest in agent.yaml
|
|
and subagent yamls resolves to an existing file/dir.
|
|
5. Every managed-agents/<slug>/ has agent.yaml, README.md, steering-examples.json.
|
|
|
|
Exit 0 if clean, 1 otherwise. Requires: pyyaml.
|
|
"""
|
|
import json
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
PLUGINS = ROOT / "plugins"
|
|
MANAGED = ROOT / "managed-agent-cookbooks"
|
|
errors: list[str] = []
|
|
checked = 0
|
|
|
|
|
|
def ensure_hooks_installed() -> None:
|
|
"""Point git at .githooks so the version-bump pre-commit runs.
|
|
|
|
Native equivalent of Husky's `prepare`, piggybacked on the script
|
|
everyone already runs before committing. Best-effort: never fatal.
|
|
"""
|
|
want = ".githooks"
|
|
try:
|
|
cur = subprocess.run(
|
|
["git", "-C", str(ROOT), "config", "--get", "core.hooksPath"],
|
|
capture_output=True, text=True,
|
|
).stdout.strip()
|
|
if cur != want:
|
|
subprocess.run(
|
|
["git", "-C", str(ROOT), "config", "core.hooksPath", want],
|
|
check=True, capture_output=True,
|
|
)
|
|
print(f"[check.py] installed git hooks (core.hooksPath -> {want})")
|
|
except (subprocess.SubprocessError, OSError):
|
|
pass # not a git checkout / git unavailable — ignore
|
|
|
|
|
|
# Install hooks before anything that can exit early (e.g. missing pyyaml),
|
|
# so a fresh checkout still gets the version-bump hook wired up.
|
|
ensure_hooks_installed()
|
|
|
|
try:
|
|
import yaml
|
|
except ImportError:
|
|
print("ERROR: requires pyyaml (pip install pyyaml)", file=sys.stderr)
|
|
sys.exit(2)
|
|
|
|
|
|
def err(msg: str) -> None:
|
|
errors.append(msg)
|
|
|
|
|
|
def rel(p: Path) -> str:
|
|
return str(p.relative_to(ROOT))
|
|
|
|
|
|
# --- 1. YAML parse ----------------------------------------------------------
|
|
for yml in sorted(MANAGED.rglob("*.yaml")):
|
|
checked += 1
|
|
try:
|
|
with open(yml) as f:
|
|
yaml.safe_load(f)
|
|
except yaml.YAMLError as e:
|
|
err(f"YAML parse: {rel(yml)}: {e}")
|
|
|
|
# --- 2. JSON parse ----------------------------------------------------------
|
|
json_globs = [
|
|
".claude-plugin/marketplace.json",
|
|
"plugins/**/.claude-plugin/plugin.json",
|
|
"managed-agent-cookbooks/*/steering-examples.json",
|
|
]
|
|
for pat in json_globs:
|
|
for jf in sorted(ROOT.glob(pat)):
|
|
checked += 1
|
|
try:
|
|
json.loads(jf.read_text())
|
|
except json.JSONDecodeError as e:
|
|
err(f"JSON parse: {rel(jf)}: {e}")
|
|
|
|
# --- 3. agent.md frontmatter -----------------------------------------------
|
|
for md in sorted(PLUGINS.glob("agent-plugins/*/agents/*.md")):
|
|
checked += 1
|
|
text = md.read_text()
|
|
if not text.startswith("---"):
|
|
err(f"frontmatter: {rel(md)}: missing leading ---")
|
|
continue
|
|
try:
|
|
_, fm, _ = text.split("---", 2)
|
|
meta = yaml.safe_load(fm)
|
|
for k in ("name", "description"):
|
|
if k not in meta:
|
|
err(f"frontmatter: {rel(md)}: missing '{k}'")
|
|
except (ValueError, yaml.YAMLError) as e:
|
|
err(f"frontmatter: {rel(md)}: {e}")
|
|
|
|
|
|
# --- 4. reference resolution -----------------------------------------------
|
|
def check_refs(yml: Path) -> None:
|
|
try:
|
|
data = yaml.safe_load(yml.read_text()) or {}
|
|
except yaml.YAMLError:
|
|
return # already reported above
|
|
base = yml.parent
|
|
|
|
sys_spec = data.get("system")
|
|
if isinstance(sys_spec, dict) and "file" in sys_spec:
|
|
p = (base / sys_spec["file"]).resolve()
|
|
if not p.is_file():
|
|
err(f"ref: {rel(yml)}: system.file -> {sys_spec['file']} (not found)")
|
|
|
|
for s in data.get("skills") or []:
|
|
if isinstance(s, dict) and "path" in s:
|
|
p = (base / s["path"]).resolve()
|
|
if not p.exists():
|
|
err(f"ref: {rel(yml)}: skills.path -> {s['path']} (not found)")
|
|
if isinstance(s, dict) and "from_plugin" in s:
|
|
p = (base / s["from_plugin"]).resolve()
|
|
if not (p / "skills").is_dir():
|
|
err(f"ref: {rel(yml)}: skills.from_plugin -> {s['from_plugin']} (no skills/ dir)")
|
|
|
|
for c in data.get("callable_agents") or []:
|
|
if isinstance(c, dict) and "manifest" in c:
|
|
p = (base / c["manifest"]).resolve()
|
|
if not p.is_file():
|
|
err(f"ref: {rel(yml)}: callable_agents.manifest -> {c['manifest']} (not found)")
|
|
|
|
|
|
for yml in sorted(MANAGED.rglob("*.yaml")):
|
|
check_refs(yml)
|
|
|
|
# --- 4b. agent-plugin bundled skills match vertical source -----------------
|
|
import filecmp # noqa: E402
|
|
import re # noqa: E402
|
|
|
|
src_by_name = {p.name: p for p in PLUGINS.glob("vertical-plugins/*/skills/*") if p.is_dir()}
|
|
for bundled in sorted(PLUGINS.glob("agent-plugins/*/skills/*")):
|
|
if not bundled.is_dir():
|
|
continue
|
|
src = src_by_name.get(bundled.name)
|
|
if not src:
|
|
err(f"bundled-skill: {rel(bundled)}: no vertical-plugins source named '{bundled.name}'")
|
|
continue
|
|
cmp = filecmp.dircmp(src, bundled)
|
|
if cmp.diff_files or cmp.left_only or cmp.right_only:
|
|
err(
|
|
f"bundled-skill: {rel(bundled)}: drifted from {rel(src)} "
|
|
f"(run scripts/sync-agent-skills.py)"
|
|
)
|
|
|
|
# --- 4b2. agent.md skill references exist in the agent's own bundle --------
|
|
for md in sorted(PLUGINS.glob("agent-plugins/*/agents/*.md")):
|
|
slug = md.parents[1].name
|
|
sk_dir = PLUGINS / "agent-plugins" / slug / "skills"
|
|
bundle = {p.name for p in sk_dir.iterdir() if p.is_dir()} if sk_dir.is_dir() else set()
|
|
for ref in set(re.findall(r"`([a-z0-9]+(?:-[a-z0-9]+)+)`", md.read_text())):
|
|
if ref in src_by_name and ref not in bundle:
|
|
err(
|
|
f"agent-prose: {rel(md)}: references `{ref}` but "
|
|
f"plugins/agent-plugins/{slug}/skills/{ref}/ is not bundled"
|
|
)
|
|
|
|
# --- 4c. marketplace source paths resolve ----------------------------------
|
|
mp = ROOT / ".claude-plugin" / "marketplace.json"
|
|
for p in json.loads(mp.read_text()).get("plugins", []):
|
|
src = (ROOT / p["source"]).resolve()
|
|
if not (src / ".claude-plugin" / "plugin.json").is_file():
|
|
err(f"marketplace: {p['name']} source -> {p['source']} (no plugin.json)")
|
|
|
|
# --- 5. required files per managed-agent -----------------------------------
|
|
for d in sorted(MANAGED.iterdir()):
|
|
if not d.is_dir():
|
|
continue
|
|
for req in ("agent.yaml", "README.md", "steering-examples.json"):
|
|
if not (d / req).is_file():
|
|
err(f"missing: {rel(d)}/{req}")
|
|
|
|
# --- report ----------------------------------------------------------------
|
|
if errors:
|
|
print(f"FAIL — {len(errors)} issue(s) across {checked} file(s):\n", file=sys.stderr)
|
|
for e in errors:
|
|
print(f" ✗ {e}", file=sys.stderr)
|
|
sys.exit(1)
|
|
print(f"OK — {checked} file(s) checked, 0 issues.")
|