klaudio 0.12.0 → 0.12.2

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/src/installer.js CHANGED
@@ -1,369 +1,389 @@
1
- import { readFile, writeFile, mkdir, copyFile } from "node:fs/promises";
2
- import { join, basename, extname } from "node:path";
3
- import { homedir } from "node:os";
4
- import { getHookPlayCommand, processSound } from "./player.js";
5
- import { EVENTS } from "./presets.js";
6
-
7
- /**
8
- * Get the target directory based on install scope.
9
- */
10
- function getTargetDir(scope) {
11
- if (scope === "global") {
12
- return join(homedir(), ".claude");
13
- }
14
- return join(process.cwd(), ".claude");
15
- }
16
-
17
- /**
18
- * Install sounds and configure hooks.
19
- *
20
- * @param {object} options
21
- * @param {string} options.scope - "global" or "project"
22
- * @param {Record<string, string>} options.sounds - Map of event ID -> source sound file path
23
- * @param {boolean} [options.tts] - Enable TTS voice summary on task complete
24
- */
25
- export async function install({ scope, sounds, tts = false, voice } = {}) {
26
- const claudeDir = getTargetDir(scope);
27
- const soundsDir = join(claudeDir, "sounds");
28
- const settingsFile = join(claudeDir, "settings.json");
29
-
30
- // Create sounds directory
31
- await mkdir(soundsDir, { recursive: true });
32
-
33
- // Process and copy sound files (clamp to 10s with fadeout via ffmpeg)
34
- const installedSounds = {};
35
- for (const [eventId, sourcePath] of Object.entries(sounds)) {
36
- const processedPath = await processSound(sourcePath);
37
- const srcName = basename(sourcePath, extname(sourcePath));
38
- const outExt = extname(processedPath) || ".wav";
39
- const fileName = `${eventId}-${srcName}${outExt}`;
40
- const destPath = join(soundsDir, fileName);
41
- await copyFile(processedPath, destPath);
42
- installedSounds[eventId] = destPath;
43
- }
44
-
45
- // Read existing settings
46
- let settings = {};
47
- try {
48
- const existing = await readFile(settingsFile, "utf-8");
49
- settings = JSON.parse(existing);
50
- } catch {
51
- // File doesn't exist or is invalid — start fresh
52
- }
53
-
54
- // Build hooks config
55
- if (!settings.hooks) {
56
- settings.hooks = {};
57
- }
58
-
59
- for (const [eventId, soundPath] of Object.entries(installedSounds)) {
60
- const event = EVENTS[eventId];
61
- if (!event) continue;
62
-
63
- // Approval event uses a PreToolUse/PostToolUse timer instead of a direct hook
64
- if (eventId === "approval") {
65
- await installApprovalHooks(settings, soundPath, claudeDir);
66
- continue;
67
- }
68
-
69
- const hookEvent = event.hookEvent;
70
- // Enable TTS only for the "stop" event (task complete)
71
- const useTts = tts && eventId === "stop";
72
- const playCommand = getHookPlayCommand(soundPath, { tts: useTts, voice });
73
-
74
- // Check if there's already a klaudio hook for this event
75
- if (!settings.hooks[hookEvent]) {
76
- settings.hooks[hookEvent] = [];
77
- }
78
-
79
- // Remove any existing klaudio/klonk entries
80
- settings.hooks[hookEvent] = settings.hooks[hookEvent].filter(
81
- (entry) => !entry._klaudio && !entry._klonk
82
- );
83
-
84
- // Add our hook
85
- settings.hooks[hookEvent].push({
86
- _klaudio: true,
87
- matcher: "",
88
- hooks: [
89
- {
90
- type: "command",
91
- command: playCommand,
92
- },
93
- ],
94
- });
95
- }
96
-
97
- // Write settings
98
- await writeFile(settingsFile, JSON.stringify(settings, null, 2) + "\n", "utf-8");
99
-
100
- // Also install Copilot coding agent hooks (.github/hooks/klaudio.json)
101
- await installCopilotHooks(installedSounds, scope);
102
-
103
- return {
104
- soundsDir,
105
- settingsFile,
106
- installedSounds,
107
- };
108
- }
109
-
110
- /**
111
- * Install approval notification hooks (PreToolUse/PostToolUse timer).
112
- * Writes a helper script and hooks that play a sound after 15s if no approval.
113
- */
114
- async function installApprovalHooks(settings, soundPath, claudeDir) {
115
- const normalized = soundPath.replace(/\\/g, "/");
116
- const scriptPath = join(claudeDir, "approval-notify.sh").replace(/\\/g, "/");
117
-
118
- // Write the timer script
119
- const script = `#!/usr/bin/env bash
120
- # klaudio: approval notification timer
121
- # Plays a sound + sends a system notification if a tool isn't approved within DELAY seconds.
122
- DELAY=120
123
- MARKER="/tmp/.claude-approval-pending"
124
- SOUND="${normalized}"
125
-
126
- case "$1" in
127
- start)
128
- TOKEN="$$-$(date +%s%N)"
129
- # Store token and CWD so the delayed notification knows the project name
130
- echo "$TOKEN" > "$MARKER"
131
- echo "$PWD" >> "$MARKER"
132
- (
133
- sleep "$DELAY"
134
- if [ -f "$MARKER" ] && [ "$(head -1 "$MARKER" 2>/dev/null)" = "$TOKEN" ]; then
135
- PROJECT=$(tail -1 "$MARKER" 2>/dev/null | sed 's|.*[/\\\\]||')
136
- rm -f "$MARKER"
137
- npx klaudio play "$SOUND" 2>/dev/null
138
- npx klaudio notify "\${PROJECT:-project}" "Waiting for your approval" 2>/dev/null
139
- npx klaudio say "\${PROJECT:-project} needs your attention" 2>/dev/null
140
- fi
141
- ) &
142
- ;;
143
- cancel)
144
- rm -f "$MARKER"
145
- ;;
146
- esac
147
- `;
148
- await writeFile(scriptPath, script, "utf-8");
149
-
150
- // Add PreToolUse hook
151
- if (!settings.hooks.PreToolUse) settings.hooks.PreToolUse = [];
152
- settings.hooks.PreToolUse = settings.hooks.PreToolUse.filter(
153
- (e) => !e._klaudio && !e._klonk
154
- );
155
- settings.hooks.PreToolUse.push({
156
- _klaudio: true,
157
- matcher: "",
158
- hooks: [{ type: "command", command: `bash "${scriptPath}" start` }],
159
- });
160
-
161
- // Add PostToolUse hook
162
- if (!settings.hooks.PostToolUse) settings.hooks.PostToolUse = [];
163
- settings.hooks.PostToolUse = settings.hooks.PostToolUse.filter(
164
- (e) => !e._klaudio && !e._klonk
165
- );
166
- settings.hooks.PostToolUse.push({
167
- _klaudio: true,
168
- matcher: "",
169
- hooks: [{ type: "command", command: `bash "${scriptPath}" cancel` }],
170
- });
171
- }
172
-
173
- /**
174
- * Install hooks for GitHub Copilot coding agent.
175
- * Writes .github/hooks/klaudio.json in the Copilot format.
176
- */
177
- async function installCopilotHooks(installedSounds, scope) {
178
- // Find the repo root (.github lives at repo root)
179
- const repoRoot = scope === "global" ? null : process.cwd();
180
- if (!repoRoot) return; // Copilot hooks are project-scoped only
181
-
182
- const hooksDir = join(repoRoot, ".github", "hooks");
183
- const hooksFile = join(hooksDir, "klaudio.json");
184
-
185
- await mkdir(hooksDir, { recursive: true });
186
-
187
- // Read existing file if present
188
- let config = { version: 1, hooks: {} };
189
- try {
190
- const existing = await readFile(hooksFile, "utf-8");
191
- config = JSON.parse(existing);
192
- if (!config.hooks) config.hooks = {};
193
- } catch { /* start fresh */ }
194
-
195
- for (const [eventId, soundPath] of Object.entries(installedSounds)) {
196
- const event = EVENTS[eventId];
197
- if (!event?.copilotHookEvent) continue;
198
-
199
- const normalized = soundPath.replace(/\\/g, "/");
200
- const bashCmd = `afplay "${normalized}" 2>/dev/null & aplay "${normalized}" 2>/dev/null &`;
201
- const psCmd = `Add-Type -AssemblyName PresentationCore; $p = New-Object System.Windows.Media.MediaPlayer; $p.Open([System.Uri]::new('${normalized.replace(/\//g, "\\")}')); Start-Sleep -Milliseconds 200; $p.Play(); Start-Sleep -Seconds 2`;
202
-
203
- if (!config.hooks[event.copilotHookEvent]) {
204
- config.hooks[event.copilotHookEvent] = [];
205
- }
206
-
207
- // Remove existing klaudio entries
208
- config.hooks[event.copilotHookEvent] = config.hooks[event.copilotHookEvent].filter(
209
- (entry) => !entry._klaudio
210
- );
211
-
212
- config.hooks[event.copilotHookEvent].push({
213
- _klaudio: true,
214
- type: "command",
215
- bash: bashCmd,
216
- powershell: psCmd,
217
- timeoutSec: 10,
218
- comment: `klaudio: ${event.name}`,
219
- });
220
- }
221
-
222
- await writeFile(hooksFile, JSON.stringify(config, null, 2) + "\n", "utf-8");
223
- }
224
-
225
- /**
226
- * Read existing klaudio sound selections from settings.
227
- * Returns a map of eventId -> soundFilePath (from the sounds/ dir).
228
- */
229
- export async function getExistingSounds(scope) {
230
- const claudeDir = getTargetDir(scope);
231
- const settingsFile = join(claudeDir, "settings.json");
232
- const sounds = {};
233
-
234
- try {
235
- const existing = await readFile(settingsFile, "utf-8");
236
- const settings = JSON.parse(existing);
237
- if (!settings.hooks) return sounds;
238
-
239
- for (const [eventId, event] of Object.entries(EVENTS)) {
240
- // Approval event: read sound from the approval-notify.sh script
241
- if (eventId === "approval") {
242
- const scriptPath = join(claudeDir, "approval-notify.sh");
243
- try {
244
- const script = await readFile(scriptPath, "utf-8");
245
- const m = script.match(/SOUND="([^"]+\.(wav|mp3|ogg|flac|aac))"/);
246
- if (m) {
247
- sounds[eventId] = m[1].replace(/\//g, join("a", "b").includes("\\") ? "\\" : "/");
248
- }
249
- } catch { /* no script */ }
250
- continue;
251
- }
252
-
253
- const hookEntries = settings.hooks[event.hookEvent];
254
- if (!hookEntries) continue;
255
- const entry = hookEntries.find((e) => e._klaudio || e._klonk
256
- || e.hooks?.[0]?.command?.includes("klaudio"));
257
- if (!entry?.hooks?.[0]?.command) continue;
258
-
259
- // Extract file path from the play command
260
- // Commands contain the path in quotes: ... "path/to/file" ...
261
- const match = entry.hooks[0].command.match(/"([^"]+\.(wav|mp3|ogg|flac|aac))"/);
262
- if (match) {
263
- const soundPath = match[1].replace(/\//g, join("a", "b").includes("\\") ? "\\" : "/");
264
- sounds[eventId] = soundPath;
265
- }
266
- }
267
- } catch { /* no existing config */ }
268
-
269
- return sounds;
270
- }
271
-
272
- /**
273
- * Check if existing hooks are outdated (missing features from newer versions).
274
- * Returns a list of reasons why hooks should be updated.
275
- */
276
- export async function checkHooksOutdated(scope) {
277
- const claudeDir = getTargetDir(scope);
278
- const settingsFile = join(claudeDir, "settings.json");
279
- const reasons = [];
280
-
281
- try {
282
- const existing = await readFile(settingsFile, "utf-8");
283
- const settings = JSON.parse(existing);
284
- if (!settings.hooks) return reasons;
285
-
286
- // Check Stop hook for --notify flag
287
- const stopEntries = settings.hooks.Stop || [];
288
- const stopHook = stopEntries.find((e) => e._klaudio || e.hooks?.[0]?.command?.includes("klaudio"));
289
- if (stopHook?.hooks?.[0]?.command && !stopHook.hooks[0].command.includes("--notify")) {
290
- reasons.push("System notifications on task complete");
291
- }
292
-
293
- // Check Notification hook for --notify flag
294
- const notifEntries = settings.hooks.Notification || [];
295
- const notifHook = notifEntries.find((e) => e._klaudio || e.hooks?.[0]?.command?.includes("klaudio"));
296
- if (notifHook?.hooks?.[0]?.command && !notifHook.hooks[0].command.includes("--notify")) {
297
- reasons.push("System notifications on background task");
298
- }
299
-
300
- // Check approval script for notify command and timer delay
301
- const scriptPath = join(claudeDir, "approval-notify.sh");
302
- try {
303
- const script = await readFile(scriptPath, "utf-8");
304
- if (!script.includes("klaudio notify")) {
305
- reasons.push("System notifications on approval wait");
306
- }
307
- const delayMatch = script.match(/DELAY=(\d+)/);
308
- if (delayMatch && parseInt(delayMatch[1]) < 120) {
309
- reasons.push("Approval timer too short (now 120s)");
310
- }
311
- } catch { /* no script */ }
312
-
313
- // Check for duplicate hooks (old bug)
314
- for (const [event, entries] of Object.entries(settings.hooks)) {
315
- const klaudioEntries = entries.filter((e) => e._klaudio || e._klonk);
316
- if (klaudioEntries.length > 1) {
317
- reasons.push(`Duplicate ${event} hooks`);
318
- }
319
- }
320
- } catch { /* no existing config */ }
321
-
322
- return reasons;
323
- }
324
-
325
- /**
326
- * Uninstall klaudio hooks from settings.
327
- */
328
- export async function uninstall(scope) {
329
- const claudeDir = getTargetDir(scope);
330
- const settingsFile = join(claudeDir, "settings.json");
331
-
332
- try {
333
- const existing = await readFile(settingsFile, "utf-8");
334
- const settings = JSON.parse(existing);
335
-
336
- if (settings.hooks) {
337
- for (const [event, entries] of Object.entries(settings.hooks)) {
338
- settings.hooks[event] = entries.filter(
339
- (entry) => !entry._klaudio && !entry._klonk
340
- );
341
- if (settings.hooks[event].length === 0) {
342
- delete settings.hooks[event];
343
- }
344
- }
345
- if (Object.keys(settings.hooks).length === 0) {
346
- delete settings.hooks;
347
- }
348
- }
349
-
350
- await writeFile(settingsFile, JSON.stringify(settings, null, 2) + "\n", "utf-8");
351
- } catch { /* no existing config */ }
352
-
353
- // Also clean up Copilot hooks
354
- await uninstallCopilotHooks(scope);
355
-
356
- return true;
357
- }
358
-
359
- /**
360
- * Remove klaudio entries from .github/hooks/klaudio.json.
361
- */
362
- async function uninstallCopilotHooks(scope) {
363
- if (scope === "global") return;
364
- const hooksFile = join(process.cwd(), ".github", "hooks", "klaudio.json");
365
- try {
366
- const { unlink } = await import("node:fs/promises");
367
- await unlink(hooksFile);
368
- } catch { /* file doesn't exist */ }
369
- }
1
+ import { readFile, writeFile, mkdir, copyFile } from "node:fs/promises";
2
+ import { join, basename, extname } from "node:path";
3
+ import { homedir } from "node:os";
4
+ import { createRequire } from "node:module";
5
+ import { getHookPlayCommand, processSound } from "./player.js";
6
+ import { EVENTS } from "./presets.js";
7
+
8
+ const _require = createRequire(import.meta.url);
9
+ const KLAUDIO_VERSION = _require("../package.json").version;
10
+
11
+ /**
12
+ * Get the target directory based on install scope.
13
+ */
14
+ function getTargetDir(scope) {
15
+ if (scope === "global") {
16
+ return join(homedir(), ".claude");
17
+ }
18
+ return join(process.cwd(), ".claude");
19
+ }
20
+
21
+ /**
22
+ * Install sounds and configure hooks.
23
+ *
24
+ * @param {object} options
25
+ * @param {string} options.scope - "global" or "project"
26
+ * @param {Record<string, string>} options.sounds - Map of event ID -> source sound file path
27
+ * @param {boolean} [options.tts] - Enable TTS voice summary on task complete
28
+ */
29
+ export async function install({ scope, sounds, tts = false, voice } = {}) {
30
+ const claudeDir = getTargetDir(scope);
31
+ const soundsDir = join(claudeDir, "sounds");
32
+ const settingsFile = join(claudeDir, "settings.json");
33
+
34
+ // Create sounds directory
35
+ await mkdir(soundsDir, { recursive: true });
36
+
37
+ // Process and copy sound files (clamp to 10s with fadeout via ffmpeg)
38
+ const installedSounds = {};
39
+ for (const [eventId, sourcePath] of Object.entries(sounds)) {
40
+ const processedPath = await processSound(sourcePath);
41
+ const srcName = basename(sourcePath, extname(sourcePath));
42
+ const outExt = extname(processedPath) || ".wav";
43
+ const fileName = `${eventId}-${srcName}${outExt}`;
44
+ const destPath = join(soundsDir, fileName);
45
+ await copyFile(processedPath, destPath);
46
+ installedSounds[eventId] = destPath;
47
+ }
48
+
49
+ // Read existing settings
50
+ let settings = {};
51
+ try {
52
+ const existing = await readFile(settingsFile, "utf-8");
53
+ settings = JSON.parse(existing);
54
+ } catch {
55
+ // File doesn't exist or is invalid — start fresh
56
+ }
57
+
58
+ // Build hooks config
59
+ if (!settings.hooks) {
60
+ settings.hooks = {};
61
+ }
62
+
63
+ for (const [eventId, soundPath] of Object.entries(installedSounds)) {
64
+ const event = EVENTS[eventId];
65
+ if (!event) continue;
66
+
67
+ // Approval event uses a PreToolUse/PostToolUse timer instead of a direct hook
68
+ if (eventId === "approval") {
69
+ await installApprovalHooks(settings, soundPath, claudeDir);
70
+ continue;
71
+ }
72
+
73
+ const hookEvent = event.hookEvent;
74
+ // Enable TTS only for the "stop" event (task complete)
75
+ const useTts = tts && eventId === "stop";
76
+ const playCommand = getHookPlayCommand(soundPath, { tts: useTts, voice });
77
+
78
+ // Check if there's already a klaudio hook for this event
79
+ if (!settings.hooks[hookEvent]) {
80
+ settings.hooks[hookEvent] = [];
81
+ }
82
+
83
+ // Remove any existing klaudio/klonk entries
84
+ settings.hooks[hookEvent] = settings.hooks[hookEvent].filter(
85
+ (entry) => !entry._klaudio && !entry._klonk
86
+ );
87
+
88
+ // Add our hook
89
+ settings.hooks[hookEvent].push({
90
+ _klaudio: true,
91
+ _klaudioVersion: KLAUDIO_VERSION,
92
+ matcher: "",
93
+ hooks: [
94
+ {
95
+ type: "command",
96
+ command: playCommand,
97
+ },
98
+ ],
99
+ });
100
+ }
101
+
102
+ // Write settings
103
+ await writeFile(settingsFile, JSON.stringify(settings, null, 2) + "\n", "utf-8");
104
+
105
+ // Also install Copilot coding agent hooks (.github/hooks/klaudio.json)
106
+ await installCopilotHooks(installedSounds, scope);
107
+
108
+ return {
109
+ soundsDir,
110
+ settingsFile,
111
+ installedSounds,
112
+ };
113
+ }
114
+
115
+ /**
116
+ * Install approval notification hooks (PreToolUse/PostToolUse timer).
117
+ * Writes a helper script and hooks that play a sound after 15s if no approval.
118
+ */
119
+ async function installApprovalHooks(settings, soundPath, claudeDir) {
120
+ const normalized = soundPath.replace(/\\/g, "/");
121
+ const scriptPath = join(claudeDir, "approval-notify.sh").replace(/\\/g, "/");
122
+
123
+ // Write the timer script
124
+ const script = `#!/usr/bin/env bash
125
+ # klaudio: approval notification timer
126
+ # Plays a sound + sends a system notification if a tool isn't approved within DELAY seconds.
127
+ DELAY=120
128
+ MARKER="/tmp/.claude-approval-pending"
129
+ SOUND="${normalized}"
130
+
131
+ case "$1" in
132
+ start)
133
+ TOKEN="$$-$(date +%s%N)"
134
+ # Store token and CWD so the delayed notification knows the project name
135
+ echo "$TOKEN" > "$MARKER"
136
+ echo "$PWD" >> "$MARKER"
137
+ (
138
+ sleep "$DELAY"
139
+ if [ -f "$MARKER" ] && [ "$(head -1 "$MARKER" 2>/dev/null)" = "$TOKEN" ]; then
140
+ PROJECT=$(tail -1 "$MARKER" 2>/dev/null | sed 's|.*[/\\\\]||')
141
+ rm -f "$MARKER"
142
+ npx klaudio play "$SOUND" 2>/dev/null
143
+ npx klaudio notify "\${PROJECT:-project}" "Waiting for your approval" 2>/dev/null
144
+ npx klaudio say "\${PROJECT:-project} needs your attention" 2>/dev/null
145
+ fi
146
+ ) &
147
+ ;;
148
+ cancel)
149
+ rm -f "$MARKER"
150
+ ;;
151
+ esac
152
+ `;
153
+ await writeFile(scriptPath, script, "utf-8");
154
+
155
+ // Add PreToolUse hook
156
+ if (!settings.hooks.PreToolUse) settings.hooks.PreToolUse = [];
157
+ settings.hooks.PreToolUse = settings.hooks.PreToolUse.filter(
158
+ (e) => !e._klaudio && !e._klonk
159
+ );
160
+ settings.hooks.PreToolUse.push({
161
+ _klaudio: true,
162
+ matcher: "",
163
+ hooks: [{ type: "command", command: `bash "${scriptPath}" start` }],
164
+ });
165
+
166
+ // Add PostToolUse hook
167
+ if (!settings.hooks.PostToolUse) settings.hooks.PostToolUse = [];
168
+ settings.hooks.PostToolUse = settings.hooks.PostToolUse.filter(
169
+ (e) => !e._klaudio && !e._klonk
170
+ );
171
+ settings.hooks.PostToolUse.push({
172
+ _klaudio: true,
173
+ matcher: "",
174
+ hooks: [{ type: "command", command: `bash "${scriptPath}" cancel` }],
175
+ });
176
+ }
177
+
178
+ /**
179
+ * Install hooks for GitHub Copilot coding agent.
180
+ * Writes .github/hooks/klaudio.json in the Copilot format.
181
+ */
182
+ async function installCopilotHooks(installedSounds, scope) {
183
+ // Find the repo root (.github lives at repo root)
184
+ const repoRoot = scope === "global" ? null : process.cwd();
185
+ if (!repoRoot) return; // Copilot hooks are project-scoped only
186
+
187
+ const hooksDir = join(repoRoot, ".github", "hooks");
188
+ const hooksFile = join(hooksDir, "klaudio.json");
189
+
190
+ await mkdir(hooksDir, { recursive: true });
191
+
192
+ // Read existing file if present
193
+ let config = { version: 1, hooks: {} };
194
+ try {
195
+ const existing = await readFile(hooksFile, "utf-8");
196
+ config = JSON.parse(existing);
197
+ if (!config.hooks) config.hooks = {};
198
+ } catch { /* start fresh */ }
199
+
200
+ for (const [eventId, soundPath] of Object.entries(installedSounds)) {
201
+ const event = EVENTS[eventId];
202
+ if (!event?.copilotHookEvent) continue;
203
+
204
+ const normalized = soundPath.replace(/\\/g, "/");
205
+ const bashCmd = `afplay "${normalized}" 2>/dev/null & aplay "${normalized}" 2>/dev/null &`;
206
+ const psCmd = `Add-Type -AssemblyName PresentationCore; $p = New-Object System.Windows.Media.MediaPlayer; $p.Open([System.Uri]::new('${normalized.replace(/\//g, "\\")}')); Start-Sleep -Milliseconds 200; $p.Play(); Start-Sleep -Seconds 2`;
207
+
208
+ if (!config.hooks[event.copilotHookEvent]) {
209
+ config.hooks[event.copilotHookEvent] = [];
210
+ }
211
+
212
+ // Remove existing klaudio entries
213
+ config.hooks[event.copilotHookEvent] = config.hooks[event.copilotHookEvent].filter(
214
+ (entry) => !entry._klaudio
215
+ );
216
+
217
+ config.hooks[event.copilotHookEvent].push({
218
+ _klaudio: true,
219
+ type: "command",
220
+ bash: bashCmd,
221
+ powershell: psCmd,
222
+ timeoutSec: 10,
223
+ comment: `klaudio: ${event.name}`,
224
+ });
225
+ }
226
+
227
+ await writeFile(hooksFile, JSON.stringify(config, null, 2) + "\n", "utf-8");
228
+ }
229
+
230
+ /**
231
+ * Read existing klaudio sound selections from settings.
232
+ * Returns a map of eventId -> soundFilePath (from the sounds/ dir).
233
+ */
234
+ export async function getExistingSounds(scope) {
235
+ const claudeDir = getTargetDir(scope);
236
+ const settingsFile = join(claudeDir, "settings.json");
237
+ const sounds = {};
238
+
239
+ try {
240
+ const existing = await readFile(settingsFile, "utf-8");
241
+ const settings = JSON.parse(existing);
242
+ if (!settings.hooks) return sounds;
243
+
244
+ for (const [eventId, event] of Object.entries(EVENTS)) {
245
+ // Approval event: read sound from the approval-notify.sh script
246
+ if (eventId === "approval") {
247
+ const scriptPath = join(claudeDir, "approval-notify.sh");
248
+ try {
249
+ const script = await readFile(scriptPath, "utf-8");
250
+ const m = script.match(/SOUND="([^"]+\.(wav|mp3|ogg|flac|aac))"/);
251
+ if (m) {
252
+ sounds[eventId] = m[1].replace(/\//g, join("a", "b").includes("\\") ? "\\" : "/");
253
+ }
254
+ } catch { /* no script */ }
255
+ continue;
256
+ }
257
+
258
+ const hookEntries = settings.hooks[event.hookEvent];
259
+ if (!hookEntries) continue;
260
+ const entry = hookEntries.find((e) => e._klaudio || e._klonk
261
+ || e.hooks?.[0]?.command?.includes("klaudio")
262
+ || e.hooks?.[0]?.command?.includes(".claude/sounds/"));
263
+ if (!entry?.hooks?.[0]?.command) continue;
264
+
265
+ // Extract file path from the play command
266
+ // Commands contain the path in quotes: ... "path/to/file" ...
267
+ const match = entry.hooks[0].command.match(/"([^"]+\.(wav|mp3|ogg|flac|aac))"/);
268
+ if (match) {
269
+ const soundPath = match[1].replace(/\//g, join("a", "b").includes("\\") ? "\\" : "/");
270
+ sounds[eventId] = soundPath;
271
+ }
272
+ }
273
+ } catch { /* no existing config */ }
274
+
275
+ return sounds;
276
+ }
277
+
278
+ /**
279
+ * Check if existing hooks are outdated (missing features from newer versions).
280
+ * Returns a list of reasons why hooks should be updated.
281
+ */
282
+ export async function checkHooksOutdated(scope) {
283
+ const claudeDir = getTargetDir(scope);
284
+ const settingsFile = join(claudeDir, "settings.json");
285
+ const reasons = [];
286
+
287
+ try {
288
+ const existing = await readFile(settingsFile, "utf-8");
289
+ const settings = JSON.parse(existing);
290
+ if (!settings.hooks) return reasons;
291
+
292
+ const isOurs = (e) => e._klaudio || e._klonk || e.hooks?.[0]?.command?.includes("klaudio") || e.hooks?.[0]?.command?.includes(".claude/sounds/");
293
+
294
+ // Check if any klaudio hook was installed with an older version
295
+ const allKlaudioHooks = Object.values(settings.hooks).flat().filter(isOurs);
296
+ if (allKlaudioHooks.length > 0) {
297
+ const hasVersionMismatch = allKlaudioHooks.some((e) => e._klaudioVersion !== KLAUDIO_VERSION);
298
+ if (hasVersionMismatch) {
299
+ const installedVer = allKlaudioHooks.find((e) => e._klaudioVersion)?._klaudioVersion;
300
+ reasons.push(installedVer
301
+ ? `Hooks from v${installedVer} → v${KLAUDIO_VERSION}`
302
+ : `Hooks need update to v${KLAUDIO_VERSION}`);
303
+ }
304
+ }
305
+
306
+ // Check Stop hook for --notify flag
307
+ const stopEntries = settings.hooks.Stop || [];
308
+ const stopHook = stopEntries.find(isOurs);
309
+ if (stopHook?.hooks?.[0]?.command && !stopHook.hooks[0].command.includes("--notify")) {
310
+ reasons.push("System notifications on task complete");
311
+ }
312
+
313
+ // Check Notification hook for --notify flag
314
+ const notifEntries = settings.hooks.Notification || [];
315
+ const notifHook = notifEntries.find(isOurs);
316
+ if (notifHook?.hooks?.[0]?.command && !notifHook.hooks[0].command.includes("--notify")) {
317
+ reasons.push("System notifications on background task");
318
+ }
319
+
320
+ // Check approval script for notify command and timer delay
321
+ const scriptPath = join(claudeDir, "approval-notify.sh");
322
+ try {
323
+ const script = await readFile(scriptPath, "utf-8");
324
+ if (!script.includes("klaudio notify")) {
325
+ reasons.push("System notifications on approval wait");
326
+ }
327
+ const delayMatch = script.match(/DELAY=(\d+)/);
328
+ if (delayMatch && parseInt(delayMatch[1]) < 120) {
329
+ reasons.push("Approval timer too short (now 120s)");
330
+ }
331
+ } catch { /* no script */ }
332
+
333
+ // Check for duplicate hooks (old bug)
334
+ for (const [event, entries] of Object.entries(settings.hooks)) {
335
+ const klaudioEntries = entries.filter((e) => e._klaudio || e._klonk);
336
+ if (klaudioEntries.length > 1) {
337
+ reasons.push(`Duplicate ${event} hooks`);
338
+ }
339
+ }
340
+ } catch { /* no existing config */ }
341
+
342
+ return reasons;
343
+ }
344
+
345
+ /**
346
+ * Uninstall klaudio hooks from settings.
347
+ */
348
+ export async function uninstall(scope) {
349
+ const claudeDir = getTargetDir(scope);
350
+ const settingsFile = join(claudeDir, "settings.json");
351
+
352
+ try {
353
+ const existing = await readFile(settingsFile, "utf-8");
354
+ const settings = JSON.parse(existing);
355
+
356
+ if (settings.hooks) {
357
+ for (const [event, entries] of Object.entries(settings.hooks)) {
358
+ settings.hooks[event] = entries.filter(
359
+ (entry) => !entry._klaudio && !entry._klonk && !entry.hooks?.[0]?.command?.includes(".claude/sounds/")
360
+ );
361
+ if (settings.hooks[event].length === 0) {
362
+ delete settings.hooks[event];
363
+ }
364
+ }
365
+ if (Object.keys(settings.hooks).length === 0) {
366
+ delete settings.hooks;
367
+ }
368
+ }
369
+
370
+ await writeFile(settingsFile, JSON.stringify(settings, null, 2) + "\n", "utf-8");
371
+ } catch { /* no existing config */ }
372
+
373
+ // Also clean up Copilot hooks
374
+ await uninstallCopilotHooks(scope);
375
+
376
+ return true;
377
+ }
378
+
379
+ /**
380
+ * Remove klaudio entries from .github/hooks/klaudio.json.
381
+ */
382
+ async function uninstallCopilotHooks(scope) {
383
+ if (scope === "global") return;
384
+ const hooksFile = join(process.cwd(), ".github", "hooks", "klaudio.json");
385
+ try {
386
+ const { unlink } = await import("node:fs/promises");
387
+ await unlink(hooksFile);
388
+ } catch { /* file doesn't exist */ }
389
+ }