Created
July 28, 2025 05:27
-
-
Save sminnee/93a321cac93454ecd62afd34bc4d0f9b to your computer and use it in GitHub Desktop.
Claude hook for running your project's typechecking / linting commands after it makes any changes
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """ | |
| Claude Code hook for running linter commands after files are modified. | |
| Configure the LINTER_CONFIG dictionary below to map glob patterns to linting commands. | |
| """ | |
| # ruff: noqa: T201 | |
| import fnmatch | |
| import json | |
| import subprocess | |
| import sys | |
| from pathlib import Path | |
| # Configuration: Map operating paths to glob patterns and linting commands | |
| # Operating paths define the working directory context for commands | |
| # File paths are matched against {operating_path}/{pattern} | |
| # Use '$@' as a placeholder for the filename (relative to operating path) | |
| LINTER_CONFIG = { | |
| "/": { | |
| ("*.ts", "*.tsx", "*.md", "*.css", "*.scss", "*.json"): [ | |
| ["npx", "prettier", "--write", "--log-level=warn", "$@"], | |
| ], | |
| "*.py": [ | |
| ["poetry", "run", "ruff", "format", "$@"], | |
| ["poetry", "run", "ruff", "check", "--fix", "$@"], | |
| ["poetry", "run", "pyright", "$@"], | |
| ], | |
| }, | |
| "frontend": { | |
| ("*.ts", "*.tsx"): [ | |
| ["npx", "tsc-files", "--noEmit", "$@"], | |
| ["npx", "eslint", "--fix", "$@"], | |
| ], | |
| }, | |
| "service/another-path": { | |
| ("*.ts", "*.tsx"): [ | |
| ["npx", "tsc-files", "--noEmit", "$@"], | |
| ], | |
| }, | |
| } | |
| def get_file_path(): | |
| """Get file path from command line argument or Claude hook input.""" | |
| # Check for command line argument first | |
| if len(sys.argv) > 1: | |
| return sys.argv[1] | |
| # Fall back to parsing Claude hook input from stdin | |
| try: | |
| hook_data = json.load(sys.stdin) | |
| # Extract file path from tool input (current format) | |
| tool_input = hook_data.get("tool_input", {}) | |
| if "file_path" in tool_input: | |
| return tool_input["file_path"] | |
| if "notebook_path" in tool_input: | |
| return tool_input["notebook_path"] | |
| except (json.JSONDecodeError, KeyError): | |
| print("Failed to get file path from command line argument or Claude hook input") | |
| return None | |
| def match_one(file_path, pattern, operating_path): | |
| if isinstance(pattern, tuple): | |
| return any(match_one(file_path, p, operating_path) for p in pattern) | |
| # Build full pattern with operating path | |
| if operating_path == "/": | |
| full_pattern = pattern | |
| else: | |
| full_pattern = f"{operating_path}/{pattern}" | |
| return fnmatch.fnmatch(file_path, full_pattern) | |
| def match_patterns(file_path): | |
| """Find all glob patterns that match the given file path.""" | |
| matching_commands = [] | |
| for operating_path, patterns in LINTER_CONFIG.items(): | |
| for pattern, commands in patterns.items(): | |
| if match_one(file_path, pattern, operating_path): | |
| # Return commands with operating path info | |
| matching_commands.extend((operating_path, command) for command in commands) | |
| return matching_commands | |
| def run_command(operating_path, command, file_path): | |
| """Execute a linting command with file path substitution.""" | |
| # Strip operating path prefix from file path | |
| if operating_path != "/" and file_path.startswith(f"{operating_path}/"): | |
| relative_file_path = file_path[len(operating_path) + 1 :] | |
| else: | |
| relative_file_path = file_path | |
| # Replace '$@' with the relative file path | |
| processed_command = [] | |
| for arg in command: | |
| if arg == "$@": | |
| processed_command.append(relative_file_path) | |
| else: | |
| processed_command.append(arg) | |
| # Set working directory | |
| if operating_path == "/": | |
| cwd = Path.cwd() | |
| else: | |
| cwd = Path.cwd() / operating_path | |
| try: | |
| result = subprocess.run(processed_command, capture_output=True, text=True, cwd=cwd) | |
| except FileNotFoundError: | |
| return 1, "", f"Command not found: {processed_command[0]}" | |
| else: | |
| return result.returncode, result.stdout, result.stderr | |
| def main(): | |
| """Main hook execution logic.""" | |
| file_path = get_file_path() | |
| if not file_path: | |
| # No file to lint, exit successfully | |
| sys.exit(0) | |
| # Convert to relative path for pattern matching | |
| try: | |
| relative_path = str(Path(file_path).relative_to(Path.cwd())) | |
| except ValueError: | |
| # File is outside working directory, use absolute path | |
| relative_path = file_path | |
| matching_commands = match_patterns(relative_path) | |
| if not matching_commands: | |
| # No linting rules for this file | |
| sys.exit(0) | |
| has_errors = False | |
| for operating_path, command in matching_commands: | |
| returncode, stdout, stderr = run_command(operating_path, command, file_path) | |
| if returncode != 0: | |
| has_errors = True | |
| print(f"Linting failed for {relative_path}:", file=sys.stderr) | |
| print(f"Command: {' '.join(command)} (in {operating_path})", file=sys.stderr) | |
| if stdout: | |
| print(f"STDOUT:\n{stdout}", file=sys.stderr) | |
| if stderr: | |
| print(f"STDERR:\n{stderr}", file=sys.stderr) | |
| print("-" * 50, file=sys.stderr) | |
| # Exit with code 2 to block the action if any linter failed | |
| if has_errors: | |
| sys.exit(2) | |
| else: | |
| sys.exit(0) | |
| if __name__ == "__main__": | |
| main() |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| { | |
| "permissions": { | |
| "allow": [ | |
| "Bash(.claude/bin/on_save.py:*)" | |
| ], | |
| }, | |
| "hooks": { | |
| "PostToolUse": [ | |
| { | |
| "matcher": "Edit|Write|MultiEdit", | |
| "hooks": [ | |
| { | |
| "type": "command", | |
| "command": ".claude/bin/on_save.py" | |
| } | |
| ] | |
| } | |
| ] | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment