klaudio 0.11.2 → 0.11.4

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,368 +1,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 { 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=60
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
- if (script.includes("DELAY=15")) {
308
- reasons.push("Approval timer too short (15s -> 60s)");
309
- }
310
- } catch { /* no script */ }
311
-
312
- // Check for duplicate hooks (old bug)
313
- for (const [event, entries] of Object.entries(settings.hooks)) {
314
- const klaudioEntries = entries.filter((e) => e._klaudio || e._klonk);
315
- if (klaudioEntries.length > 1) {
316
- reasons.push(`Duplicate ${event} hooks`);
317
- }
318
- }
319
- } catch { /* no existing config */ }
320
-
321
- return reasons;
322
- }
323
-
324
- /**
325
- * Uninstall klaudio hooks from settings.
326
- */
327
- export async function uninstall(scope) {
328
- const claudeDir = getTargetDir(scope);
329
- const settingsFile = join(claudeDir, "settings.json");
330
-
331
- try {
332
- const existing = await readFile(settingsFile, "utf-8");
333
- const settings = JSON.parse(existing);
334
-
335
- if (settings.hooks) {
336
- for (const [event, entries] of Object.entries(settings.hooks)) {
337
- settings.hooks[event] = entries.filter(
338
- (entry) => !entry._klaudio && !entry._klonk
339
- );
340
- if (settings.hooks[event].length === 0) {
341
- delete settings.hooks[event];
342
- }
343
- }
344
- if (Object.keys(settings.hooks).length === 0) {
345
- delete settings.hooks;
346
- }
347
- }
348
-
349
- await writeFile(settingsFile, JSON.stringify(settings, null, 2) + "\n", "utf-8");
350
- } catch { /* no existing config */ }
351
-
352
- // Also clean up Copilot hooks
353
- await uninstallCopilotHooks(scope);
354
-
355
- return true;
356
- }
357
-
358
- /**
359
- * Remove klaudio entries from .github/hooks/klaudio.json.
360
- */
361
- async function uninstallCopilotHooks(scope) {
362
- if (scope === "global") return;
363
- const hooksFile = join(process.cwd(), ".github", "hooks", "klaudio.json");
364
- try {
365
- const { unlink } = await import("node:fs/promises");
366
- await unlink(hooksFile);
367
- } catch { /* file doesn't exist */ }
368
- }
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
+ }