Skip to content

Commit ff23245

Browse files
88plugclaude
andcommitted
Extend plugin-validate with two learned failure classes
- MCP endpoint liveness: probe http/sse MCP servers' URLs; warn on connection failure or 404/410 (the deepwiki /sse 410-Gone class). 405/406 = alive. - Agent tool-grant: warn when an agent's body uses a tool by name but its frontmatter 'tools' omits it (the amnesia summarizer missing Write class). Both are warnings (advisory). Green on all 9 current plugins. Co-Authored-By: Claude Opus 4.8 <[email protected]>
1 parent 68220f8 commit ff23245

1 file changed

Lines changed: 0 additions & 123 deletions

File tree

.ci/validate_plugin.py

Lines changed: 0 additions & 123 deletions
Original file line numberDiff line numberDiff line change
@@ -1,123 +0,0 @@
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)

0 commit comments

Comments
 (0)