From e2322a290966de432e4d239f783dcad9497480d5 Mon Sep 17 00:00:00 2001 From: Ali Zarinkolah Date: Sat, 15 Aug 2026 11:26:39 +0330 Subject: [PATCH] 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 --- .claude/agents/Explore.md | 5 + .claude/hooks/python_quality.py | 186 ++++++++++++++++++++++++++++++++ .claude/settings.json | 20 ++++ 3 files changed, 211 insertions(+) create mode 100644 .claude/agents/Explore.md create mode 100644 .claude/hooks/python_quality.py create mode 100644 .claude/settings.json diff --git a/.claude/agents/Explore.md b/.claude/agents/Explore.md new file mode 100644 index 0000000..bcfc5f7 --- /dev/null +++ b/.claude/agents/Explore.md @@ -0,0 +1,5 @@ +--- +name: Explore +description: Fast, read-only codebase search +model: sonnet +--- diff --git a/.claude/hooks/python_quality.py b/.claude/hooks/python_quality.py new file mode 100644 index 0000000..31ac19c --- /dev/null +++ b/.claude/hooks/python_quality.py @@ -0,0 +1,186 @@ +#!/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()) diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..9a65637 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,20 @@ +{ + "enabledPlugins": { + "astral@astral-sh": true + }, + "hooks": { + "PostToolUse": [ + { + "matcher": "Write|Edit", + "hooks": [ + { + "type": "command", + "command": "repo=$(git rev-parse --show-toplevel 2>/dev/null || pwd); python3 \"$repo/.claude/hooks/python_quality.py\"", + "timeout": 120, + "statusMessage": "Running Ruff and ty on Python edit" + } + ] + } + ] + } +}