agent-noti 1.3.0 → 1.4.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/README.md CHANGED
@@ -37,16 +37,18 @@ Each theme includes a separate idle and input sound.
37
37
  ## Commands
38
38
 
39
39
  ```sh
40
- agent-noti install # Add hooks + pick theme (i)
40
+ agent-noti install # Add hooks + pick theme (i)
41
41
  agent-noti uninstall # Remove hooks
42
- agent-noti test # Play current sounds (t)
43
- agent-noti sounds # List available themes (s)
44
- agent-noti pick # Interactive sound picker (p)
45
- agent-noti add-custom # Use your own sound files (ac)
46
- agent-noti volume <1-10> # Set volume level (v)
47
- agent-noti mute # Mute notifications (m)
48
- agent-noti unmute # Unmute notifications (u)
49
- agent-noti reset # Reset everything (r)
42
+ agent-noti test # Play current sounds (t)
43
+ agent-noti sounds # List available themes (s)
44
+ agent-noti pick # Interactive sound picker (p)
45
+ agent-noti add-custom # Use your own sound files (ac)
46
+ agent-noti volume <1-10> # Set volume level (v)
47
+ agent-noti mute # Mute notifications (m)
48
+ agent-noti unmute # Unmute notifications (u)
49
+ agent-noti ntfy # Configure ntfy.sh push alerts (n)
50
+ agent-noti ntfy-test # Send a test push notification (nt)
51
+ agent-noti reset # Reset everything (r)
50
52
  ```
51
53
 
52
54
  Every command has a short alias shown in parentheses — e.g. `agent-noti v 5` instead of `agent-noti volume 5`.
@@ -87,6 +89,43 @@ agent-noti unmute # Re-enable notifications
87
89
 
88
90
  Setting volume while muted auto-unmutes. Volume works across all platforms.
89
91
 
92
+ ## Push notifications (ntfy.sh)
93
+
94
+ Get push notifications on your phone or desktop via [ntfy.sh](https://ntfy.sh) — even when you're away from the terminal. ntfy works independently from audio: you can mute sounds and still receive push alerts.
95
+
96
+ ### Setup
97
+
98
+ ```sh
99
+ agent-noti ntfy
100
+ ```
101
+
102
+ This opens an interactive TUI where you can:
103
+ - Set your ntfy server and topic
104
+ - Toggle which events send push notifications (task complete / approval needed)
105
+ - Set priority level
106
+ - Send a test notification
107
+
108
+ On first run you'll be prompted for a topic name. Subscribe to the same topic in the [ntfy app](https://ntfy.sh) (Android/iOS/web) to receive notifications.
109
+
110
+ ### Quick test
111
+
112
+ ```sh
113
+ agent-noti ntfy-test
114
+ ```
115
+
116
+ Sends a test notification to your configured topic without opening the interactive TUI.
117
+
118
+ ### Config fields
119
+
120
+ | Field | Default | Description |
121
+ |---|---|---|
122
+ | `ntfy.enabled` | `false` | Master on/off |
123
+ | `ntfy.server` | `https://ntfy.sh` | ntfy server URL |
124
+ | `ntfy.topic` | — | Your topic name (required) |
125
+ | `ntfy.priority` | `default` | `min`, `low`, `default`, `high`, `urgent` |
126
+ | `ntfy.idle` | `true` | Notify on task complete |
127
+ | `ntfy.input` | `true` | Notify on approval needed |
128
+
90
129
  ## Config
91
130
 
92
131
  All settings are stored in `~/.agent-noti/config.json`:
@@ -96,7 +135,15 @@ All settings are stored in `~/.agent-noti/config.json`:
96
135
  "idle": "cow",
97
136
  "input": "cow",
98
137
  "volume": 10,
99
- "muted": false
138
+ "muted": false,
139
+ "ntfy": {
140
+ "enabled": true,
141
+ "server": "https://ntfy.sh",
142
+ "topic": "my-agent-alerts",
143
+ "priority": "default",
144
+ "idle": true,
145
+ "input": true
146
+ }
100
147
  }
