agent-noti 1.0.2 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/cli.mjs +538 -18
- package/bin/play.mjs +81 -12
- package/package.json +2 -2
- package/sounds/car-idle.wav +0 -0
- package/sounds/car-input.wav +0 -0
- package/sounds/cow-idle.wav +0 -0
- package/sounds/cow-input.wav +0 -0
- package/sounds/digital-glass-idle.wav +0 -0
- package/sounds/digital-glass-input.wav +0 -0
- package/sounds/duck-idle.wav +0 -0
- package/sounds/duck-input.wav +0 -0
- package/sounds/goose-idle.wav +0 -0
- package/sounds/goose-input.wav +0 -0
- package/sounds/slide-whistle-idle.wav +0 -0
- package/sounds/slide-whistle-input.wav +0 -0
- package/sounds/video-game-idle.wav +0 -0
- package/sounds/video-game-input.wav +0 -0
package/bin/cli.mjs
CHANGED
|
@@ -1,21 +1,98 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
|
|
4
|
-
import { join, dirname } from "path";
|
|
5
|
-
import { execSync } from "child_process";
|
|
3
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, copyFileSync } from "fs";
|
|
4
|
+
import { join, dirname, extname } from "path";
|
|
5
|
+
import { execSync, spawn } from "child_process";
|
|
6
|
+
import { createInterface } from "readline";
|
|
6
7
|
import { fileURLToPath } from "url";
|
|
7
|
-
import { homedir } from "os";
|
|
8
|
+
import { homedir, platform } from "os";
|
|
8
9
|
|
|
9
10
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
10
11
|
const PLAY_SCRIPT = join(__dirname, "play.mjs");
|
|
11
12
|
const CODEX_NOTIFY_SCRIPT = join(__dirname, "codex-notify.mjs");
|
|
13
|
+
const SOUNDS_DIR = join(__dirname, "..", "sounds");
|
|
12
14
|
|
|
13
15
|
const CLAUDE_SETTINGS = join(homedir(), ".claude", "settings.json");
|
|
14
16
|
const CODEX_CONFIG = join(homedir(), ".codex", "config.toml");
|
|
17
|
+
const CONFIG_DIR = join(homedir(), ".agent-noti");
|
|
18
|
+
const CONFIG_PATH = join(CONFIG_DIR, "config.json");
|
|
19
|
+
const CUSTOM_SOUNDS_DIR = join(CONFIG_DIR, "sounds");
|
|
15
20
|
|
|
16
21
|
const HOOK_ID = "agent-noti";
|
|
17
22
|
const CODEX_MARKER = "# agent-noti";
|
|
18
23
|
|
|
24
|
+
// --- Sound catalog ---
|
|
25
|
+
|
|
26
|
+
const SOUND_THEMES = [
|
|
27
|
+
{ name: "default", desc: "Original notification" },
|
|
28
|
+
{ name: "cow", desc: "Moo!" },
|
|
29
|
+
{ name: "goose", desc: "Honk!" },
|
|
30
|
+
{ name: "duck", desc: "Quack quack" },
|
|
31
|
+
{ name: "car", desc: "Vroom vroom" },
|
|
32
|
+
{ name: "slide-whistle", desc: "Wheee!" },
|
|
33
|
+
{ name: "video-game", desc: "Retro gaming" },
|
|
34
|
+
{ name: "digital-glass", desc: "Sleek & modern" },
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
// --- Sound file resolution ---
|
|
38
|
+
|
|
39
|
+
function findThemeFile(theme, event) {
|
|
40
|
+
if (theme === "default") {
|
|
41
|
+
return join(SOUNDS_DIR, `${event}.mp3`);
|
|
42
|
+
}
|
|
43
|
+
for (const ext of [".wav", ".mp3", ".aiff", ".ogg"]) {
|
|
44
|
+
const f = join(SOUNDS_DIR, `${theme}-${event}${ext}`);
|
|
45
|
+
if (existsSync(f)) return f;
|
|
46
|
+
}
|
|
47
|
+
// Fallback to default
|
|
48
|
+
return join(SOUNDS_DIR, `${event}.mp3`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// --- Cross-platform audio spawner (non-blocking, returns killable process) ---
|
|
52
|
+
|
|
53
|
+
function spawnPlayer(file, volOverride) {
|
|
54
|
+
const config = readConfig();
|
|
55
|
+
const vol = volOverride ?? Math.max(1, Math.min(10, config.volume ?? 10));
|
|
56
|
+
const volFloat = vol / 10;
|
|
57
|
+
const volPct = vol * 10;
|
|
58
|
+
const volPulse = Math.round(volFloat * 65536);
|
|
59
|
+
|
|
60
|
+
const os = platform();
|
|
61
|
+
if (os === "darwin") {
|
|
62
|
+
return spawn("afplay", ["-v", String(volFloat), file], { stdio: "ignore" });
|
|
63
|
+
} else if (os === "win32") {
|
|
64
|
+
return spawn("powershell", [
|
|
65
|
+
"-NoProfile", "-Command",
|
|
66
|
+
`Add-Type -AssemblyName PresentationCore; $p = New-Object System.Windows.Media.MediaPlayer; $p.Open([uri]'${file.replace(/'/g, "''")}'); $p.Volume = ${volFloat}; $p.Play(); Start-Sleep -Seconds 3`,
|
|
67
|
+
], { stdio: "ignore" });
|
|
68
|
+
} else {
|
|
69
|
+
const proc = spawn("ffplay", ["-nodisp", "-autoexit", "-loglevel", "quiet", "-volume", String(volPct), file], { stdio: "ignore" });
|
|
70
|
+
proc.on("error", () => {
|
|
71
|
+
const p2 = spawn("paplay", ["--volume", String(volPulse), file], { stdio: "ignore" });
|
|
72
|
+
p2.on("error", () => {
|
|
73
|
+
spawn("mpv", ["--no-video", `--volume=${volPct}`, file], { stdio: "ignore" });
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
return proc;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// --- Config ---
|
|
81
|
+
|
|
82
|
+
function readConfig() {
|
|
83
|
+
try {
|
|
84
|
+
if (existsSync(CONFIG_PATH)) {
|
|
85
|
+
return JSON.parse(readFileSync(CONFIG_PATH, "utf-8"));
|
|
86
|
+
}
|
|
87
|
+
} catch {}
|
|
88
|
+
return { idle: "default", input: "default" };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function writeConfig(config) {
|
|
92
|
+
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
93
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + "\n");
|
|
94
|
+
}
|
|
95
|
+
|
|
19
96
|
// --- Claude ---
|
|
20
97
|
|
|
21
98
|
function buildClaudeHooks() {
|
|
@@ -80,11 +157,9 @@ function installCodex() {
|
|
|
80
157
|
|
|
81
158
|
if (existsSync(CODEX_CONFIG)) {
|
|
82
159
|
let toml = readFileSync(CODEX_CONFIG, "utf-8");
|
|
83
|
-
// Remove existing agent-noti or notify lines
|
|
84
160
|
const lines = toml
|
|
85
161
|
.split("\n")
|
|
86
162
|
.filter((l) => !l.includes(CODEX_MARKER) && !l.match(/^\s*notify\s*=/));
|
|
87
|
-
// Insert at top (before any [section] headers) to stay at root level
|
|
88
163
|
const firstSection = lines.findIndex((l) => l.match(/^\s*\[/));
|
|
89
164
|
if (firstSection === -1) {
|
|
90
165
|
lines.push(notifyLine);
|
|
@@ -107,15 +182,194 @@ function uninstallCodex() {
|
|
|
107
182
|
console.log(" Codex: notify removed");
|
|
108
183
|
}
|
|
109
184
|
|
|
110
|
-
// ---
|
|
185
|
+
// --- Interactive picker ---
|
|
186
|
+
|
|
187
|
+
function isCustomPath(val) {
|
|
188
|
+
return val && (val.startsWith("/") || /^[A-Z]:\\/.test(val));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function buildPickerThemes() {
|
|
192
|
+
const config = readConfig();
|
|
193
|
+
const themes = [SOUND_THEMES[0]]; // default first
|
|
194
|
+
|
|
195
|
+
// Show custom option below default if custom sounds have been configured
|
|
196
|
+
if (config.customIdle || config.customInput) {
|
|
197
|
+
themes.push({ name: "custom", desc: "Your custom sounds" });
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Regular themes
|
|
201
|
+
themes.push(...SOUND_THEMES.slice(1));
|
|
202
|
+
|
|
203
|
+
// Add custom trigger at the bottom
|
|
204
|
+
themes.push({ name: "+ Add custom", desc: "Import your own sounds" });
|
|
205
|
+
|
|
206
|
+
return themes;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function resolvePickerPreview(themeName, event, config) {
|
|
210
|
+
if (themeName === "custom") {
|
|
211
|
+
const path = event === "idle" ? config.customIdle : config.customInput;
|
|
212
|
+
if (path && existsSync(path)) return path;
|
|
213
|
+
return join(SOUNDS_DIR, `${event}.mp3`); // fallback
|
|
214
|
+
}
|
|
215
|
+
if (themeName === "+ Add custom") return null;
|
|
216
|
+
return findThemeFile(themeName, event);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function picker() {
|
|
220
|
+
return new Promise((resolve) => {
|
|
221
|
+
if (!process.stdin.isTTY) {
|
|
222
|
+
resolve(null);
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const themes = buildPickerThemes();
|
|
227
|
+
const config = readConfig();
|
|
228
|
+
|
|
229
|
+
// Determine current theme for pre-selection
|
|
230
|
+
const currentTheme = isCustomPath(config.idle) ? "custom" : (config.idle || "default");
|
|
231
|
+
let selected = Math.max(0, themes.findIndex((t) => t.name === currentTheme));
|
|
111
232
|
|
|
112
|
-
|
|
233
|
+
let nowPlaying = "";
|
|
234
|
+
let previewProc = null;
|
|
235
|
+
const maxName = Math.max(...themes.map((s) => s.name.length));
|
|
236
|
+
|
|
237
|
+
const totalLines = themes.length + 5;
|
|
238
|
+
|
|
239
|
+
function killPreview() {
|
|
240
|
+
if (previewProc) {
|
|
241
|
+
try { previewProc.kill(); } catch {}
|
|
242
|
+
previewProc = null;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function playPreview(themeName, event) {
|
|
247
|
+
killPreview();
|
|
248
|
+
const file = resolvePickerPreview(themeName, event, config);
|
|
249
|
+
if (!file || !existsSync(file)) return;
|
|
250
|
+
nowPlaying = `${themeName} ${event}`;
|
|
251
|
+
previewProc = spawnPlayer(file);
|
|
252
|
+
previewProc.on("close", () => {
|
|
253
|
+
if (nowPlaying === `${themeName} ${event}`) nowPlaying = "";
|
|
254
|
+
render();
|
|
255
|
+
});
|
|
256
|
+
render();
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function render(firstTime) {
|
|
260
|
+
if (!firstTime) {
|
|
261
|
+
process.stdout.write(`\x1b[${totalLines}A`);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
process.stdout.write("\x1b[2K\n");
|
|
265
|
+
process.stdout.write(`\x1b[2K \x1b[1mSelect notification theme:\x1b[0m\n`);
|
|
266
|
+
process.stdout.write("\x1b[2K\n");
|
|
267
|
+
|
|
268
|
+
themes.forEach((theme, i) => {
|
|
269
|
+
const isSelected = i === selected;
|
|
270
|
+
const arrow = isSelected ? "\x1b[36m> " : " ";
|
|
271
|
+
const color = isSelected ? "\x1b[36m" : "\x1b[90m";
|
|
272
|
+
const reset = "\x1b[0m";
|
|
273
|
+
|
|
274
|
+
let active = "";
|
|
275
|
+
if (theme.name === "custom" && isCustomPath(config.idle)) {
|
|
276
|
+
active = " \x1b[32m(current)\x1b[0m";
|
|
277
|
+
} else if (theme.name !== "custom" && theme.name !== "+ Add custom"
|
|
278
|
+
&& config.idle === theme.name && config.input === theme.name) {
|
|
279
|
+
active = " \x1b[32m(current)\x1b[0m";
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
process.stdout.write(
|
|
283
|
+
`\x1b[2K ${arrow}${color}${theme.name.padEnd(maxName + 2)}${theme.desc}${reset}${active}\n`
|
|
284
|
+
);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
process.stdout.write("\x1b[2K\n");
|
|
288
|
+
const isAddCustom = themes[selected].name === "+ Add custom";
|
|
289
|
+
const playInfo = nowPlaying ? ` \x1b[33m♪ ${nowPlaying}\x1b[0m` : "";
|
|
290
|
+
const controls = isAddCustom
|
|
291
|
+
? `\x1b[2K \x1b[90m[up/down] Navigate [enter] Add custom [q] Quit\x1b[0m${playInfo}\n`
|
|
292
|
+
: `\x1b[2K \x1b[90m[up/down] Navigate [<] Play idle [>] Play input [enter] Select [q] Quit\x1b[0m${playInfo}\n`;
|
|
293
|
+
process.stdout.write(controls);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const stdin = process.stdin;
|
|
297
|
+
stdin.setRawMode(true);
|
|
298
|
+
stdin.resume();
|
|
299
|
+
stdin.setEncoding("utf8");
|
|
300
|
+
|
|
301
|
+
render(true);
|
|
302
|
+
|
|
303
|
+
function cleanup() {
|
|
304
|
+
killPreview();
|
|
305
|
+
stdin.removeListener("data", onKey);
|
|
306
|
+
stdin.setRawMode(false);
|
|
307
|
+
stdin.pause();
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function onKey(key) {
|
|
311
|
+
if (key === "\x03") {
|
|
312
|
+
cleanup();
|
|
313
|
+
console.log("");
|
|
314
|
+
process.exit(0);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (key === "q" || key === "Q") {
|
|
318
|
+
cleanup();
|
|
319
|
+
console.log("\n No changes made.\n");
|
|
320
|
+
resolve(null);
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (key === "\r" || key === "\n") {
|
|
325
|
+
cleanup();
|
|
326
|
+
resolve(themes[selected].name);
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (key === "\x1b[A" || key === "k") {
|
|
331
|
+
selected = (selected - 1 + themes.length) % themes.length;
|
|
332
|
+
render();
|
|
333
|
+
}
|
|
334
|
+
else if (key === "\x1b[B" || key === "j") {
|
|
335
|
+
selected = (selected + 1) % themes.length;
|
|
336
|
+
render();
|
|
337
|
+
}
|
|
338
|
+
else if (key === "\x1b[D") {
|
|
339
|
+
playPreview(themes[selected].name, "idle");
|
|
340
|
+
}
|
|
341
|
+
else if (key === "\x1b[C") {
|
|
342
|
+
playPreview(themes[selected].name, "input");
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
stdin.on("data", onKey);
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// --- CLI commands ---
|
|
351
|
+
|
|
352
|
+
async function install() {
|
|
113
353
|
console.log("");
|
|
114
354
|
installClaude();
|
|
115
355
|
installCodex();
|
|
116
356
|
console.log("");
|
|
117
357
|
console.log(" Restart Claude Code / Codex to activate.");
|
|
118
358
|
console.log("");
|
|
359
|
+
|
|
360
|
+
if (process.stdin.isTTY) {
|
|
361
|
+
while (true) {
|
|
362
|
+
const choice = await picker();
|
|
363
|
+
if (choice === "+ Add custom") {
|
|
364
|
+
await addCustom();
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
if (choice) applyPickerChoice(choice);
|
|
368
|
+
break;
|
|
369
|
+
}
|
|
370
|
+
} else {
|
|
371
|
+
console.log(" Run 'agent-noti pick' to choose a sound theme.\n");
|
|
372
|
+
}
|
|
119
373
|
}
|
|
120
374
|
|
|
121
375
|
function uninstall() {
|
|
@@ -127,24 +381,290 @@ function uninstall() {
|
|
|
127
381
|
|
|
128
382
|
function test() {
|
|
129
383
|
console.log("");
|
|
384
|
+
const config = readConfig();
|
|
130
385
|
for (const name of ["idle", "input"]) {
|
|
131
|
-
|
|
386
|
+
const theme = config[name] || "default";
|
|
387
|
+
console.log(` Playing ${name} (${theme})...`);
|
|
132
388
|
execSync(`node "${PLAY_SCRIPT}" ${name}`, { stdio: "inherit" });
|
|
133
389
|
execSync(process.platform === "win32" ? "timeout /t 1 >nul" : "sleep 1");
|
|
134
390
|
}
|
|
135
391
|
console.log("");
|
|
136
392
|
}
|
|
137
393
|
|
|
138
|
-
|
|
394
|
+
function sounds() {
|
|
395
|
+
const config = readConfig();
|
|
396
|
+
const maxName = Math.max(...SOUND_THEMES.map((s) => s.name.length));
|
|
397
|
+
const vol = config.volume ?? 10;
|
|
398
|
+
const muted = config.muted ?? false;
|
|
399
|
+
|
|
400
|
+
console.log("");
|
|
401
|
+
console.log(" Available sound themes:");
|
|
402
|
+
console.log("");
|
|
403
|
+
|
|
404
|
+
for (const { name, desc } of SOUND_THEMES) {
|
|
405
|
+
const current =
|
|
406
|
+
(config.idle === name ? " [idle]" : "") +
|
|
407
|
+
(config.input === name ? " [input]" : "");
|
|
408
|
+
const marker = config.idle === name && config.input === name ? " [active]" : current;
|
|
409
|
+
console.log(` ${name.padEnd(maxName + 2)} ${desc}${marker}`);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
if (config.customIdle || config.customInput) {
|
|
413
|
+
const isActive = isCustomPath(config.idle) || isCustomPath(config.input);
|
|
414
|
+
const marker = isActive ? " [active]" : "";
|
|
415
|
+
console.log(` ${"custom".padEnd(maxName + 2)} Your custom sounds${marker}`);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const idleLabel = isCustomPath(config.idle) ? "custom" : (config.idle || "default");
|
|
419
|
+
const inputLabel = isCustomPath(config.input) ? "custom" : (config.input || "default");
|
|
420
|
+
console.log("");
|
|
421
|
+
console.log(" Theme: idle=%s, input=%s", idleLabel, inputLabel);
|
|
422
|
+
const volBar = "#".repeat(vol) + "-".repeat(10 - vol);
|
|
423
|
+
console.log(` Volume: [${volBar}] ${vol}/10${muted ? " (MUTED)" : ""}`);
|
|
424
|
+
console.log("");
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// --- Interactive add-custom ---
|
|
428
|
+
|
|
429
|
+
function selectOption(title, options) {
|
|
430
|
+
return new Promise((resolve) => {
|
|
431
|
+
if (!process.stdin.isTTY) { resolve(null); return; }
|
|
432
|
+
|
|
433
|
+
let selected = 0;
|
|
434
|
+
const totalLines = options.length + 4; // blank + title + blank + options + blank
|
|
435
|
+
|
|
436
|
+
function render(firstTime) {
|
|
437
|
+
if (!firstTime) process.stdout.write(`\x1b[${totalLines}A`);
|
|
438
|
+
process.stdout.write("\x1b[2K\n");
|
|
439
|
+
process.stdout.write(`\x1b[2K \x1b[1m${title}\x1b[0m\n`);
|
|
440
|
+
process.stdout.write("\x1b[2K\n");
|
|
441
|
+
options.forEach((opt, i) => {
|
|
442
|
+
const arrow = i === selected ? "\x1b[36m> " : " ";
|
|
443
|
+
const color = i === selected ? "\x1b[36m" : "\x1b[90m";
|
|
444
|
+
process.stdout.write(`\x1b[2K ${arrow}${color}${opt.label}\x1b[0m\n`);
|
|
445
|
+
});
|
|
446
|
+
process.stdout.write("\x1b[2K\n");
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const stdin = process.stdin;
|
|
450
|
+
stdin.setRawMode(true);
|
|
451
|
+
stdin.resume();
|
|
452
|
+
stdin.setEncoding("utf8");
|
|
453
|
+
render(true);
|
|
454
|
+
|
|
455
|
+
function cleanup() {
|
|
456
|
+
stdin.removeListener("data", onKey);
|
|
457
|
+
stdin.setRawMode(false);
|
|
458
|
+
stdin.pause();
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function onKey(key) {
|
|
462
|
+
if (key === "\x03") { cleanup(); console.log(""); process.exit(0); }
|
|
463
|
+
if (key === "q" || key === "Q") { cleanup(); resolve(null); return; }
|
|
464
|
+
if (key === "\r" || key === "\n") { cleanup(); resolve(options[selected].value); return; }
|
|
465
|
+
if (key === "\x1b[A" || key === "k") { selected = (selected - 1 + options.length) % options.length; render(); }
|
|
466
|
+
else if (key === "\x1b[B" || key === "j") { selected = (selected + 1) % options.length; render(); }
|
|
467
|
+
}
|
|
468
|
+
stdin.on("data", onKey);
|
|
469
|
+
});
|
|
470
|
+
}
|
|
139
471
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
472
|
+
function promptPath(label) {
|
|
473
|
+
return new Promise((resolve) => {
|
|
474
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
475
|
+
rl.question(` ${label}`, (answer) => {
|
|
476
|
+
rl.close();
|
|
477
|
+
resolve(answer.trim());
|
|
478
|
+
});
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function copyCustomSound(sourcePath, event) {
|
|
483
|
+
const ext = extname(sourcePath);
|
|
484
|
+
const destName = `custom-${event}${ext}`;
|
|
485
|
+
const destPath = join(CUSTOM_SOUNDS_DIR, destName);
|
|
486
|
+
mkdirSync(CUSTOM_SOUNDS_DIR, { recursive: true });
|
|
487
|
+
copyFileSync(sourcePath, destPath);
|
|
488
|
+
return destPath;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
async function addCustom() {
|
|
492
|
+
if (!process.stdin.isTTY) {
|
|
493
|
+
console.log("\n This command requires an interactive terminal.\n");
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const config = readConfig();
|
|
498
|
+
let idlePath = null;
|
|
499
|
+
|
|
500
|
+
// Step 1: Idle sound
|
|
501
|
+
const idleChoice = await selectOption("Idle sound (when agent finishes):", [
|
|
502
|
+
{ label: "Enter file path", value: "path" },
|
|
503
|
+
{ label: "Skip (use default)", value: "skip" },
|
|
504
|
+
]);
|
|
505
|
+
|
|
506
|
+
if (idleChoice === null) { console.log("\n No changes made.\n"); return; }
|
|
507
|
+
|
|
508
|
+
if (idleChoice === "path") {
|
|
145
509
|
console.log("");
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
510
|
+
const p = await promptPath("Path to idle sound: ");
|
|
511
|
+
if (!p) {
|
|
512
|
+
console.log(" No path provided, using default.\n");
|
|
513
|
+
} else if (!existsSync(p)) {
|
|
514
|
+
console.log(` File not found: ${p} — using default.\n`);
|
|
515
|
+
} else {
|
|
516
|
+
idlePath = p;
|
|
517
|
+
const dest = copyCustomSound(p, "idle");
|
|
518
|
+
config.idle = dest;
|
|
519
|
+
config.customIdle = dest;
|
|
520
|
+
console.log(` Copied to: ${dest}\n`);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
if (!idlePath && idleChoice !== "skip") {
|
|
525
|
+
config.idle = config.idle || "default";
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// Step 2: Input sound
|
|
529
|
+
const inputOptions = [
|
|
530
|
+
{ label: "Enter file path", value: "path" },
|
|
531
|
+
...(idlePath ? [{ label: "Same as idle", value: "same" }] : []),
|
|
532
|
+
{ label: "Skip (use default)", value: "skip" },
|
|
533
|
+
];
|
|
534
|
+
|
|
535
|
+
const inputChoice = await selectOption("Input sound (when agent needs approval):", inputOptions);
|
|
536
|
+
|
|
537
|
+
if (inputChoice === null) { console.log("\n No changes made.\n"); return; }
|
|
538
|
+
|
|
539
|
+
if (inputChoice === "path") {
|
|
149
540
|
console.log("");
|
|
541
|
+
const p = await promptPath("Path to input sound: ");
|
|
542
|
+
if (!p) {
|
|
543
|
+
console.log(" No path provided, using default.\n");
|
|
544
|
+
} else if (!existsSync(p)) {
|
|
545
|
+
console.log(` File not found: ${p} — using default.\n`);
|
|
546
|
+
} else {
|
|
547
|
+
const dest = copyCustomSound(p, "input");
|
|
548
|
+
config.input = dest;
|
|
549
|
+
config.customInput = dest;
|
|
550
|
+
console.log(` Copied to: ${dest}\n`);
|
|
551
|
+
}
|
|
552
|
+
} else if (inputChoice === "same" && idlePath) {
|
|
553
|
+
const dest = copyCustomSound(idlePath, "input");
|
|
554
|
+
config.input = dest;
|
|
555
|
+
config.customInput = dest;
|
|
556
|
+
console.log(`\n Input set to same as idle.\n`);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// Ensure both have values
|
|
560
|
+
if (!config.idle) config.idle = "default";
|
|
561
|
+
if (!config.input) config.input = "default";
|
|
562
|
+
|
|
563
|
+
writeConfig(config);
|
|
564
|
+
console.log(" Custom sounds applied.\n");
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function mute() {
|
|
568
|
+
const config = readConfig();
|
|
569
|
+
config.muted = true;
|
|
570
|
+
writeConfig(config);
|
|
571
|
+
console.log("\n Notifications muted.\n");
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function unmute() {
|
|
575
|
+
const config = readConfig();
|
|
576
|
+
config.muted = false;
|
|
577
|
+
writeConfig(config);
|
|
578
|
+
console.log("\n Notifications unmuted.\n");
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function volume(args) {
|
|
582
|
+
const config = readConfig();
|
|
583
|
+
|
|
584
|
+
if (args.length === 0) {
|
|
585
|
+
const vol = config.volume ?? 10;
|
|
586
|
+
const muted = config.muted ?? false;
|
|
587
|
+
const bar = "#".repeat(vol) + "-".repeat(10 - vol);
|
|
588
|
+
console.log(`\n Volume: [${bar}] ${vol}/10${muted ? " (MUTED)" : ""}\n`);
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
const val = parseInt(args[0], 10);
|
|
593
|
+
if (isNaN(val) || val < 1 || val > 10) {
|
|
594
|
+
console.log("\n Usage: agent-noti volume <1-10>\n");
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
config.volume = val;
|
|
599
|
+
if (config.muted) config.muted = false; // setting volume implies unmute
|
|
600
|
+
writeConfig(config);
|
|
601
|
+
const bar = "#".repeat(val) + "-".repeat(10 - val);
|
|
602
|
+
console.log(`\n Volume: [${bar}] ${val}/10\n`);
|
|
150
603
|
}
|
|
604
|
+
|
|
605
|
+
function reset() {
|
|
606
|
+
writeConfig({ idle: "default", input: "default", volume: 10, muted: false });
|
|
607
|
+
console.log("\n Reset to defaults (theme=default, volume=10, unmuted).\n");
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function applyPickerChoice(choice) {
|
|
611
|
+
const config = readConfig();
|
|
612
|
+
if (choice === "custom") {
|
|
613
|
+
config.idle = config.customIdle || "default";
|
|
614
|
+
config.input = config.customInput || "default";
|
|
615
|
+
writeConfig(config);
|
|
616
|
+
console.log(`\n Theme set to: custom\n`);
|
|
617
|
+
} else {
|
|
618
|
+
config.idle = choice;
|
|
619
|
+
config.input = choice;
|
|
620
|
+
writeConfig(config);
|
|
621
|
+
console.log(`\n Theme set to: ${choice}\n`);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
async function pick() {
|
|
626
|
+
while (true) {
|
|
627
|
+
const choice = await picker();
|
|
628
|
+
if (choice === "+ Add custom") {
|
|
629
|
+
await addCustom();
|
|
630
|
+
continue; // restart picker to show updated custom entry
|
|
631
|
+
}
|
|
632
|
+
if (choice) applyPickerChoice(choice);
|
|
633
|
+
break;
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// --- Main ---
|
|
638
|
+
|
|
639
|
+
const cmd = process.argv[2];
|
|
640
|
+
const args = process.argv.slice(3);
|
|
641
|
+
|
|
642
|
+
async function main() {
|
|
643
|
+
switch (cmd) {
|
|
644
|
+
case "install": case "i": await install(); break;
|
|
645
|
+
case "uninstall": uninstall(); break;
|
|
646
|
+
case "test": case "t": test(); break;
|
|
647
|
+
case "sounds": case "s": sounds(); break;
|
|
648
|
+
case "pick": case "p": await pick(); break;
|
|
649
|
+
case "add-custom": case "ac": await addCustom(); break;
|
|
650
|
+
case "volume": case "v": volume(args); break;
|
|
651
|
+
case "mute": case "m": mute(); break;
|
|
652
|
+
case "unmute": case "u": unmute(); break;
|
|
653
|
+
case "reset": case "r": reset(); break;
|
|
654
|
+
default:
|
|
655
|
+
console.log("");
|
|
656
|
+
console.log(" agent-noti install (i) Add hooks + pick theme");
|
|
657
|
+
console.log(" agent-noti uninstall Remove hooks");
|
|
658
|
+
console.log(" agent-noti test (t) Play current sounds");
|
|
659
|
+
console.log(" agent-noti sounds (s) List available themes");
|
|
660
|
+
console.log(" agent-noti pick (p) Interactive sound picker");
|
|
661
|
+
console.log(" agent-noti add-custom(ac) Use your own sound files");
|
|
662
|
+
console.log(" agent-noti volume (v) Set volume <1-10>");
|
|
663
|
+
console.log(" agent-noti mute (m) Mute notifications");
|
|
664
|
+
console.log(" agent-noti unmute (u) Unmute notifications");
|
|
665
|
+
console.log(" agent-noti reset (r) Reset everything");
|
|
666
|
+
console.log("");
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
main();
|
package/bin/play.mjs
CHANGED
|
@@ -1,35 +1,104 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* Cross-platform audio player. Called
|
|
4
|
-
* Usage: node play.mjs <idle|input>
|
|
3
|
+
* Cross-platform audio player. Called by hooks or CLI.
|
|
4
|
+
* Usage: node play.mjs <idle|input> — plays configured sound for event
|
|
5
|
+
* node play.mjs <theme> — plays theme's idle sound (preview)
|
|
6
|
+
* node play.mjs --file <path> — plays a file directly
|
|
7
|
+
*
|
|
8
|
+
* Respects ~/.agent-noti/config.json for mute and volume (1-10).
|
|
5
9
|
*/
|
|
6
10
|
|
|
7
11
|
import { execFile, exec } from "child_process";
|
|
8
12
|
import { join, dirname } from "path";
|
|
9
13
|
import { fileURLToPath } from "url";
|
|
10
|
-
import { platform } from "os";
|
|
14
|
+
import { platform, homedir } from "os";
|
|
15
|
+
import { existsSync, readFileSync } from "fs";
|
|
11
16
|
|
|
12
17
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
13
18
|
const SOUNDS_DIR = join(__dirname, "..", "sounds");
|
|
14
|
-
const
|
|
19
|
+
const CONFIG_PATH = join(homedir(), ".agent-noti", "config.json");
|
|
15
20
|
|
|
16
|
-
|
|
21
|
+
const EVENTS = ["idle", "input"];
|
|
22
|
+
|
|
23
|
+
function readConfig() {
|
|
24
|
+
try {
|
|
25
|
+
if (existsSync(CONFIG_PATH)) {
|
|
26
|
+
return JSON.parse(readFileSync(CONFIG_PATH, "utf-8"));
|
|
27
|
+
}
|
|
28
|
+
} catch {}
|
|
29
|
+
return {};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function findFile(name) {
|
|
33
|
+
for (const ext of [".wav", ".mp3", ".aiff", ".ogg"]) {
|
|
34
|
+
const f = join(SOUNDS_DIR, name + ext);
|
|
35
|
+
if (existsSync(f)) return f;
|
|
36
|
+
}
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function resolveSound(arg) {
|
|
41
|
+
// --file <path>: play a file directly
|
|
42
|
+
if (arg === "--file") {
|
|
43
|
+
const fp = process.argv[3];
|
|
44
|
+
return fp && existsSync(fp) ? fp : null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const config = readConfig();
|
|
48
|
+
|
|
49
|
+
// Event name (idle / input) — resolve through config
|
|
50
|
+
if (EVENTS.includes(arg)) {
|
|
51
|
+
const theme = config[arg] || "default";
|
|
52
|
+
|
|
53
|
+
// Absolute path (custom sound)
|
|
54
|
+
if (theme.startsWith("/") || /^[A-Z]:\\/.test(theme)) {
|
|
55
|
+
if (existsSync(theme)) return theme;
|
|
56
|
+
return join(SOUNDS_DIR, `${arg}.mp3`); // fallback
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Default theme
|
|
60
|
+
if (theme === "default") {
|
|
61
|
+
return join(SOUNDS_DIR, `${arg}.mp3`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Named theme: <theme>-<event>.wav
|
|
65
|
+
return findFile(`${theme}-${arg}`) || join(SOUNDS_DIR, `${arg}.mp3`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Direct sound name (for preview): try <name>-idle first, then <name>
|
|
69
|
+
return findFile(`${arg}-idle`) || findFile(arg) || null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const arg = process.argv[2];
|
|
73
|
+
if (!arg) process.exit(0);
|
|
74
|
+
|
|
75
|
+
const config = readConfig();
|
|
76
|
+
|
|
77
|
+
// Mute check (skip for --file, which is used by picker previews)
|
|
78
|
+
if (arg !== "--file" && config.muted) process.exit(0);
|
|
79
|
+
|
|
80
|
+
const file = resolveSound(arg);
|
|
81
|
+
if (!file) process.exit(1);
|
|
82
|
+
|
|
83
|
+
// Volume: 1-10 config → 0.0-1.0 native scale
|
|
84
|
+
const vol = Math.max(1, Math.min(10, config.volume ?? 10));
|
|
85
|
+
const volFloat = vol / 10; // 0.1 – 1.0 (macOS, Windows)
|
|
86
|
+
const volPct = vol * 10; // 10 – 100 (Linux ffplay, mpv)
|
|
87
|
+
const volPulse = Math.round(volFloat * 65536); // paplay scale
|
|
17
88
|
|
|
18
|
-
const file = join(SOUNDS_DIR, `${sound}.mp3`);
|
|
19
89
|
const os = platform();
|
|
20
90
|
|
|
21
91
|
if (os === "darwin") {
|
|
22
|
-
execFile("afplay", [file], () => {});
|
|
92
|
+
execFile("afplay", ["-v", String(volFloat), file], () => {});
|
|
23
93
|
} else if (os === "win32") {
|
|
24
94
|
exec(
|
|
25
|
-
`powershell -NoProfile -Command "Add-Type -AssemblyName PresentationCore; $p = New-Object System.Windows.Media.MediaPlayer; $p.Open([uri]'${file.replace(/'/g, "''")}'); $p.Play(); Start-Sleep -Seconds 3"`,
|
|
95
|
+
`powershell -NoProfile -Command "Add-Type -AssemblyName PresentationCore; $p = New-Object System.Windows.Media.MediaPlayer; $p.Open([uri]'${file.replace(/'/g, "''")}'); $p.Volume = ${volFloat}; $p.Play(); Start-Sleep -Seconds 3"`,
|
|
26
96
|
() => {}
|
|
27
97
|
);
|
|
28
98
|
} else {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
if (err2) execFile("mpv", ["--no-video", file], () => {});
|
|
99
|
+
execFile("ffplay", ["-nodisp", "-autoexit", "-loglevel", "quiet", "-volume", String(volPct), file], (err) => {
|
|
100
|
+
if (err) execFile("paplay", ["--volume", String(volPulse), file], (err2) => {
|
|
101
|
+
if (err2) execFile("mpv", ["--no-video", `--volume=${volPct}`, file], () => {});
|
|
33
102
|
});
|
|
34
103
|
});
|
|
35
104
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-noti",
|
|
3
|
-
"version": "1.0
|
|
4
|
-
"description": "Audio notifications for Claude Code
|
|
3
|
+
"version": "1.2.0",
|
|
4
|
+
"description": "Audio notifications for Claude Code & Codex — customizable sound themes",
|
|
5
5
|
"bin": {
|
|
6
6
|
"agent-noti": "./bin/cli.mjs"
|
|
7
7
|
},
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|