Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 14 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<div align="center">
<img src="assets/logo_full.png" alt="stack-nudge" width="300" />
<img src="assets/logo_full.png" alt="StackNudge" width="200" />
<h1>StackNudge</h1>
<p><strong>Notifications for AI coding agents.</strong></p>
<p>Get a banner + sound when your agent finishes a task or pauses for your approval — step away without missing a beat.</p>
<p>
Expand All @@ -22,10 +23,12 @@
|-------|--------|
| Claude Code | ✅ |
| Cursor | ✅ |
| Gemini CLI | ✅ *(experimental)* |
| Codex | ✅ *(experimental)* |
| Codex | ✅ |
| Gemini CLI | ✅ |
| Any hooks-capable agent | ✅ — point it at `notify.sh` |

† Gemini's tool-permission hook is observability-only — the banner shows the prompt, but the actual Allow / Deny click has to happen in Gemini's terminal. Claude Code, Cursor, and Codex permission events can be approved from the panel directly.

**Platforms:** macOS — full app with panel, click-to-focus banners, auto-update, quota tracking, voice. Linux (PulseAudio / ALSA / libnotify) and Windows (Git Bash / WSL) get audio + basic notifications via `notify.sh` only.

## Install
Expand Down Expand Up @@ -329,15 +332,19 @@ Same set of cleanups as the in-app path, useful when the .app isn't reachable or

## Manual setup

Every supported agent just needs a hook that runs `notify.sh <agent-name> <event>`. Example for Codex (or any other hooks-capable agent):
Claude Code, Cursor, Codex, and Gemini CLI are auto-wired by the first-launch wizard. For other hooks-capable agents (or to integrate from a custom script), all you need is to invoke `notify.sh <agent-label> <event>` from wherever your agent emits lifecycle events. `<event>` should be `stop` (agent finished a turn) or `permission` (waiting for approval); `<agent-label>` can be anything — it just controls the banner title.

Example block in any agent's hooks config:

```json
{
"hooks": {
"stop": [
"Stop": [
{
"type": "command",
"command": "$HOME/.stack-nudge/notify.sh codex stop"
"matcher": "",
"hooks": [
{ "type": "command", "command": "$HOME/.stack-nudge/notify.sh my-agent stop", "timeout": 30 }
]
}
]
}
Expand Down
193 changes: 130 additions & 63 deletions panel/Bootstrap.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import SwiftUI
enum BootstrapAgent: String, CaseIterable, Identifiable, Equatable {
case claude
case cursor
case codex
case gemini

var id: String { rawValue }
Expand All @@ -25,6 +26,7 @@ enum BootstrapAgent: String, CaseIterable, Identifiable, Equatable {
switch self {
case .claude: return "Claude Code"
case .cursor: return "Cursor"
case .codex: return "Codex"
case .gemini: return "Gemini CLI"
}
}
Expand All @@ -35,6 +37,7 @@ enum BootstrapAgent: String, CaseIterable, Identifiable, Equatable {
switch self {
case .claude: return "\(NSHomeDirectory())/.claude"
case .cursor: return "\(NSHomeDirectory())/.cursor"
case .codex: return "\(NSHomeDirectory())/.codex"
case .gemini: return "\(NSHomeDirectory())/.gemini"
}
}
Expand All @@ -45,9 +48,14 @@ enum BootstrapAgent: String, CaseIterable, Identifiable, Equatable {
switch self {
case .claude: return "\(NSHomeDirectory())/.claude/settings.json"
case .cursor: return "\(NSHomeDirectory())/.cursor/hooks.json"
// Gemini hook support is experimental; install.sh writes nothing
// for it today. We mirror that — Bootstrap.install skips Gemini
// hook wiring. Selecting it is a no-op aside from acknowledging.
// Codex's hooks file shares Claude Code's matcher-group JSON
// shape and event names (Stop + PermissionRequest). See
// https://developers.openai.com/codex/hooks.
case .codex: return "\(NSHomeDirectory())/.codex/hooks.json"
// Gemini CLI uses ~/.gemini/settings.json (same path as Claude's
// settings.json analog), but its events are renamed: AfterAgent
// (turn end) and Notification (tool-permission alerts). See
// https://geminicli.com/docs/hooks/.
case .gemini: return "\(NSHomeDirectory())/.gemini/settings.json"
}
}
Expand Down Expand Up @@ -163,6 +171,57 @@ enum Bootstrap {
}
}

// MARK: Reconciliation

// Agents detected on disk whose hook config has no entry pointing at
// our notify.sh. Powers the Settings-tab "agent detected without hooks"
// banner: covers three scenarios in one mechanism —
// 1. User installs a NEW agent on their Mac after first-run wizard.
// 2. New StackNudge release adds support for an agent the user already
// has installed (eg v1.8 lands Codex support; v1.7 user updates).
// 3. User manually deleted our hook entry then forgot.
//
// Cheap: one JSON parse per detected agent, all on the main thread.
// Called from app launch + every Settings tab open.
static func unwiredAgents() -> [BootstrapAgent] {
availableAgents().filter { !isAgentWired($0) }
}

// Looks at the agent's on-disk config for any hook command matching
// our notify.sh path. The same staleHookRegex used for uninstall does
// the right thing here — it matches both `tinynudge/notify.sh` and
// `stack-nudge/notify.sh`, which is what we want for "is *anything*
// of ours wired?"
static func isAgentWired(_ agent: BootstrapAgent) -> Bool {
let path = agent.hookConfigPath
guard let root = try? readJSONObject(at: path),
let hooks = root["hooks"] as? [String: Any] else { return false }
for (_, value) in hooks {
if let groups = value as? [[String: Any]] {
// Matcher-group shape (Claude / Codex / Gemini).
if groups.contains(where: { group in
let inner = group["hooks"] as? [[String: Any]] ?? []
return inner.contains { isStaleHook(command: ($0["command"] as? String) ?? "") }
}) { return true }
// Cursor's flat shape (entries directly under the event).
if groups.contains(where: { isStaleHook(command: ($0["command"] as? String) ?? "") }) {
return true
}
}
}
return false
}

// Wire a single agent — used by the reconciliation row's "Set up"
// action. Same per-agent dispatch as install(agents:) does in its
// loop, just exposed for one-at-a-time wiring without re-running
// the full bootstrap. Idempotent: calling on an already-wired agent
// adds another entry (existing entries are detected by the next
// unwiredAgents() refresh).
static func wireSingleAgent(_ agent: BootstrapAgent) throws {
try wireHooks(for: agent)
}

// MARK: Install

// Install stack-nudge: copy bundled resources to ~/.stack-nudge/,
Expand Down Expand Up @@ -333,39 +392,64 @@ enum Bootstrap {
let path = agent.hookConfigPath
switch agent {
case .claude:
try wireClaudeHooks(at: path)
// Stop (30s) + PermissionRequest (600s); Claude's
// matcher-group JSON shape.
try wireClaudeShapedHooks(at: path, agentArg: "claude-code",
events: [("Stop", "stop", 30),
("PermissionRequest", "permission", 600)])
case .cursor:
try wireCursorHooks(at: path)
case .codex:
// Codex's hooks file is structurally identical to Claude's
// (matcher-groups), with the same event names. Only the
// file path and the agent-arg differ.
try wireClaudeShapedHooks(at: path, agentArg: "codex",
events: [("Stop", "stop", 30),
("PermissionRequest", "permission", 600)])
case .gemini:
// install.sh just prints "experimental, see README" for
// Gemini today. Mirror that: no-op, but accept the agent
// in `agents` so the wizard checkbox does something
// (acknowledges the user's choice).
break
// Gemini renames Claude's `Stop` to `AfterAgent` and routes
// tool-permission prompts through `Notification` (with
// `notification_type=ToolPermission` on stdin). Same
// matcher-group JSON shape otherwise.
//
// CRITICAL DIFFERENCE FROM CLAUDE/CODEX: Gemini's `timeout`
// is measured in **milliseconds**, not seconds. Sending 30
// would kill the hook after 30 ms — before the shell even
// forks. Multiply by 1000 to match the writer's
// milliseconds-only convention for Gemini.
//
// Note: Notification is observability-only — our hook can
// surface the banner but can't return an allow/deny
// decision the way Claude's PermissionRequest can.
try wireClaudeShapedHooks(at: path, agentArg: "gemini",
events: [("AfterAgent", "stop", 30_000),
("Notification", "permission", 30_000)])
}
}

private static func wireClaudeHooks(at path: String) throws {
// Generic writer for the matcher-group JSON shape that Claude,
// Codex, and Gemini all use. Differs from agent to agent only in
// file path, agent-arg passed to notify.sh, and the set of event
// names. Cursor uses a flat-array shape and has its own writer.
private static func wireClaudeShapedHooks(
at path: String,
agentArg: String,
events: [(event: String, arg: String, timeout: Int)]
) throws {
var root = try readJSONObject(at: path)
var hooks = root["hooks"] as? [String: Any] ?? [:]

// Two Claude events: Stop (turn ends, 30s timeout) and
// PermissionRequest (blocking on user approval, 600s).
let entries: [(event: String, arg: String, timeout: Int)] = [
("Stop", "stop", 30),
("PermissionRequest", "permission", 600),
]

for (event, arg, timeout) in entries {
for (event, arg, timeout) in events {
var groups = hooks[event] as? [[String: Any]] ?? []
groups = pruneStaleHookGroups(groups)
let ourHook: [String: Any] = [
"type": "command",
"command": "\(notifyPath) claude-code \(arg)",
"command": "\(notifyPath) \(agentArg) \(arg)",
"timeout": timeout,
]
// Swift's [String: Any] doesn't preserve key order; the
// resulting JSON is still valid. Claude Code parses by key.
// resulting JSON is still valid. All three agents parse
// by key.
groups.append([
"matcher": "",
"hooks": [ourHook],
Expand Down Expand Up @@ -432,8 +516,11 @@ enum Bootstrap {
var root = try readJSONObject(at: path)
guard var hooks = root["hooks"] as? [String: Any] else { return }

// Claude shape: matcher-groups
for event in ["Stop", "PermissionRequest"] {
// Matcher-group shape — shared by Claude (Stop/PermissionRequest),
// Codex (same names), and Gemini (AfterAgent/Notification).
// Iterating all four event names is harmless: events not present
// are simply skipped.
for event in ["Stop", "PermissionRequest", "AfterAgent", "Notification"] {
if let groups = hooks[event] as? [[String: Any]] {
let cleaned = pruneStaleHookGroups(groups)
if cleaned.isEmpty {
Expand Down Expand Up @@ -809,7 +896,7 @@ struct BootstrapView: View {
@ViewBuilder
private var agentList: some View {
if nav.bootstrapAvailableAgents.isEmpty {
Text("No supported agents detected (~/.claude, ~/.cursor, ~/.gemini). Install one and restart StackNudge to wire it up.")
Text("No supported agents detected (~/.claude, ~/.cursor, ~/.codex, ~/.gemini). Install one and restart StackNudge to wire it up.")
.font(.callout)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
Expand All @@ -829,47 +916,27 @@ struct BootstrapView: View {

@ViewBuilder
private func agentRow(_ agent: BootstrapAgent) -> some View {
if agent == .gemini {
// Gemini hook wiring isn't implemented — show the row as
// informational only so the user doesn't think they can toggle
// it on.
HStack(alignment: .top, spacing: 10) {
Image(systemName: "info.circle")
.font(.callout)
.foregroundStyle(.tertiary)
.frame(width: 20, alignment: .center)
.padding(.top, 2)
VStack(alignment: .leading, spacing: 1) {
Text(agent.displayName).font(.subheadline.weight(.medium))
Text("Detected, but hook wiring is manual. See README for setup.")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
}
} else {
let isSelected = nav.bootstrapSelectedAgents.contains(agent)
HStack(alignment: .top, spacing: 10) {
Image(systemName: isSelected ? "checkmark.circle.fill" : "circle")
.font(.callout)
.foregroundStyle(isSelected ? Color.accentColor : Color.secondary)
.frame(width: 20, alignment: .center)
.padding(.top, 2)
VStack(alignment: .leading, spacing: 1) {
Text(agent.displayName).font(.subheadline.weight(.medium))
Text("Hooks will be added to \((agent.hookConfigPath as NSString).abbreviatingWithTildeInPath)")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
let isSelected = nav.bootstrapSelectedAgents.contains(agent)
HStack(alignment: .top, spacing: 10) {
Image(systemName: isSelected ? "checkmark.circle.fill" : "circle")
.font(.callout)
.foregroundStyle(isSelected ? Color.accentColor : Color.secondary)
.frame(width: 20, alignment: .center)
.padding(.top, 2)
VStack(alignment: .leading, spacing: 1) {
Text(agent.displayName).font(.subheadline.weight(.medium))
Text("Hooks will be added to \((agent.hookConfigPath as NSString).abbreviatingWithTildeInPath)")
.font(.caption)
.foregroundStyle(.secondary)
}
.contentShape(Rectangle())
.onTapGesture {
if isSelected {
nav.bootstrapSelectedAgents.remove(agent)
} else {
nav.bootstrapSelectedAgents.insert(agent)
}
Spacer()
}
.contentShape(Rectangle())
.onTapGesture {
if isSelected {
nav.bootstrapSelectedAgents.remove(agent)
} else {
nav.bootstrapSelectedAgents.insert(agent)
}
}
}
Expand Down
Loading