101
148
  ```
102
149
 
package/bin/cli.mjs CHANGED
@@ -421,6 +421,14 @@ function sounds() {
421
421
  console.log(" Theme: idle=%s, input=%s", idleLabel, inputLabel);
422
422
  const volBar = "#".repeat(vol) + "-".repeat(10 - vol);
423
423
  console.log(` Volume: [${volBar}] ${vol}/10${muted ? " (MUTED)" : ""}`);
424
+
425
+ if (config.ntfy && config.ntfy.topic) {
426
+ const n = config.ntfy;
427
+ const server = (n.server || "https://ntfy.sh").replace(/^https?:\/\//, "").replace(/\/+$/, "");
428
+ const status = n.enabled ? "enabled" : "disabled";
429
+ console.log(` ntfy: ${status} (${server}/${n.topic})`);
430
+ }
431
+
424
432
  console.log("");
425
433
  }
426
434
 
@@ -564,6 +572,232 @@ async function addCustom() {
564
572
  console.log(" Custom sounds applied.\n");
565
573
  }
566
574
 
575
+ // --- ntfy.sh push notifications ---
576
+
577
+ const NTFY_PRIORITIES = ["min", "low", "default", "high", "urgent"];
578
+
579
+ function ntfyPrompt(label) {
580
+ return new Promise((resolve) => {
581
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
582
+ rl.question(` ${label}`, (answer) => {
583
+ rl.close();
584
+ resolve(answer.trim());
585
+ });
586
+ });
587
+ }
588
+
589
+ async function ntfyInitialSetup() {
590
+ console.log("\n First-time ntfy.sh setup\n");
591
+ const topic = await ntfyPrompt("Topic (required): ");
592
+ if (!topic) {
593
+ console.log(" Topic is required. Aborting.\n");
594
+ return null;
595
+ }
596
+ const server = await ntfyPrompt("Server (enter for https://ntfy.sh): ");
597
+ return { topic, server: server || "https://ntfy.sh" };
598
+ }
599
+
600
+ async function ntfySendTest(ntfyConfig) {
601
+ const server = (ntfyConfig.server || "https://ntfy.sh").replace(/\/+$/, "");
602
+ const url = `${server}/${ntfyConfig.topic}`;
603
+ const res = await fetch(url, {
604
+ method: "POST",
605
+ headers: {
606
+ Title: "Test Notification",
607
+ Priority: ntfyConfig.priority || "default",
608
+ Tags: "bell",
609
+ },
610
+ body: "agent-noti test notification",
611
+ });
612
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
613
+ }
614
+
615
+ function ntfy() {
616
+ return new Promise(async (resolve) => {
617
+ if (!process.stdin.isTTY) {
618
+ console.log("\n This command requires an interactive terminal.\n");
619
+ resolve();
620
+ return;
621
+ }
622
+
623
+ const config = readConfig();
624
+
625
+ // First-time setup: prompt for topic
626
+ if (!config.ntfy || !config.ntfy.topic) {
627
+ const setup = await ntfyInitialSetup();
628
+ if (!setup) { resolve(); return; }
629
+ config.ntfy = {
630
+ enabled: true,
631
+ server: setup.server,
632
+ topic: setup.topic,
633
+ priority: "default",
634
+ idle: true,
635
+ input: true,
636
+ };
637
+ writeConfig(config);
638
+ }
639
+
640
+ const ntfyConf = config.ntfy;
641
+ const rows = ["idle", "input"];
642
+ let selected = 0;
643
+ let statusMsg = "";
644
+ const totalLines = 12;
645
+
646
+ function render(firstTime) {
647
+ if (!firstTime) process.stdout.write(`\x1b[${totalLines}A`);
648
+
649
+ process.stdout.write("\x1b[2K\n");
650
+ process.stdout.write(`\x1b[2K \x1b[1mntfy.sh push notifications\x1b[0m\n`);
651
+ process.stdout.write("\x1b[2K\n");
652
+ process.stdout.write(`\x1b[2K Server: \x1b[36m${ntfyConf.server || "https://ntfy.sh"}\x1b[0m\n`);
653
+ process.stdout.write(`\x1b[2K Topic: \x1b[36m${ntfyConf.topic}\x1b[0m\n`);
654
+ process.stdout.write(`\x1b[2K Priority: \x1b[36m${ntfyConf.priority || "default"}\x1b[0m\n`);
655
+ process.stdout.write("\x1b[2K\n");
656
+
657
+ for (let i = 0; i < rows.length; i++) {
658
+ const checked = ntfyConf[rows[i]] ? "x" : " ";
659
+ const label = rows[i] === "idle" ? "Notify on task complete (idle)" : "Notify on approval needed (input)";
660
+ const arrow = i === selected ? "\x1b[36m> " : " ";
661
+ const color = i === selected ? "\x1b[36m" : "\x1b[0m";
662
+ process.stdout.write(`\x1b[2K ${arrow}${color}[${checked}] ${label}\x1b[0m\n`);
663
+ }
664
+
665
+ process.stdout.write("\x1b[2K\n");
666
+ const status = statusMsg ? ` ${statusMsg}` : "";
667
+ process.stdout.write(`\x1b[2K \x1b[90m[space] Toggle [up/down] Navigate [e] Edit [t] Test [q] Save & quit\x1b[0m${status}\n`);
668
+ process.stdout.write(`\x1b[2K\n`);
669
+ }
670
+
671
+ const stdin = process.stdin;
672
+ stdin.setRawMode(true);
673
+ stdin.resume();
674
+ stdin.setEncoding("utf8");
675
+
676
+ render(true);
677
+
678
+ function cleanup() {
679
+ stdin.removeListener("data", onKey);
680
+ stdin.setRawMode(false);
681
+ stdin.pause();
682
+ }
683
+
684
+ function save() {
685
+ ntfyConf.enabled = ntfyConf.idle || ntfyConf.input;
686
+ config.ntfy = ntfyConf;
687
+ writeConfig(config);
688
+ }
689
+
690
+ async function editFields() {
691
+ cleanup();
692
+ console.log("");
693
+
694
+ const newServer = await ntfyPrompt(`Server (${ntfyConf.server || "https://ntfy.sh"}): `);
695
+ if (newServer) ntfyConf.server = newServer;
696
+
697
+ const newTopic = await ntfyPrompt(`Topic (${ntfyConf.topic}): `);
698
+ if (newTopic) ntfyConf.topic = newTopic;
699
+
700
+ const priIdx = NTFY_PRIORITIES.indexOf(ntfyConf.priority || "default");
701
+ const newPri = await ntfyPrompt(`Priority [${NTFY_PRIORITIES.join("/")}] (${NTFY_PRIORITIES[priIdx]}): `);
702
+ if (newPri && NTFY_PRIORITIES.includes(newPri)) ntfyConf.priority = newPri;
703
+
704
+ // Re-enter raw mode and re-render
705
+ stdin.setRawMode(true);
706
+ stdin.resume();
707
+ stdin.setEncoding("utf8");
708
+ stdin.on("data", onKey);
709
+ render(true);
710
+ }
711
+
712
+ async function testNotification() {
713
+ cleanup();
714
+ statusMsg = "\x1b[33mSending...\x1b[0m";
715
+ // Re-enter raw mode to render
716
+ stdin.setRawMode(true);
717
+ stdin.resume();
718
+ stdin.setEncoding("utf8");
719
+ render(true);
720
+
721
+ try {
722
+ await ntfySendTest(ntfyConf);
723
+ statusMsg = "\x1b[32mSent!\x1b[0m";
724
+ } catch (e) {
725
+ statusMsg = `\x1b[31mFailed: ${e.message}\x1b[0m`;
726
+ }
727
+
728
+ stdin.on("data", onKey);
729
+ render();
730
+ setTimeout(() => { statusMsg = ""; render(); }, 3000);
731
+ }
732
+
733
+ async function onKey(key) {
734
+ if (key === "\x03") {
735
+ cleanup();
736
+ save();
737
+ console.log("");
738
+ process.exit(0);
739
+ }
740
+
741
+ if (key === "q" || key === "Q" || key === "\r" || key === "\n") {
742
+ cleanup();
743
+ save();
744
+ console.log(`\n ntfy config saved (${ntfyConf.enabled ? "enabled" : "disabled"}).\n`);
745
+ resolve();
746
+ return;
747
+ }
748
+
749
+ if (key === " ") {
750
+ ntfyConf[rows[selected]] = !ntfyConf[rows[selected]];
751
+ render();
752
+ return;
753
+ }
754
+
755
+ if (key === "\x1b[A" || key === "k") {
756
+ selected = (selected - 1 + rows.length) % rows.length;
757
+ render();
758
+ return;
759
+ }
760
+
761
+ if (key === "\x1b[B" || key === "j") {
762
+ selected = (selected + 1) % rows.length;
763
+ render();
764
+ return;
765
+ }
766
+
767
+ if (key === "e" || key === "E") {
768
+ await editFields();
769
+ return;
770
+ }
771
+
772
+ if (key === "t" || key === "T") {
773
+ stdin.removeListener("data", onKey);
774
+ await testNotification();
775
+ return;
776
+ }
777
+ }
778
+
779
+ stdin.on("data", onKey);
780
+ });
781
+ }
782
+
783
+ async function ntfyTest() {
784
+ const config = readConfig();
785
+ const ntfyConf = config.ntfy;
786
+
787
+ if (!ntfyConf || !ntfyConf.topic) {
788
+ console.log("\n ntfy not configured. Run 'agent-noti ntfy' first.\n");
789
+ return;
790
+ }
791
+
792
+ process.stdout.write("\n Sending test notification...");
793
+ try {
794
+ await ntfySendTest(ntfyConf);
795
+ console.log(" sent!\n");
796
+ } catch (e) {
797
+ console.log(` failed: ${e.message}\n`);
798
+ }
799
+ }
800
+
567
801
  function mute() {
568
802
  const config = readConfig();
569
803
  config.muted = true;
@@ -604,7 +838,7 @@ function volume(args) {
604
838
 
605
839
  function reset() {
606
840
  writeConfig({ idle: "default", input: "default", volume: 10, muted: false });
607
- console.log("\n Reset to defaults (theme=default, volume=10, unmuted).\n");
841
+ console.log("\n Reset to defaults (theme=default, volume=10, unmuted, ntfy cleared).\n");
608
842
  }
609
843
 
610
844
  function applyPickerChoice(choice) {
@@ -651,6 +885,8 @@ async function main() {
651
885
  case "mute": case "m": mute(); break;
652
886
  case "unmute": case "u": unmute(); break;
653
887
  case "reset": case "r": reset(); break;
888
+ case "ntfy": case "n": await ntfy(); break;
889
+ case "ntfy-test": case "nt": await ntfyTest(); break;
654
890
  default:
655
891
  console.log("");
656
892
  console.log(" agent-noti install (i) Add hooks + pick theme");
@@ -662,6 +898,8 @@ async function main() {
662
898
  console.log(" agent-noti volume (v) Set volume <1-10>");
663
899
  console.log(" agent-noti mute (m) Mute notifications");
664
900
  console.log(" agent-noti unmute (u) Unmute notifications");
901
+ console.log(" agent-noti ntfy (n) Configure ntfy.sh push notifications");
902
+ console.log(" agent-noti ntfy-test (nt) Send a test push notification");
665
903
  console.log(" agent-noti reset (r) Reset everything");
666
904
  console.log("");
667
905
  }
package/bin/play.mjs CHANGED
@@ -6,6 +6,7 @@
6
6
  * node play.mjs --file <path> — plays a file directly
7
7
  *
8
8
  * Respects ~/.agent-noti/config.json for mute and volume (1-10).
9
+ * Also sends ntfy.sh push notifications when configured.
9
10
  */
10
11
 
11
12
  import { execFile, exec } from "child_process";
@@ -69,36 +70,72 @@ function resolveSound(arg) {
69
70
  return findFile(`${arg}-idle`) || findFile(arg) || null;
70
71
  }
71
72
 
72
- const arg = process.argv[2];
73
- if (!arg) process.exit(0);
73
+ const NTFY_MESSAGES = {
74
+ idle: { title: "Task Complete", tags: "white_check_mark", body: "Agent finished task" },
75
+ input: { title: "Approval Needed", tags: "warning", body: "Agent needs your approval" },
76
+ };
74
77
 
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);
78
+ async function sendNtfy(event, config) {
79
+ try {
80
+ const ntfy = config.ntfy;
81
+ if (!ntfy || !ntfy.enabled || !ntfy.topic) return;
82
+ if (!ntfy[event]) return;
83
+
84
+ const msg = NTFY_MESSAGES[event];
85
+ if (!msg) return;
86
+
87
+ const server = (ntfy.server || "https://ntfy.sh").replace(/\/+$/, "");
88
+ const url = `${server}/${ntfy.topic}`;
89
+
90
+ await fetch(url, {
91
+ method: "POST",
92
+ headers: {
93
+ Title: msg.title,
94
+ Priority: ntfy.priority || "default",
95
+ Tags: msg.tags,
96
+ },
97
+ body: msg.body,
98
+ });
99
+ } catch {}
100
+ }
79
101
 
80
- const file = resolveSound(arg);
81
- if (!file) process.exit(1);
102
+ (async () => {
103
+ const arg = process.argv[2];
104
+ if (!arg) process.exit(0);
82
105
 
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
106
+ const config = readConfig();
88
107
 
89
- const os = platform();
108
+ // Send ntfy push notification for actual events (not --file previews)
109
+ if (EVENTS.includes(arg)) {
110
+ await sendNtfy(arg, config);
111
+ }
90
112
 
91
- if (os === "darwin") {
92
- execFile("afplay", ["-v", String(volFloat), file], () => {});
93
- } else if (os === "win32") {
94
- exec(
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"`,
96
- () => {}
97
- );
98
- } else {
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], () => {});
113
+ // Mute check (skip for --file, which is used by picker previews)
114
+ if (arg !== "--file" && config.muted) process.exit(0);
115
+
116
+ const file = resolveSound(arg);
117
+ if (!file) process.exit(1);
118
+
119
+ // Volume: 1-10 config → 0.0-1.0 native scale
120
+ const vol = Math.max(1, Math.min(10, config.volume ?? 10));
121
+ const volFloat = vol / 10; // 0.1 – 1.0 (macOS, Windows)
122
+ const volPct = vol * 10; // 10 – 100 (Linux ffplay, mpv)
123
+ const volPulse = Math.round(volFloat * 65536); // paplay scale
124
+
125
+ const os = platform();
126
+
127
+ if (os === "darwin") {
128
+ execFile("afplay", ["-v", String(volFloat), file], () => {});
129
+ } else if (os === "win32") {
130
+ exec(
131
+ `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"`,
132
+ () => {}
133
+ );
134
+ } else {
135
+ execFile("ffplay", ["-nodisp", "-autoexit", "-loglevel", "quiet", "-volume", String(volPct), file], (err) => {
136
+ if (err) execFile("paplay", ["--volume", String(volPulse), file], (err2) => {
137
+ if (err2) execFile("mpv", ["--no-video", `--volume=${volPct}`, file], () => {});
138
+ });
102
139
  });
103
- });
104
- }
140
+ }
141
+ })();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-noti",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "Audio notifications for Claude Code & Codex — customizable sound themes",
5
5
  "bin": {
6
6
  "agent-noti": "./bin/cli.mjs"