Initial commit
This commit is contained in:
249
hooks/lint-on-write.py
Executable file
249
hooks/lint-on-write.py
Executable file
@@ -0,0 +1,249 @@
|
||||
#!/usr/bin/env -S uv run --quiet --script
|
||||
# /// script
|
||||
# dependencies = []
|
||||
# ///
|
||||
"""
|
||||
Generic PostToolUse linting hook for Claude Code.
|
||||
|
||||
Reads stdin JSON to extract file paths, detects file type by extension,
|
||||
and runs appropriate linter commands. Designed for extensibility to support
|
||||
multiple languages (markdown, JavaScript, Python, Go, etc.).
|
||||
|
||||
Exit codes:
|
||||
- 0: Success or silently skipped (file formatted or no issues)
|
||||
- 1: Non-blocking error (unfixable linting issues, linter errors)
|
||||
"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
|
||||
# Configuration: Map file extensions to linter configurations
|
||||
# Each config includes: command, extensions, success messages, error handling
|
||||
LINTER_CONFIG = {
|
||||
"markdown": {
|
||||
"extensions": [".md", ".markdown"],
|
||||
"command": ["markdownlint-cli2", "--fix"],
|
||||
"check_installed": "markdownlint-cli2",
|
||||
"success_message": "Markdown formatted: {file_path}",
|
||||
"unfixable_message": "Markdown linting found unfixable issues in {file_path}",
|
||||
"unfixable_hint": "Run: markdownlint-cli2 {file_path} to see details",
|
||||
"unfixable_exit_code": 1, # markdownlint-cli2 returns 1 for unfixable issues
|
||||
},
|
||||
# Example: Future JavaScript/TypeScript support
|
||||
# "javascript": {
|
||||
# "extensions": [".js", ".jsx", ".ts", ".tsx"],
|
||||
# "command": ["eslint", "--fix"],
|
||||
# "check_installed": "eslint",
|
||||
# "success_message": "JavaScript formatted: {file_path}",
|
||||
# "unfixable_message": "ESLint found unfixable issues in {file_path}",
|
||||
# "unfixable_hint": "Run: eslint {file_path} to see details",
|
||||
# "unfixable_exit_code": 1,
|
||||
# },
|
||||
# Example: Future Python support
|
||||
# "python": {
|
||||
# "extensions": [".py"],
|
||||
# "command": ["ruff", "check", "--fix"],
|
||||
# "check_installed": "ruff",
|
||||
# "success_message": "Python formatted: {file_path}",
|
||||
# "unfixable_message": "Ruff found unfixable issues in {file_path}",
|
||||
# "unfixable_hint": "Run: ruff check {file_path} to see details",
|
||||
# "unfixable_exit_code": 1,
|
||||
# },
|
||||
}
|
||||
|
||||
|
||||
def read_stdin_json() -> Dict:
|
||||
"""Read and parse JSON from stdin (hook input)."""
|
||||
try:
|
||||
return json.load(sys.stdin)
|
||||
except json.JSONDecodeError:
|
||||
# Invalid JSON - exit silently (non-blocking)
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
def extract_file_path(hook_input: Dict) -> Optional[str]:
|
||||
"""Extract file_path from tool_input in hook JSON."""
|
||||
tool_input = hook_input.get("tool_input", {})
|
||||
file_path = tool_input.get("file_path")
|
||||
|
||||
# Handle both string and null/missing values
|
||||
if not file_path or not isinstance(file_path, str):
|
||||
return None
|
||||
|
||||
return file_path
|
||||
|
||||
|
||||
def get_linter_config(file_path: str) -> Optional[Tuple[str, Dict]]:
|
||||
"""
|
||||
Determine linter config based on file extension.
|
||||
|
||||
Returns:
|
||||
Tuple of (language_name, config_dict) or None if no match
|
||||
"""
|
||||
file_ext = Path(file_path).suffix.lower()
|
||||
|
||||
for language, config in LINTER_CONFIG.items():
|
||||
if file_ext in config["extensions"]:
|
||||
return (language, config)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def check_tool_installed(command_name: str) -> bool:
|
||||
"""Check if a command-line tool is installed and available."""
|
||||
try:
|
||||
subprocess.run(
|
||||
["command", "-v", command_name],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
shell=False
|
||||
)
|
||||
return True
|
||||
except subprocess.CalledProcessError:
|
||||
return False
|
||||
|
||||
|
||||
def run_linter(file_path: str, config: Dict) -> Tuple[int, str]:
|
||||
"""
|
||||
Run linter command on file.
|
||||
|
||||
Returns:
|
||||
Tuple of (exit_code, output)
|
||||
"""
|
||||
command = config["command"] + [file_path]
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
command,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60 # Safety timeout
|
||||
)
|
||||
|
||||
# Combine stdout and stderr for complete output
|
||||
output = result.stdout + result.stderr
|
||||
return (result.returncode, output.strip())
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
return (1, f"Linter timed out after 60 seconds")
|
||||
except Exception as e:
|
||||
return (1, f"Linter execution error: {str(e)}")
|
||||
|
||||
|
||||
def format_message(template: str, **kwargs) -> str:
|
||||
"""Format message template with provided variables."""
|
||||
return template.format(**kwargs)
|
||||
|
||||
|
||||
def output_json_response(system_message: Optional[str] = None, additional_context: Optional[str] = None, decision: Optional[str] = None, reason: Optional[str] = None):
|
||||
"""Output JSON response to stdout for Claude to process."""
|
||||
response = {}
|
||||
|
||||
if decision:
|
||||
response["decision"] = decision
|
||||
|
||||
if reason:
|
||||
response["reason"] = reason
|
||||
|
||||
if system_message:
|
||||
response["systemMessage"] = system_message
|
||||
|
||||
if additional_context:
|
||||
response["hookSpecificOutput"] = {
|
||||
"hookEventName": "PostToolUse",
|
||||
"additionalContext": additional_context
|
||||
}
|
||||
|
||||
print(json.dumps(response), flush=True)
|
||||
|
||||
|
||||
def main():
|
||||
"""Main hook execution logic."""
|
||||
# Debug: log that hook was triggered
|
||||
import datetime
|
||||
with open("/tmp/hook-test.log", "a") as f:
|
||||
f.write(f"{datetime.datetime.now()}: Hook triggered\n")
|
||||
|
||||
# Read hook input from stdin
|
||||
hook_input = read_stdin_json()
|
||||
|
||||
# Extract file path from tool_input
|
||||
file_path = extract_file_path(hook_input)
|
||||
|
||||
# Exit silently if no file path (not a file write/edit operation)
|
||||
if not file_path:
|
||||
sys.exit(0)
|
||||
|
||||
# Verify file exists
|
||||
if not Path(file_path).is_file():
|
||||
sys.exit(0)
|
||||
|
||||
# Get linter configuration for this file type
|
||||
linter_info = get_linter_config(file_path)
|
||||
|
||||
# Exit silently if no linter configured for this file type
|
||||
if not linter_info:
|
||||
sys.exit(0)
|
||||
|
||||
_language, config = linter_info
|
||||
|
||||
# Check if linter tool is installed
|
||||
if not check_tool_installed(config["check_installed"]):
|
||||
# Not installed - exit silently (non-blocking)
|
||||
# User can install the tool if desired
|
||||
sys.exit(0)
|
||||
|
||||
# Run the linter
|
||||
exit_code, output = run_linter(file_path, config)
|
||||
|
||||
# Handle linter results
|
||||
if exit_code == 0:
|
||||
# Success - file was clean or successfully fixed
|
||||
success_msg = format_message(
|
||||
config["success_message"],
|
||||
file_path=file_path
|
||||
)
|
||||
output_json_response(system_message=success_msg)
|
||||
sys.exit(0)
|
||||
|
||||
elif exit_code == config.get("unfixable_exit_code", 1):
|
||||
# Linter found issues that couldn't be auto-fixed
|
||||
unfixable_msg = format_message(
|
||||
config["unfixable_message"],
|
||||
file_path=file_path
|
||||
)
|
||||
|
||||
# Build complete context message
|
||||
context_parts = [unfixable_msg]
|
||||
if output:
|
||||
context_parts.append(output)
|
||||
if "unfixable_hint" in config:
|
||||
hint = format_message(
|
||||
config["unfixable_hint"],
|
||||
file_path=file_path
|
||||
)
|
||||
context_parts.append(hint)
|
||||
|
||||
full_context = "\n".join(context_parts)
|
||||
output_json_response(system_message=full_context)
|
||||
sys.exit(0)
|
||||
|
||||
else:
|
||||
# Unexpected exit code - report error
|
||||
error_msg = f"Linter error (exit code {exit_code}) in {file_path}:"
|
||||
|
||||
context_parts = [error_msg]
|
||||
if output:
|
||||
context_parts.append(output)
|
||||
|
||||
full_context = "\n".join(context_parts)
|
||||
output_json_response(system_message=full_context)
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user