feat: add inbox

This commit is contained in:
2026-05-26 17:49:31 +08:00
parent f7fcd0e966
commit 68b5c8bc81
25 changed files with 2108 additions and 1 deletions
+213
View File
@@ -0,0 +1,213 @@
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"io"
"net"
"net/http"
"os"
"path/filepath"
"time"
"github.com/longbin/agent-notify/internal/config"
"github.com/longbin/agent-notify/internal/inbox"
)
func cmdInbox(args []string, stdout, stderr io.Writer) error {
if len(args) == 0 {
return fmt.Errorf("usage: agent-notify inbox <list|show|done|rm|clear|serve|ssh-config>")
}
switch args[0] {
case "list":
return inboxList(args[1:], stdout)
case "show":
return inboxShow(args[1:], stdout)
case "done":
return inboxDone(args[1:], stdout)
case "rm":
return inboxRemove(args[1:], stdout)
case "clear":
return inboxClear(args[1:], stdout)
case "serve":
return inboxServe(args[1:], stderr)
case "ssh-config":
return inboxSSHConfig(args[1:], stdout)
case "tui":
return inbox.RunTUI(inbox.NewStore(""))
default:
return fmt.Errorf("unknown inbox command %q", args[0])
}
}
func inboxList(args []string, stdout io.Writer) error {
fs := flag.NewFlagSet("inbox list", flag.ExitOnError)
all := fs.Bool("all", false, "include all records")
done := fs.Bool("done", false, "show done records")
_ = fs.Parse(args)
records, err := inbox.NewStore("").List()
if err != nil {
return err
}
for _, rec := range records {
if !*all && !*done && rec.Status != inbox.StatusPending {
continue
}
if *done && rec.Status != inbox.StatusDone {
continue
}
fmt.Fprintf(stdout, "%s\t%s\t%s\t%s/%s\t%s\t%s\n",
rec.ID, rec.Status, rec.Host, rec.Agent, rec.Event, rec.CWD, rec.Title)
}
return nil
}
func inboxShow(args []string, stdout io.Writer) error {
if len(args) != 1 {
return fmt.Errorf("usage: agent-notify inbox show <id>")
}
rec, ok, err := findRecord(args[0])
if err != nil {
return err
}
if !ok {
return fmt.Errorf("inbox record %q not found", args[0])
}
enc := json.NewEncoder(stdout)
enc.SetIndent("", " ")
return enc.Encode(rec)
}
func inboxDone(args []string, stdout io.Writer) error {
if len(args) == 0 {
return fmt.Errorf("usage: agent-notify inbox done <id...>")
}
n, err := inbox.NewStore("").MarkDone(args)
if err != nil {
return err
}
fmt.Fprintf(stdout, "marked %d done\n", n)
return nil
}
func inboxRemove(args []string, stdout io.Writer) error {
if len(args) == 0 {
return fmt.Errorf("usage: agent-notify inbox rm <id...>")
}
n, err := inbox.NewStore("").Remove(args)
if err != nil {
return err
}
fmt.Fprintf(stdout, "removed %d\n", n)
return nil
}
func inboxClear(args []string, stdout io.Writer) error {
fs := flag.NewFlagSet("inbox clear", flag.ExitOnError)
done := fs.Bool("done", false, "clear done records only")
_ = fs.Parse(args)
var (
n int
err error
)
if *done {
n, err = inbox.NewStore("").ClearDone()
} else {
n, err = inbox.NewStore("").ClearAll()
}
if err != nil {
return err
}
fmt.Fprintf(stdout, "cleared %d\n", n)
return nil
}
func inboxServe(args []string, stderr io.Writer) error {
cfg, err := config.LoadDefault()
if err != nil {
return err
}
fs := flag.NewFlagSet("inbox serve", flag.ExitOnError)
socket := fs.String("socket", cfg.Inbox.Socket, "Unix socket path")
addr := fs.String("addr", "", "TCP address")
_ = fs.Parse(args)
handler := inbox.NewHandler(inbox.NewStore(""))
server := &http.Server{Handler: handler}
if *addr != "" {
ln, err := net.Listen("tcp", *addr)
if err != nil {
return err
}
fmt.Fprintf(stderr, "agent-notify inbox listening on tcp %s\n", *addr)
return server.Serve(ln)
}
if err := os.RemoveAll(*socket); err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(*socket), 0700); err != nil {
return err
}
ln, err := net.Listen("unix", *socket)
if err != nil {
return err
}
if err := os.Chmod(*socket, 0600); err != nil {
ln.Close()
return err
}
fmt.Fprintf(stderr, "agent-notify inbox listening on unix %s\n", *socket)
return server.Serve(ln)
}
func inboxSSHConfig(args []string, stdout io.Writer) error {
if len(args) == 0 || args[0] != "install" {
return fmt.Errorf("usage: agent-notify inbox ssh-config install [--path PATH] [--socket PATH]")
}
cfg, err := config.LoadDefault()
if err != nil {
return err
}
fs := flag.NewFlagSet("inbox ssh-config install", flag.ExitOnError)
path := fs.String("path", inbox.DefaultSSHConfigPath(), "SSH config path")
socket := fs.String("socket", cfg.Inbox.Socket, "local agent-notify Unix socket path")
remoteSocket := fs.String("remote-socket", cfg.Inbox.RemoteSocket, "remote agent-notify Unix socket path")
_ = fs.Parse(args[1:])
block, err := inbox.InstallSSHConfig(*path, *remoteSocket, *socket)
if err != nil {
return err
}
fmt.Fprintf(stdout, "wrote SSH config: %s\n\n%s\nReconnect existing SSH sessions for RemoteForward to take effect.\n", *path, block)
return nil
}
func findRecord(id string) (inbox.Record, bool, error) {
records, err := inbox.NewStore("").List()
if err != nil {
return inbox.Record{}, false, err
}
for _, rec := range records {
if rec.ID == id {
return rec, true, nil
}
}
return inbox.Record{}, false, nil
}
func uploadTestRecord(cfg config.Config) error {
rec := inbox.BuildRecord(inbox.BuildInput{
Agent: "agent-notify",
Event: "test",
Title: "agent-notify test",
Body: "inbox test",
Source: inbox.SourceLocal,
})
timeout := time.Duration(cfg.Inbox.TimeoutMS) * time.Millisecond
client := inbox.NewClient(inbox.ClientConfig{Socket: cfg.Inbox.Socket, Addr: cfg.Inbox.Addr, Timeout: timeout})
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
return client.Upload(ctx, rec)
}
+86
View File
@@ -0,0 +1,86 @@
package main
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
"github.com/longbin/agent-notify/internal/inbox"
)
func TestInboxListAndDoneCommands(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
store := inbox.NewStore("")
if err := store.Append(inbox.Record{ID: "id-1", Status: inbox.StatusPending, Host: "host-a", Agent: "Cursor", Event: "stop", CWD: "/tmp/proj", Title: "ready"}); err != nil {
t.Fatal(err)
}
var out bytes.Buffer
if err := cmdInbox([]string{"list"}, &out, &bytes.Buffer{}); err != nil {
t.Fatal(err)
}
if !strings.Contains(out.String(), "id-1") || !strings.Contains(out.String(), "ready") {
t.Fatalf("unexpected list output: %s", out.String())
}
out.Reset()
if err := cmdInbox([]string{"done", "id-1"}, &out, &bytes.Buffer{}); err != nil {
t.Fatal(err)
}
if !strings.Contains(out.String(), "marked 1 done") {
t.Fatalf("unexpected done output: %s", out.String())
}
out.Reset()
if err := cmdInbox([]string{"list"}, &out, &bytes.Buffer{}); err != nil {
t.Fatal(err)
}
if strings.Contains(out.String(), "id-1") {
t.Fatalf("done record should be hidden by default: %s", out.String())
}
}
func TestInboxShowAndRemoveCommands(t *testing.T) {
t.Setenv("HOME", t.TempDir())
store := inbox.NewStore("")
if err := store.Append(inbox.Record{ID: "id-1", Status: inbox.StatusPending, Body: "body text", Title: "title"}); err != nil {
t.Fatal(err)
}
var out bytes.Buffer
if err := cmdInbox([]string{"show", "id-1"}, &out, &bytes.Buffer{}); err != nil {
t.Fatal(err)
}
if !strings.Contains(out.String(), "body text") {
t.Fatalf("unexpected show output: %s", out.String())
}
out.Reset()
if err := cmdInbox([]string{"rm", "id-1"}, &out, &bytes.Buffer{}); err != nil {
t.Fatal(err)
}
if !strings.Contains(out.String(), "removed 1") {
t.Fatalf("unexpected rm output: %s", out.String())
}
}
func TestInboxSSHConfigInstallCommandPrintsBlock(t *testing.T) {
t.Setenv("HOME", t.TempDir())
path := filepath.Join(t.TempDir(), "ssh_config")
var out bytes.Buffer
if err := cmdInbox([]string{"ssh-config", "install", "--path", path, "--remote-socket", "/tmp/remote-agent-notify.sock", "--socket", "/run/user/1000/agent-notify.sock"}, &out, &bytes.Buffer{}); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(data), "RemoteForward /tmp/remote-agent-notify.sock /run/user/1000/agent-notify.sock") {
t.Fatalf("config not written: %s", string(data))
}
if !strings.Contains(out.String(), "wrote SSH config") || !strings.Contains(out.String(), "# BEGIN agent-notify inbox") {
t.Fatalf("block not printed: %s", out.String())
}
}
+3
View File
@@ -39,6 +39,8 @@ func run(cmd string, args []string) error {
return cmdDoctor()
case "logs":
return cmdLogs(args)
case "inbox":
return cmdInbox(args, os.Stdout, os.Stderr)
case "version", "-V":
return cmdVersion()
case "help", "-h", "--help":
@@ -202,6 +204,7 @@ Commands:
test cursor [--try-all]
test claude [--apply]
logs [--tail 30]
inbox <list|show|done|rm|clear|serve|ssh-config>
version
doctor
`)