claude-code-voice-hooks added
This commit is contained in:
+140
-23
@@ -3,9 +3,13 @@
|
||||
Claude Code Hook Handler
|
||||
=============================================
|
||||
This script handles events from Claude Code and plays sounds for different hook events.
|
||||
Supports all 16 Claude Code hook event names: https://docs.claude.com/en/docs/claude-code/hooks-guide
|
||||
Supports all 18 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
|
||||
@@ -13,6 +17,7 @@ import json
|
||||
import subprocess
|
||||
import re
|
||||
import platform
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
# Windows-only module for playing WAV files
|
||||
@@ -24,22 +29,36 @@ except ImportError:
|
||||
# ===== HOOK EVENT TO SOUND FOLDER MAPPING =====
|
||||
# Maps each hook event to its corresponding sound folder
|
||||
HOOK_SOUND_MAP = {
|
||||
"SessionStart": "sessionstart",
|
||||
"SessionEnd": "sessionend",
|
||||
"UserPromptSubmit": "userpromptsubmit",
|
||||
"PreToolUse": "pretooluse",
|
||||
"PermissionRequest": "permissionrequest",
|
||||
"PostToolUse": "posttooluse",
|
||||
"PostToolUseFailure": "posttoolusefailure",
|
||||
"PermissionRequest": "permissionrequest",
|
||||
"UserPromptSubmit": "userpromptsubmit",
|
||||
"Notification": "notification",
|
||||
"Stop": "stop",
|
||||
"SubagentStart": "subagentstart",
|
||||
"SubagentStop": "subagentstop",
|
||||
"PreCompact": "precompact",
|
||||
"SessionStart": "sessionstart",
|
||||
"SessionEnd": "sessionend",
|
||||
"Setup": "setup",
|
||||
"TeammateIdle": "teammateidle",
|
||||
"TaskCompleted": "taskcompleted",
|
||||
"ConfigChange": "configchange",
|
||||
"WorktreeCreate": "worktreecreate",
|
||||
"WorktreeRemove": "worktreeremove"
|
||||
}
|
||||
|
||||
# ===== 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 =====
|
||||
@@ -232,11 +251,68 @@ def is_hook_disabled(event_name):
|
||||
print(f"Error in is_hook_disabled: {e}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
def log_hook_data(hook_data):
|
||||
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/
|
||||
@@ -246,9 +322,20 @@ def log_hook_data(hook_data):
|
||||
# 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(hook_data, ensure_ascii=False, indent=2) + "\n")
|
||||
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)
|
||||
@@ -274,12 +361,13 @@ def detect_bash_command_sound(command):
|
||||
return None
|
||||
|
||||
|
||||
def get_sound_name(hook_data):
|
||||
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
|
||||
@@ -287,6 +375,10 @@ def get_sound_name(hook_data):
|
||||
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", {})
|
||||
@@ -300,20 +392,43 @@ def get_sound_name(hook_data):
|
||||
# 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. 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
|
||||
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: Read the event data from Claude
|
||||
# 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)
|
||||
@@ -321,22 +436,24 @@ def main():
|
||||
sys.exit(0)
|
||||
|
||||
input_data = json.loads(stdin_content)
|
||||
log_hook_data(input_data)
|
||||
|
||||
# Step 2: Check if this hook is disabled
|
||||
# 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 is_hook_disabled(event_name):
|
||||
if not args.agent and 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: Determine which sound to play (may be special, default, or agent-specific)
|
||||
sound_name = get_sound_name(input_data, agent_name=args.agent)
|
||||
|
||||
# Step 4: Play the sound (if we found one)
|
||||
# Step 5: Play the sound (if we found one)
|
||||
if sound_name:
|
||||
play_sound(sound_name)
|
||||
|
||||
# Step 5: Exit successfully
|
||||
# Step 6: Exit successfully
|
||||
# Always exit with code 0 so we don't interrupt Claude's work
|
||||
sys.exit(0)
|
||||
|
||||
@@ -352,4 +469,4 @@ def main():
|
||||
|
||||
# Entry point - Python calls main() when the script runs
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
main()
|
||||
@@ -1,24 +0,0 @@
|
||||
#!/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