feat: implement agent-notify CLI with hooks and tmux passthrough

Add OSC 777 notification sender, Cursor/Claude hook adapters,
config/install commands, and README for Ghostty + tmux setup.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-26 11:19:34 +08:00
parent f13dcd6cae
commit 5fecb6d215
22 changed files with 2729 additions and 11 deletions
+104
View File
@@ -0,0 +1,104 @@
package install
import (
"encoding/json"
"os"
"path/filepath"
"github.com/longbin/agent-notify/internal/config"
)
const cursorHookCmd = "agent-notify hook cursor stop"
const cursorToolCmd = "agent-notify hook cursor tool"
const claudeStopCmd = "agent-notify hook claude stop"
const claudeIdleCmd = "agent-notify hook claude idle"
func CursorHooksPath() string {
return filepath.Join(os.Getenv("HOME"), ".cursor", "hooks.json")
}
func ClaudeSettingsPath() string {
return filepath.Join(os.Getenv("HOME"), ".claude", "settings.json")
}
func MergeCursorHooks(path string, force bool) error {
doc := map[string]any{"version": 1, "hooks": map[string]any{}}
if data, err := os.ReadFile(path); err == nil {
_ = json.Unmarshal(data, &doc)
}
hooks, _ := doc["hooks"].(map[string]any)
if hooks == nil {
hooks = map[string]any{}
doc["hooks"] = hooks
}
addHook(hooks, "stop", cursorHookCmd, force)
cfg, _ := config.LoadDefault()
if cfg.Events.Tool {
addHook(hooks, "afterShellExecution", cursorToolCmd, force)
}
return writeJSON(path, doc)
}
func addHook(hooks map[string]any, name, command string, force bool) {
if existing, ok := hooks[name]; ok && !force {
_ = existing
return
}
hooks[name] = []any{map[string]string{"command": command}}
}
func MergeClaudeSettings(path string, force bool) error {
doc := map[string]any{}
if data, err := os.ReadFile(path); err == nil {
_ = json.Unmarshal(data, &doc)
}
hooks, _ := doc["hooks"].(map[string]any)
if hooks == nil {
hooks = map[string]any{}
doc["hooks"] = hooks
}
setClaudeHook(hooks, "Stop", claudeStopCmd, force)
cfg, _ := config.LoadDefault()
if cfg.Events.Idle {
setClaudeHook(hooks, "Notification", claudeIdleCmd, force)
}
return writeJSON(path, doc)
}
func setClaudeHook(hooks map[string]any, event, command string, force bool) {
if _, ok := hooks[event]; ok && !force {
return
}
hooks[event] = []any{
map[string]any{
"hooks": []any{
map[string]string{
"type": "command",
"command": command,
},
},
},
}
}
func writeJSON(path string, doc any) error {
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return err
}
data, err := json.MarshalIndent(doc, "", " ")
if err != nil {
return err
}
data = append(data, '\n')
return os.WriteFile(path, data, 0644)
}
func InstallAll(force bool) error {
if err := config.Default().WriteDefault(config.DefaultPath()); err != nil {
return err
}
if err := MergeCursorHooks(CursorHooksPath(), force); err != nil {
return err
}
return MergeClaudeSettings(ClaudeSettingsPath(), force)
}
+44
View File
@@ -0,0 +1,44 @@
package install
import (
"encoding/json"
"os"
"path/filepath"
"testing"
)
func TestMergeCursorHooks(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "hooks.json")
existing := `{"version":1,"hooks":{"beforeShellExecution":[{"command":"other"}]}}`
os.WriteFile(path, []byte(existing), 0644)
if err := MergeCursorHooks(path, false); err != nil {
t.Fatal(err)
}
data, _ := os.ReadFile(path)
var doc map[string]any
json.Unmarshal(data, &doc)
hooks := doc["hooks"].(map[string]any)
stop := hooks["stop"].([]any)
if len(stop) != 1 {
t.Fatalf("expected stop hook added")
}
}
func TestMergeCursorHooksNoOverwrite(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "hooks.json")
existing := `{"version":1,"hooks":{"stop":[{"command":"existing"}]}}`
os.WriteFile(path, []byte(existing), 0644)
MergeCursorHooks(path, false)
data, _ := os.ReadFile(path)
var doc map[string]any
json.Unmarshal(data, &doc)
hooks := doc["hooks"].(map[string]any)
stop := hooks["stop"].([]any)
entry := stop[0].(map[string]any)
if entry["command"] != "existing" {
t.Fatal("should not overwrite existing stop hook without force")
}
}