Files
claude-scripts/hooks/cli.py

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()