Skip to content

Instantly share code, notes, and snippets.

@lolpack
Last active September 30, 2025 22:19
Show Gist options
  • Select an option

  • Save lolpack/8aac9f88b1d02eb776a5b96f019339ce to your computer and use it in GitHub Desktop.

Select an option

Save lolpack/8aac9f88b1d02eb776a5b96f019339ce to your computer and use it in GitHub Desktop.
Claude Hooks for Pyrefly
#!/usr/bin/env python3
"""
Claude Code hook: run 'pyrefly check' to keep the repo type-clean.
Behavior
- PostToolUse (after Edit/Write/MultiEdit): fast check of the changed python file if possible,
else fall back to full project check.
- Stop: full project check at iteration end. If errors exist, block stop (exit 2) so Claude
continues and fixes them. Use stop_hook_active guard to prevent loops.
Exit codes per Claude Code hooks:
0 = success, 2 = blocking error (stderr is given to Claude). Others = non-blocking.
"""
import json
import os
import shutil
import subprocess
import sys
PYTHON_SUFFIXES = (".py", ".pyi")
def _is_python_file(path: str) -> bool:
return path.endswith(PYTHON_SUFFIXES)
def _resolve_pyrefly(project_dir: str) -> str:
# Prefer venv-local pyrefly, else PATH
candidates = [
os.path.join(project_dir, ".venv", "bin", "pyrefly"),
os.path.join(project_dir, "venv", "bin", "pyrefly"),
"pyrefly",
]
for c in candidates:
exe = shutil.which(c) if os.path.basename(c) == c else (c if os.path.isfile(c) and os.access(c, os.X_OK) else None)
if exe:
return exe
return "pyrefly" # best-effort; subprocess will fail clearly
def _run(cmd, cwd):
return subprocess.run(cmd, cwd=cwd, text=True, capture_output=True)
def main():
try:
data = json.load(sys.stdin)
except Exception as e:
print(f"Failed to parse hook input JSON: {e}", file=sys.stderr)
sys.exit(1)
event = data.get("hook_event_name", "")
stop_active = bool(data.get("stop_hook_active", False))
tool_input = data.get("tool_input") or {}
changed_path = tool_input.get("file_path") or ""
# Prefer Claude's project dir if set; fall back to hook cwd.
project_dir = os.environ.get("CLAUDE_PROJECT_DIR") or data.get("cwd") or os.getcwd()
pyrefly = _resolve_pyrefly(project_dir)
# Avoid infinite retry loops if this Stop hook already triggered a continuation
if event in ("Stop", "SubagentStop") and stop_active:
print("Skipping pyrefly check to avoid loop (stop_hook_active=true).", file=sys.stderr)
sys.exit(1)
# Decide scope
args = []
if event in ("PostToolUse", "PreToolUse") and changed_path and _is_python_file(changed_path):
# Try a quick single-file check; Pyrefly still consults project config for env/imports.
args = [changed_path]
# Run the check
result = _run([pyrefly, "check", *args], cwd=project_dir)
if result.returncode == 0:
# Progress text appears in transcript mode for PostToolUse/Stop.
print("✓ Pyrefly type check passed")
sys.exit(0)
# Forward errors to Claude via stderr and block with exit code 2
# Prefer stderr; if empty, fall back to stdout.
message = (result.stderr or result.stdout or "").strip() or "pyrefly check failed with unknown error."
print(message, file=sys.stderr)
sys.exit(2)
if __name__ == "__main__":
main()
@lolpack
Copy link
Author

lolpack commented Sep 30, 2025

Install pyrefly with pip/uv

Create .claude/hooks/pyrefly_check.py and make it executable:

chmod +x .claude/hooks/pyrefly_check.py

Add to your project settings at .claude/settings.json:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|MultiEdit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/pyrefly_check.py"
          }
        ]
      }
    ],
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/pyrefly_check.py"
          }
        ]
      }
    ]
  }
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment