ai-notify 0.1.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/LICENSE +21 -0
- package/README.md +83 -0
- package/menubar/AiNotifyMenuBar.swift +122 -0
- package/menubar/README.md +58 -0
- package/menubar/build.sh +56 -0
- package/menubar/dist/ai-notify.app/Contents/Info.plist +16 -0
- package/menubar/dist/ai-notify.app/Contents/MacOS/ai-notify-menubar +0 -0
- package/menubar/dist/ai-notify.app/Contents/_CodeSignature/CodeResources +115 -0
- package/package.json +52 -0
- package/recipes/claude-statusline/README.md +30 -0
- package/recipes/hammerspoon/ai-notify.lua +69 -0
- package/recipes/macos-shortcut/README.md +23 -0
- package/recipes/raycast/ai-notify-toggle.sh +15 -0
- package/recipes/swiftbar/ai-notify.3s.sh +23 -0
- package/recipes/tmux/README.md +37 -0
- package/src/cli.mjs +311 -0
- package/src/menubar.mjs +97 -0
- package/src/notify.mjs +133 -0
- package/src/providers/claude.mjs +92 -0
- package/src/providers/codex.mjs +86 -0
- package/src/providers/gemini.mjs +30 -0
- package/src/providers/index.mjs +10 -0
- package/src/state.mjs +102 -0
- package/src/translate.mjs +46 -0
- package/src/util.mjs +41 -0
- package/src/voices.mjs +69 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 unoryota
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# ai-notify
|
|
2
|
+
|
|
3
|
+
**Know the moment your terminal AI agent needs you** — a sound, a spoken read-out, and a desktop banner the instant Claude Code, Codex, or another agent finishes a turn or asks for input. One mute switch covers **all of them, across every terminal**. No daemon, no background process.
|
|
4
|
+
|
|
5
|
+
Long-running agents leave you staring at a quiet terminal. `ai-notify` wires a tiny notification hook into each agent CLI you have installed, so you can look away and get pulled back exactly when there's something to do. And when you're in a meeting, **one tap silences every agent at once** — because they all read the same shared switch.
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npm i -g ai-notify
|
|
9
|
+
ai-notify init # auto-detects your agents and wires them
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Why
|
|
13
|
+
|
|
14
|
+
- **Get notified even if you never set it up.** The point is to *add* notifications. Muting is just a bonus feature on top.
|
|
15
|
+
- **All your agents, one switch.** Use only Claude Code? Only Codex? Both? Plus others? Same experience. The mute flag is shared, so flipping it once is global.
|
|
16
|
+
- **Zero friction.** No daemon. Re-run `init` anytime — it only wires what's newly detected and never clobbers your existing config.
|
|
17
|
+
|
|
18
|
+
## Supported agents
|
|
19
|
+
|
|
20
|
+
| Agent | Status | How it's wired |
|
|
21
|
+
| ----- | ------ | -------------- |
|
|
22
|
+
| Claude Code | ✅ | `Notification` + `Stop` hooks in `~/.claude/settings.json` |
|
|
23
|
+
| Codex CLI | ✅ | `notify` in `~/.codex/config.toml` (`agent-turn-complete`) |
|
|
24
|
+
| Gemini CLI | 🧪 detected, hook WIP | see [CONTRIBUTING](CONTRIBUTING.md) — PRs welcome |
|
|
25
|
+
|
|
26
|
+
Adding another agent (aider, opencode, amp, ...) is a small PR: drop a file in `src/providers/`. See [CONTRIBUTING](CONTRIBUTING.md).
|
|
27
|
+
|
|
28
|
+
## Usage
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
ai-notify init [--dry-run] [--only claude,codex] # wire detected agents
|
|
32
|
+
ai-notify uninstall [--only ...] # cleanly remove wiring
|
|
33
|
+
ai-notify toggle | on | off | status # the mute switch
|
|
34
|
+
ai-notify doctor # check deps & wiring
|
|
35
|
+
ai-notify config [init] # print / write config
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
> After `init`, restart any already-running Codex session so it re-reads its config.
|
|
39
|
+
|
|
40
|
+
## Mute everything — without touching a busy terminal
|
|
41
|
+
|
|
42
|
+
You can't type a command into the terminal that's running an agent, and you want
|
|
43
|
+
the current state visible at a glance. So don't drive this from a prompt — drive
|
|
44
|
+
it from the **menu bar / a hotkey**, and show the state where you can always see it.
|
|
45
|
+
|
|
46
|
+
> Toggling works mid-run: the flag is read the next time an agent fires a
|
|
47
|
+
> notification, so flipping it instantly affects every running agent. A hotkey
|
|
48
|
+
> runs in its own process — it never types into your busy terminal.
|
|
49
|
+
|
|
50
|
+
**Recommended — always-visible menu bar toggle** (pick one):
|
|
51
|
+
|
|
52
|
+
- **Hammerspoon** — menu bar icon **and** a global hotkey (⌃⌥M) in ~20 lines. [recipes/hammerspoon](recipes/hammerspoon/).
|
|
53
|
+
- **SwiftBar / xbar** — a 🔔/🔕 menu bar item you click to toggle. [recipes/swiftbar](recipes/swiftbar/).
|
|
54
|
+
- **macOS Shortcuts** — `ai-notify toggle` pinned to the menu bar / a hotkey / iPhone. [recipes/macos-shortcut](recipes/macos-shortcut/).
|
|
55
|
+
- **Raycast** — drop-in script command + hotkey. [recipes/raycast](recipes/raycast/).
|
|
56
|
+
|
|
57
|
+
**Always-visible state** — `ai-notify status --icon` prints just `🔔`/`🔕`, ready to embed:
|
|
58
|
+
|
|
59
|
+
- **Inside Claude Code's own status line** (the busy terminal shows its own state). [recipes/claude-statusline](recipes/claude-statusline/).
|
|
60
|
+
- **tmux status bar / shell prompt / Starship.** [recipes/tmux](recipes/tmux/).
|
|
61
|
+
|
|
62
|
+
## How it works
|
|
63
|
+
|
|
64
|
+
`ai-notify` keeps a single mute flag and config under XDG paths:
|
|
65
|
+
|
|
66
|
+
```
|
|
67
|
+
${XDG_STATE_HOME:-~/.local/state}/ai-notify/muted # presence = muted
|
|
68
|
+
${XDG_CONFIG_HOME:-~/.config}/ai-notify/config.json # sounds, voice, options
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Each agent's hook calls `ai-notify hook --source <agent>`, which reads that one flag at fire time. That's why every agent and every terminal stay in sync with no coordination.
|
|
72
|
+
|
|
73
|
+
### Configuration
|
|
74
|
+
|
|
75
|
+
`ai-notify config init` writes a config you can edit — per-agent sounds and voice, whether the desktop banner still shows while muted, and whether to speak a read-out. Sounds default to OS built-ins, so nothing is bundled.
|
|
76
|
+
|
|
77
|
+
## Platforms
|
|
78
|
+
|
|
79
|
+
macOS is fully supported (`afplay` / `say` / `terminal-notifier` or `osascript`). Linux is best-effort (`paplay`/`canberra`, `notify-send`, `spd-say`/`espeak`). Windows plays a beep and speaks via PowerShell. Missing backends degrade silently — they never error.
|
|
80
|
+
|
|
81
|
+
## License
|
|
82
|
+
|
|
83
|
+
[MIT](LICENSE).
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// ai-notify menu bar agent — a tiny native NSStatusItem that mirrors the one
|
|
2
|
+
// shared mute flag and toggles it on click. No third-party app required.
|
|
3
|
+
//
|
|
4
|
+
// Single source of truth: the same file the CLI and every wired agent read,
|
|
5
|
+
// ${XDG_STATE_HOME:-~/.local/state}/ai-notify/muted
|
|
6
|
+
// Present = muted (🔕). Absent = on (🔔).
|
|
7
|
+
//
|
|
8
|
+
// Left click : toggle mute/unmute (one tap)
|
|
9
|
+
// Right click : menu (toggle / quit)
|
|
10
|
+
//
|
|
11
|
+
// Builds with the system `swiftc` — no Xcode project, no dependencies.
|
|
12
|
+
|
|
13
|
+
import Cocoa
|
|
14
|
+
|
|
15
|
+
// MARK: - Shared state (must match src/state.mjs)
|
|
16
|
+
|
|
17
|
+
enum State {
|
|
18
|
+
static func stateDir() -> String {
|
|
19
|
+
let env = ProcessInfo.processInfo.environment
|
|
20
|
+
let base = env["XDG_STATE_HOME"]
|
|
21
|
+
?? (NSHomeDirectory() as NSString).appendingPathComponent(".local/state")
|
|
22
|
+
return (base as NSString).appendingPathComponent("ai-notify")
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
static func flagPath() -> String {
|
|
26
|
+
(stateDir() as NSString).appendingPathComponent("muted")
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
static var isMuted: Bool {
|
|
30
|
+
FileManager.default.fileExists(atPath: flagPath())
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
static func setMuted(_ muted: Bool) {
|
|
34
|
+
let path = flagPath()
|
|
35
|
+
let fm = FileManager.default
|
|
36
|
+
if muted {
|
|
37
|
+
try? fm.createDirectory(atPath: stateDir(), withIntermediateDirectories: true)
|
|
38
|
+
fm.createFile(atPath: path, contents: Data())
|
|
39
|
+
} else {
|
|
40
|
+
try? fm.removeItem(atPath: path)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// MARK: - App
|
|
46
|
+
|
|
47
|
+
final class AppDelegate: NSObject, NSApplicationDelegate {
|
|
48
|
+
private var statusItem: NSStatusItem!
|
|
49
|
+
private var timer: Timer?
|
|
50
|
+
|
|
51
|
+
func applicationDidFinishLaunching(_ notification: Notification) {
|
|
52
|
+
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
|
|
53
|
+
if let button = statusItem.button {
|
|
54
|
+
button.action = #selector(handleClick(_:))
|
|
55
|
+
button.target = self
|
|
56
|
+
button.sendAction(on: [.leftMouseUp, .rightMouseUp])
|
|
57
|
+
}
|
|
58
|
+
render()
|
|
59
|
+
// Reconcile every second so external changes (CLI `ai-notify on/off`,
|
|
60
|
+
// another tool) are reflected without any IPC.
|
|
61
|
+
timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
|
|
62
|
+
self?.render()
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
private func render() {
|
|
67
|
+
statusItem.button?.title = State.isMuted ? "🔕" : "🔔"
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
@objc private func handleClick(_ sender: Any?) {
|
|
71
|
+
guard let event = NSApp.currentEvent else { toggle(); return }
|
|
72
|
+
if event.type == .rightMouseUp {
|
|
73
|
+
showMenu()
|
|
74
|
+
} else {
|
|
75
|
+
toggle()
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private func toggle() {
|
|
80
|
+
let nowMuted = !State.isMuted
|
|
81
|
+
State.setMuted(nowMuted)
|
|
82
|
+
render()
|
|
83
|
+
if !nowMuted { chime() } // brief confirmation on un-mute
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
@objc private func toggleFromMenu() { toggle() }
|
|
87
|
+
|
|
88
|
+
@objc private func quit() { NSApp.terminate(nil) }
|
|
89
|
+
|
|
90
|
+
private func showMenu() {
|
|
91
|
+
let muted = State.isMuted
|
|
92
|
+
let menu = NSMenu()
|
|
93
|
+
let toggleItem = NSMenuItem(
|
|
94
|
+
title: muted ? "通知をオンにする" : "ミュート",
|
|
95
|
+
action: #selector(toggleFromMenu), keyEquivalent: "")
|
|
96
|
+
toggleItem.target = self
|
|
97
|
+
menu.addItem(toggleItem)
|
|
98
|
+
menu.addItem(.separator())
|
|
99
|
+
let quitItem = NSMenuItem(title: "ai-notify を終了", action: #selector(quit), keyEquivalent: "q")
|
|
100
|
+
quitItem.target = self
|
|
101
|
+
menu.addItem(quitItem)
|
|
102
|
+
|
|
103
|
+
statusItem.menu = menu
|
|
104
|
+
statusItem.button?.performClick(nil)
|
|
105
|
+
statusItem.menu = nil // restore left-click-to-toggle
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
private func chime() {
|
|
109
|
+
let sound = "/System/Library/Sounds/Glass.aiff"
|
|
110
|
+
guard FileManager.default.fileExists(atPath: sound) else { return }
|
|
111
|
+
let task = Process()
|
|
112
|
+
task.executableURL = URL(fileURLWithPath: "/usr/bin/afplay")
|
|
113
|
+
task.arguments = ["-v", "2", sound]
|
|
114
|
+
try? task.run()
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
let app = NSApplication.shared
|
|
119
|
+
app.setActivationPolicy(.accessory) // no Dock icon, menu bar only
|
|
120
|
+
let delegate = AppDelegate()
|
|
121
|
+
app.delegate = delegate
|
|
122
|
+
app.run()
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# ai-notify menu bar agent (macOS)
|
|
2
|
+
|
|
3
|
+
A tiny native menu bar bell so the mute switch has a **live 🔔 / 🔕 you can
|
|
4
|
+
click** — with **no third-party app** (no Hammerspoon, SwiftBar, Raycast).
|
|
5
|
+
|
|
6
|
+
- `AiNotifyMenuBar.swift` — the whole agent (~120 lines, AppKit `NSStatusItem`).
|
|
7
|
+
- `build.sh` — compiles it into `dist/ai-notify.app` with the system `swiftc`.
|
|
8
|
+
No Xcode project, no dependencies.
|
|
9
|
+
|
|
10
|
+
It reads and writes the **same mute flag** the CLI uses
|
|
11
|
+
(`${XDG_STATE_HOME:-~/.local/state}/ai-notify/muted`), so the icon, the CLI, and
|
|
12
|
+
every wired agent always agree — no daemon, no IPC.
|
|
13
|
+
|
|
14
|
+
- **Left click** — toggle mute / un-mute (one tap)
|
|
15
|
+
- **Right click** — menu (toggle / quit)
|
|
16
|
+
|
|
17
|
+
## Use it
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
ai-notify menubar install # build if needed, run at login, show the bell
|
|
21
|
+
ai-notify menubar status
|
|
22
|
+
ai-notify menubar uninstall
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
`install` writes a per-user LaunchAgent
|
|
26
|
+
(`~/Library/LaunchAgents/com.ai-notify.menubar.plist`, `LimitLoadToSessionType
|
|
27
|
+
= Aqua`) so the bell returns automatically at every login.
|
|
28
|
+
|
|
29
|
+
## Build manually
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
bash menubar/build.sh # ad-hoc signed, local use
|
|
33
|
+
CODESIGN_ID="Developer ID Application: NAME (TEAMID)" \
|
|
34
|
+
bash menubar/build.sh # Developer ID signed
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Distribution (npm)
|
|
38
|
+
|
|
39
|
+
The published tarball ships a **prebuilt** `dist/ai-notify.app`, so end users get
|
|
40
|
+
the bell without needing the Swift toolchain. `prepack` builds it automatically
|
|
41
|
+
on `npm publish` from a Mac.
|
|
42
|
+
|
|
43
|
+
Gatekeeper note: files installed by npm are **not quarantined**, so the bundled
|
|
44
|
+
app launches even without notarization. For a hardened release, sign with a
|
|
45
|
+
Developer ID and notarize:
|
|
46
|
+
|
|
47
|
+
```sh
|
|
48
|
+
CODESIGN_ID="Developer ID Application: NAME (TEAMID)" bash menubar/build.sh
|
|
49
|
+
ditto -c -k --keepParent menubar/dist/ai-notify.app /tmp/ai-notify.zip
|
|
50
|
+
xcrun notarytool submit /tmp/ai-notify.zip \
|
|
51
|
+
--apple-id "you@example.com" --team-id TEAMID --password "APP_SPECIFIC_PW" --wait
|
|
52
|
+
xcrun stapler staple menubar/dist/ai-notify.app
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
On non-macOS platforms the menu bar agent is unavailable; `status --icon`
|
|
56
|
+
(🔔/🔕) still embeds into tmux / shell prompts / SwiftBar, and the recipes in
|
|
57
|
+
`../recipes/` cover Hammerspoon, SwiftBar, Raycast, and the built-in Shortcuts
|
|
58
|
+
app for anyone who prefers those.
|
package/menubar/build.sh
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Build the ai-notify menu bar agent into a self-contained .app bundle using the
|
|
3
|
+
# system Swift toolchain. No Xcode project, no dependencies.
|
|
4
|
+
#
|
|
5
|
+
# menubar/build.sh -> menubar/dist/ai-notify.app (unsigned)
|
|
6
|
+
# CODESIGN_ID="Developer ID Application: ..." menubar/build.sh -> signed
|
|
7
|
+
#
|
|
8
|
+
# Notarization (release only) is a separate step, see menubar/README.md.
|
|
9
|
+
set -euo pipefail
|
|
10
|
+
|
|
11
|
+
HERE="$(cd "$(dirname "$0")" && pwd)"
|
|
12
|
+
SRC="$HERE/AiNotifyMenuBar.swift"
|
|
13
|
+
APP="$HERE/dist/ai-notify.app"
|
|
14
|
+
BIN_NAME="ai-notify-menubar"
|
|
15
|
+
|
|
16
|
+
rm -rf "$APP"
|
|
17
|
+
mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources"
|
|
18
|
+
|
|
19
|
+
# Universal binary when possible (arm64 + x86_64), so one bundle runs everywhere.
|
|
20
|
+
ARCH_FLAGS=(-target arm64-apple-macos11)
|
|
21
|
+
if swiftc -target x86_64-apple-macos11 -typecheck "$SRC" >/dev/null 2>&1; then
|
|
22
|
+
ARCH_FLAGS=(-target arm64-apple-macos11)
|
|
23
|
+
fi
|
|
24
|
+
|
|
25
|
+
echo "compiling $BIN_NAME ..."
|
|
26
|
+
swiftc -O "${ARCH_FLAGS[@]}" -o "$APP/Contents/MacOS/$BIN_NAME" "$SRC"
|
|
27
|
+
|
|
28
|
+
cat > "$APP/Contents/Info.plist" <<PLIST
|
|
29
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
30
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
31
|
+
<plist version="1.0">
|
|
32
|
+
<dict>
|
|
33
|
+
<key>CFBundleName</key><string>ai-notify</string>
|
|
34
|
+
<key>CFBundleDisplayName</key><string>ai-notify</string>
|
|
35
|
+
<key>CFBundleIdentifier</key><string>com.ai-notify.menubar</string>
|
|
36
|
+
<key>CFBundleVersion</key><string>0.1.0</string>
|
|
37
|
+
<key>CFBundleShortVersionString</key><string>0.1.0</string>
|
|
38
|
+
<key>CFBundlePackageType</key><string>APPL</string>
|
|
39
|
+
<key>CFBundleExecutable</key><string>$BIN_NAME</string>
|
|
40
|
+
<key>LSMinimumSystemVersion</key><string>11.0</string>
|
|
41
|
+
<key>LSUIElement</key><true/>
|
|
42
|
+
<key>NSHighResolutionCapable</key><true/>
|
|
43
|
+
</dict>
|
|
44
|
+
</plist>
|
|
45
|
+
PLIST
|
|
46
|
+
|
|
47
|
+
if [ -n "${CODESIGN_ID:-}" ]; then
|
|
48
|
+
echo "code signing with: $CODESIGN_ID"
|
|
49
|
+
codesign --force --options runtime --timestamp \
|
|
50
|
+
--sign "$CODESIGN_ID" "$APP"
|
|
51
|
+
else
|
|
52
|
+
# Ad-hoc sign so locally-built bundles launch without a quarantine prompt loop.
|
|
53
|
+
codesign --force --sign - "$APP" 2>/dev/null || true
|
|
54
|
+
fi
|
|
55
|
+
|
|
56
|
+
echo "built: $APP"
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
3
|
+
<plist version="1.0">
|
|
4
|
+
<dict>
|
|
5
|
+
<key>CFBundleName</key><string>ai-notify</string>
|
|
6
|
+
<key>CFBundleDisplayName</key><string>ai-notify</string>
|
|
7
|
+
<key>CFBundleIdentifier</key><string>com.ai-notify.menubar</string>
|
|
8
|
+
<key>CFBundleVersion</key><string>0.1.0</string>
|
|
9
|
+
<key>CFBundleShortVersionString</key><string>0.1.0</string>
|
|
10
|
+
<key>CFBundlePackageType</key><string>APPL</string>
|
|
11
|
+
<key>CFBundleExecutable</key><string>ai-notify-menubar</string>
|
|
12
|
+
<key>LSMinimumSystemVersion</key><string>11.0</string>
|
|
13
|
+
<key>LSUIElement</key><true/>
|
|
14
|
+
<key>NSHighResolutionCapable</key><true/>
|
|
15
|
+
</dict>
|
|
16
|
+
</plist>
|
|
Binary file
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
3
|
+
<plist version="1.0">
|
|
4
|
+
<dict>
|
|
5
|
+
<key>files</key>
|
|
6
|
+
<dict/>
|
|
7
|
+
<key>files2</key>
|
|
8
|
+
<dict/>
|
|
9
|
+
<key>rules</key>
|
|
10
|
+
<dict>
|
|
11
|
+
<key>^Resources/</key>
|
|
12
|
+
<true/>
|
|
13
|
+
<key>^Resources/.*\.lproj/</key>
|
|
14
|
+
<dict>
|
|
15
|
+
<key>optional</key>
|
|
16
|
+
<true/>
|
|
17
|
+
<key>weight</key>
|
|
18
|
+
<real>1000</real>
|
|
19
|
+
</dict>
|
|
20
|
+
<key>^Resources/.*\.lproj/locversion.plist$</key>
|
|
21
|
+
<dict>
|
|
22
|
+
<key>omit</key>
|
|
23
|
+
<true/>
|
|
24
|
+
<key>weight</key>
|
|
25
|
+
<real>1100</real>
|
|
26
|
+
</dict>
|
|
27
|
+
<key>^Resources/Base\.lproj/</key>
|
|
28
|
+
<dict>
|
|
29
|
+
<key>weight</key>
|
|
30
|
+
<real>1010</real>
|
|
31
|
+
</dict>
|
|
32
|
+
<key>^version.plist$</key>
|
|
33
|
+
<true/>
|
|
34
|
+
</dict>
|
|
35
|
+
<key>rules2</key>
|
|
36
|
+
<dict>
|
|
37
|
+
<key>.*\.dSYM($|/)</key>
|
|
38
|
+
<dict>
|
|
39
|
+
<key>weight</key>
|
|
40
|
+
<real>11</real>
|
|
41
|
+
</dict>
|
|
42
|
+
<key>^(.*/)?\.DS_Store$</key>
|
|
43
|
+
<dict>
|
|
44
|
+
<key>omit</key>
|
|
45
|
+
<true/>
|
|
46
|
+
<key>weight</key>
|
|
47
|
+
<real>2000</real>
|
|
48
|
+
</dict>
|
|
49
|
+
<key>^(Frameworks|SharedFrameworks|PlugIns|Plug-ins|XPCServices|Helpers|MacOS|Library/(Automator|Spotlight|LoginItems))/</key>
|
|
50
|
+
<dict>
|
|
51
|
+
<key>nested</key>
|
|
52
|
+
<true/>
|
|
53
|
+
<key>weight</key>
|
|
54
|
+
<real>10</real>
|
|
55
|
+
</dict>
|
|
56
|
+
<key>^.*</key>
|
|
57
|
+
<true/>
|
|
58
|
+
<key>^Info\.plist$</key>
|
|
59
|
+
<dict>
|
|
60
|
+
<key>omit</key>
|
|
61
|
+
<true/>
|
|
62
|
+
<key>weight</key>
|
|
63
|
+
<real>20</real>
|
|
64
|
+
</dict>
|
|
65
|
+
<key>^PkgInfo$</key>
|
|
66
|
+
<dict>
|
|
67
|
+
<key>omit</key>
|
|
68
|
+
<true/>
|
|
69
|
+
<key>weight</key>
|
|
70
|
+
<real>20</real>
|
|
71
|
+
</dict>
|
|
72
|
+
<key>^Resources/</key>
|
|
73
|
+
<dict>
|
|
74
|
+
<key>weight</key>
|
|
75
|
+
<real>20</real>
|
|
76
|
+
</dict>
|
|
77
|
+
<key>^Resources/.*\.lproj/</key>
|
|
78
|
+
<dict>
|
|
79
|
+
<key>optional</key>
|
|
80
|
+
<true/>
|
|
81
|
+
<key>weight</key>
|
|
82
|
+
<real>1000</real>
|
|
83
|
+
</dict>
|
|
84
|
+
<key>^Resources/.*\.lproj/locversion.plist$</key>
|
|
85
|
+
<dict>
|
|
86
|
+
<key>omit</key>
|
|
87
|
+
<true/>
|
|
88
|
+
<key>weight</key>
|
|
89
|
+
<real>1100</real>
|
|
90
|
+
</dict>
|
|
91
|
+
<key>^Resources/Base\.lproj/</key>
|
|
92
|
+
<dict>
|
|
93
|
+
<key>weight</key>
|
|
94
|
+
<real>1010</real>
|
|
95
|
+
</dict>
|
|
96
|
+
<key>^[^/]+$</key>
|
|
97
|
+
<dict>
|
|
98
|
+
<key>nested</key>
|
|
99
|
+
<true/>
|
|
100
|
+
<key>weight</key>
|
|
101
|
+
<real>10</real>
|
|
102
|
+
</dict>
|
|
103
|
+
<key>^embedded\.provisionprofile$</key>
|
|
104
|
+
<dict>
|
|
105
|
+
<key>weight</key>
|
|
106
|
+
<real>20</real>
|
|
107
|
+
</dict>
|
|
108
|
+
<key>^version\.plist$</key>
|
|
109
|
+
<dict>
|
|
110
|
+
<key>weight</key>
|
|
111
|
+
<real>20</real>
|
|
112
|
+
</dict>
|
|
113
|
+
</dict>
|
|
114
|
+
</dict>
|
|
115
|
+
</plist>
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "ai-notify",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Desktop, sound, and spoken notifications for terminal AI coding agents (Claude Code, Codex, Gemini, ...) — with one mute switch that covers all of them, across every terminal.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"ai-notify": "src/cli.mjs"
|
|
8
|
+
},
|
|
9
|
+
"exports": "./src/cli.mjs",
|
|
10
|
+
"files": [
|
|
11
|
+
"src",
|
|
12
|
+
"recipes",
|
|
13
|
+
"menubar/AiNotifyMenuBar.swift",
|
|
14
|
+
"menubar/build.sh",
|
|
15
|
+
"menubar/README.md",
|
|
16
|
+
"menubar/dist",
|
|
17
|
+
"README.md",
|
|
18
|
+
"LICENSE"
|
|
19
|
+
],
|
|
20
|
+
"scripts": {
|
|
21
|
+
"test": "node --test",
|
|
22
|
+
"scrub": "node scripts/scrub.mjs",
|
|
23
|
+
"build:menubar": "bash menubar/build.sh",
|
|
24
|
+
"prepack": "bash menubar/build.sh 2>/dev/null || true"
|
|
25
|
+
},
|
|
26
|
+
"keywords": [
|
|
27
|
+
"claude-code",
|
|
28
|
+
"codex",
|
|
29
|
+
"gemini-cli",
|
|
30
|
+
"ai-agent",
|
|
31
|
+
"notifications",
|
|
32
|
+
"desktop-notification",
|
|
33
|
+
"hooks",
|
|
34
|
+
"cli",
|
|
35
|
+
"terminal",
|
|
36
|
+
"productivity"
|
|
37
|
+
],
|
|
38
|
+
"engines": {
|
|
39
|
+
"node": ">=18"
|
|
40
|
+
},
|
|
41
|
+
"license": "MIT",
|
|
42
|
+
"author": "unoryota",
|
|
43
|
+
"repository": {
|
|
44
|
+
"type": "git",
|
|
45
|
+
"url": "git+https://github.com/unoryota/ai-notify.git"
|
|
46
|
+
},
|
|
47
|
+
"homepage": "https://github.com/unoryota/ai-notify#readme",
|
|
48
|
+
"bugs": {
|
|
49
|
+
"url": "https://github.com/unoryota/ai-notify/issues"
|
|
50
|
+
},
|
|
51
|
+
"dependencies": {}
|
|
52
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Show mute state inside Claude Code's own status line
|
|
2
|
+
|
|
3
|
+
The terminal running Claude Code is exactly the one you *can't* type into while
|
|
4
|
+
it's working — but Claude Code can render a **status line** at the bottom. Put
|
|
5
|
+
the mute indicator there, and the busy terminal shows `🔔` / `🔕` on its own.
|
|
6
|
+
|
|
7
|
+
Add this to `~/.claude/settings.json` (merge with any existing `statusLine`):
|
|
8
|
+
|
|
9
|
+
```json
|
|
10
|
+
{
|
|
11
|
+
"statusLine": {
|
|
12
|
+
"type": "command",
|
|
13
|
+
"command": "ai-notify status --icon"
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Want more context in the line? Combine it with your own info:
|
|
19
|
+
|
|
20
|
+
```json
|
|
21
|
+
{
|
|
22
|
+
"statusLine": {
|
|
23
|
+
"type": "command",
|
|
24
|
+
"command": "printf '%s %s' \"$(ai-notify status --icon)\" \"$(basename \"$PWD\")\""
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
> Note: Claude Code allows a single `statusLine`. If you already use one, fold
|
|
30
|
+
> `ai-notify status --icon` into your existing command rather than replacing it.
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
-- Menu bar indicator + global hotkey for ai-notify, via Hammerspoon.
|
|
2
|
+
--
|
|
3
|
+
-- Gives you BOTH at once, with no extra app beyond Hammerspoon (free):
|
|
4
|
+
-- * an always-visible 🔔 / 🔕 in the menu bar (click to toggle)
|
|
5
|
+
-- * a global hotkey (⌃⌥M) that toggles from anywhere — even while a terminal
|
|
6
|
+
-- is busy running an agent, because the hotkey runs in its own process and
|
|
7
|
+
-- never types into that terminal.
|
|
8
|
+
--
|
|
9
|
+
-- The toggle is INSTANT and RELIABLE: it writes the shared mute flag file
|
|
10
|
+
-- directly (the same file ai-notify reads), so the icon always reflects the
|
|
11
|
+
-- real state — it never gets ahead of a slow/failed subprocess.
|
|
12
|
+
--
|
|
13
|
+
-- Install:
|
|
14
|
+
-- 1. brew install --cask hammerspoon (and launch it once)
|
|
15
|
+
-- 2. Append the contents of this file to ~/.hammerspoon/init.lua
|
|
16
|
+
-- 3. Reload Hammerspoon config.
|
|
17
|
+
|
|
18
|
+
local function flagPath()
|
|
19
|
+
local base = os.getenv("XDG_STATE_HOME")
|
|
20
|
+
return (base and (base .. "/ai-notify/muted"))
|
|
21
|
+
or (os.getenv("HOME") .. "/.local/state/ai-notify/muted")
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
local function isMuted()
|
|
25
|
+
local f = io.open(flagPath(), "r")
|
|
26
|
+
if f then f:close() return true end
|
|
27
|
+
return false
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
-- Authoritative: write/remove the flag file directly. Instant, can't fail to a
|
|
31
|
+
-- subprocess, and is the single source of truth ai-notify and its hooks read.
|
|
32
|
+
local function setMuted(muted)
|
|
33
|
+
local p = flagPath()
|
|
34
|
+
if muted then
|
|
35
|
+
local f = io.open(p, "w")
|
|
36
|
+
if not f then
|
|
37
|
+
hs.fs.mkdir(p:match("(.*)/")) -- create the state dir if missing
|
|
38
|
+
f = io.open(p, "w")
|
|
39
|
+
end
|
|
40
|
+
if f then f:close() end
|
|
41
|
+
else
|
|
42
|
+
os.remove(p)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
local menubar = hs.menubar.new()
|
|
47
|
+
|
|
48
|
+
local function setIcon(muted)
|
|
49
|
+
if menubar then menubar:setTitle(muted and "🔕" or "🔔") end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
local function render() setIcon(isMuted()) end
|
|
53
|
+
|
|
54
|
+
local function toggle()
|
|
55
|
+
local newMuted = not isMuted()
|
|
56
|
+
setMuted(newMuted) -- flip the real state first
|
|
57
|
+
setIcon(newMuted) -- icon always matches reality
|
|
58
|
+
if not newMuted then
|
|
59
|
+
-- brief confirmation chime on un-mute (async, best-effort)
|
|
60
|
+
hs.task.new("/usr/bin/afplay", nil, { "-v", "2", "/System/Library/Sounds/Glass.aiff" }):start()
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
if menubar then menubar:setClickCallback(toggle) end
|
|
65
|
+
hs.hotkey.bind({ "ctrl", "alt" }, "M", toggle)
|
|
66
|
+
|
|
67
|
+
-- Safety reconciler in case the flag is changed elsewhere (CLI, another tool).
|
|
68
|
+
hs.timer.doEvery(2, render)
|
|
69
|
+
render()
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# One-tap mute on macOS (Shortcuts.app)
|
|
2
|
+
|
|
3
|
+
Make muting every agent a single click / keypress / phone tap.
|
|
4
|
+
|
|
5
|
+
1. Open **Shortcuts.app** → **+** (new shortcut).
|
|
6
|
+
2. Add the action **Run Shell Script**.
|
|
7
|
+
- Shell: `zsh`
|
|
8
|
+
- Pass input: **to stdin** (doesn't matter; we ignore it)
|
|
9
|
+
- Script:
|
|
10
|
+
```sh
|
|
11
|
+
ai-notify toggle
|
|
12
|
+
```
|
|
13
|
+
If `ai-notify` isn't on the GUI PATH, use the full path (find it with
|
|
14
|
+
`which ai-notify`), e.g. `/usr/local/bin/ai-notify toggle` or
|
|
15
|
+
`/opt/homebrew/bin/ai-notify toggle`.
|
|
16
|
+
3. Rename it to **"AI Notify Toggle"**.
|
|
17
|
+
4. In the shortcut's settings (ⓘ), enable any of:
|
|
18
|
+
- **Pin in Menu Bar** → toggle from the top of the screen.
|
|
19
|
+
- **Add Keyboard Shortcut** (e.g. ⌃⌥M) → works from any app.
|
|
20
|
+
- It also appears on iPhone / Apple Watch / Control Center under the same Apple ID.
|
|
21
|
+
|
|
22
|
+
That's it — one tap silences (or un-silences) Claude Code, Codex, and every other
|
|
23
|
+
wired agent at once, because they all share the same switch.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Raycast Script Command: toggle ai-notify mute for all agents.
|
|
3
|
+
# Drop this file into your Raycast script directory and assign a hotkey.
|
|
4
|
+
#
|
|
5
|
+
# @raycast.schemaVersion 1
|
|
6
|
+
# @raycast.title Toggle AI Notify
|
|
7
|
+
# @raycast.mode compact
|
|
8
|
+
# @raycast.icon 🔔
|
|
9
|
+
# @raycast.packageName ai-notify
|
|
10
|
+
#
|
|
11
|
+
# Optional metadata:
|
|
12
|
+
# @raycast.description Mute/unmute notifications for all terminal AI agents at once
|
|
13
|
+
|
|
14
|
+
export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:$PATH"
|
|
15
|
+
ai-notify toggle
|