claude-code-voice-hooks added

This commit is contained in:
Shayan Rais
2026-02-28 15:05:00 +05:00
parent 027009b766
commit 8cfb5b66f5
37 changed files with 652 additions and 93 deletions
+140 -23
View File
@@ -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