ec967be1de
Co-Authored-By: Claude <noreply@anthropic.com>
481 lines
17 KiB
Python
481 lines
17 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Claude Code Hook Handler
|
|
=============================================
|
|
This script handles events from Claude Code and plays sounds for different hook events.
|
|
Supports all 27 Claude Code hooks: https://code.claude.com/docs/en/hooks
|
|
|
|
Special handling for git commits: plays pretooluse-git-committing.mp3
|
|
|
|
Agent Support:
|
|
Use --agent=<name> to play agent-specific sounds from agent_* folders.
|
|
Agent frontmatter hooks support 6 hooks: PreToolUse, PostToolUse, PermissionRequest, PostToolUseFailure, Stop, SubagentStop
|
|
"""
|
|
|
|
import sys
|
|
import json
|
|
import subprocess
|
|
import re
|
|
import platform
|
|
import argparse
|
|
from pathlib import Path
|
|
|
|
# Windows-only module for playing WAV files
|
|
try:
|
|
import winsound
|
|
except ImportError:
|
|
winsound = None
|
|
|
|
# ===== HOOK EVENT TO SOUND FOLDER MAPPING =====
|
|
# Maps each hook event to its corresponding sound folder
|
|
HOOK_SOUND_MAP = {
|
|
"PreToolUse": "pretooluse",
|
|
"PermissionRequest": "permissionrequest",
|
|
"PostToolUse": "posttooluse",
|
|
"PostToolUseFailure": "posttoolusefailure",
|
|
"UserPromptSubmit": "userpromptsubmit",
|
|
"Notification": "notification",
|
|
"Stop": "stop",
|
|
"SubagentStart": "subagentstart",
|
|
"SubagentStop": "subagentstop",
|
|
"PreCompact": "precompact",
|
|
"PostCompact": "postcompact",
|
|
"SessionStart": "sessionstart",
|
|
"SessionEnd": "sessionend",
|
|
"Setup": "setup",
|
|
"TeammateIdle": "teammateidle",
|
|
"TaskCreated": "taskcreated",
|
|
"TaskCompleted": "taskcompleted",
|
|
"ConfigChange": "configchange",
|
|
"WorktreeCreate": "worktreecreate",
|
|
"WorktreeRemove": "worktreeremove",
|
|
"InstructionsLoaded": "instructionsloaded",
|
|
"Elicitation": "elicitation",
|
|
"ElicitationResult": "elicitationresult",
|
|
"StopFailure": "stopfailure",
|
|
"CwdChanged": "cwdchanged",
|
|
"FileChanged": "filechanged",
|
|
"PermissionDenied": "permissiondenied"
|
|
}
|
|
|
|
# ===== AGENT HOOK EVENT TO SOUND FOLDER MAPPING =====
|
|
# Maps agent hook events to agent-specific sound folders
|
|
# Only the 6 hooks that actually fire in agent contexts are mapped
|
|
AGENT_HOOK_SOUND_MAP = {
|
|
"PreToolUse": "agent_pretooluse",
|
|
"PostToolUse": "agent_posttooluse",
|
|
"PermissionRequest": "agent_permissionrequest",
|
|
"PostToolUseFailure": "agent_posttoolusefailure",
|
|
"Stop": "agent_stop",
|
|
"SubagentStop": "agent_subagentstop"
|
|
}
|
|
|
|
# ===== BASH COMMAND PATTERNS =====
|
|
# Regex patterns to detect specific bash commands and map to special sounds
|
|
BASH_PATTERNS = [
|
|
(r'git commit', "pretooluse-git-committing"), # Git commits (anywhere in command)
|
|
]
|
|
|
|
def get_audio_player():
|
|
"""
|
|
Detect the appropriate audio player for the current platform.
|
|
|
|
Returns:
|
|
List of command and args to use for playing audio, or None if no player found
|
|
"""
|
|
system = platform.system()
|
|
|
|
if system == "Darwin":
|
|
# macOS: use afplay (built-in)
|
|
return ["afplay"]
|
|
elif system == "Linux":
|
|
# Linux: try different players in order of preference
|
|
# Try to find an available player
|
|
players = [
|
|
["paplay"], # PulseAudio (most common on modern Linux)
|
|
["aplay"], # ALSA (fallback)
|
|
["ffplay", "-nodisp", "-autoexit"], # FFmpeg (if installed)
|
|
["mpg123", "-q"], # mpg123 (if installed)
|
|
]
|
|
|
|
for player in players:
|
|
try:
|
|
# Check if the player exists
|
|
subprocess.run(
|
|
["which", player[0]],
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
check=True
|
|
)
|
|
return player
|
|
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
continue
|
|
|
|
# No player found
|
|
return None
|
|
elif system == "Windows":
|
|
# Windows: Use winsound for WAV, PowerShell for MP3
|
|
return ["WINDOWS"]
|
|
else:
|
|
# Other OS - not supported yet
|
|
return None
|
|
|
|
|
|
def play_sound(sound_name):
|
|
"""
|
|
Play a sound file for the given sound name.
|
|
|
|
Args:
|
|
sound_name: Name of the sound file (e.g., "pretooluse", "pretooluse-git-committing")
|
|
The file should be at .claude/hooks/sounds/{folder}/{sound_name}.{mp3|wav}
|
|
|
|
Returns:
|
|
True if sound played successfully, False otherwise
|
|
"""
|
|
# Security check: Prevent directory traversal attacks
|
|
if "/" in sound_name or "\\" in sound_name or ".." in sound_name:
|
|
print(f"Invalid sound name: {sound_name}", file=sys.stderr)
|
|
return False
|
|
|
|
# Get the appropriate audio player for this platform
|
|
audio_player = get_audio_player()
|
|
if not audio_player:
|
|
# No audio player available - fail silently
|
|
return False
|
|
|
|
# Build the path to the sound folder
|
|
# Scripts are in .claude/hooks/scripts/, sounds are in .claude/hooks/sounds/
|
|
script_dir = Path(__file__).parent # .claude/hooks/scripts/
|
|
hooks_dir = script_dir.parent # .claude/hooks/
|
|
|
|
# Determine the folder based on the sound name prefix
|
|
# For special sounds like "pretooluse-git-committing", look in "pretooluse" folder
|
|
folder_name = sound_name.split('-')[0]
|
|
sounds_dir = hooks_dir / "sounds" / folder_name
|
|
|
|
# Check if we're on Windows and need special handling
|
|
is_windows = audio_player[0] == "WINDOWS"
|
|
|
|
# Try different audio formats
|
|
# Note: paplay (PulseAudio) doesn't support MP3, so try WAV first
|
|
# On Windows, only use WAV files to avoid PowerShell/COM issues
|
|
extensions = ['.wav'] if is_windows else ['.wav', '.mp3']
|
|
|
|
for extension in extensions:
|
|
file_path = sounds_dir / f"{sound_name}{extension}"
|
|
|
|
if file_path.exists():
|
|
try:
|
|
if is_windows:
|
|
# Windows: Use winsound for WAV files (built-in, reliable, fast)
|
|
if winsound:
|
|
# SND_FILENAME: file_path is a filename
|
|
# SND_SYNC: play sound synchronously (wait until complete)
|
|
# SND_NODEFAULT: don't play default sound if file not found
|
|
# Note: Using SND_SYNC instead of SND_ASYNC because the script exits immediately
|
|
# after this call, which would terminate async playback before it completes
|
|
winsound.PlaySound(str(file_path),
|
|
winsound.SND_FILENAME | winsound.SND_NODEFAULT)
|
|
return True
|
|
else:
|
|
# winsound not available, fail silently
|
|
return False
|
|
else:
|
|
# Unix/Linux/macOS: use subprocess with audio player
|
|
subprocess.Popen(
|
|
audio_player + [str(file_path)],
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
start_new_session=True
|
|
)
|
|
return True
|
|
except (FileNotFoundError, OSError) as e:
|
|
print(f"Error playing sound {file_path.name}: {e}", file=sys.stderr)
|
|
return False
|
|
except Exception as e:
|
|
# Catch any other exceptions (e.g., winsound errors)
|
|
print(f"Error playing sound {file_path.name}: {e}", file=sys.stderr)
|
|
return False
|
|
|
|
# Sound not found - fail silently to avoid disrupting Claude's work
|
|
return False
|
|
|
|
def is_hook_disabled(event_name):
|
|
"""
|
|
Check if a specific hook is disabled in the config files.
|
|
Uses fallback logic: hooks-config.local.json -> hooks-config.json
|
|
|
|
Priority:
|
|
1. If hooks-config.local.json exists and has the setting, use it
|
|
2. Otherwise, fall back to hooks-config.json
|
|
3. If neither exists or the key is missing, assume hook is enabled (return False)
|
|
|
|
Args:
|
|
event_name: The hook event name (e.g., "PreToolUse", "PostToolUse")
|
|
|
|
Returns:
|
|
True if the hook is disabled, False otherwise
|
|
"""
|
|
try:
|
|
# Scripts are in .claude/hooks/scripts/, config is in .claude/hooks/config/
|
|
script_dir = Path(__file__).parent # .claude/hooks/scripts/
|
|
hooks_dir = script_dir.parent # .claude/hooks/
|
|
config_dir = hooks_dir / "config" # .claude/hooks/config/
|
|
|
|
local_config_path = config_dir / "hooks-config.local.json"
|
|
default_config_path = config_dir / "hooks-config.json"
|
|
|
|
# Map event names to config keys
|
|
config_key = f"disable{event_name}Hook"
|
|
|
|
# Try to load local config first
|
|
local_config = None
|
|
if local_config_path.exists():
|
|
try:
|
|
with open(local_config_path, "r", encoding="utf-8") as config_file:
|
|
local_config = json.load(config_file)
|
|
except Exception as e:
|
|
print(f"Error reading local config: {e}", file=sys.stderr)
|
|
|
|
# Try to load default config
|
|
default_config = None
|
|
if default_config_path.exists():
|
|
try:
|
|
with open(default_config_path, "r", encoding="utf-8") as config_file:
|
|
default_config = json.load(config_file)
|
|
except Exception as e:
|
|
print(f"Error reading default config: {e}", file=sys.stderr)
|
|
|
|
# Apply fallback logic: local -> default -> False (enabled)
|
|
if local_config is not None and config_key in local_config:
|
|
return local_config[config_key]
|
|
elif default_config is not None and config_key in default_config:
|
|
return default_config[config_key]
|
|
else:
|
|
# If neither config has the key, assume hook is enabled
|
|
return False
|
|
|
|
except Exception as e:
|
|
# If anything goes wrong, assume hook is enabled
|
|
print(f"Error in is_hook_disabled: {e}", file=sys.stderr)
|
|
return False
|
|
|
|
def is_logging_disabled():
|
|
"""
|
|
Check if logging is disabled in the config files.
|
|
Uses fallback logic: hooks-config.local.json -> hooks-config.json
|
|
|
|
Returns:
|
|
True if logging is disabled, False otherwise
|
|
"""
|
|
try:
|
|
# Scripts are in .claude/hooks/scripts/, config is in .claude/hooks/config/
|
|
script_dir = Path(__file__).parent # .claude/hooks/scripts/
|
|
hooks_dir = script_dir.parent # .claude/hooks/
|
|
config_dir = hooks_dir / "config" # .claude/hooks/config/
|
|
|
|
local_config_path = config_dir / "hooks-config.local.json"
|
|
default_config_path = config_dir / "hooks-config.json"
|
|
|
|
# Try to load local config first
|
|
local_config = None
|
|
if local_config_path.exists():
|
|
try:
|
|
with open(local_config_path, "r", encoding="utf-8") as config_file:
|
|
local_config = json.load(config_file)
|
|
except Exception as e:
|
|
print(f"Error reading local config: {e}", file=sys.stderr)
|
|
|
|
# Try to load default config
|
|
default_config = None
|
|
if default_config_path.exists():
|
|
try:
|
|
with open(default_config_path, "r", encoding="utf-8") as config_file:
|
|
default_config = json.load(config_file)
|
|
except Exception as e:
|
|
print(f"Error reading default config: {e}", file=sys.stderr)
|
|
|
|
# Apply fallback logic: local -> default -> False (logging enabled)
|
|
if local_config is not None and "disableLogging" in local_config:
|
|
return local_config["disableLogging"]
|
|
elif default_config is not None and "disableLogging" in default_config:
|
|
return default_config["disableLogging"]
|
|
else:
|
|
# If neither config has the key, assume logging is enabled
|
|
return False
|
|
|
|
except Exception as e:
|
|
# If anything goes wrong, assume logging is enabled
|
|
print(f"Error in is_logging_disabled: {e}", file=sys.stderr)
|
|
return False
|
|
|
|
def log_hook_data(hook_data, agent_name=None):
|
|
"""
|
|
Log the full hook_data to hooks-log.jsonl for debugging/auditing.
|
|
Log file is stored at .claude/hooks/logs/hooks-log.jsonl
|
|
|
|
Args:
|
|
hook_data: Dictionary containing event information from Claude
|
|
agent_name: Optional agent name if hook was invoked from a sub-agent
|
|
"""
|
|
# Check if logging is disabled
|
|
if is_logging_disabled():
|
|
return
|
|
|
|
try:
|
|
# Scripts are in .claude/hooks/scripts/, logs are in .claude/hooks/logs/
|
|
script_dir = Path(__file__).parent # .claude/hooks/scripts/
|
|
hooks_dir = script_dir.parent # .claude/hooks/
|
|
logs_dir = hooks_dir / "logs" # .claude/hooks/logs/
|
|
|
|
# Ensure logs directory exists
|
|
logs_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Add source field to indicate if hook was called from main session or sub-agent
|
|
log_entry = hook_data.copy()
|
|
|
|
# Remove fields we don't need in logs
|
|
log_entry.pop("transcript_path", None)
|
|
log_entry.pop("cwd", None)
|
|
|
|
# Only add agent name if hook was invoked from a sub-agent
|
|
if agent_name:
|
|
log_entry["invoked_by_agent"] = agent_name
|
|
|
|
log_path = logs_dir / "hooks-log.jsonl"
|
|
with open(log_path, "a", encoding="utf-8") as log_file:
|
|
log_file.write(json.dumps(log_entry, ensure_ascii=False, indent=2) + "\n")
|
|
except Exception as e:
|
|
# Fail silently, but print to stderr for visibility
|
|
print(f"Failed to log hook_data: {e}", file=sys.stderr)
|
|
|
|
|
|
def detect_bash_command_sound(command):
|
|
"""
|
|
Detect special bash commands and return the corresponding sound name.
|
|
|
|
Args:
|
|
command: The bash command string
|
|
|
|
Returns:
|
|
Sound name (string) if a pattern matches, None otherwise
|
|
"""
|
|
if not command:
|
|
return None
|
|
|
|
for pattern, sound_name in BASH_PATTERNS:
|
|
if re.search(pattern, command.strip()):
|
|
return sound_name
|
|
|
|
return None
|
|
|
|
|
|
def get_sound_name(hook_data, agent_name=None):
|
|
"""
|
|
Determine which sound to play based on the hook event and context.
|
|
|
|
Args:
|
|
hook_data: Dictionary containing event information from Claude
|
|
agent_name: Optional agent name for agent-specific sounds
|
|
|
|
Returns:
|
|
Sound name (string) or None if no sound should play
|
|
"""
|
|
event_name = hook_data.get("hook_event_name", "")
|
|
tool_name = hook_data.get("tool_name", "")
|
|
|
|
# If this is an agent hook, use agent-specific sounds
|
|
if agent_name:
|
|
return AGENT_HOOK_SOUND_MAP.get(event_name)
|
|
|
|
# Check if this is a PreToolUse event with Bash tool
|
|
if event_name == "PreToolUse" and tool_name == "Bash":
|
|
tool_input = hook_data.get("tool_input", {})
|
|
command = tool_input.get("command", "")
|
|
|
|
# Check for special bash command patterns (e.g., git commit)
|
|
special_sound = detect_bash_command_sound(command)
|
|
if special_sound:
|
|
return special_sound
|
|
|
|
# Return the default sound for this hook event
|
|
return HOOK_SOUND_MAP.get(event_name)
|
|
|
|
def parse_arguments():
|
|
"""
|
|
Parse command line arguments.
|
|
|
|
Returns:
|
|
Parsed arguments namespace
|
|
"""
|
|
parser = argparse.ArgumentParser(
|
|
description="Claude Code Hook Handler - plays sounds for hook events"
|
|
)
|
|
parser.add_argument(
|
|
"--agent",
|
|
type=str,
|
|
default=None,
|
|
help="Agent name for agent-specific sounds (used by agent frontmatter hooks)"
|
|
)
|
|
return parser.parse_args()
|
|
|
|
|
|
def main():
|
|
"""
|
|
Main program - this runs when Claude triggers a hook.
|
|
|
|
How it works:
|
|
1. Parse command line arguments (--agent for agent-specific sounds)
|
|
2. Claude sends event data as JSON through stdin
|
|
3. We check if this specific hook is disabled in hooks-config.json
|
|
4. We parse the JSON to understand which hook event occurred
|
|
5. We check for special bash commands (like git commit)
|
|
6. We play the corresponding sound for that event
|
|
7. We exit successfully
|
|
"""
|
|
try:
|
|
# Step 1: Parse command line arguments
|
|
args = parse_arguments()
|
|
|
|
# Step 2: Read the event data from Claude
|
|
stdin_content = sys.stdin.read().strip()
|
|
|
|
# If stdin is empty, exit gracefully (hook was called without data)
|
|
if not stdin_content:
|
|
sys.exit(0)
|
|
|
|
input_data = json.loads(stdin_content)
|
|
|
|
# Log hook data with source information (main session vs sub-agent)
|
|
log_hook_data(input_data, agent_name=args.agent)
|
|
|
|
# Step 3: Check if this hook is disabled (skip for agent hooks)
|
|
event_name = input_data.get("hook_event_name", "")
|
|
if not args.agent and is_hook_disabled(event_name):
|
|
# Hook is disabled, exit silently without playing sound
|
|
sys.exit(0)
|
|
|
|
# Step 4: Determine which sound to play (may be special, default, or agent-specific)
|
|
sound_name = get_sound_name(input_data, agent_name=args.agent)
|
|
|
|
# Step 5: Play the sound (if we found one)
|
|
if sound_name:
|
|
play_sound(sound_name)
|
|
|
|
# Step 6: Exit successfully
|
|
# Always exit with code 0 so we don't interrupt Claude's work
|
|
sys.exit(0)
|
|
|
|
except json.JSONDecodeError as e:
|
|
print(f"Error parsing JSON input: {e}", file=sys.stderr)
|
|
# Exit with 0 to avoid blocking Claude's work
|
|
sys.exit(0)
|
|
except Exception as e:
|
|
print(f"Unexpected error: {e}", file=sys.stderr)
|
|
# Exit with 0 to avoid blocking Claude's work
|
|
sys.exit(0)
|
|
|
|
|
|
# Entry point - Python calls main() when the script runs
|
|
if __name__ == "__main__":
|
|
main() |