- Mail commands (send/inbox/read/delete) now wrap bd mail CLI - Address translation: mayor/ → mayor, rig/polecat → rig-polecat - Beads stores messages as type=message issues - Legacy JSONL mode retained for crew workers (local mail) - Refinery notifications use new mail interface - Swarm landing notifications use new mail interface Closes gt-u1j.6, gt-u1j.12 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
366 lines
8.6 KiB
Go
366 lines
8.6 KiB
Go
package cmd
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/spf13/cobra"
|
|
"github.com/steveyegge/gastown/internal/mail"
|
|
"github.com/steveyegge/gastown/internal/style"
|
|
"github.com/steveyegge/gastown/internal/workspace"
|
|
)
|
|
|
|
// Mail command flags
|
|
var (
|
|
mailSubject string
|
|
mailBody string
|
|
mailPriority string
|
|
mailNotify bool
|
|
mailInboxJSON bool
|
|
mailReadJSON bool
|
|
mailInboxUnread bool
|
|
)
|
|
|
|
var mailCmd = &cobra.Command{
|
|
Use: "mail",
|
|
Short: "Agent messaging system",
|
|
Long: `Send and receive messages between agents.
|
|
|
|
The mail system allows Mayor, polecats, and the Refinery to communicate.
|
|
Messages are stored in beads as issues with type=message.`,
|
|
}
|
|
|
|
var mailSendCmd = &cobra.Command{
|
|
Use: "send <address>",
|
|
Short: "Send a message",
|
|
Long: `Send a message to an agent.
|
|
|
|
Addresses:
|
|
mayor/ - Send to Mayor
|
|
<rig>/refinery - Send to a rig's Refinery
|
|
<rig>/<polecat> - Send to a specific polecat
|
|
<rig>/ - Broadcast to a rig
|
|
|
|
Examples:
|
|
gt mail send gastown/Toast -s "Status check" -m "How's that bug fix going?"
|
|
gt mail send mayor/ -s "Work complete" -m "Finished gt-abc"
|
|
gt mail send gastown/ -s "All hands" -m "Swarm starting" --notify`,
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: runMailSend,
|
|
}
|
|
|
|
var mailInboxCmd = &cobra.Command{
|
|
Use: "inbox [address]",
|
|
Short: "Check inbox",
|
|
Long: `Check messages in an inbox.
|
|
|
|
If no address is specified, shows the current context's inbox.
|
|
|
|
Examples:
|
|
gt mail inbox # Current context
|
|
gt mail inbox mayor/ # Mayor's inbox
|
|
gt mail inbox gastown/Toast # Polecat's inbox`,
|
|
Args: cobra.MaximumNArgs(1),
|
|
RunE: runMailInbox,
|
|
}
|
|
|
|
var mailReadCmd = &cobra.Command{
|
|
Use: "read <message-id>",
|
|
Short: "Read a message",
|
|
Long: `Read a specific message and mark it as read.
|
|
|
|
The message ID can be found from 'gt mail inbox'.`,
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: runMailRead,
|
|
}
|
|
|
|
var mailDeleteCmd = &cobra.Command{
|
|
Use: "delete <message-id>",
|
|
Short: "Delete a message",
|
|
Long: `Delete (acknowledge) a message.
|
|
|
|
This closes the message in beads.`,
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: runMailDelete,
|
|
}
|
|
|
|
func init() {
|
|
// Send flags
|
|
mailSendCmd.Flags().StringVarP(&mailSubject, "subject", "s", "", "Message subject (required)")
|
|
mailSendCmd.Flags().StringVarP(&mailBody, "message", "m", "", "Message body")
|
|
mailSendCmd.Flags().StringVar(&mailPriority, "priority", "normal", "Message priority (normal, high)")
|
|
mailSendCmd.Flags().BoolVarP(&mailNotify, "notify", "n", false, "Send tmux notification to recipient")
|
|
mailSendCmd.MarkFlagRequired("subject")
|
|
|
|
// Inbox flags
|
|
mailInboxCmd.Flags().BoolVar(&mailInboxJSON, "json", false, "Output as JSON")
|
|
mailInboxCmd.Flags().BoolVarP(&mailInboxUnread, "unread", "u", false, "Show only unread messages")
|
|
|
|
// Read flags
|
|
mailReadCmd.Flags().BoolVar(&mailReadJSON, "json", false, "Output as JSON")
|
|
|
|
// Add subcommands
|
|
mailCmd.AddCommand(mailSendCmd)
|
|
mailCmd.AddCommand(mailInboxCmd)
|
|
mailCmd.AddCommand(mailReadCmd)
|
|
mailCmd.AddCommand(mailDeleteCmd)
|
|
|
|
rootCmd.AddCommand(mailCmd)
|
|
}
|
|
|
|
func runMailSend(cmd *cobra.Command, args []string) error {
|
|
to := args[0]
|
|
|
|
// Find workspace - we need a directory with .beads
|
|
workDir, err := findBeadsWorkDir()
|
|
if err != nil {
|
|
return fmt.Errorf("not in a Gas Town workspace: %w", err)
|
|
}
|
|
|
|
// Determine sender
|
|
from := detectSender()
|
|
|
|
// Create message
|
|
msg := &mail.Message{
|
|
From: from,
|
|
To: to,
|
|
Subject: mailSubject,
|
|
Body: mailBody,
|
|
}
|
|
|
|
// Set priority
|
|
if mailPriority == "high" || mailNotify {
|
|
msg.Priority = mail.PriorityHigh
|
|
}
|
|
|
|
// Send via router
|
|
router := mail.NewRouter(workDir)
|
|
if err := router.Send(msg); err != nil {
|
|
return fmt.Errorf("sending message: %w", err)
|
|
}
|
|
|
|
fmt.Printf("%s Message sent to %s\n", style.Bold.Render("✓"), to)
|
|
fmt.Printf(" Subject: %s\n", mailSubject)
|
|
|
|
return nil
|
|
}
|
|
|
|
func runMailInbox(cmd *cobra.Command, args []string) error {
|
|
// Find workspace
|
|
workDir, err := findBeadsWorkDir()
|
|
if err != nil {
|
|
return fmt.Errorf("not in a Gas Town workspace: %w", err)
|
|
}
|
|
|
|
// Determine which inbox to check
|
|
address := ""
|
|
if len(args) > 0 {
|
|
address = args[0]
|
|
} else {
|
|
address = detectSender()
|
|
}
|
|
|
|
// Get mailbox
|
|
router := mail.NewRouter(workDir)
|
|
mailbox, err := router.GetMailbox(address)
|
|
if err != nil {
|
|
return fmt.Errorf("getting mailbox: %w", err)
|
|
}
|
|
|
|
// Get messages
|
|
var messages []*mail.Message
|
|
if mailInboxUnread {
|
|
messages, err = mailbox.ListUnread()
|
|
} else {
|
|
messages, err = mailbox.List()
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("listing messages: %w", err)
|
|
}
|
|
|
|
// JSON output
|
|
if mailInboxJSON {
|
|
enc := json.NewEncoder(os.Stdout)
|
|
enc.SetIndent("", " ")
|
|
return enc.Encode(messages)
|
|
}
|
|
|
|
// Human-readable output
|
|
total, unread, _ := mailbox.Count()
|
|
fmt.Printf("%s Inbox: %s (%d messages, %d unread)\n\n",
|
|
style.Bold.Render("📬"), address, total, unread)
|
|
|
|
if len(messages) == 0 {
|
|
fmt.Printf(" %s\n", style.Dim.Render("(no messages)"))
|
|
return nil
|
|
}
|
|
|
|
for _, msg := range messages {
|
|
readMarker := "●"
|
|
if msg.Read {
|
|
readMarker = "○"
|
|
}
|
|
priorityMarker := ""
|
|
if msg.Priority == mail.PriorityHigh {
|
|
priorityMarker = " " + style.Bold.Render("!")
|
|
}
|
|
|
|
fmt.Printf(" %s %s%s\n", readMarker, msg.Subject, priorityMarker)
|
|
fmt.Printf(" %s from %s\n",
|
|
style.Dim.Render(msg.ID),
|
|
msg.From)
|
|
fmt.Printf(" %s\n",
|
|
style.Dim.Render(msg.Timestamp.Format("2006-01-02 15:04")))
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func runMailRead(cmd *cobra.Command, args []string) error {
|
|
msgID := args[0]
|
|
|
|
// Find workspace
|
|
workDir, err := findBeadsWorkDir()
|
|
if err != nil {
|
|
return fmt.Errorf("not in a Gas Town workspace: %w", err)
|
|
}
|
|
|
|
// Determine which inbox
|
|
address := detectSender()
|
|
|
|
// Get mailbox and message
|
|
router := mail.NewRouter(workDir)
|
|
mailbox, err := router.GetMailbox(address)
|
|
if err != nil {
|
|
return fmt.Errorf("getting mailbox: %w", err)
|
|
}
|
|
|
|
msg, err := mailbox.Get(msgID)
|
|
if err != nil {
|
|
return fmt.Errorf("getting message: %w", err)
|
|
}
|
|
|
|
// Mark as read
|
|
mailbox.MarkRead(msgID)
|
|
|
|
// JSON output
|
|
if mailReadJSON {
|
|
enc := json.NewEncoder(os.Stdout)
|
|
enc.SetIndent("", " ")
|
|
return enc.Encode(msg)
|
|
}
|
|
|
|
// Human-readable output
|
|
priorityStr := ""
|
|
if msg.Priority == mail.PriorityHigh {
|
|
priorityStr = " " + style.Bold.Render("[HIGH PRIORITY]")
|
|
}
|
|
|
|
fmt.Printf("%s %s%s\n\n", style.Bold.Render("Subject:"), msg.Subject, priorityStr)
|
|
fmt.Printf("From: %s\n", msg.From)
|
|
fmt.Printf("To: %s\n", msg.To)
|
|
fmt.Printf("Date: %s\n", msg.Timestamp.Format("2006-01-02 15:04:05"))
|
|
fmt.Printf("ID: %s\n", style.Dim.Render(msg.ID))
|
|
|
|
if msg.Body != "" {
|
|
fmt.Printf("\n%s\n", msg.Body)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func runMailDelete(cmd *cobra.Command, args []string) error {
|
|
msgID := args[0]
|
|
|
|
// Find workspace
|
|
workDir, err := findBeadsWorkDir()
|
|
if err != nil {
|
|
return fmt.Errorf("not in a Gas Town workspace: %w", err)
|
|
}
|
|
|
|
// Determine which inbox
|
|
address := detectSender()
|
|
|
|
// Get mailbox
|
|
router := mail.NewRouter(workDir)
|
|
mailbox, err := router.GetMailbox(address)
|
|
if err != nil {
|
|
return fmt.Errorf("getting mailbox: %w", err)
|
|
}
|
|
|
|
if err := mailbox.Delete(msgID); err != nil {
|
|
return fmt.Errorf("deleting message: %w", err)
|
|
}
|
|
|
|
fmt.Printf("%s Message deleted\n", style.Bold.Render("✓"))
|
|
return nil
|
|
}
|
|
|
|
// findBeadsWorkDir finds a directory with a .beads database.
|
|
// Walks up from CWD looking for .beads/ directory.
|
|
func findBeadsWorkDir() (string, error) {
|
|
// First try workspace root
|
|
townRoot, err := workspace.FindFromCwdOrError()
|
|
if err == nil {
|
|
// Check if town root has .beads
|
|
if _, err := os.Stat(filepath.Join(townRoot, ".beads")); err == nil {
|
|
return townRoot, nil
|
|
}
|
|
}
|
|
|
|
// Walk up from CWD looking for .beads
|
|
cwd, err := os.Getwd()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
path := cwd
|
|
for {
|
|
if _, err := os.Stat(filepath.Join(path, ".beads")); err == nil {
|
|
return path, nil
|
|
}
|
|
|
|
parent := filepath.Dir(path)
|
|
if parent == path {
|
|
break // Reached root
|
|
}
|
|
path = parent
|
|
}
|
|
|
|
return "", fmt.Errorf("no .beads directory found")
|
|
}
|
|
|
|
// detectSender determines the current context's address.
|
|
func detectSender() string {
|
|
// Check environment variables (set by session start)
|
|
rig := os.Getenv("GT_RIG")
|
|
polecat := os.Getenv("GT_POLECAT")
|
|
|
|
if rig != "" && polecat != "" {
|
|
return fmt.Sprintf("%s/%s", rig, polecat)
|
|
}
|
|
|
|
// Check current directory
|
|
cwd, err := os.Getwd()
|
|
if err != nil {
|
|
return "mayor/"
|
|
}
|
|
|
|
// If in a rig's polecats directory, extract address
|
|
if strings.Contains(cwd, "/polecats/") {
|
|
parts := strings.Split(cwd, "/polecats/")
|
|
if len(parts) >= 2 {
|
|
rigPath := parts[0]
|
|
polecatPath := strings.Split(parts[1], "/")[0]
|
|
rigName := filepath.Base(rigPath)
|
|
return fmt.Sprintf("%s/%s", rigName, polecatPath)
|
|
}
|
|
}
|
|
|
|
// Default to mayor
|
|
return "mayor/"
|
|
}
|