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,16 @@
|
||||
package notify
|
||||
|
||||
import "strings"
|
||||
|
||||
func BuildSequence(protocol, title, body string) string {
|
||||
switch protocol {
|
||||
case "osc9":
|
||||
return "\033]9;" + escapeOSCField(body) + "\007"
|
||||
default:
|
||||
return "\033]777;notify;" + escapeOSCField(title) + ";" + escapeOSCField(body) + "\007"
|
||||
}
|
||||
}
|
||||
|
||||
func escapeOSCField(s string) string {
|
||||
return strings.ReplaceAll(s, ";", "\\;")
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package notify
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestBuildOSC777(t *testing.T) {
|
||||
seq := BuildSequence("osc777", "Claude — proj", "等待输入")
|
||||
want := "\033]777;notify;Claude — proj;等待输入\007"
|
||||
if seq != want {
|
||||
t.Fatalf("got %q want %q", seq, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildOSC9(t *testing.T) {
|
||||
seq := BuildSequence("osc9", "ignored", "等待输入")
|
||||
want := "\033]9;等待输入\007"
|
||||
if seq != want {
|
||||
t.Fatalf("got %q want %q", seq, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSemicolonInTitleEscaped(t *testing.T) {
|
||||
seq := BuildSequence("osc777", "a;b", "body")
|
||||
if seq != "\033]777;notify;a\\;b;body\007" {
|
||||
t.Fatalf("unexpected escape: %q", seq)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"github.com/longbin/agent-notify/internal/tmux"
|
||||
)
|
||||
|
||||
type SendOptions struct {
|
||||
Protocol string
|
||||
Title string
|
||||
Body string
|
||||
Writer io.Writer
|
||||
InTmux bool
|
||||
Layers int
|
||||
ClientTTY string
|
||||
}
|
||||
|
||||
func Send(opts SendOptions) error {
|
||||
seq := BuildSequence(opts.Protocol, opts.Title, opts.Body)
|
||||
w := opts.Writer
|
||||
if w == nil {
|
||||
w = os.Stdout
|
||||
}
|
||||
|
||||
if opts.InTmux && opts.ClientTTY != "" {
|
||||
f, err := os.OpenFile(opts.ClientTTY, os.O_WRONLY, 0)
|
||||
if err == nil {
|
||||
defer f.Close()
|
||||
_, err = io.WriteString(f, seq)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
out := seq
|
||||
if opts.InTmux {
|
||||
layers := opts.Layers
|
||||
if layers <= 0 {
|
||||
layers = 1
|
||||
}
|
||||
out = tmux.WrapPassthroughLayers(seq, layers)
|
||||
}
|
||||
_, err := io.WriteString(w, out)
|
||||
return err
|
||||
}
|
||||
|
||||
func SendAuto(protocol, title, body string) error {
|
||||
inTmux := tmux.InTmux()
|
||||
clientTTY, _ := tmux.ClientTTY()
|
||||
layers := 0
|
||||
if inTmux {
|
||||
layers = 1
|
||||
}
|
||||
return Send(SendOptions{
|
||||
Protocol: protocol,
|
||||
Title: title,
|
||||
Body: body,
|
||||
InTmux: inTmux,
|
||||
Layers: layers,
|
||||
ClientTTY: clientTTY,
|
||||
})
|
||||
}
|
||||
|
||||
func TestNotification(title, body string) error {
|
||||
if err := SendAuto("osc777", title, body); err != nil {
|
||||
return fmt.Errorf("send test notification: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSendDirect(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
err := Send(SendOptions{
|
||||
Protocol: "osc777",
|
||||
Title: "Cursor — app",
|
||||
Body: "等待输入",
|
||||
Writer: &buf,
|
||||
InTmux: false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Contains(buf.Bytes(), []byte("777;notify")) {
|
||||
t.Fatalf("missing osc777: %q", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendTmuxUsesPassthroughWhenNoTTY(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
err := Send(SendOptions{
|
||||
Protocol: "osc777",
|
||||
Title: "t",
|
||||
Body: "b",
|
||||
Writer: &buf,
|
||||
InTmux: true,
|
||||
Layers: 1,
|
||||
ClientTTY: "",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.HasPrefix(buf.Bytes(), []byte("\033Ptmux;")) {
|
||||
t.Fatalf("expected passthrough prefix, got %q", buf.String())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user