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/README.md +96 -96
- package/bin/cli.js +44 -44
- package/package.json +40 -44
- package/src/cache.js +306 -306
- package/src/cli.js +1846 -1821
- package/src/extractor.js +213 -213
- package/src/installer.js +369 -368
- package/src/notify.js +138 -135
- package/src/player.js +488 -488
- package/src/presets.js +87 -87
- package/src/scanner.js +445 -445
- package/src/scumm.js +560 -560
- package/src/tts.js +398 -391
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=
|
|
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
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
const
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
const
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
await
|
|
367
|
-
|
|
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
|
+
}
|