142 lines
4.3 KiB
Python
Executable File
142 lines
4.3 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""CLI entry point for Claude Code hooks.
|
|
|
|
This script serves as the single command invoked by Claude Code for all hook
|
|
events (PreToolUse, PostToolUse, Stop). It reads JSON from stdin, routes to
|
|
the appropriate handler, and outputs the response.
|
|
|
|
Usage:
|
|
echo '{"tool_name": "Write", ...}' | python hooks/cli.py --event pre
|
|
echo '{"tool_name": "Bash", ...}' | python hooks/cli.py --event post
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import TypeGuard
|
|
|
|
from pydantic import BaseModel, ValidationError
|
|
|
|
# Try relative import first (when run as module), fall back to path manipulation
|
|
try:
|
|
from .facade import Guards
|
|
except ImportError:
|
|
# Add parent directory to path for imports (when run as script)
|
|
sys.path.insert(0, str(Path(__file__).parent))
|
|
from facade import Guards
|
|
|
|
|
|
class PayloadValidator(BaseModel):
|
|
"""Validates and normalizes JSON payload at boundary."""
|
|
|
|
tool_name: str = ""
|
|
tool_input: dict[str, object] = {}
|
|
tool_response: object = None
|
|
tool_output: object = None
|
|
content: str = ""
|
|
file_path: str = ""
|
|
|
|
class Config:
|
|
"""Pydantic config."""
|
|
|
|
extra = "ignore"
|
|
|
|
|
|
def _is_dict(value: object) -> TypeGuard[dict[str, object]]:
|
|
"""Type guard to narrow dict values."""
|
|
return isinstance(value, dict)
|
|
|
|
|
|
def _normalize_dict(data: object) -> dict[str, object]:
|
|
"""Normalize untyped dict to dict[str, object] using Pydantic validation.
|
|
|
|
This converts JSON-deserialized data (which has Unknown types) to a
|
|
strongly-typed dict using Pydantic at the boundary.
|
|
"""
|
|
try:
|
|
if not isinstance(data, dict):
|
|
return {}
|
|
validated = PayloadValidator.model_validate(data)
|
|
return validated.model_dump(exclude_none=True)
|
|
except ValidationError:
|
|
return {}
|
|
|
|
|
|
def main() -> None:
|
|
"""Main CLI entry point for hook processing."""
|
|
parser = argparse.ArgumentParser(description="Claude Code unified hook handler")
|
|
parser.add_argument(
|
|
"--event",
|
|
choices={"pre", "post", "stop"},
|
|
required=True,
|
|
help="Hook event type to handle",
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
try:
|
|
# Read hook payload from stdin
|
|
raw_input = sys.stdin.read()
|
|
if not raw_input.strip():
|
|
# Empty input - return default response
|
|
payload: dict[str, object] = {}
|
|
else:
|
|
try:
|
|
parsed = json.loads(raw_input)
|
|
payload = _normalize_dict(parsed)
|
|
except json.JSONDecodeError:
|
|
# Invalid JSON - return default response
|
|
payload = {}
|
|
|
|
# Initialize guards and route to appropriate handler
|
|
guards = Guards()
|
|
|
|
if args.event == "pre":
|
|
response = guards.handle_pretooluse(payload)
|
|
elif args.event == "post":
|
|
response = guards.handle_posttooluse(payload)
|
|
else: # stop
|
|
response = guards.handle_stop(payload)
|
|
|
|
# Output response as JSON
|
|
sys.stdout.write(json.dumps(response))
|
|
sys.stdout.write("\n")
|
|
sys.stdout.flush()
|
|
|
|
# Check if we should exit with error code
|
|
hook_output = response.get("hookSpecificOutput", {})
|
|
if _is_dict(hook_output):
|
|
permission = hook_output.get("permissionDecision")
|
|
if permission == "deny":
|
|
reason = hook_output.get(
|
|
"permissionDecisionReason", "Permission denied",
|
|
)
|
|
sys.stderr.write(str(reason))
|
|
sys.stderr.flush()
|
|
sys.exit(2)
|
|
|
|
if permission == "ask":
|
|
reason = hook_output.get(
|
|
"permissionDecisionReason", "Permission request",
|
|
)
|
|
sys.stderr.write(str(reason))
|
|
sys.stderr.flush()
|
|
sys.exit(2)
|
|
|
|
# Check for block decision
|
|
if response.get("decision") == "block":
|
|
reason = response.get("reason", "Validation failed")
|
|
sys.stderr.write(str(reason))
|
|
sys.stderr.flush()
|
|
sys.exit(2)
|
|
|
|
except (KeyError, ValueError, TypeError, OSError, RuntimeError) as exc:
|
|
# Unexpected error - log but don't block
|
|
sys.stderr.write(f"Hook error: {exc}\n")
|
|
sys.stderr.flush()
|
|
sys.exit(1)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|