Skip to content

Commit e4c6972

Browse files
88plugclaude
andcommitted
Add plugin-validate CI: gate every push against the known footguns
Validates plugin.json, skill/agent/command frontmatter (the ': ' YAML break), ${CLAUDE_PLUGIN_*:-default} manifest var form, hooks.json + script syntax, and shell-portability (bash + zsh -n). Green on the current tree. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent e4504b1 commit e4c6972

2 files changed

Lines changed: 143 additions & 0 deletions

File tree

.ci/validate_plugin.py

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
#!/usr/bin/env python3
2+
"""Validate one Claude Code plugin against the failure modes that have actually
3+
shipped to 88plug users. Designed for CI on every push (rolling plugins ship each
4+
commit, so this is the safety net). Hard-errors only on unambiguous breakage;
5+
softer portability/hygiene issues are warnings.
6+
7+
usage: validate_plugin.py [PLUGIN_ROOT] (default ".")
8+
exit 0 = clean, 1 = errors found.
9+
"""
10+
from __future__ import annotations
11+
import sys, os, re, json, shutil, subprocess
12+
from pathlib import Path
13+
14+
try:
15+
import yaml
16+
except Exception:
17+
yaml = None
18+
19+
ROOT = Path(sys.argv[1] if len(sys.argv) > 1 else ".").resolve()
20+
errors: list[str] = []
21+
warns: list[str] = []
22+
23+
24+
def err(m): errors.append(m)
25+
def warn(m): warns.append(m)
26+
27+
28+
def rel(p):
29+
try: return str(Path(p).relative_to(ROOT))
30+
except Exception: return str(p)
31+
32+
33+
# --- 1. plugin.json: valid JSON + has a name ---------------------------------
34+
man = ROOT / ".claude-plugin" / "plugin.json"
35+
if not man.exists():
36+
err(".claude-plugin/plugin.json is missing")
37+
else:
38+
try:
39+
m = json.loads(man.read_text())
40+
if not m.get("name"):
41+
err("plugin.json: required 'name' field missing")
42+
except Exception as e:
43+
err(f"plugin.json: invalid JSON — {e}")
44+
45+
# --- 2. bash default-form var in a MANIFEST (Claude Code does not substitute it)
46+
# Only manifests are variable-substituted; the ${VAR:-default} form inside a .sh
47+
# script is legitimate, so scope this to the JSON manifests only.
48+
BAD = re.compile(r'\$\{CLAUDE_PLUGIN_(?:ROOT|DATA):-')
49+
manifests = [man, ROOT / ".mcp.json", ROOT / "hooks" / "hooks.json"]
50+
for p in manifests:
51+
if p.exists():
52+
if BAD.search(p.read_text()):
53+
err(f"{rel(p)}: uses ${{CLAUDE_PLUGIN_*:-default}} — Claude Code substitutes "
54+
f"only the plain ${{CLAUDE_PLUGIN_ROOT}} form; the :- default is left literal")
55+
56+
# --- 3. skill/command/agent frontmatter must parse (the ': ' YAML break) ------
57+
# Skills & agents REQUIRE name+description (that pair is the trigger surface, and
58+
# the ': ' break silently drops it). Commands derive their name from the filename
59+
# and only carry a description, so for commands we just require a clean parse.
60+
def _frontmatter(md):
61+
txt = md.read_text()
62+
if not txt.lstrip().startswith("---"):
63+
return None, "no YAML frontmatter"
64+
parts = txt.split("---", 2)
65+
if len(parts) < 3:
66+
return None, "unterminated frontmatter"
67+
if yaml is None:
68+
return {}, None
69+
try:
70+
d = yaml.safe_load(parts[1])
71+
except Exception as e:
72+
return None, (f"frontmatter YAML parse error ({e.__class__.__name__}) — "
73+
"often an unquoted description containing ': '")
74+
if not isinstance(d, dict):
75+
return None, "frontmatter is not a mapping"
76+
return d, None
77+
78+
for md in list(ROOT.glob("skills/**/SKILL.md")) + list(ROOT.glob("agents/**/*.md")):
79+
d, e = _frontmatter(md)
80+
if e:
81+
err(f"{rel(md)}: {e}")
82+
elif not d.get("name") or not d.get("description"):
83+
err(f"{rel(md)}: frontmatter missing name/description (silently dropped by a ': ' break?)")
84+
85+
for md in list(ROOT.glob("commands/**/*.md")):
86+
d, e = _frontmatter(md)
87+
if e:
88+
err(f"{rel(md)}: {e}")
89+
elif not d.get("description"):
90+
warn(f"{rel(md)}: command frontmatter has no description")
91+
92+
# --- 4. hooks.json valid + scripts present/executable/parse -------------------
93+
hj = ROOT / "hooks" / "hooks.json"
94+
if hj.exists():
95+
try:
96+
json.loads(hj.read_text())
97+
except Exception as e:
98+
err(f"hooks/hooks.json: invalid JSON — {e}")
99+
100+
# --- 5. shell scripts: bash -n is a hard error; zsh -n is a portability warning
101+
shells = {s: shutil.which(s) for s in ("bash", "zsh")}
102+
for sh in sorted(set(ROOT.glob("hooks/**/*.sh")) | set(ROOT.glob("scripts/**/*.sh"))):
103+
if shells["bash"]:
104+
r = subprocess.run(["bash", "-n", str(sh)], capture_output=True, text=True)
105+
if r.returncode != 0:
106+
tail = (r.stderr.strip().splitlines() or ["parse error"])[-1]
107+
err(f"{rel(sh)}: bash -n syntax error — {tail}")
108+
if shells["zsh"]:
109+
r = subprocess.run(["zsh", "-n", str(sh)], capture_output=True, text=True)
110+
if r.returncode != 0:
111+
tail = (r.stderr.strip().splitlines() or ["parse error"])[-1]
112+
warn(f"{rel(sh)}: not zsh-parseable — {tail} "
113+
"(breaks if a slash command sources it in a zsh user shell)")
114+
if "hooks" in sh.parts and not os.access(sh, os.X_OK):
115+
warn(f"{rel(sh)}: missing executable bit (test -x fails)")
116+
117+
# --- report ------------------------------------------------------------------
118+
for w in warns:
119+
print(f"::warning:: {w}" if os.environ.get("GITHUB_ACTIONS") else f"WARN {w}")
120+
for e in errors:
121+
print(f"::error:: {e}" if os.environ.get("GITHUB_ACTIONS") else f"ERROR {e}")
122+
print(f"\n{rel(ROOT) or '.'}: {len(errors)} error(s), {len(warns)} warning(s)")
123+
sys.exit(1 if errors else 0)
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
name: plugin-validate
2+
on:
3+
push:
4+
pull_request:
5+
workflow_dispatch:
6+
permissions:
7+
contents: read
8+
jobs:
9+
validate:
10+
runs-on: ubuntu-latest
11+
steps:
12+
- uses: actions/checkout@v4
13+
- name: Install zsh (for shell-portability checks)
14+
run: sudo apt-get update -qq && sudo apt-get install -y -qq zsh
15+
- uses: actions/setup-python@v5
16+
with:
17+
python-version: "3.12"
18+
- run: pip install --quiet pyyaml
19+
- name: Validate plugin manifest & surfaces
20+
run: python .ci/validate_plugin.py .

0 commit comments

Comments
 (0)