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:
@@ -0,0 +1,41 @@
|
||||
package hook
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
|
||||
"github.com/longbin/agent-notify/internal/config"
|
||||
"github.com/longbin/agent-notify/internal/context"
|
||||
"github.com/longbin/agent-notify/internal/notify"
|
||||
)
|
||||
|
||||
type claudePayload struct {
|
||||
StopHookActive bool `json:"stop_hook_active"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type claudeResponse struct {
|
||||
TerminalSequence string `json:"terminalSequence,omitempty"`
|
||||
}
|
||||
|
||||
func RunClaude(r io.Reader, cfg config.Config, event string, w io.Writer) error {
|
||||
if !cfg.EventEnabled(event) {
|
||||
_, err := io.WriteString(w, "{}\n")
|
||||
return err
|
||||
}
|
||||
var payload claudePayload
|
||||
_ = json.NewDecoder(r).Decode(&payload)
|
||||
if event == "stop" && payload.StopHookActive {
|
||||
_, err := io.WriteString(w, "{}\n")
|
||||
return err
|
||||
}
|
||||
|
||||
meta := context.MetaFromEnv("Claude", event)
|
||||
title := context.Render(cfg.Notify.TitleTemplate, meta)
|
||||
body := cfg.BodyForEvent(event)
|
||||
seq := notify.BuildSequence(cfg.Notify.Protocol, title, body)
|
||||
resp := claudeResponse{TerminalSequence: seq}
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetEscapeHTML(false)
|
||||
return enc.Encode(resp)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package hook
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
|
||||
"github.com/longbin/agent-notify/internal/config"
|
||||
"github.com/longbin/agent-notify/internal/context"
|
||||
"github.com/longbin/agent-notify/internal/notify"
|
||||
)
|
||||
|
||||
type cursorPayload struct {
|
||||
WorkspaceRoots []string `json:"workspace_roots"`
|
||||
}
|
||||
|
||||
func RunCursor(r io.Reader, cfg config.Config, event string, _ io.Writer) error {
|
||||
if !cfg.EventEnabled(event) {
|
||||
return nil
|
||||
}
|
||||
var payload cursorPayload
|
||||
_ = json.NewDecoder(r).Decode(&payload)
|
||||
|
||||
meta := context.MetaFromEnv("Cursor", event)
|
||||
if len(payload.WorkspaceRoots) > 0 {
|
||||
meta.CWD = payload.WorkspaceRoots[0]
|
||||
}
|
||||
title := context.Render(cfg.Notify.TitleTemplate, meta)
|
||||
body := cfg.BodyForEvent(event)
|
||||
return notify.SendAuto(cfg.Notify.Protocol, title, body)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package hook
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/longbin/agent-notify/internal/config"
|
||||
)
|
||||
|
||||
func TestCursorStopHookDisabled(t *testing.T) {
|
||||
cfg := config.Default()
|
||||
cfg.Events.Stop = false
|
||||
err := RunCursor(bytes.NewReader([]byte(`{"workspace_roots":["/tmp/proj"]}`)), cfg, "stop", &bytes.Buffer{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeStopOutputsTerminalSequence(t *testing.T) {
|
||||
cfg := config.Default()
|
||||
var out bytes.Buffer
|
||||
err := RunClaude(strings.NewReader(`{"stop_hook_active":false}`), cfg, "stop", &out)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var resp map[string]string
|
||||
if err := json.Unmarshal(out.Bytes(), &resp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(resp["terminalSequence"], "777;notify") {
|
||||
t.Fatalf("bad sequence: %v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeStopHookActiveSkips(t *testing.T) {
|
||||
cfg := config.Default()
|
||||
var out bytes.Buffer
|
||||
err := RunClaude(strings.NewReader(`{"stop_hook_active":true}`), cfg, "stop", &out)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.TrimSpace(out.String()) != "{}" {
|
||||
t.Fatalf("expected {}, got %q", out.String())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user