Files
chatbot_v3/.claude/hooks/python_quality.py
Ali Zarinkolah e2322a2909 chore(claude-code): add project agent and quality-check hook config
Add the Explore subagent definition and a PostToolUse hook that runs
Ruff and ty on Python file edits, plus the settings.json wiring it in.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 11:26:39 +03:30

187 lines
4.9 KiB
Python

#!/usr/bin/env python3
"""Run Python quality tools for Claude Code Write/Edit hooks.
The hook is intentionally non-blocking: it formats/fixes what Ruff can fix safely,
then reports remaining Ruff/ty diagnostics back to Claude as additional context.
"""
from __future__ import annotations
import hashlib
import json
import subprocess
import sys
from pathlib import Path
PYTHON_SUFFIXES = {".py", ".pyi"}
SKIP_PARTS = {".git", ".venv", "__pycache__"}
def main() -> int:
payload = _read_payload()
file_path = _extract_file_path(payload)
if file_path is None:
return 0
repo_root = _repo_root()
path = _resolve_path(file_path, repo_root)
if not _should_check(path):
return 0
relative_path = _display_path(path, repo_root)
before = _sha256(path)
fix = _run(["uv", "run", "ruff", "check", "--fix", str(path)], repo_root)
fmt = _run(["uv", "run", "ruff", "format", str(path)], repo_root)
lint = _run(["uv", "run", "ruff", "check", str(path)], repo_root)
# `--error-on-warning` makes ty warnings visible to the hook while this
# script still exits 0, so warnings are reported but do not stop Claude.
types = _run(["uv", "run", "ty", "check", "--error-on-warning", str(path)], repo_root)
after = _sha256(path)
changed = before != after
message = _build_message(
relative_path=relative_path,
changed=changed,
fix=fix,
fmt=fmt,
lint=lint,
types=types,
)
if message:
print(
json.dumps(
{
"suppressOutput": True,
"hookSpecificOutput": {
"hookEventName": "PostToolUse",
"additionalContext": message,
},
}
)
)
return 0
def _read_payload() -> dict[str, object]:
try:
raw = sys.stdin.read()
return json.loads(raw) if raw.strip() else {}
except json.JSONDecodeError:
return {}
def _extract_file_path(payload: dict[str, object]) -> str | None:
response = payload.get("tool_response")
if isinstance(response, dict):
for key in ("filePath", "file_path"):
value = response.get(key)
if isinstance(value, str) and value:
return value
tool_input = payload.get("tool_input")
if isinstance(tool_input, dict):
value = tool_input.get("file_path")
if isinstance(value, str) and value:
return value
return None
def _repo_root() -> Path:
result = subprocess.run(
["git", "rev-parse", "--show-toplevel"],
capture_output=True,
text=True,
check=False,
)
if result.returncode == 0:
return Path(result.stdout.strip()).resolve()
return Path.cwd().resolve()
def _resolve_path(file_path: str, repo_root: Path) -> Path:
path = Path(file_path).expanduser()
if not path.is_absolute():
path = repo_root / path
return path.resolve()
def _should_check(path: Path) -> bool:
return path.is_file() and path.suffix in PYTHON_SUFFIXES and SKIP_PARTS.isdisjoint(path.parts)
def _display_path(path: Path, repo_root: Path) -> str:
try:
return str(path.relative_to(repo_root))
except ValueError:
return str(path)
def _sha256(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
def _run(command: list[str], cwd: Path) -> subprocess.CompletedProcess[str]:
return subprocess.run(
command,
cwd=cwd,
capture_output=True,
text=True,
check=False,
)
def _combined_output(result: subprocess.CompletedProcess[str]) -> str:
return "\n".join(part.strip() for part in (result.stdout, result.stderr) if part.strip())
def _command_label(result: subprocess.CompletedProcess[str]) -> str:
return " ".join(result.args) if isinstance(result.args, list) else str(result.args)
def _build_message(
*,
relative_path: str,
changed: bool,
fix: subprocess.CompletedProcess[str],
fmt: subprocess.CompletedProcess[str],
lint: subprocess.CompletedProcess[str],
types: subprocess.CompletedProcess[str],
) -> str:
lines: list[str] = []
if changed:
lines.append(
f"Python quality hook updated `{relative_path}` with Ruff safe fixes/formatting."
)
lines.append("Read the file before making another manual edit to avoid stale text.")
for result in (fix, fmt, lint, types):
if result.returncode == 0:
continue
output = _combined_output(result)
if not output:
output = f"Command exited with status {result.returncode}."
lines.extend(
[
"",
f"Remaining diagnostics from `{_command_label(result)}`:",
"```text",
output[-6000:],
"```",
]
)
return "\n".join(lines).strip()
if __name__ == "__main__":
raise SystemExit(main())