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
", Short: "Send a message", Long: `Send a message to an agent. Addresses: mayor/ - Send to Mayor /refinery - Send to a rig's Refinery / - Send to a specific polecat / - 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 ", 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 ", 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/" }