forge-cw 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/console/assets/addon-fit-BlxrFPDK.js +1 -0
- package/console/assets/addon-web-links-BWjmmSgS.js +12 -0
- package/console/assets/index-Digctq9W.css +32 -0
- package/console/assets/index-ItVUTuQf.js +31 -0
- package/console/assets/xterm-DlVFs1Kw.js +9 -0
- package/console/index.html +13 -0
- package/cw/.bundle.json +4 -0
- package/cw/agents/presentation-curator.md +119 -0
- package/cw/agents/weather-agent.md +67 -0
- package/cw/cw +6188 -0
- package/cw/cw-shell-integration.sh +132 -0
- package/cw/hooks/HOOKS-README.md +471 -0
- package/cw/hooks/config/hooks-config.json +24 -0
- package/cw/hooks/scripts/auto-compact.py +133 -0
- package/cw/hooks/scripts/hooks.py +472 -0
- package/cw/hooks/scripts/review-autoclose.py +171 -0
- package/cw/hooks/sounds/agent_permissionrequest/agent_permissionrequest.mp3 +0 -0
- package/cw/hooks/sounds/agent_permissionrequest/agent_permissionrequest.wav +0 -0
- package/cw/hooks/sounds/agent_posttooluse/agent_posttooluse.mp3 +0 -0
- package/cw/hooks/sounds/agent_posttooluse/agent_posttooluse.wav +0 -0
- package/cw/hooks/sounds/agent_posttoolusefailure/agent_posttoolusefailure.mp3 +0 -0
- package/cw/hooks/sounds/agent_posttoolusefailure/agent_posttoolusefailure.wav +0 -0
- package/cw/hooks/sounds/agent_pretooluse/agent_pretooluse.mp3 +0 -0
- package/cw/hooks/sounds/agent_pretooluse/agent_pretooluse.wav +0 -0
- package/cw/hooks/sounds/agent_stop/agent_stop.mp3 +0 -0
- package/cw/hooks/sounds/agent_stop/agent_stop.wav +0 -0
- package/cw/hooks/sounds/agent_subagentstop/agent_subagentstop.mp3 +0 -0
- package/cw/hooks/sounds/agent_subagentstop/agent_subagentstop.wav +0 -0
- package/cw/hooks/sounds/configchange/configchange.mp3 +0 -0
- package/cw/hooks/sounds/configchange/configchange.wav +0 -0
- package/cw/hooks/sounds/notification/notification.mp3 +0 -0
- package/cw/hooks/sounds/notification/notification.wav +0 -0
- package/cw/hooks/sounds/permissionrequest/permissionrequest.mp3 +0 -0
- package/cw/hooks/sounds/permissionrequest/permissionrequest.wav +0 -0
- package/cw/hooks/sounds/posttooluse/posttooluse.mp3 +0 -0
- package/cw/hooks/sounds/posttooluse/posttooluse.wav +0 -0
- package/cw/hooks/sounds/posttoolusefailure/posttoolusefailure.mp3 +0 -0
- package/cw/hooks/sounds/posttoolusefailure/posttoolusefailure.wav +0 -0
- package/cw/hooks/sounds/precompact/precompact.mp3 +0 -0
- package/cw/hooks/sounds/precompact/precompact.wav +0 -0
- package/cw/hooks/sounds/pretooluse/pretooluse-git-committing.mp3 +0 -0
- package/cw/hooks/sounds/pretooluse/pretooluse-git-committing.wav +0 -0
- package/cw/hooks/sounds/pretooluse/pretooluse.mp3 +0 -0
- package/cw/hooks/sounds/pretooluse/pretooluse.wav +0 -0
- package/cw/hooks/sounds/sessionend/sessionend.mp3 +0 -0
- package/cw/hooks/sounds/sessionend/sessionend.wav +0 -0
- package/cw/hooks/sounds/sessionstart/sessionstart.mp3 +0 -0
- package/cw/hooks/sounds/sessionstart/sessionstart.wav +0 -0
- package/cw/hooks/sounds/setup/Setup.mp3 +0 -0
- package/cw/hooks/sounds/setup/Setup.wav +0 -0
- package/cw/hooks/sounds/stop/stop.mp3 +0 -0
- package/cw/hooks/sounds/stop/stop.wav +0 -0
- package/cw/hooks/sounds/subagentstart/subagentstart.mp3 +0 -0
- package/cw/hooks/sounds/subagentstart/subagentstart.wav +0 -0
- package/cw/hooks/sounds/subagentstop/subagentstop.mp3 +0 -0
- package/cw/hooks/sounds/subagentstop/subagentstop.wav +0 -0
- package/cw/hooks/sounds/taskcompleted/taskcompleted.mp3 +0 -0
- package/cw/hooks/sounds/taskcompleted/taskcompleted.wav +0 -0
- package/cw/hooks/sounds/teammateidle/teammateidle.mp3 +0 -0
- package/cw/hooks/sounds/teammateidle/teammateidle.wav +0 -0
- package/cw/hooks/sounds/userpromptsubmit/userpromptsubmit.mp3 +0 -0
- package/cw/hooks/sounds/userpromptsubmit/userpromptsubmit.wav +0 -0
- package/cw/hooks/sounds/worktreecreate/worktreecreate.mp3 +0 -0
- package/cw/hooks/sounds/worktreecreate/worktreecreate.wav +0 -0
- package/cw/hooks/sounds/worktreeremove/worktreeremove.mp3 +0 -0
- package/cw/hooks/sounds/worktreeremove/worktreeremove.wav +0 -0
- package/cw/install.sh +243 -0
- package/cw/lib/context/github.sh +51 -0
- package/cw/lib/context/linear.sh +49 -0
- package/cw/lib/context/notion.sh +38 -0
- package/cw/lib/dashboard/activity-hook.py +134 -0
- package/cw/lib/dashboard/index.html +1515 -0
- package/cw/lib/dashboard/server.py +379 -0
- package/cw/lib/dashboard/start.sh +15 -0
- package/cw/lib/harnesses/claude.sh +92 -0
- package/cw/lib/harnesses/codex.sh +245 -0
- package/cw/lib/harnesses/opencode.sh +120 -0
- package/cw/lib/harnesses/pi.sh +66 -0
- package/cw/lib/iterm2.sh +140 -0
- package/cw/templates/CLAUDE.template.md +48 -0
- package/dist/index.js +4376 -0
- package/package.json +51 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
CW Auto-Compact Hook (PostToolUse)
|
|
4
|
+
===================================
|
|
5
|
+
Counts tool calls per session. After a threshold, injects additionalContext
|
|
6
|
+
telling Claude to run /compact proactively before the system auto-compacts.
|
|
7
|
+
|
|
8
|
+
Counter files stored in ~/.cw/compact-counters/<session_hash>.json
|
|
9
|
+
Configurable via hooks-config.json: "disableAutoCompactHook": true/false
|
|
10
|
+
"autoCompactThreshold": N (default 100)
|
|
11
|
+
"autoCompactCooldown": N (default 60)
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import sys
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import hashlib
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
DEFAULT_THRESHOLD = 100 # Tool calls before first compact suggestion
|
|
22
|
+
DEFAULT_COOLDOWN = 60 # Tool calls between subsequent suggestions
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _read_config():
|
|
26
|
+
"""Read auto-compact settings from hooks-config files."""
|
|
27
|
+
script_dir = Path(__file__).parent
|
|
28
|
+
config_dir = script_dir.parent / "config"
|
|
29
|
+
local_path = config_dir / "hooks-config.local.json"
|
|
30
|
+
default_path = config_dir / "hooks-config.json"
|
|
31
|
+
|
|
32
|
+
local_cfg = {}
|
|
33
|
+
default_cfg = {}
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
if local_path.exists():
|
|
37
|
+
with open(local_path) as f:
|
|
38
|
+
local_cfg = json.load(f)
|
|
39
|
+
except Exception:
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
if default_path.exists():
|
|
44
|
+
with open(default_path) as f:
|
|
45
|
+
default_cfg = json.load(f)
|
|
46
|
+
except Exception:
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
def _get(key, fallback):
|
|
50
|
+
if key in local_cfg:
|
|
51
|
+
return local_cfg[key]
|
|
52
|
+
if key in default_cfg:
|
|
53
|
+
return default_cfg[key]
|
|
54
|
+
return fallback
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
"disabled": _get("disableAutoCompactHook", False),
|
|
58
|
+
"threshold": _get("autoCompactThreshold", DEFAULT_THRESHOLD),
|
|
59
|
+
"cooldown": _get("autoCompactCooldown", DEFAULT_COOLDOWN),
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def main():
|
|
64
|
+
try:
|
|
65
|
+
stdin_content = sys.stdin.read().strip()
|
|
66
|
+
if not stdin_content:
|
|
67
|
+
sys.exit(0)
|
|
68
|
+
|
|
69
|
+
hook_data = json.loads(stdin_content)
|
|
70
|
+
session_id = hook_data.get("session_id", "")
|
|
71
|
+
if not session_id:
|
|
72
|
+
sys.exit(0)
|
|
73
|
+
|
|
74
|
+
cfg = _read_config()
|
|
75
|
+
if cfg["disabled"]:
|
|
76
|
+
sys.exit(0)
|
|
77
|
+
|
|
78
|
+
# Counter directory
|
|
79
|
+
cw_home = os.environ.get("CW_HOME", os.path.expanduser("~/.cw"))
|
|
80
|
+
counter_dir = os.path.join(cw_home, "compact-counters")
|
|
81
|
+
os.makedirs(counter_dir, exist_ok=True)
|
|
82
|
+
|
|
83
|
+
# Hash session_id for filename safety
|
|
84
|
+
session_hash = hashlib.sha256(session_id.encode()).hexdigest()[:16]
|
|
85
|
+
counter_file = os.path.join(counter_dir, f"{session_hash}.json")
|
|
86
|
+
|
|
87
|
+
# Read current state
|
|
88
|
+
count = 0
|
|
89
|
+
last_compact_at = 0
|
|
90
|
+
try:
|
|
91
|
+
with open(counter_file) as f:
|
|
92
|
+
data = json.load(f)
|
|
93
|
+
count = data.get("count", 0)
|
|
94
|
+
last_compact_at = data.get("last_compact_at", 0)
|
|
95
|
+
except (FileNotFoundError, json.JSONDecodeError):
|
|
96
|
+
pass
|
|
97
|
+
|
|
98
|
+
count += 1
|
|
99
|
+
|
|
100
|
+
# Decide whether to suggest compact
|
|
101
|
+
suggest = False
|
|
102
|
+
if last_compact_at == 0 and count >= cfg["threshold"]:
|
|
103
|
+
suggest = True
|
|
104
|
+
elif last_compact_at > 0 and (count - last_compact_at) >= cfg["cooldown"]:
|
|
105
|
+
suggest = True
|
|
106
|
+
|
|
107
|
+
# Save updated counter
|
|
108
|
+
save_data = {"count": count, "last_compact_at": last_compact_at}
|
|
109
|
+
if suggest:
|
|
110
|
+
save_data["last_compact_at"] = count
|
|
111
|
+
with open(counter_file, "w") as f:
|
|
112
|
+
json.dump(save_data, f)
|
|
113
|
+
|
|
114
|
+
# Inject context for Claude
|
|
115
|
+
if suggest:
|
|
116
|
+
result = {
|
|
117
|
+
"additionalContext": (
|
|
118
|
+
f"[CW Auto-Compact] This session has reached {count} tool calls. "
|
|
119
|
+
"You MUST run /compact now to preserve context quality. "
|
|
120
|
+
"Tell the user you're compacting proactively, then run /compact."
|
|
121
|
+
)
|
|
122
|
+
}
|
|
123
|
+
print(json.dumps(result))
|
|
124
|
+
|
|
125
|
+
sys.exit(0)
|
|
126
|
+
|
|
127
|
+
except Exception:
|
|
128
|
+
# Never block Claude's work
|
|
129
|
+
sys.exit(0)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
if __name__ == "__main__":
|
|
133
|
+
main()
|
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Claude Code Hook Handler
|
|
4
|
+
=============================================
|
|
5
|
+
This script handles events from Claude Code and plays sounds for different hook events.
|
|
6
|
+
Supports all 18 Claude Code hooks: https://code.claude.com/docs/en/hooks
|
|
7
|
+
|
|
8
|
+
Special handling for git commits: plays pretooluse-git-committing.mp3
|
|
9
|
+
|
|
10
|
+
Agent Support:
|
|
11
|
+
Use --agent=<name> to play agent-specific sounds from agent_* folders.
|
|
12
|
+
Agent frontmatter hooks support 6 hooks: PreToolUse, PostToolUse, PermissionRequest, PostToolUseFailure, Stop, SubagentStop
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import sys
|
|
16
|
+
import json
|
|
17
|
+
import subprocess
|
|
18
|
+
import re
|
|
19
|
+
import platform
|
|
20
|
+
import argparse
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
# Windows-only module for playing WAV files
|
|
24
|
+
try:
|
|
25
|
+
import winsound
|
|
26
|
+
except ImportError:
|
|
27
|
+
winsound = None
|
|
28
|
+
|
|
29
|
+
# ===== HOOK EVENT TO SOUND FOLDER MAPPING =====
|
|
30
|
+
# Maps each hook event to its corresponding sound folder
|
|
31
|
+
HOOK_SOUND_MAP = {
|
|
32
|
+
"PreToolUse": "pretooluse",
|
|
33
|
+
"PermissionRequest": "permissionrequest",
|
|
34
|
+
"PostToolUse": "posttooluse",
|
|
35
|
+
"PostToolUseFailure": "posttoolusefailure",
|
|
36
|
+
"UserPromptSubmit": "userpromptsubmit",
|
|
37
|
+
"Notification": "notification",
|
|
38
|
+
"Stop": "stop",
|
|
39
|
+
"SubagentStart": "subagentstart",
|
|
40
|
+
"SubagentStop": "subagentstop",
|
|
41
|
+
"PreCompact": "precompact",
|
|
42
|
+
"SessionStart": "sessionstart",
|
|
43
|
+
"SessionEnd": "sessionend",
|
|
44
|
+
"Setup": "setup",
|
|
45
|
+
"TeammateIdle": "teammateidle",
|
|
46
|
+
"TaskCompleted": "taskcompleted",
|
|
47
|
+
"ConfigChange": "configchange",
|
|
48
|
+
"WorktreeCreate": "worktreecreate",
|
|
49
|
+
"WorktreeRemove": "worktreeremove"
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
# ===== AGENT HOOK EVENT TO SOUND FOLDER MAPPING =====
|
|
53
|
+
# Maps agent hook events to agent-specific sound folders
|
|
54
|
+
# Only the 6 hooks that actually fire in agent contexts are mapped
|
|
55
|
+
AGENT_HOOK_SOUND_MAP = {
|
|
56
|
+
"PreToolUse": "agent_pretooluse",
|
|
57
|
+
"PostToolUse": "agent_posttooluse",
|
|
58
|
+
"PermissionRequest": "agent_permissionrequest",
|
|
59
|
+
"PostToolUseFailure": "agent_posttoolusefailure",
|
|
60
|
+
"Stop": "agent_stop",
|
|
61
|
+
"SubagentStop": "agent_subagentstop"
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
# ===== BASH COMMAND PATTERNS =====
|
|
65
|
+
# Regex patterns to detect specific bash commands and map to special sounds
|
|
66
|
+
BASH_PATTERNS = [
|
|
67
|
+
(r'git commit', "pretooluse-git-committing"), # Git commits (anywhere in command)
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
def get_audio_player():
|
|
71
|
+
"""
|
|
72
|
+
Detect the appropriate audio player for the current platform.
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
List of command and args to use for playing audio, or None if no player found
|
|
76
|
+
"""
|
|
77
|
+
system = platform.system()
|
|
78
|
+
|
|
79
|
+
if system == "Darwin":
|
|
80
|
+
# macOS: use afplay (built-in)
|
|
81
|
+
return ["afplay"]
|
|
82
|
+
elif system == "Linux":
|
|
83
|
+
# Linux: try different players in order of preference
|
|
84
|
+
# Try to find an available player
|
|
85
|
+
players = [
|
|
86
|
+
["paplay"], # PulseAudio (most common on modern Linux)
|
|
87
|
+
["aplay"], # ALSA (fallback)
|
|
88
|
+
["ffplay", "-nodisp", "-autoexit"], # FFmpeg (if installed)
|
|
89
|
+
["mpg123", "-q"], # mpg123 (if installed)
|
|
90
|
+
]
|
|
91
|
+
|
|
92
|
+
for player in players:
|
|
93
|
+
try:
|
|
94
|
+
# Check if the player exists
|
|
95
|
+
subprocess.run(
|
|
96
|
+
["which", player[0]],
|
|
97
|
+
stdout=subprocess.DEVNULL,
|
|
98
|
+
stderr=subprocess.DEVNULL,
|
|
99
|
+
check=True
|
|
100
|
+
)
|
|
101
|
+
return player
|
|
102
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
103
|
+
continue
|
|
104
|
+
|
|
105
|
+
# No player found
|
|
106
|
+
return None
|
|
107
|
+
elif system == "Windows":
|
|
108
|
+
# Windows: Use winsound for WAV, PowerShell for MP3
|
|
109
|
+
return ["WINDOWS"]
|
|
110
|
+
else:
|
|
111
|
+
# Other OS - not supported yet
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def play_sound(sound_name):
|
|
116
|
+
"""
|
|
117
|
+
Play a sound file for the given sound name.
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
sound_name: Name of the sound file (e.g., "pretooluse", "pretooluse-git-committing")
|
|
121
|
+
The file should be at .claude/hooks/sounds/{folder}/{sound_name}.{mp3|wav}
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
True if sound played successfully, False otherwise
|
|
125
|
+
"""
|
|
126
|
+
# Security check: Prevent directory traversal attacks
|
|
127
|
+
if "/" in sound_name or "\\" in sound_name or ".." in sound_name:
|
|
128
|
+
print(f"Invalid sound name: {sound_name}", file=sys.stderr)
|
|
129
|
+
return False
|
|
130
|
+
|
|
131
|
+
# Get the appropriate audio player for this platform
|
|
132
|
+
audio_player = get_audio_player()
|
|
133
|
+
if not audio_player:
|
|
134
|
+
# No audio player available - fail silently
|
|
135
|
+
return False
|
|
136
|
+
|
|
137
|
+
# Build the path to the sound folder
|
|
138
|
+
# Scripts are in .claude/hooks/scripts/, sounds are in .claude/hooks/sounds/
|
|
139
|
+
script_dir = Path(__file__).parent # .claude/hooks/scripts/
|
|
140
|
+
hooks_dir = script_dir.parent # .claude/hooks/
|
|
141
|
+
|
|
142
|
+
# Determine the folder based on the sound name prefix
|
|
143
|
+
# For special sounds like "pretooluse-git-committing", look in "pretooluse" folder
|
|
144
|
+
folder_name = sound_name.split('-')[0]
|
|
145
|
+
sounds_dir = hooks_dir / "sounds" / folder_name
|
|
146
|
+
|
|
147
|
+
# Check if we're on Windows and need special handling
|
|
148
|
+
is_windows = audio_player[0] == "WINDOWS"
|
|
149
|
+
|
|
150
|
+
# Try different audio formats
|
|
151
|
+
# Note: paplay (PulseAudio) doesn't support MP3, so try WAV first
|
|
152
|
+
# On Windows, only use WAV files to avoid PowerShell/COM issues
|
|
153
|
+
extensions = ['.wav'] if is_windows else ['.wav', '.mp3']
|
|
154
|
+
|
|
155
|
+
for extension in extensions:
|
|
156
|
+
file_path = sounds_dir / f"{sound_name}{extension}"
|
|
157
|
+
|
|
158
|
+
if file_path.exists():
|
|
159
|
+
try:
|
|
160
|
+
if is_windows:
|
|
161
|
+
# Windows: Use winsound for WAV files (built-in, reliable, fast)
|
|
162
|
+
if winsound:
|
|
163
|
+
# SND_FILENAME: file_path is a filename
|
|
164
|
+
# SND_SYNC: play sound synchronously (wait until complete)
|
|
165
|
+
# SND_NODEFAULT: don't play default sound if file not found
|
|
166
|
+
# Note: Using SND_SYNC instead of SND_ASYNC because the script exits immediately
|
|
167
|
+
# after this call, which would terminate async playback before it completes
|
|
168
|
+
winsound.PlaySound(str(file_path),
|
|
169
|
+
winsound.SND_FILENAME | winsound.SND_NODEFAULT)
|
|
170
|
+
return True
|
|
171
|
+
else:
|
|
172
|
+
# winsound not available, fail silently
|
|
173
|
+
return False
|
|
174
|
+
else:
|
|
175
|
+
# Unix/Linux/macOS: use subprocess with audio player
|
|
176
|
+
subprocess.Popen(
|
|
177
|
+
audio_player + [str(file_path)],
|
|
178
|
+
stdout=subprocess.DEVNULL,
|
|
179
|
+
stderr=subprocess.DEVNULL,
|
|
180
|
+
start_new_session=True
|
|
181
|
+
)
|
|
182
|
+
return True
|
|
183
|
+
except (FileNotFoundError, OSError) as e:
|
|
184
|
+
print(f"Error playing sound {file_path.name}: {e}", file=sys.stderr)
|
|
185
|
+
return False
|
|
186
|
+
except Exception as e:
|
|
187
|
+
# Catch any other exceptions (e.g., winsound errors)
|
|
188
|
+
print(f"Error playing sound {file_path.name}: {e}", file=sys.stderr)
|
|
189
|
+
return False
|
|
190
|
+
|
|
191
|
+
# Sound not found - fail silently to avoid disrupting Claude's work
|
|
192
|
+
return False
|
|
193
|
+
|
|
194
|
+
def is_hook_disabled(event_name):
|
|
195
|
+
"""
|
|
196
|
+
Check if a specific hook is disabled in the config files.
|
|
197
|
+
Uses fallback logic: hooks-config.local.json -> hooks-config.json
|
|
198
|
+
|
|
199
|
+
Priority:
|
|
200
|
+
1. If hooks-config.local.json exists and has the setting, use it
|
|
201
|
+
2. Otherwise, fall back to hooks-config.json
|
|
202
|
+
3. If neither exists or the key is missing, assume hook is enabled (return False)
|
|
203
|
+
|
|
204
|
+
Args:
|
|
205
|
+
event_name: The hook event name (e.g., "PreToolUse", "PostToolUse")
|
|
206
|
+
|
|
207
|
+
Returns:
|
|
208
|
+
True if the hook is disabled, False otherwise
|
|
209
|
+
"""
|
|
210
|
+
try:
|
|
211
|
+
# Scripts are in .claude/hooks/scripts/, config is in .claude/hooks/config/
|
|
212
|
+
script_dir = Path(__file__).parent # .claude/hooks/scripts/
|
|
213
|
+
hooks_dir = script_dir.parent # .claude/hooks/
|
|
214
|
+
config_dir = hooks_dir / "config" # .claude/hooks/config/
|
|
215
|
+
|
|
216
|
+
local_config_path = config_dir / "hooks-config.local.json"
|
|
217
|
+
default_config_path = config_dir / "hooks-config.json"
|
|
218
|
+
|
|
219
|
+
# Map event names to config keys
|
|
220
|
+
config_key = f"disable{event_name}Hook"
|
|
221
|
+
|
|
222
|
+
# Try to load local config first
|
|
223
|
+
local_config = None
|
|
224
|
+
if local_config_path.exists():
|
|
225
|
+
try:
|
|
226
|
+
with open(local_config_path, "r", encoding="utf-8") as config_file:
|
|
227
|
+
local_config = json.load(config_file)
|
|
228
|
+
except Exception as e:
|
|
229
|
+
print(f"Error reading local config: {e}", file=sys.stderr)
|
|
230
|
+
|
|
231
|
+
# Try to load default config
|
|
232
|
+
default_config = None
|
|
233
|
+
if default_config_path.exists():
|
|
234
|
+
try:
|
|
235
|
+
with open(default_config_path, "r", encoding="utf-8") as config_file:
|
|
236
|
+
default_config = json.load(config_file)
|
|
237
|
+
except Exception as e:
|
|
238
|
+
print(f"Error reading default config: {e}", file=sys.stderr)
|
|
239
|
+
|
|
240
|
+
# Apply fallback logic: local -> default -> False (enabled)
|
|
241
|
+
if local_config is not None and config_key in local_config:
|
|
242
|
+
return local_config[config_key]
|
|
243
|
+
elif default_config is not None and config_key in default_config:
|
|
244
|
+
return default_config[config_key]
|
|
245
|
+
else:
|
|
246
|
+
# If neither config has the key, assume hook is enabled
|
|
247
|
+
return False
|
|
248
|
+
|
|
249
|
+
except Exception as e:
|
|
250
|
+
# If anything goes wrong, assume hook is enabled
|
|
251
|
+
print(f"Error in is_hook_disabled: {e}", file=sys.stderr)
|
|
252
|
+
return False
|
|
253
|
+
|
|
254
|
+
def is_logging_disabled():
|
|
255
|
+
"""
|
|
256
|
+
Check if logging is disabled in the config files.
|
|
257
|
+
Uses fallback logic: hooks-config.local.json -> hooks-config.json
|
|
258
|
+
|
|
259
|
+
Returns:
|
|
260
|
+
True if logging is disabled, False otherwise
|
|
261
|
+
"""
|
|
262
|
+
try:
|
|
263
|
+
# Scripts are in .claude/hooks/scripts/, config is in .claude/hooks/config/
|
|
264
|
+
script_dir = Path(__file__).parent # .claude/hooks/scripts/
|
|
265
|
+
hooks_dir = script_dir.parent # .claude/hooks/
|
|
266
|
+
config_dir = hooks_dir / "config" # .claude/hooks/config/
|
|
267
|
+
|
|
268
|
+
local_config_path = config_dir / "hooks-config.local.json"
|
|
269
|
+
default_config_path = config_dir / "hooks-config.json"
|
|
270
|
+
|
|
271
|
+
# Try to load local config first
|
|
272
|
+
local_config = None
|
|
273
|
+
if local_config_path.exists():
|
|
274
|
+
try:
|
|
275
|
+
with open(local_config_path, "r", encoding="utf-8") as config_file:
|
|
276
|
+
local_config = json.load(config_file)
|
|
277
|
+
except Exception as e:
|
|
278
|
+
print(f"Error reading local config: {e}", file=sys.stderr)
|
|
279
|
+
|
|
280
|
+
# Try to load default config
|
|
281
|
+
default_config = None
|
|
282
|
+
if default_config_path.exists():
|
|
283
|
+
try:
|
|
284
|
+
with open(default_config_path, "r", encoding="utf-8") as config_file:
|
|
285
|
+
default_config = json.load(config_file)
|
|
286
|
+
except Exception as e:
|
|
287
|
+
print(f"Error reading default config: {e}", file=sys.stderr)
|
|
288
|
+
|
|
289
|
+
# Apply fallback logic: local -> default -> False (logging enabled)
|
|
290
|
+
if local_config is not None and "disableLogging" in local_config:
|
|
291
|
+
return local_config["disableLogging"]
|
|
292
|
+
elif default_config is not None and "disableLogging" in default_config:
|
|
293
|
+
return default_config["disableLogging"]
|
|
294
|
+
else:
|
|
295
|
+
# If neither config has the key, assume logging is enabled
|
|
296
|
+
return False
|
|
297
|
+
|
|
298
|
+
except Exception as e:
|
|
299
|
+
# If anything goes wrong, assume logging is enabled
|
|
300
|
+
print(f"Error in is_logging_disabled: {e}", file=sys.stderr)
|
|
301
|
+
return False
|
|
302
|
+
|
|
303
|
+
def log_hook_data(hook_data, agent_name=None):
|
|
304
|
+
"""
|
|
305
|
+
Log the full hook_data to hooks-log.jsonl for debugging/auditing.
|
|
306
|
+
Log file is stored at .claude/hooks/logs/hooks-log.jsonl
|
|
307
|
+
|
|
308
|
+
Args:
|
|
309
|
+
hook_data: Dictionary containing event information from Claude
|
|
310
|
+
agent_name: Optional agent name if hook was invoked from a sub-agent
|
|
311
|
+
"""
|
|
312
|
+
# Check if logging is disabled
|
|
313
|
+
if is_logging_disabled():
|
|
314
|
+
return
|
|
315
|
+
|
|
316
|
+
try:
|
|
317
|
+
# Scripts are in .claude/hooks/scripts/, logs are in .claude/hooks/logs/
|
|
318
|
+
script_dir = Path(__file__).parent # .claude/hooks/scripts/
|
|
319
|
+
hooks_dir = script_dir.parent # .claude/hooks/
|
|
320
|
+
logs_dir = hooks_dir / "logs" # .claude/hooks/logs/
|
|
321
|
+
|
|
322
|
+
# Ensure logs directory exists
|
|
323
|
+
logs_dir.mkdir(parents=True, exist_ok=True)
|
|
324
|
+
|
|
325
|
+
# Add source field to indicate if hook was called from main session or sub-agent
|
|
326
|
+
log_entry = hook_data.copy()
|
|
327
|
+
|
|
328
|
+
# Remove fields we don't need in logs
|
|
329
|
+
log_entry.pop("transcript_path", None)
|
|
330
|
+
log_entry.pop("cwd", None)
|
|
331
|
+
|
|
332
|
+
# Only add agent name if hook was invoked from a sub-agent
|
|
333
|
+
if agent_name:
|
|
334
|
+
log_entry["invoked_by_agent"] = agent_name
|
|
335
|
+
|
|
336
|
+
log_path = logs_dir / "hooks-log.jsonl"
|
|
337
|
+
with open(log_path, "a", encoding="utf-8") as log_file:
|
|
338
|
+
log_file.write(json.dumps(log_entry, ensure_ascii=False, indent=2) + "\n")
|
|
339
|
+
except Exception as e:
|
|
340
|
+
# Fail silently, but print to stderr for visibility
|
|
341
|
+
print(f"Failed to log hook_data: {e}", file=sys.stderr)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def detect_bash_command_sound(command):
|
|
345
|
+
"""
|
|
346
|
+
Detect special bash commands and return the corresponding sound name.
|
|
347
|
+
|
|
348
|
+
Args:
|
|
349
|
+
command: The bash command string
|
|
350
|
+
|
|
351
|
+
Returns:
|
|
352
|
+
Sound name (string) if a pattern matches, None otherwise
|
|
353
|
+
"""
|
|
354
|
+
if not command:
|
|
355
|
+
return None
|
|
356
|
+
|
|
357
|
+
for pattern, sound_name in BASH_PATTERNS:
|
|
358
|
+
if re.search(pattern, command.strip()):
|
|
359
|
+
return sound_name
|
|
360
|
+
|
|
361
|
+
return None
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def get_sound_name(hook_data, agent_name=None):
|
|
365
|
+
"""
|
|
366
|
+
Determine which sound to play based on the hook event and context.
|
|
367
|
+
|
|
368
|
+
Args:
|
|
369
|
+
hook_data: Dictionary containing event information from Claude
|
|
370
|
+
agent_name: Optional agent name for agent-specific sounds
|
|
371
|
+
|
|
372
|
+
Returns:
|
|
373
|
+
Sound name (string) or None if no sound should play
|
|
374
|
+
"""
|
|
375
|
+
event_name = hook_data.get("hook_event_name", "")
|
|
376
|
+
tool_name = hook_data.get("tool_name", "")
|
|
377
|
+
|
|
378
|
+
# If this is an agent hook, use agent-specific sounds
|
|
379
|
+
if agent_name:
|
|
380
|
+
return AGENT_HOOK_SOUND_MAP.get(event_name)
|
|
381
|
+
|
|
382
|
+
# Check if this is a PreToolUse event with Bash tool
|
|
383
|
+
if event_name == "PreToolUse" and tool_name == "Bash":
|
|
384
|
+
tool_input = hook_data.get("tool_input", {})
|
|
385
|
+
command = tool_input.get("command", "")
|
|
386
|
+
|
|
387
|
+
# Check for special bash command patterns (e.g., git commit)
|
|
388
|
+
special_sound = detect_bash_command_sound(command)
|
|
389
|
+
if special_sound:
|
|
390
|
+
return special_sound
|
|
391
|
+
|
|
392
|
+
# Return the default sound for this hook event
|
|
393
|
+
return HOOK_SOUND_MAP.get(event_name)
|
|
394
|
+
|
|
395
|
+
def parse_arguments():
|
|
396
|
+
"""
|
|
397
|
+
Parse command line arguments.
|
|
398
|
+
|
|
399
|
+
Returns:
|
|
400
|
+
Parsed arguments namespace
|
|
401
|
+
"""
|
|
402
|
+
parser = argparse.ArgumentParser(
|
|
403
|
+
description="Claude Code Hook Handler - plays sounds for hook events"
|
|
404
|
+
)
|
|
405
|
+
parser.add_argument(
|
|
406
|
+
"--agent",
|
|
407
|
+
type=str,
|
|
408
|
+
default=None,
|
|
409
|
+
help="Agent name for agent-specific sounds (used by agent frontmatter hooks)"
|
|
410
|
+
)
|
|
411
|
+
return parser.parse_args()
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def main():
|
|
415
|
+
"""
|
|
416
|
+
Main program - this runs when Claude triggers a hook.
|
|
417
|
+
|
|
418
|
+
How it works:
|
|
419
|
+
1. Parse command line arguments (--agent for agent-specific sounds)
|
|
420
|
+
2. Claude sends event data as JSON through stdin
|
|
421
|
+
3. We check if this specific hook is disabled in hooks-config.json
|
|
422
|
+
4. We parse the JSON to understand which hook event occurred
|
|
423
|
+
5. We check for special bash commands (like git commit)
|
|
424
|
+
6. We play the corresponding sound for that event
|
|
425
|
+
7. We exit successfully
|
|
426
|
+
"""
|
|
427
|
+
try:
|
|
428
|
+
# Step 1: Parse command line arguments
|
|
429
|
+
args = parse_arguments()
|
|
430
|
+
|
|
431
|
+
# Step 2: Read the event data from Claude
|
|
432
|
+
stdin_content = sys.stdin.read().strip()
|
|
433
|
+
|
|
434
|
+
# If stdin is empty, exit gracefully (hook was called without data)
|
|
435
|
+
if not stdin_content:
|
|
436
|
+
sys.exit(0)
|
|
437
|
+
|
|
438
|
+
input_data = json.loads(stdin_content)
|
|
439
|
+
|
|
440
|
+
# Log hook data with source information (main session vs sub-agent)
|
|
441
|
+
log_hook_data(input_data, agent_name=args.agent)
|
|
442
|
+
|
|
443
|
+
# Step 3: Check if this hook is disabled (skip for agent hooks)
|
|
444
|
+
event_name = input_data.get("hook_event_name", "")
|
|
445
|
+
if not args.agent and is_hook_disabled(event_name):
|
|
446
|
+
# Hook is disabled, exit silently without playing sound
|
|
447
|
+
sys.exit(0)
|
|
448
|
+
|
|
449
|
+
# Step 4: Determine which sound to play (may be special, default, or agent-specific)
|
|
450
|
+
sound_name = get_sound_name(input_data, agent_name=args.agent)
|
|
451
|
+
|
|
452
|
+
# Step 5: Play the sound (if we found one)
|
|
453
|
+
if sound_name:
|
|
454
|
+
play_sound(sound_name)
|
|
455
|
+
|
|
456
|
+
# Step 6: Exit successfully
|
|
457
|
+
# Always exit with code 0 so we don't interrupt Claude's work
|
|
458
|
+
sys.exit(0)
|
|
459
|
+
|
|
460
|
+
except json.JSONDecodeError as e:
|
|
461
|
+
print(f"Error parsing JSON input: {e}", file=sys.stderr)
|
|
462
|
+
# Exit with 0 to avoid blocking Claude's work
|
|
463
|
+
sys.exit(0)
|
|
464
|
+
except Exception as e:
|
|
465
|
+
print(f"Unexpected error: {e}", file=sys.stderr)
|
|
466
|
+
# Exit with 0 to avoid blocking Claude's work
|
|
467
|
+
sys.exit(0)
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
# Entry point - Python calls main() when the script runs
|
|
471
|
+
if __name__ == "__main__":
|
|
472
|
+
main()
|