validate-hooks-json.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353
  1. #!/usr/bin/env python3
  2. # Lint a hooks.json (or the "hooks" block of a settings.json) against the
  3. # current Claude Code hook contract. Offline / structural only — no network.
  4. #
  5. # Usage: validate-hooks-json.py [--json] [--strict] [PATH]
  6. # Input: PATH to a hooks.json or settings.json (positional). Default: the
  7. # repo's hooks/hooks.json if present (resolved from cwd or git root).
  8. # Output: stdout = findings (plain text, or JSON envelope with --json) — data only
  9. # Stderr: headers, progress, per-finding human framing, summary, errors
  10. # Exit: 0 clean, 2 usage, 3 file-not-found, 4 malformed-JSON,
  11. # 10 findings present (DOMAIN SIGNAL — "ran fine, found issues")
  12. #
  13. # --strict makes warnings count toward exit 10 (default: only errors do).
  14. #
  15. # The 30-event catalog and the matcher/hook-type/output rules enforced here
  16. # are derived from the authoritative reference shipped alongside this script:
  17. # ../references/hooks-reference.md (the Event Catalog + Hook Types tables).
  18. # Keep KNOWN_EVENTS / HOOK_TYPES in sync with that file when the contract moves.
  19. #
  20. # Examples:
  21. # validate-hooks-json.py hooks/hooks.json
  22. # validate-hooks-json.py --json .claude/settings.json | jq '.data[]'
  23. # validate-hooks-json.py --strict ./hooks.json # warnings also fail
  24. import argparse
  25. import json
  26. import os
  27. import subprocess
  28. import sys
  29. # Windows consoles default to cp1252; force UTF-8 so glyphs/em-dashes in framing
  30. # don't raise UnicodeEncodeError (the repo's standard fix).
  31. for _stream in (sys.stdout, sys.stderr):
  32. try:
  33. _stream.reconfigure(encoding="utf-8") # type: ignore[attr-defined]
  34. except (AttributeError, ValueError):
  35. pass
  36. class Term:
  37. """Tiny ANSI helper mirroring skills/_lib/term.sh (bash-only; per
  38. TERMINAL-DESIGN.md §9 the Python port is inline). Honors FORCE_COLOR /
  39. NO_COLOR / TERM_ASCII; ASCII glyph fallback on TERM_ASCII or a non-UTF stream."""
  40. _C = {"green": "\033[32m", "yellow": "\033[33m", "orange": "\033[38;5;208m",
  41. "red": "\033[31m", "cyan": "\033[36m", "dim": "\033[2m", "off": "\033[0m"}
  42. _GLYPH = {"ok": "✓", "bad": "✗", "warn": "▲", "skip": "—", "na": "—", "unknown": "?"}
  43. _ASCII = {"ok": "+", "bad": "x", "warn": "!", "skip": "-", "na": "-", "unknown": "?"}
  44. _MARK_COLOR = {"ok": "green", "bad": "red", "warn": "orange", "skip": "dim",
  45. "na": "dim", "unknown": "yellow"}
  46. def __init__(self, stream=sys.stderr):
  47. enc = (getattr(stream, "encoding", "") or "").lower()
  48. self.ascii = (os.environ.get("TERM_ASCII") == "1"
  49. or os.environ.get("FLEET_ASCII") == "1" or "utf" not in enc)
  50. if os.environ.get("FORCE_COLOR"):
  51. self.color = True
  52. elif (os.environ.get("NO_COLOR") is not None or os.environ.get("TERM") == "dumb"
  53. or not getattr(stream, "isatty", lambda: False)()):
  54. self.color = False
  55. else:
  56. self.color = True
  57. def c(self, name, text):
  58. return f"{self._C.get(name, '')}{text}{self._C['off']}" if self.color else text
  59. def mark(self, state):
  60. return self.c(self._MARK_COLOR.get(state, ""),
  61. (self._ASCII if self.ascii else self._GLYPH).get(state, "."))
  62. def hdr(self, text):
  63. return self.c("cyan", f"=== {text} ===")
  64. TERM = Term(sys.stderr)
  65. SCHEMA = "claude-mods.claude-code-ops.hooks-lint/v1"
  66. EXIT_OK = 0
  67. EXIT_USAGE = 2
  68. EXIT_NOT_FOUND = 3
  69. EXIT_MALFORMED = 4
  70. EXIT_FINDINGS = 10
  71. # --- Source of truth: ../references/hooks-reference.md "Event Catalog" table. ---
  72. # The 30 hook events Claude Code recognises (June 2026 contract). An event key
  73. # outside this set is a finding — almost always a typo or a stale name.
  74. KNOWN_EVENTS = [
  75. "SessionStart", "SessionEnd", "Setup",
  76. "UserPromptSubmit", "UserPromptExpansion",
  77. "PreToolUse", "PermissionRequest", "PermissionDenied",
  78. "PostToolUse", "PostToolUseFailure", "PostToolBatch",
  79. "Stop", "StopFailure",
  80. "SubagentStart", "SubagentStop",
  81. "TaskCreated", "TaskCompleted",
  82. "TeammateIdle", "Notification", "MessageDisplay",
  83. "ConfigChange", "CwdChanged", "FileChanged",
  84. "PreCompact", "PostCompact",
  85. "InstructionsLoaded",
  86. "WorktreeCreate", "WorktreeRemove",
  87. "Elicitation", "ElicitationResult",
  88. ] # len == 30
  89. # Source of truth: ../references/hooks-reference.md "Hook Types" section.
  90. HOOK_TYPES = ["command", "http", "mcp_tool", "prompt", "agent"]
  91. # Portability-recommended placeholders for command paths.
  92. ROOTED_PLACEHOLDERS = ("${CLAUDE_PLUGIN_ROOT}", "${CLAUDE_PROJECT_DIR}",
  93. "${CLAUDE_PLUGIN_DATA}", "${CLAUDE_SKILL_DIR}")
  94. class Finding:
  95. __slots__ = ("pointer", "severity", "message")
  96. def __init__(self, pointer, severity, message):
  97. self.pointer = pointer
  98. self.severity = severity # "error" | "warning"
  99. self.message = message
  100. def as_dict(self):
  101. return {"pointer": self.pointer, "severity": self.severity,
  102. "message": self.message}
  103. def add(findings, pointer, severity, message):
  104. findings.append(Finding(pointer, severity, message))
  105. def looks_like_permission_rule(s):
  106. # Permission-rule syntax: "Tool(args)" e.g. Bash(git *), Edit(*.ts).
  107. if not isinstance(s, str) or "(" not in s or not s.endswith(")"):
  108. return False
  109. head = s.split("(", 1)[0]
  110. return bool(head) and head[0].isalpha()
  111. def check_hook_entry(findings, entry, ptr):
  112. if not isinstance(entry, dict):
  113. add(findings, ptr, "error",
  114. "hook entry must be an object, got %s" % type(entry).__name__)
  115. return
  116. htype = entry.get("type")
  117. if htype is None:
  118. add(findings, ptr, "error", "hook entry missing 'type'")
  119. elif htype not in HOOK_TYPES:
  120. add(findings, ptr, "error",
  121. "unknown hook type %r (expected one of: %s)"
  122. % (htype, ", ".join(HOOK_TYPES)))
  123. if htype == "command":
  124. cmd = entry.get("command")
  125. if not cmd or not isinstance(cmd, str):
  126. add(findings, ptr, "error",
  127. "command hook must have a non-empty string 'command'")
  128. elif not any(p in cmd for p in ROOTED_PLACEHOLDERS):
  129. add(findings, ptr, "warning",
  130. "command path is not rooted at ${CLAUDE_PLUGIN_ROOT}/"
  131. "${CLAUDE_PROJECT_DIR} — may break when cwd varies")
  132. elif htype == "http":
  133. if not entry.get("url"):
  134. add(findings, ptr, "error", "http hook must have a 'url'")
  135. elif htype == "mcp_tool":
  136. if not entry.get("server") or not entry.get("tool"):
  137. add(findings, ptr, "error",
  138. "mcp_tool hook must have 'server' and 'tool'")
  139. elif htype in ("prompt", "agent"):
  140. if not entry.get("prompt"):
  141. add(findings, ptr, "warning",
  142. "%s hook usually needs a 'prompt'" % htype)
  143. iff = entry.get("if")
  144. if iff is not None:
  145. if not isinstance(iff, str):
  146. add(findings, ptr, "error", "'if' filter must be a string")
  147. elif not looks_like_permission_rule(iff):
  148. add(findings, ptr, "warning",
  149. "'if' filter %r does not look like a permission rule "
  150. "(e.g. \"Bash(git *)\", \"Edit(*.ts)\")" % iff)
  151. def check_matcher_group(findings, group, ptr):
  152. if not isinstance(group, dict):
  153. add(findings, ptr, "error",
  154. "matcher group must be an object, got %s" % type(group).__name__)
  155. return
  156. if "matcher" in group and not isinstance(group["matcher"], str):
  157. if isinstance(group["matcher"], list):
  158. add(findings, ptr + "/matcher", "error",
  159. "'matcher' must be a STRING (use \"Edit|Write\"), not an array "
  160. "— an array is a schema error and the hook is silently dropped")
  161. else:
  162. add(findings, ptr + "/matcher", "error",
  163. "'matcher' must be a string, got %s"
  164. % type(group["matcher"]).__name__)
  165. hooks = group.get("hooks")
  166. if hooks is None:
  167. add(findings, ptr, "error", "matcher group missing 'hooks' list")
  168. elif not isinstance(hooks, list):
  169. add(findings, ptr + "/hooks", "error",
  170. "'hooks' must be a list, got %s" % type(hooks).__name__)
  171. else:
  172. for i, entry in enumerate(hooks):
  173. check_hook_entry(findings, entry, "%s/hooks/%d" % (ptr, i))
  174. def lint(doc):
  175. """Return list[Finding] for a parsed hooks.json / settings.json document."""
  176. findings = []
  177. if not isinstance(doc, dict):
  178. add(findings, "", "error",
  179. "top-level value must be an object "
  180. '({"hooks": {...}} or a bare event map)')
  181. return findings
  182. # Accept either {"hooks": {<Event>: [...]}} or a bare event map.
  183. if "hooks" in doc and isinstance(doc["hooks"], dict):
  184. events = doc["hooks"]
  185. base = "/hooks"
  186. else:
  187. # Bare event map only if keys look like events; otherwise flag shape.
  188. keys = list(doc.keys())
  189. if keys and any(k in KNOWN_EVENTS for k in keys):
  190. events = doc
  191. base = ""
  192. else:
  193. add(findings, "", "error",
  194. 'expected {"hooks": {<Event>: [...]}} or a bare event map; '
  195. "found object with keys: %s" % ", ".join(keys) or "(empty)")
  196. return findings
  197. for event, groups in events.items():
  198. eptr = "%s/%s" % (base, event)
  199. if event not in KNOWN_EVENTS:
  200. add(findings, eptr, "error",
  201. "unknown hook event %r — not in the 30-event catalog "
  202. "(see references/hooks-reference.md)" % event)
  203. # still structurally validate its groups below
  204. if not isinstance(groups, list):
  205. add(findings, eptr, "error",
  206. "event value must be a list of matcher groups, got %s"
  207. % type(groups).__name__)
  208. continue
  209. for i, group in enumerate(groups):
  210. check_matcher_group(findings, group, "%s/%d" % (eptr, i))
  211. return findings
  212. def default_path():
  213. """Repo's hooks/hooks.json: try cwd, then git toplevel."""
  214. cand = os.path.join(os.getcwd(), "hooks", "hooks.json")
  215. if os.path.isfile(cand):
  216. return cand
  217. try:
  218. top = subprocess.run(
  219. ["git", "rev-parse", "--show-toplevel"],
  220. capture_output=True, text=True, timeout=5)
  221. if top.returncode == 0:
  222. cand = os.path.join(top.stdout.strip(), "hooks", "hooks.json")
  223. if os.path.isfile(cand):
  224. return cand
  225. except (OSError, subprocess.SubprocessError):
  226. pass
  227. return None
  228. def main(argv):
  229. p = argparse.ArgumentParser(
  230. prog="validate-hooks-json.py",
  231. description="Lint a hooks.json / settings.json hooks block against "
  232. "the Claude Code hook contract (offline, structural).",
  233. epilog="EXAMPLES:\n"
  234. " validate-hooks-json.py hooks/hooks.json\n"
  235. " validate-hooks-json.py --json .claude/settings.json | jq '.data[]'\n"
  236. " validate-hooks-json.py --strict ./hooks.json\n"
  237. "\nEXIT: 0 clean, 2 usage, 3 not-found, 4 malformed-JSON, "
  238. "10 findings present.",
  239. formatter_class=argparse.RawDescriptionHelpFormatter)
  240. p.add_argument("path", nargs="?",
  241. help="hooks.json or settings.json (default: repo hooks/hooks.json)")
  242. p.add_argument("--json", action="store_true",
  243. help="emit a JSON envelope (schema %s)" % SCHEMA)
  244. p.add_argument("--strict", action="store_true",
  245. help="count warnings toward the exit-10 signal")
  246. try:
  247. args = p.parse_args(argv)
  248. except SystemExit as e:
  249. # argparse exits 0 for --help (good), 2 for bad args (matches USAGE).
  250. return e.code if e.code is not None else EXIT_USAGE
  251. path = args.path or default_path()
  252. if not path:
  253. msg = ("no path given and no repo hooks/hooks.json found "
  254. "(pass a path explicitly)")
  255. if args.json:
  256. print(json.dumps({"error": {"code": "NOT_FOUND", "message": msg}}))
  257. print("ERROR: %s" % msg, file=sys.stderr)
  258. return EXIT_NOT_FOUND
  259. if not os.path.isfile(path):
  260. msg = "file not found: %s" % path
  261. if args.json:
  262. print(json.dumps({"error": {"code": "NOT_FOUND", "message": msg}}))
  263. print("ERROR: %s" % msg, file=sys.stderr)
  264. return EXIT_NOT_FOUND
  265. try:
  266. with open(path, "r", encoding="utf-8") as fh:
  267. doc = json.load(fh)
  268. except (json.JSONDecodeError, UnicodeDecodeError) as e:
  269. msg = "malformed JSON in %s: %s" % (path, e)
  270. if args.json:
  271. print(json.dumps({"error": {"code": "VALIDATION", "message": msg}}))
  272. print("ERROR: %s" % msg, file=sys.stderr)
  273. return EXIT_MALFORMED
  274. print(TERM.hdr("hooks-lint: %s" % path), file=sys.stderr)
  275. findings = lint(doc)
  276. errors = [f for f in findings if f.severity == "error"]
  277. warnings = [f for f in findings if f.severity == "warning"]
  278. if args.json:
  279. print(json.dumps({
  280. "data": [f.as_dict() for f in findings],
  281. "meta": {"count": len(findings),
  282. "errors": len(errors), "warnings": len(warnings),
  283. "path": path, "schema": SCHEMA},
  284. }, indent=2))
  285. else:
  286. for f in findings:
  287. print("%s\t%s\t%s" % (f.severity, f.pointer or "/", f.message))
  288. # Human framing → stderr.
  289. for f in findings:
  290. if f.severity == "error":
  291. mk, tag = TERM.mark("bad"), TERM.c("red", "ERROR")
  292. else:
  293. mk, tag = TERM.mark("warn"), TERM.c("orange", "warn")
  294. print(" %s %s %s: %s" % (mk, tag, f.pointer or "/", f.message),
  295. file=sys.stderr)
  296. if not findings:
  297. print(" %s clean, no findings" % TERM.mark("ok"), file=sys.stderr)
  298. print("--- %s error(s), %s warning(s) ---"
  299. % (TERM.c("red", str(len(errors))), TERM.c("orange", str(len(warnings)))),
  300. file=sys.stderr)
  301. if errors or (args.strict and warnings):
  302. return EXIT_FINDINGS
  303. return EXIT_OK
  304. if __name__ == "__main__":
  305. sys.exit(main(sys.argv[1:]))