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 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
- // --- CLI ---
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
- function install() {
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
- console.log(` Playing: ${name}`);
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
- const cmd = process.argv[2];
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
- switch (cmd) {
141
- case "install": install(); break;
142
- case "uninstall": uninstall(); break;
143
- case "test": test(); break;
144
- default:
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
- console.log(" agent-noti install Add hooks");
147
- console.log(" agent-noti uninstall Remove hooks");
148
- console.log(" agent-noti test Play sounds");
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 directly by Claude Code hooks.
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 sound = process.argv[2];
19
+ const CONFIG_PATH = join(homedir(), ".agent-noti", "config.json");
15
20
 
16
- if (!sound) process.exit(0);
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
- // Linux: try players in order
30
- execFile("ffplay", ["-nodisp", "-autoexit", "-loglevel", "quiet", file], (err) => {
31
- if (err) execFile("paplay", [file], (err2) => {
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.2",
4
- "description": "Audio notifications for Claude Code hear when Claude is done or needs your input",
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