hooks added
This commit is contained in:
@@ -0,0 +1,348 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Claude Code Hook Handler
|
||||
=============================================
|
||||
This script handles events from Claude Code and plays sounds for different hook events.
|
||||
Supports all 9 Claude Code hooks: https://docs.claude.com/en/docs/claude-code/hooks-guide
|
||||
|
||||
Special handling for git commits: plays pretooluse-git-committing.mp3
|
||||
"""
|
||||
|
||||
import sys
|
||||
import json
|
||||
import subprocess
|
||||
import re
|
||||
import platform
|
||||
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",
|
||||
"PostToolUse": "posttooluse",
|
||||
"UserPromptSubmit": "userpromptsubmit",
|
||||
"Notification": "notification",
|
||||
"Stop": "stop",
|
||||
"SubagentStop": "subagentstop",
|
||||
"PreCompact": "precompact",
|
||||
"SessionStart": "sessionstart",
|
||||
"SessionEnd": "sessionend"
|
||||
}
|
||||
|
||||
# ===== 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 log_hook_data(hook_data):
|
||||
"""
|
||||
Log the full hook_data to hooks-log.jsonl for debugging/auditing.
|
||||
Log file is stored at .claude/hooks/logs/hooks-log.jsonl
|
||||
"""
|
||||
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)
|
||||
|
||||
log_path = logs_dir / "hooks-log.jsonl"
|
||||
with open(log_path, "a", encoding="utf-8") as log_file:
|
||||
log_file.write(json.dumps(hook_data, 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):
|
||||
"""
|
||||
Determine which sound to play based on the hook event and context.
|
||||
|
||||
Args:
|
||||
hook_data: Dictionary containing event information from Claude
|
||||
|
||||
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", "")
|
||||
|
||||
# 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 main():
|
||||
"""
|
||||
Main program - this runs when Claude triggers a hook.
|
||||
|
||||
How it works:
|
||||
1. Claude sends event data as JSON through stdin
|
||||
2. We check if this specific hook is disabled in hooks-config.json
|
||||
3. We parse the JSON to understand which hook event occurred
|
||||
4. We check for special bash commands (like git commit)
|
||||
5. We play the corresponding sound for that event
|
||||
6. We exit successfully
|
||||
"""
|
||||
try:
|
||||
# Step 1: 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(input_data)
|
||||
|
||||
# Step 2: Check if this hook is disabled
|
||||
event_name = input_data.get("hook_event_name", "")
|
||||
if is_hook_disabled(event_name):
|
||||
# Hook is disabled, exit silently without playing sound
|
||||
sys.exit(0)
|
||||
|
||||
# Step 3: Determine which sound to play (may be special or default)
|
||||
sound_name = get_sound_name(input_data)
|
||||
|
||||
# Step 4: Play the sound (if we found one)
|
||||
if sound_name:
|
||||
play_sound(sound_name)
|
||||
|
||||
# Step 5: 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()
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
#!/bin/bash
|
||||
# Cross-platform Python wrapper for Claude Code hooks
|
||||
# Automatically selects python or python3 based on OS and availability
|
||||
|
||||
# Get the directory where this script is located
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PYTHON_SCRIPT="$SCRIPT_DIR/hooks.py"
|
||||
|
||||
# Function to detect and use the correct Python command
|
||||
run_python() {
|
||||
# Check if python3 is available (preferred on Unix-like systems)
|
||||
if command -v python3 &> /dev/null; then
|
||||
python3 "$PYTHON_SCRIPT"
|
||||
# Fallback to python command (Windows and some systems)
|
||||
elif command -v python &> /dev/null; then
|
||||
python "$PYTHON_SCRIPT"
|
||||
else
|
||||
echo "Error: Neither python nor python3 command found" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Run the Python script
|
||||
run_python
|
||||
Reference in New Issue
Block a user