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.
Files changed (83) hide show
  1. package/LICENSE +21 -0
  2. package/console/assets/addon-fit-BlxrFPDK.js +1 -0
  3. package/console/assets/addon-web-links-BWjmmSgS.js +12 -0
  4. package/console/assets/index-Digctq9W.css +32 -0
  5. package/console/assets/index-ItVUTuQf.js +31 -0
  6. package/console/assets/xterm-DlVFs1Kw.js +9 -0
  7. package/console/index.html +13 -0
  8. package/cw/.bundle.json +4 -0
  9. package/cw/agents/presentation-curator.md +119 -0
  10. package/cw/agents/weather-agent.md +67 -0
  11. package/cw/cw +6188 -0
  12. package/cw/cw-shell-integration.sh +132 -0
  13. package/cw/hooks/HOOKS-README.md +471 -0
  14. package/cw/hooks/config/hooks-config.json +24 -0
  15. package/cw/hooks/scripts/auto-compact.py +133 -0
  16. package/cw/hooks/scripts/hooks.py +472 -0
  17. package/cw/hooks/scripts/review-autoclose.py +171 -0
  18. package/cw/hooks/sounds/agent_permissionrequest/agent_permissionrequest.mp3 +0 -0
  19. package/cw/hooks/sounds/agent_permissionrequest/agent_permissionrequest.wav +0 -0
  20. package/cw/hooks/sounds/agent_posttooluse/agent_posttooluse.mp3 +0 -0
  21. package/cw/hooks/sounds/agent_posttooluse/agent_posttooluse.wav +0 -0
  22. package/cw/hooks/sounds/agent_posttoolusefailure/agent_posttoolusefailure.mp3 +0 -0
  23. package/cw/hooks/sounds/agent_posttoolusefailure/agent_posttoolusefailure.wav +0 -0
  24. package/cw/hooks/sounds/agent_pretooluse/agent_pretooluse.mp3 +0 -0
  25. package/cw/hooks/sounds/agent_pretooluse/agent_pretooluse.wav +0 -0
  26. package/cw/hooks/sounds/agent_stop/agent_stop.mp3 +0 -0
  27. package/cw/hooks/sounds/agent_stop/agent_stop.wav +0 -0
  28. package/cw/hooks/sounds/agent_subagentstop/agent_subagentstop.mp3 +0 -0
  29. package/cw/hooks/sounds/agent_subagentstop/agent_subagentstop.wav +0 -0
  30. package/cw/hooks/sounds/configchange/configchange.mp3 +0 -0
  31. package/cw/hooks/sounds/configchange/configchange.wav +0 -0
  32. package/cw/hooks/sounds/notification/notification.mp3 +0 -0
  33. package/cw/hooks/sounds/notification/notification.wav +0 -0
  34. package/cw/hooks/sounds/permissionrequest/permissionrequest.mp3 +0 -0
  35. package/cw/hooks/sounds/permissionrequest/permissionrequest.wav +0 -0
  36. package/cw/hooks/sounds/posttooluse/posttooluse.mp3 +0 -0
  37. package/cw/hooks/sounds/posttooluse/posttooluse.wav +0 -0
  38. package/cw/hooks/sounds/posttoolusefailure/posttoolusefailure.mp3 +0 -0
  39. package/cw/hooks/sounds/posttoolusefailure/posttoolusefailure.wav +0 -0
  40. package/cw/hooks/sounds/precompact/precompact.mp3 +0 -0
  41. package/cw/hooks/sounds/precompact/precompact.wav +0 -0
  42. package/cw/hooks/sounds/pretooluse/pretooluse-git-committing.mp3 +0 -0
  43. package/cw/hooks/sounds/pretooluse/pretooluse-git-committing.wav +0 -0
  44. package/cw/hooks/sounds/pretooluse/pretooluse.mp3 +0 -0
  45. package/cw/hooks/sounds/pretooluse/pretooluse.wav +0 -0
  46. package/cw/hooks/sounds/sessionend/sessionend.mp3 +0 -0
  47. package/cw/hooks/sounds/sessionend/sessionend.wav +0 -0
  48. package/cw/hooks/sounds/sessionstart/sessionstart.mp3 +0 -0
  49. package/cw/hooks/sounds/sessionstart/sessionstart.wav +0 -0
  50. package/cw/hooks/sounds/setup/Setup.mp3 +0 -0
  51. package/cw/hooks/sounds/setup/Setup.wav +0 -0
  52. package/cw/hooks/sounds/stop/stop.mp3 +0 -0
  53. package/cw/hooks/sounds/stop/stop.wav +0 -0
  54. package/cw/hooks/sounds/subagentstart/subagentstart.mp3 +0 -0
  55. package/cw/hooks/sounds/subagentstart/subagentstart.wav +0 -0
  56. package/cw/hooks/sounds/subagentstop/subagentstop.mp3 +0 -0
  57. package/cw/hooks/sounds/subagentstop/subagentstop.wav +0 -0
  58. package/cw/hooks/sounds/taskcompleted/taskcompleted.mp3 +0 -0
  59. package/cw/hooks/sounds/taskcompleted/taskcompleted.wav +0 -0
  60. package/cw/hooks/sounds/teammateidle/teammateidle.mp3 +0 -0
  61. package/cw/hooks/sounds/teammateidle/teammateidle.wav +0 -0
  62. package/cw/hooks/sounds/userpromptsubmit/userpromptsubmit.mp3 +0 -0
  63. package/cw/hooks/sounds/userpromptsubmit/userpromptsubmit.wav +0 -0
  64. package/cw/hooks/sounds/worktreecreate/worktreecreate.mp3 +0 -0
  65. package/cw/hooks/sounds/worktreecreate/worktreecreate.wav +0 -0
  66. package/cw/hooks/sounds/worktreeremove/worktreeremove.mp3 +0 -0
  67. package/cw/hooks/sounds/worktreeremove/worktreeremove.wav +0 -0
  68. package/cw/install.sh +243 -0
  69. package/cw/lib/context/github.sh +51 -0
  70. package/cw/lib/context/linear.sh +49 -0
  71. package/cw/lib/context/notion.sh +38 -0
  72. package/cw/lib/dashboard/activity-hook.py +134 -0
  73. package/cw/lib/dashboard/index.html +1515 -0
  74. package/cw/lib/dashboard/server.py +379 -0
  75. package/cw/lib/dashboard/start.sh +15 -0
  76. package/cw/lib/harnesses/claude.sh +92 -0
  77. package/cw/lib/harnesses/codex.sh +245 -0
  78. package/cw/lib/harnesses/opencode.sh +120 -0
  79. package/cw/lib/harnesses/pi.sh +66 -0
  80. package/cw/lib/iterm2.sh +140 -0
  81. package/cw/templates/CLAUDE.template.md +48 -0
  82. package/dist/index.js +4376 -0
  83. 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()