quick_validate.py 4.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117
  1. #!/usr/bin/env python3
  2. # /// script
  3. # requires-python = ">=3.9"
  4. # ///
  5. """quick_validate — structural lint for a skill's SKILL.md frontmatter.
  6. Checks the few things a structural error makes obvious: the frontmatter parses,
  7. it carries only allowed keys, the name is hyphen-case and within length, and the
  8. description is present, within bounds, and free of angle brackets (which break
  9. the router). The allowed-key set is configurable, never baked to one provider:
  10. pass --allow-key to extend it or --allow-keys to replace it.
  11. Exit code is 0 when every check passes and 1 when any check fails, so a build or
  12. CI step can gate on it. Findings print as one JSON object on stdout.
  13. Usage:
  14. quick_validate.py <skill-dir-or-SKILL.md>
  15. quick_validate.py <path> --allow-key license --allow-key version
  16. quick_validate.py <path> --allow-keys name,description
  17. quick_validate.py <path> --max-name 64 --max-desc 1024
  18. """
  19. from __future__ import annotations
  20. import argparse
  21. import json
  22. import re
  23. import sys
  24. from pathlib import Path
  25. DEFAULT_ALLOWED_KEYS = ["name", "description"]
  26. HYPHEN_CASE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
  27. def split_frontmatter(content: str):
  28. """Return (ok, frontmatter dict, error). ok is False when there is no parseable block."""
  29. lines = content.splitlines()
  30. if not lines or lines[0].strip() != "---":
  31. return False, {}, "no frontmatter block (file does not open with ---)"
  32. end = next((i for i in range(1, len(lines)) if lines[i].strip() == "---"), None)
  33. if end is None:
  34. return False, {}, "frontmatter block is not terminated with a closing ---"
  35. meta: dict[str, str] = {}
  36. for line in lines[1:end]:
  37. if not line.strip():
  38. continue
  39. if ":" not in line:
  40. return False, {}, f"frontmatter line is not key: value -> {line.strip()!r}"
  41. k, v = line.split(":", 1)
  42. meta[k.strip()] = v.strip()
  43. return True, meta, ""
  44. def validate(content: str, allowed_keys, max_name: int, max_desc: int) -> list[dict]:
  45. errors: list[dict] = []
  46. ok, meta, parse_error = split_frontmatter(content)
  47. if not ok:
  48. return [{"check": "frontmatter", "message": parse_error}]
  49. extra = [k for k in meta if k not in allowed_keys]
  50. if extra:
  51. errors.append({
  52. "check": "allowed-keys",
  53. "message": f"unexpected frontmatter keys: {', '.join(sorted(extra))}; allowed: {', '.join(allowed_keys)}",
  54. })
  55. name = meta.get("name", "")
  56. if not name:
  57. errors.append({"check": "name", "message": "name is missing or empty"})
  58. else:
  59. if not HYPHEN_CASE.match(name):
  60. errors.append({"check": "name", "message": f"name {name!r} is not hyphen-case (lowercase, digits, single hyphens)"})
  61. if len(name) > max_name:
  62. errors.append({"check": "name", "message": f"name is {len(name)} chars, over the {max_name} limit"})
  63. desc = meta.get("description", "")
  64. if not desc:
  65. errors.append({"check": "description", "message": "description is missing or empty"})
  66. else:
  67. if len(desc) > max_desc:
  68. errors.append({"check": "description", "message": f"description is {len(desc)} chars, over the {max_desc} limit"})
  69. if "<" in desc or ">" in desc:
  70. errors.append({"check": "description", "message": "description contains angle brackets, which break router matching"})
  71. return errors
  72. def resolve_skill_md(path: Path) -> Path:
  73. return path / "SKILL.md" if path.is_dir() else path
  74. def main(argv: list[str] | None = None) -> int:
  75. p = argparse.ArgumentParser(description="Structural lint for a skill's SKILL.md frontmatter")
  76. p.add_argument("path", type=Path, help="skill directory or a SKILL.md file")
  77. p.add_argument("--allow-key", action="append", default=[], help="add one key to the allowed set (repeatable)")
  78. p.add_argument("--allow-keys", help="comma-separated set that REPLACES the default allowed keys")
  79. p.add_argument("--max-name", type=int, default=64, help="max name length (default 64)")
  80. p.add_argument("--max-desc", type=int, default=1024, help="max description length (default 1024)")
  81. args = p.parse_args(argv)
  82. skill_md = resolve_skill_md(args.path)
  83. if not skill_md.is_file():
  84. print(json.dumps({"ok": False, "errors": [{"check": "path", "message": f"{skill_md} not found"}]}))
  85. return 1
  86. if args.allow_keys:
  87. allowed = [k.strip() for k in args.allow_keys.split(",") if k.strip()]
  88. else:
  89. allowed = list(DEFAULT_ALLOWED_KEYS) + list(args.allow_key)
  90. errors = validate(skill_md.read_text(encoding="utf-8"), allowed, args.max_name, args.max_desc)
  91. print(json.dumps({"ok": not errors, "file": str(skill_md), "errors": errors}))
  92. return 0 if not errors else 1
  93. if __name__ == "__main__":
  94. sys.exit(main())