#!/usr/bin/env python3 """Validate plugins and SKILL.md files against the Agent Skills spec + team rules. Usage: python3 scripts/validate-skills.py [--strict] Exit 0 = pass, 1 = errors found. """ from __future__ import annotations import json import re import sys from pathlib import Path ROOT = Path(__file__).resolve().parent.parent ALLOWED_FRONTMATTER = { "name", "description", "license", "compatibility", "metadata", "allowed-tools", "disable-model-invocation", "version", } NAME_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,63}$") BODY_MAX_BYTES = 20 * 1024 DESC_MIN, DESC_MAX = 30, 1024 STATUSES = {"promoted", "beta", "deprecated"} INVOCATIONS = {"model-invoked", "user-invoked"} REQUIRED_PLUGIN_FIELDS = ("install_name", "install_type", "dir_category", "version") errors: list[str] = [] warnings: list[str] = [] def parse_frontmatter(text: str): """Return (frontmatter dict, body text). Supports one level of nesting.""" if not text.startswith("---"): return {}, text end = text.find("\n---", 3) if end == -1: return {}, text raw, body = text[3:end], text[end + 4:] data: dict = {} metadata: dict = {} key = None for line in raw.splitlines(): if not line.strip(): continue m = re.match(r"^([A-Za-z0-9_-]+):\s*(.*)$", line) if m: key = m.group(1) data[key] = m.group(2).strip().strip('"').strip("'") continue m = re.match(r"^\s+([A-Za-z0-9_-]+):\s*(.*)$", line) if m and key == "metadata": metadata[m.group(1)] = m.group(2).strip().strip('"').strip("'") elif key: data[key] = (data.get(key, "") + " " + line.strip()).strip() if metadata: data["metadata"] = metadata return data, body def check_plugin(plugin_json: Path): plugin_dir = plugin_json.parent.parent rel = plugin_dir.relative_to(ROOT) try: meta = json.loads(plugin_json.read_text(encoding="utf-8")) except Exception as exc: # noqa: BLE001 errors.append(f"{rel}: plugin.json 解析失败: {exc}") return for field in REQUIRED_PLUGIN_FIELDS: if not meta.get(field): errors.append(f"{rel}: plugin.json 缺少 {field}") status = meta.get("status", "") if status and status not in STATUSES: errors.append(f"{rel}: status 非法 {status!r}(应为 {sorted(STATUSES)})") install_name = meta.get("install_name", "") if install_name and not NAME_RE.match(install_name): errors.append(f"{rel}: install_name 不合规 {install_name!r}") skills_dir = plugin_dir / "skills" skill_files = sorted(skills_dir.rglob("SKILL.md")) if skills_dir.is_dir() else [] if not skill_files: errors.append(f"{rel}: 没有找到 skills/**/SKILL.md") return for skill_md in skill_files: check_skill(rel, skill_md, meta) def check_skill(rel: Path, skill_md: Path, meta: dict): text = skill_md.read_text(encoding="utf-8", errors="replace") fm, body = parse_frontmatter(text) where = f"{rel}/{skill_md.name}" name = fm.get("name", "") if not name: errors.append(f"{where}: frontmatter 缺少 name") elif not NAME_RE.match(name): errors.append(f"{where}: name 不合规 {name!r}(小写字母/数字/连字符,≤64)") desc = fm.get("description", "") if not desc: errors.append(f"{where}: frontmatter 缺少 description") elif not (DESC_MIN <= len(desc) <= DESC_MAX): warnings.append(f"{where}: description 长度 {len(desc)},建议 {DESC_MIN}-{DESC_MAX}(决定是否被触发)") for key in fm: if key == "metadata": continue if key not in ALLOWED_FRONTMATTER: warnings.append(f"{where}: 非标准 frontmatter 字段 {key!r},建议移入 metadata") body_bytes = len(body.encode("utf-8")) if body_bytes > BODY_MAX_BYTES: warnings.append(f"{where}: 正文 {body_bytes} 字节,超过 20KB 预算,建议拆 references/") metadata = fm.get("metadata") or {} invocation = metadata.get("invocation", "") if invocation and invocation not in INVOCATIONS: errors.append(f"{where}: metadata.invocation 非法 {invocation!r}") disable_model = str(fm.get("disable-model-invocation", "")).lower() == "true" if invocation == "user-invoked" and not disable_model: errors.append(f"{where}: user-invoked 技能必须设置 disable-model-invocation: true") if invocation == "model-invoked" and disable_model: errors.append(f"{where}: model-invoked 技能不应设置 disable-model-invocation: true") status = meta.get("status", "") if status == "promoted": docs = ROOT / "docs" / "skills" / f"{meta.get('install_name', name)}.md" if not docs.is_file(): warnings.append(f"{where}: promoted 技能缺少四段式文档 docs/skills/{docs.name}") # Relative links must resolve (inline code spans are examples, skip them). scan_body = re.sub(r"`[^`]*`", "", body) for target in re.findall(r"\]\((\.[^)\s]+|[A-Za-z0-9_-]+/[^)\s]+)\)", scan_body): if target.startswith(("http://", "https://", "#")): continue if not (skill_md.parent / target).exists(): warnings.append(f"{where}: 引用的相对路径不存在: {target}") IGNORED_ROUTING_TOKENS = { "completed", "released", "delivery_stage", "status", "true", "false", "user-invoked", "model-invoked", "promoted", "beta", "deprecated", "scoop", "scoop.md", } EXTERNAL_SKILLS = { # 本仓库之外、由 DSH 用户目录提供的技能(docs/skill-routing.md 允许引用) "project-management", "topic-distill", "diary", "dsh-worktable-page", "code-review", "release-notes", "disk-cleanup", "workflow-one", } def check_routing(catalog_names: set[str]): routing = ROOT / "docs" / "skill-routing.md" if not routing.is_file(): return text = routing.read_text(encoding="utf-8") for token in sorted(set(re.findall(r"`([a-z][a-z0-9-]{2,40})`", text))): if token in IGNORED_ROUTING_TOKENS or token in EXTERNAL_SKILLS: continue if token not in catalog_names: warnings.append(f"docs/skill-routing.md: 路由到不存在的技能 {token!r}") def coverage_report() -> int: missing_evals, missing_docs = [], [] for plugin_json in sorted(ROOT.glob("skills-*/*-plugin/.claude-plugin/plugin.json")): meta = json.loads(plugin_json.read_text()) if meta.get("status", "promoted") != "promoted": continue skill_md = next((plugin_json.parent.parent / "skills").rglob("SKILL.md"), None) if skill_md is None: continue fm, _ = parse_frontmatter(skill_md.read_text(encoding="utf-8", errors="replace")) if (fm.get("metadata") or {}).get("invocation", "model-invoked") != "model-invoked": continue name = meta.get("install_name") or fm.get("name") if not any((plugin_json.parent.parent / "skills").rglob("evals/evals.json")): missing_evals.append(name) if not (ROOT / "docs" / "skills" / f"{name}.md").is_file(): missing_docs.append(name) print(f"覆盖率(promoted + model-invoked):缺 evals {len(missing_evals)} 个,缺四段式文档 {len(missing_docs)} 个") if missing_evals: print("缺 evals:", ", ".join(missing_evals[:20]) + ("..." if len(missing_evals) > 20 else "")) if missing_docs: print("缺文档:", ", ".join(missing_docs[:20]) + ("..." if len(missing_docs) > 20 else "")) return 0 def main() -> int: strict = "--strict" in sys.argv if "--coverage" in sys.argv: return coverage_report() plugins = sorted(ROOT.glob("skills-*/*-plugin/.claude-plugin/plugin.json")) if not plugins: print("未找到任何 plugin.json", file=sys.stderr) return 1 for plugin_json in plugins: check_plugin(plugin_json) catalog = ROOT / "catalog.json" if catalog.is_file(): names = {x["name"] for x in json.loads(catalog.read_text()).get("skills", [])} check_routing(names) for warning in warnings: print(f"[warn] {warning}") for error in errors: print(f"[error] {error}") print(f"\n检查 {len(plugins)} 个插件:{len(errors)} 个错误,{len(warnings)} 个警告") if errors: return 1 if strict and warnings: print("--strict 模式下,警告视为失败") return 1 return 0 if __name__ == "__main__": raise SystemExit(main())