- AgentHub: 13 files updated with non-engineering examples (content drafts, research, strategy) — engineering stays primary, cross-domain secondary - AgentHub: 7 slash commands, 5 Python scripts, 3 references, 1 agent, dry_run.py validation (57 checks) - Marketplace: agenthub entry added with cross-domain keywords, engineering POWERFUL updated (25→30), product (12→13), counts synced across all configs - SEO: generate-docs.py now produces keyword-rich <title> tags and meta descriptions using SKILL.md frontmatter — "Claude Code Skills" in site_name propagates to all 276 HTML pages - SEO: per-domain title suffixes (Agent Skill for Codex & OpenClaw, etc.), slug-as-title cleanup, domain label stripping from titles - Broken links: 141→0 warnings — new rewrite_skill_internal_links() converts references/, scripts/, assets/ links to GitHub source URLs; skills/index.md phantom slugs fixed (6 marketing, 7 RA/QM) - Counts synced: 204 skills, 266 tools, 382 refs, 16 agents, 17 commands, 21 plugins — consistent across CLAUDE.md, README.md, docs/index.md, marketplace.json, getting-started.md, mkdocs.yml - Platform sync: Codex 163 skills, Gemini 246 items, OpenClaw compatible Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
319 lines
11 KiB
Python
319 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
"""Dry-run validation for the AgentHub plugin.
|
|
|
|
Checks JSON validity, YAML frontmatter, markdown structure, cross-file
|
|
consistency, script --help, and referenced file existence — without
|
|
creating any sessions or worktrees.
|
|
|
|
Usage:
|
|
python dry_run.py # Run all checks
|
|
python dry_run.py --verbose # Show per-file details
|
|
python dry_run.py --help
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
|
|
PLUGIN_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
|
|
# ── Helpers ──────────────────────────────────────────────────────────
|
|
|
|
PASS = "\033[32m✓\033[0m"
|
|
FAIL = "\033[31m✗\033[0m"
|
|
WARN = "\033[33m!\033[0m"
|
|
|
|
|
|
class Results:
|
|
def __init__(self):
|
|
self.passed = 0
|
|
self.failed = 0
|
|
self.warnings = 0
|
|
self.details = []
|
|
|
|
def ok(self, msg):
|
|
self.passed += 1
|
|
self.details.append((PASS, msg))
|
|
|
|
def fail(self, msg):
|
|
self.failed += 1
|
|
self.details.append((FAIL, msg))
|
|
|
|
def warn(self, msg):
|
|
self.warnings += 1
|
|
self.details.append((WARN, msg))
|
|
|
|
def print(self, verbose=False):
|
|
if verbose:
|
|
for icon, msg in self.details:
|
|
print(f" {icon} {msg}")
|
|
print()
|
|
total = self.passed + self.failed
|
|
status = "PASS" if self.failed == 0 else "FAIL"
|
|
color = "\033[32m" if self.failed == 0 else "\033[31m"
|
|
warn_str = f", {self.warnings} warnings" if self.warnings else ""
|
|
print(f"{color}{status}\033[0m {self.passed}/{total} checks passed{warn_str}")
|
|
return self.failed == 0
|
|
|
|
|
|
def rel(path):
|
|
"""Path relative to plugin root for display."""
|
|
return os.path.relpath(path, PLUGIN_ROOT)
|
|
|
|
|
|
# ── Check 1: JSON files ─────────────────────────────────────────────
|
|
|
|
def check_json(results):
|
|
"""Validate settings.json and plugin.json."""
|
|
json_files = [
|
|
os.path.join(PLUGIN_ROOT, "settings.json"),
|
|
os.path.join(PLUGIN_ROOT, ".claude-plugin", "plugin.json"),
|
|
]
|
|
for path in json_files:
|
|
name = rel(path)
|
|
if not os.path.exists(path):
|
|
results.fail(f"{name} — file missing")
|
|
continue
|
|
try:
|
|
with open(path) as f:
|
|
data = json.load(f)
|
|
results.ok(f"{name} — valid JSON")
|
|
except json.JSONDecodeError as e:
|
|
results.fail(f"{name} — invalid JSON: {e}")
|
|
continue
|
|
|
|
# plugin.json: only allowed fields
|
|
if name.endswith("plugin.json"):
|
|
allowed = {"name", "description", "version", "author", "homepage",
|
|
"repository", "license", "skills"}
|
|
extra = set(data.keys()) - allowed
|
|
if extra:
|
|
results.fail(f"{name} — disallowed fields: {extra}")
|
|
else:
|
|
results.ok(f"{name} — schema fields OK")
|
|
|
|
# Cross-check versions
|
|
try:
|
|
with open(json_files[0]) as f:
|
|
v1 = json.load(f).get("version")
|
|
with open(json_files[1]) as f:
|
|
v2 = json.load(f).get("version")
|
|
if v1 and v2 and v1 == v2:
|
|
results.ok(f"version match ({v1})")
|
|
elif v1 and v2:
|
|
results.fail(f"version mismatch: settings={v1}, plugin={v2}")
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
# ── Check 2: YAML frontmatter ───────────────────────────────────────
|
|
|
|
FRONTMATTER_RE = re.compile(r"^---\n(.+?)\n---", re.DOTALL)
|
|
REQUIRED_FM_KEYS = {"name", "description"}
|
|
|
|
|
|
def check_frontmatter(results):
|
|
"""Validate YAML frontmatter in all SKILL.md files."""
|
|
skill_files = []
|
|
for root, _dirs, files in os.walk(PLUGIN_ROOT):
|
|
for f in files:
|
|
if f == "SKILL.md":
|
|
skill_files.append(os.path.join(root, f))
|
|
|
|
for path in skill_files:
|
|
name = rel(path)
|
|
with open(path) as f:
|
|
content = f.read()
|
|
m = FRONTMATTER_RE.match(content)
|
|
if not m:
|
|
results.fail(f"{name} — missing YAML frontmatter")
|
|
continue
|
|
# Lightweight key check (no PyYAML dependency)
|
|
fm_text = m.group(1)
|
|
found_keys = set()
|
|
for line in fm_text.splitlines():
|
|
if ":" in line:
|
|
key = line.split(":", 1)[0].strip()
|
|
found_keys.add(key)
|
|
missing = REQUIRED_FM_KEYS - found_keys
|
|
if missing:
|
|
results.fail(f"{name} — frontmatter missing keys: {missing}")
|
|
else:
|
|
results.ok(f"{name} — frontmatter OK")
|
|
|
|
|
|
# ── Check 3: Markdown structure ──────────────────────────────────────
|
|
|
|
def check_markdown(results):
|
|
"""Check for broken code fences and table rows in all .md files."""
|
|
md_files = []
|
|
for root, _dirs, files in os.walk(PLUGIN_ROOT):
|
|
for f in files:
|
|
if f.endswith(".md"):
|
|
md_files.append(os.path.join(root, f))
|
|
|
|
for path in md_files:
|
|
name = rel(path)
|
|
with open(path) as f:
|
|
lines = f.readlines()
|
|
|
|
# Code fences must be balanced
|
|
fence_count = sum(1 for ln in lines if ln.strip().startswith("```"))
|
|
if fence_count % 2 != 0:
|
|
results.fail(f"{name} — unbalanced code fences ({fence_count} found)")
|
|
else:
|
|
results.ok(f"{name} — code fences balanced")
|
|
|
|
# Tables: rows inside a table should have consistent pipe count
|
|
in_table = False
|
|
table_pipes = 0
|
|
table_ok = True
|
|
for i, ln in enumerate(lines, 1):
|
|
stripped = ln.strip()
|
|
if stripped.startswith("|") and stripped.endswith("|"):
|
|
pipes = stripped.count("|")
|
|
if not in_table:
|
|
in_table = True
|
|
table_pipes = pipes
|
|
elif pipes != table_pipes:
|
|
# Separator rows (|---|---| ) can differ slightly; skip
|
|
if not re.match(r"^\|[\s\-:|]+\|$", stripped):
|
|
results.warn(f"{name}:{i} — table column count mismatch ({pipes} vs {table_pipes})")
|
|
table_ok = False
|
|
else:
|
|
in_table = False
|
|
table_pipes = 0
|
|
|
|
|
|
# ── Check 4: Scripts --help ──────────────────────────────────────────
|
|
|
|
def check_scripts(results):
|
|
"""Verify every Python script exits 0 on --help."""
|
|
scripts_dir = os.path.join(PLUGIN_ROOT, "scripts")
|
|
if not os.path.isdir(scripts_dir):
|
|
results.warn("scripts/ directory not found")
|
|
return
|
|
|
|
for fname in sorted(os.listdir(scripts_dir)):
|
|
if not fname.endswith(".py") or fname == "dry_run.py":
|
|
continue
|
|
path = os.path.join(scripts_dir, fname)
|
|
try:
|
|
proc = subprocess.run(
|
|
[sys.executable, path, "--help"],
|
|
capture_output=True, text=True, timeout=10,
|
|
)
|
|
if proc.returncode == 0:
|
|
results.ok(f"scripts/{fname} --help exits 0")
|
|
else:
|
|
results.fail(f"scripts/{fname} --help exits {proc.returncode}")
|
|
except subprocess.TimeoutExpired:
|
|
results.fail(f"scripts/{fname} --help timed out")
|
|
except Exception as e:
|
|
results.fail(f"scripts/{fname} --help error: {e}")
|
|
|
|
|
|
# ── Check 5: Referenced files exist ──────────────────────────────────
|
|
|
|
def check_references(results):
|
|
"""Verify that key files referenced in docs actually exist."""
|
|
expected = [
|
|
"settings.json",
|
|
".claude-plugin/plugin.json",
|
|
"CLAUDE.md",
|
|
"SKILL.md",
|
|
"README.md",
|
|
"agents/hub-coordinator.md",
|
|
"references/agent-templates.md",
|
|
"references/coordination-strategies.md",
|
|
"scripts/hub_init.py",
|
|
"scripts/dag_analyzer.py",
|
|
"scripts/board_manager.py",
|
|
"scripts/result_ranker.py",
|
|
"scripts/session_manager.py",
|
|
]
|
|
for ref in expected:
|
|
path = os.path.join(PLUGIN_ROOT, ref)
|
|
if os.path.exists(path):
|
|
results.ok(f"{ref} exists")
|
|
else:
|
|
results.fail(f"{ref} — referenced but missing")
|
|
|
|
|
|
# ── Check 6: Cross-domain coverage ──────────────────────────────────
|
|
|
|
def check_cross_domain(results):
|
|
"""Verify non-engineering examples exist in key files (the whole point of this update)."""
|
|
checks = [
|
|
("settings.json", "content-generation"),
|
|
(".claude-plugin/plugin.json", "content drafts"),
|
|
("CLAUDE.md", "content drafts"),
|
|
("SKILL.md", "content variation"),
|
|
("README.md", "content generation"),
|
|
("skills/run/SKILL.md", "--judge"),
|
|
("skills/init/SKILL.md", "LLM judge"),
|
|
("skills/eval/SKILL.md", "narrative"),
|
|
("skills/board/SKILL.md", "Storytelling"),
|
|
("skills/status/SKILL.md", "Storytelling"),
|
|
("references/agent-templates.md", "landing page copy"),
|
|
("references/coordination-strategies.md", "flesch_score"),
|
|
("agents/hub-coordinator.md", "qualitative verdict"),
|
|
]
|
|
for filepath, needle in checks:
|
|
path = os.path.join(PLUGIN_ROOT, filepath)
|
|
if not os.path.exists(path):
|
|
results.fail(f"{filepath} — missing (cannot check cross-domain)")
|
|
continue
|
|
with open(path) as f:
|
|
content = f.read()
|
|
if needle.lower() in content.lower():
|
|
results.ok(f"{filepath} — contains cross-domain example (\"{needle}\")")
|
|
else:
|
|
results.fail(f"{filepath} — missing cross-domain marker \"{needle}\"")
|
|
|
|
|
|
# ── Main ─────────────────────────────────────────────────────────────
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(
|
|
description="Dry-run validation for the AgentHub plugin."
|
|
)
|
|
parser.add_argument("--verbose", "-v", action="store_true",
|
|
help="Show per-file check details")
|
|
args = parser.parse_args()
|
|
|
|
print(f"AgentHub dry-run validation")
|
|
print(f"Plugin root: {PLUGIN_ROOT}\n")
|
|
|
|
all_ok = True
|
|
sections = [
|
|
("JSON validity", check_json),
|
|
("YAML frontmatter", check_frontmatter),
|
|
("Markdown structure", check_markdown),
|
|
("Script --help", check_scripts),
|
|
("Referenced files", check_references),
|
|
("Cross-domain examples", check_cross_domain),
|
|
]
|
|
|
|
for title, fn in sections:
|
|
print(f"── {title} ──")
|
|
r = Results()
|
|
fn(r)
|
|
ok = r.print(verbose=args.verbose)
|
|
if not ok:
|
|
all_ok = False
|
|
print()
|
|
|
|
if all_ok:
|
|
print("\033[32mAll checks passed.\033[0m")
|
|
else:
|
|
print("\033[31mSome checks failed — see above.\033[0m")
|
|
sys.exit(1)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|