#!/usr/bin/env python3 # Lint a hooks.json (or the "hooks" block of a settings.json) against the # current Claude Code hook contract. Offline / structural only — no network. # # Usage: validate-hooks-json.py [--json] [--strict] [PATH] # Input: PATH to a hooks.json or settings.json (positional). Default: the # repo's hooks/hooks.json if present (resolved from cwd or git root). # Output: stdout = findings (plain text, or JSON envelope with --json) — data only # Stderr: headers, progress, per-finding human framing, summary, errors # Exit: 0 clean, 2 usage, 3 file-not-found, 4 malformed-JSON, # 10 findings present (DOMAIN SIGNAL — "ran fine, found issues") # # --strict makes warnings count toward exit 10 (default: only errors do). # # The 30-event catalog and the matcher/hook-type/output rules enforced here # are derived from the authoritative reference shipped alongside this script: # ../references/hooks-reference.md (the Event Catalog + Hook Types tables). # Keep KNOWN_EVENTS / HOOK_TYPES in sync with that file when the contract moves. # # Examples: # validate-hooks-json.py hooks/hooks.json # validate-hooks-json.py --json .claude/settings.json | jq '.data[]' # validate-hooks-json.py --strict ./hooks.json # warnings also fail import argparse import json import os import subprocess import sys SCHEMA = "claude-mods.claude-code-ops.hooks-lint/v1" EXIT_OK = 0 EXIT_USAGE = 2 EXIT_NOT_FOUND = 3 EXIT_MALFORMED = 4 EXIT_FINDINGS = 10 # --- Source of truth: ../references/hooks-reference.md "Event Catalog" table. --- # The 30 hook events Claude Code recognises (June 2026 contract). An event key # outside this set is a finding — almost always a typo or a stale name. KNOWN_EVENTS = [ "SessionStart", "SessionEnd", "Setup", "UserPromptSubmit", "UserPromptExpansion", "PreToolUse", "PermissionRequest", "PermissionDenied", "PostToolUse", "PostToolUseFailure", "PostToolBatch", "Stop", "StopFailure", "SubagentStart", "SubagentStop", "TaskCreated", "TaskCompleted", "TeammateIdle", "Notification", "MessageDisplay", "ConfigChange", "CwdChanged", "FileChanged", "PreCompact", "PostCompact", "InstructionsLoaded", "WorktreeCreate", "WorktreeRemove", "Elicitation", "ElicitationResult", ] # len == 30 # Source of truth: ../references/hooks-reference.md "Hook Types" section. HOOK_TYPES = ["command", "http", "mcp_tool", "prompt", "agent"] # Portability-recommended placeholders for command paths. ROOTED_PLACEHOLDERS = ("${CLAUDE_PLUGIN_ROOT}", "${CLAUDE_PROJECT_DIR}", "${CLAUDE_PLUGIN_DATA}", "${CLAUDE_SKILL_DIR}") class Finding: __slots__ = ("pointer", "severity", "message") def __init__(self, pointer, severity, message): self.pointer = pointer self.severity = severity # "error" | "warning" self.message = message def as_dict(self): return {"pointer": self.pointer, "severity": self.severity, "message": self.message} def add(findings, pointer, severity, message): findings.append(Finding(pointer, severity, message)) def looks_like_permission_rule(s): # Permission-rule syntax: "Tool(args)" e.g. Bash(git *), Edit(*.ts). if not isinstance(s, str) or "(" not in s or not s.endswith(")"): return False head = s.split("(", 1)[0] return bool(head) and head[0].isalpha() def check_hook_entry(findings, entry, ptr): if not isinstance(entry, dict): add(findings, ptr, "error", "hook entry must be an object, got %s" % type(entry).__name__) return htype = entry.get("type") if htype is None: add(findings, ptr, "error", "hook entry missing 'type'") elif htype not in HOOK_TYPES: add(findings, ptr, "error", "unknown hook type %r (expected one of: %s)" % (htype, ", ".join(HOOK_TYPES))) if htype == "command": cmd = entry.get("command") if not cmd or not isinstance(cmd, str): add(findings, ptr, "error", "command hook must have a non-empty string 'command'") elif not any(p in cmd for p in ROOTED_PLACEHOLDERS): add(findings, ptr, "warning", "command path is not rooted at ${CLAUDE_PLUGIN_ROOT}/" "${CLAUDE_PROJECT_DIR} — may break when cwd varies") elif htype == "http": if not entry.get("url"): add(findings, ptr, "error", "http hook must have a 'url'") elif htype == "mcp_tool": if not entry.get("server") or not entry.get("tool"): add(findings, ptr, "error", "mcp_tool hook must have 'server' and 'tool'") elif htype in ("prompt", "agent"): if not entry.get("prompt"): add(findings, ptr, "warning", "%s hook usually needs a 'prompt'" % htype) iff = entry.get("if") if iff is not None: if not isinstance(iff, str): add(findings, ptr, "error", "'if' filter must be a string") elif not looks_like_permission_rule(iff): add(findings, ptr, "warning", "'if' filter %r does not look like a permission rule " "(e.g. \"Bash(git *)\", \"Edit(*.ts)\")" % iff) def check_matcher_group(findings, group, ptr): if not isinstance(group, dict): add(findings, ptr, "error", "matcher group must be an object, got %s" % type(group).__name__) return if "matcher" in group and not isinstance(group["matcher"], str): if isinstance(group["matcher"], list): add(findings, ptr + "/matcher", "error", "'matcher' must be a STRING (use \"Edit|Write\"), not an array " "— an array is a schema error and the hook is silently dropped") else: add(findings, ptr + "/matcher", "error", "'matcher' must be a string, got %s" % type(group["matcher"]).__name__) hooks = group.get("hooks") if hooks is None: add(findings, ptr, "error", "matcher group missing 'hooks' list") elif not isinstance(hooks, list): add(findings, ptr + "/hooks", "error", "'hooks' must be a list, got %s" % type(hooks).__name__) else: for i, entry in enumerate(hooks): check_hook_entry(findings, entry, "%s/hooks/%d" % (ptr, i)) def lint(doc): """Return list[Finding] for a parsed hooks.json / settings.json document.""" findings = [] if not isinstance(doc, dict): add(findings, "", "error", "top-level value must be an object " '({"hooks": {...}} or a bare event map)') return findings # Accept either {"hooks": {: [...]}} or a bare event map. if "hooks" in doc and isinstance(doc["hooks"], dict): events = doc["hooks"] base = "/hooks" else: # Bare event map only if keys look like events; otherwise flag shape. keys = list(doc.keys()) if keys and any(k in KNOWN_EVENTS for k in keys): events = doc base = "" else: add(findings, "", "error", 'expected {"hooks": {: [...]}} or a bare event map; ' "found object with keys: %s" % ", ".join(keys) or "(empty)") return findings for event, groups in events.items(): eptr = "%s/%s" % (base, event) if event not in KNOWN_EVENTS: add(findings, eptr, "error", "unknown hook event %r — not in the 30-event catalog " "(see references/hooks-reference.md)" % event) # still structurally validate its groups below if not isinstance(groups, list): add(findings, eptr, "error", "event value must be a list of matcher groups, got %s" % type(groups).__name__) continue for i, group in enumerate(groups): check_matcher_group(findings, group, "%s/%d" % (eptr, i)) return findings def default_path(): """Repo's hooks/hooks.json: try cwd, then git toplevel.""" cand = os.path.join(os.getcwd(), "hooks", "hooks.json") if os.path.isfile(cand): return cand try: top = subprocess.run( ["git", "rev-parse", "--show-toplevel"], capture_output=True, text=True, timeout=5) if top.returncode == 0: cand = os.path.join(top.stdout.strip(), "hooks", "hooks.json") if os.path.isfile(cand): return cand except (OSError, subprocess.SubprocessError): pass return None def main(argv): p = argparse.ArgumentParser( prog="validate-hooks-json.py", description="Lint a hooks.json / settings.json hooks block against " "the Claude Code hook contract (offline, structural).", epilog="EXAMPLES:\n" " validate-hooks-json.py hooks/hooks.json\n" " validate-hooks-json.py --json .claude/settings.json | jq '.data[]'\n" " validate-hooks-json.py --strict ./hooks.json\n" "\nEXIT: 0 clean, 2 usage, 3 not-found, 4 malformed-JSON, " "10 findings present.", formatter_class=argparse.RawDescriptionHelpFormatter) p.add_argument("path", nargs="?", help="hooks.json or settings.json (default: repo hooks/hooks.json)") p.add_argument("--json", action="store_true", help="emit a JSON envelope (schema %s)" % SCHEMA) p.add_argument("--strict", action="store_true", help="count warnings toward the exit-10 signal") try: args = p.parse_args(argv) except SystemExit as e: # argparse exits 0 for --help (good), 2 for bad args (matches USAGE). return e.code if e.code is not None else EXIT_USAGE path = args.path or default_path() if not path: msg = ("no path given and no repo hooks/hooks.json found " "(pass a path explicitly)") if args.json: print(json.dumps({"error": {"code": "NOT_FOUND", "message": msg}})) print("ERROR: %s" % msg, file=sys.stderr) return EXIT_NOT_FOUND if not os.path.isfile(path): msg = "file not found: %s" % path if args.json: print(json.dumps({"error": {"code": "NOT_FOUND", "message": msg}})) print("ERROR: %s" % msg, file=sys.stderr) return EXIT_NOT_FOUND try: with open(path, "r", encoding="utf-8") as fh: doc = json.load(fh) except (json.JSONDecodeError, UnicodeDecodeError) as e: msg = "malformed JSON in %s: %s" % (path, e) if args.json: print(json.dumps({"error": {"code": "VALIDATION", "message": msg}})) print("ERROR: %s" % msg, file=sys.stderr) return EXIT_MALFORMED print("=== hooks-lint: %s ===" % path, file=sys.stderr) findings = lint(doc) errors = [f for f in findings if f.severity == "error"] warnings = [f for f in findings if f.severity == "warning"] if args.json: print(json.dumps({ "data": [f.as_dict() for f in findings], "meta": {"count": len(findings), "errors": len(errors), "warnings": len(warnings), "path": path, "schema": SCHEMA}, }, indent=2)) else: for f in findings: print("%s\t%s\t%s" % (f.severity, f.pointer or "/", f.message)) # Human framing → stderr. for f in findings: tag = "ERROR" if f.severity == "error" else "warn " print(" [%s] %s: %s" % (tag, f.pointer or "/", f.message), file=sys.stderr) if not findings: print(" clean — no findings", file=sys.stderr) print("--- %d error(s), %d warning(s) ---" % (len(errors), len(warnings)), file=sys.stderr) if errors or (args.strict and warnings): return EXIT_FINDINGS return EXIT_OK if __name__ == "__main__": sys.exit(main(sys.argv[1:]))