Files
gastown/internal/cmd/mail.go
gastown/crew/joe df46e75a51 Fix: Unknown subcommands now error instead of silently showing help
Parent commands (mol, mail, crew, polecat, etc.) previously showed help
and exited 0 for unknown subcommands like "gt mol foobar". This masked
errors in scripts and confused users.

Added requireSubcommand() helper to root.go and applied it to all parent
commands. Now unknown subcommands properly error with exit code 1.

Example before: gt mol unhook → shows help, exits 0
Example after:  gt mol unhook → "Error: unknown command "unhook"", exits 1

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 20:52:23 -08:00

1069 lines
30 KiB
Go

package cmd
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/events"
"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 int
mailUrgent bool
mailPinned bool
mailWisp bool
mailPermanent bool
mailType string
mailReplyTo string
mailNotify bool
mailSendSelf bool
mailCC []string // CC recipients
mailInboxJSON bool
mailReadJSON bool
mailInboxUnread bool
mailInboxIdentity string
mailCheckInject bool
mailCheckJSON bool
mailCheckIdentity string
mailThreadJSON bool
mailReplySubject string
mailReplyMessage string
)
var mailCmd = &cobra.Command{
Use: "mail",
GroupID: GroupComm,
Short: "Agent messaging system",
RunE: requireSubcommand,
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.
MAIL ROUTING:
┌─────────────────────────────────────────────────────┐
│ Town (.beads/) │
│ ┌─────────────────────────────────────────────┐ │
│ │ Mayor Inbox │ │
│ │ └── mayor/ │ │
│ └─────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────┐ │
│ │ gastown/ (rig mailboxes) │ │
│ │ ├── witness ← greenplace/witness │ │
│ │ ├── refinery ← greenplace/refinery │ │
│ │ ├── Toast ← greenplace/Toast │ │
│ │ └── crew/max ← greenplace/crew/max │ │
│ └─────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────┘
ADDRESS FORMATS:
mayor/ → Mayor inbox
<rig>/witness → Rig's Witness
<rig>/refinery → Rig's Refinery
<rig>/<polecat> → Polecat (e.g., greenplace/Toast)
<rig>/crew/<name> → Crew worker (e.g., greenplace/crew/max)
--human → Special: human overseer
COMMANDS:
inbox View your inbox
send Send a message
read Read a specific message
mark Mark messages read/unread`,
}
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
list:<name> - Send to a mailing list (fans out to all members)
Mailing lists are defined in ~/gt/config/messaging.json and allow
sending to multiple recipients at once. Each recipient gets their
own copy of the message.
Message types:
task - Required processing
scavenge - Optional first-come work
notification - Informational (default)
reply - Response to message
Priority levels:
0 - urgent/critical
1 - high
2 - normal (default)
3 - low
4 - backlog
Use --urgent as shortcut for --priority 0.
Examples:
gt mail send greenplace/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
gt mail send greenplace/Toast -s "Task" -m "Fix bug" --type task --priority 1
gt mail send greenplace/Toast -s "Urgent" -m "Help!" --urgent
gt mail send mayor/ -s "Re: Status" -m "Done" --reply-to msg-abc123
gt mail send --self -s "Handoff" -m "Context for next session"
gt mail send greenplace/Toast -s "Update" -m "Progress report" --cc overseer
gt mail send list:oncall -s "Alert" -m "System down"`,
Args: cobra.MaximumNArgs(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.
Use --identity for polecats to explicitly specify their identity.
Examples:
gt mail inbox # Current context (auto-detected)
gt mail inbox mayor/ # Mayor's inbox
gt mail inbox greenplace/Toast # Polecat's inbox
gt mail inbox --identity greenplace/Toast # Explicit polecat identity`,
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 mailPeekCmd = &cobra.Command{
Use: "peek",
Short: "Show preview of first unread message",
Long: `Display a compact preview of the first unread message.
Useful for status bar popups - shows subject, sender, and body preview.
Exits silently with code 1 if no unread messages.`,
RunE: runMailPeek,
}
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,
}
var mailArchiveCmd = &cobra.Command{
Use: "archive <message-id>",
Short: "Archive a message",
Long: `Archive a message (alias for delete).
Removes the message from your inbox by closing it in beads.`,
Args: cobra.ExactArgs(1),
RunE: runMailArchive,
}
var mailCheckCmd = &cobra.Command{
Use: "check",
Short: "Check for new mail (for hooks)",
Long: `Check for new mail - useful for Claude Code hooks.
Exit codes (normal mode):
0 - New mail available
1 - No new mail
Exit codes (--inject mode):
0 - Always (hooks should never block)
Output: system-reminder if mail exists, silent if no mail
Use --identity for polecats to explicitly specify their identity.
Examples:
gt mail check # Simple check (auto-detect identity)
gt mail check --inject # For hooks
gt mail check --identity greenplace/Toast # Explicit polecat identity`,
RunE: runMailCheck,
}
var mailThreadCmd = &cobra.Command{
Use: "thread <thread-id>",
Short: "View a message thread",
Long: `View all messages in a conversation thread.
Shows messages in chronological order (oldest first).
Examples:
gt mail thread thread-abc123`,
Args: cobra.ExactArgs(1),
RunE: runMailThread,
}
var mailReplyCmd = &cobra.Command{
Use: "reply <message-id>",
Short: "Reply to a message",
Long: `Reply to a specific message.
This is a convenience command that automatically:
- Sets the reply-to field to the original message
- Prefixes the subject with "Re: " (if not already present)
- Sends to the original sender
Examples:
gt mail reply msg-abc123 -m "Thanks, working on it now"
gt mail reply msg-abc123 -s "Custom subject" -m "Reply body"`,
Args: cobra.ExactArgs(1),
RunE: runMailReply,
}
func init() {
// Send flags
mailSendCmd.Flags().StringVarP(&mailSubject, "subject", "s", "", "Message subject (required)")
mailSendCmd.Flags().StringVarP(&mailBody, "message", "m", "", "Message body")
mailSendCmd.Flags().IntVar(&mailPriority, "priority", 2, "Message priority (0=urgent, 1=high, 2=normal, 3=low, 4=backlog)")
mailSendCmd.Flags().BoolVar(&mailUrgent, "urgent", false, "Set priority=0 (urgent)")
mailSendCmd.Flags().StringVar(&mailType, "type", "notification", "Message type (task, scavenge, notification, reply)")
mailSendCmd.Flags().StringVar(&mailReplyTo, "reply-to", "", "Message ID this is replying to")
mailSendCmd.Flags().BoolVarP(&mailNotify, "notify", "n", false, "Send tmux notification to recipient")
mailSendCmd.Flags().BoolVar(&mailPinned, "pinned", false, "Pin message (for handoff context that persists)")
mailSendCmd.Flags().BoolVar(&mailWisp, "wisp", true, "Send as wisp (ephemeral, default)")
mailSendCmd.Flags().BoolVar(&mailPermanent, "permanent", false, "Send as permanent (not ephemeral, synced to remote)")
mailSendCmd.Flags().BoolVar(&mailSendSelf, "self", false, "Send to self (auto-detect from cwd)")
mailSendCmd.Flags().StringArrayVar(&mailCC, "cc", nil, "CC recipients (can be used multiple times)")
_ = mailSendCmd.MarkFlagRequired("subject") // cobra flags: error only at runtime if missing
// Inbox flags
mailInboxCmd.Flags().BoolVar(&mailInboxJSON, "json", false, "Output as JSON")
mailInboxCmd.Flags().BoolVarP(&mailInboxUnread, "unread", "u", false, "Show only unread messages")
mailInboxCmd.Flags().StringVar(&mailInboxIdentity, "identity", "", "Explicit identity for inbox (e.g., greenplace/Toast)")
mailInboxCmd.Flags().StringVar(&mailInboxIdentity, "address", "", "Alias for --identity")
// Read flags
mailReadCmd.Flags().BoolVar(&mailReadJSON, "json", false, "Output as JSON")
// Check flags
mailCheckCmd.Flags().BoolVar(&mailCheckInject, "inject", false, "Output format for Claude Code hooks")
mailCheckCmd.Flags().BoolVar(&mailCheckJSON, "json", false, "Output as JSON")
mailCheckCmd.Flags().StringVar(&mailCheckIdentity, "identity", "", "Explicit identity for inbox (e.g., greenplace/Toast)")
mailCheckCmd.Flags().StringVar(&mailCheckIdentity, "address", "", "Alias for --identity")
// Thread flags
mailThreadCmd.Flags().BoolVar(&mailThreadJSON, "json", false, "Output as JSON")
// Reply flags
mailReplyCmd.Flags().StringVarP(&mailReplySubject, "subject", "s", "", "Override reply subject (default: Re: <original>)")
mailReplyCmd.Flags().StringVarP(&mailReplyMessage, "message", "m", "", "Reply message body (required)")
mailReplyCmd.MarkFlagRequired("message")
// Add subcommands
mailCmd.AddCommand(mailSendCmd)
mailCmd.AddCommand(mailInboxCmd)
mailCmd.AddCommand(mailReadCmd)
mailCmd.AddCommand(mailPeekCmd)
mailCmd.AddCommand(mailDeleteCmd)
mailCmd.AddCommand(mailArchiveCmd)
mailCmd.AddCommand(mailCheckCmd)
mailCmd.AddCommand(mailThreadCmd)
mailCmd.AddCommand(mailReplyCmd)
rootCmd.AddCommand(mailCmd)
}
func runMailSend(cmd *cobra.Command, args []string) error {
var to string
if mailSendSelf {
// Auto-detect identity from cwd
cwd, err := os.Getwd()
if err != nil {
return fmt.Errorf("getting current directory: %w", err)
}
townRoot, err := workspace.FindFromCwd()
if err != nil || townRoot == "" {
return fmt.Errorf("not in a Gas Town workspace")
}
roleInfo, err := GetRoleWithContext(cwd, townRoot)
if err != nil {
return fmt.Errorf("detecting role: %w", err)
}
ctx := RoleContext{
Role: roleInfo.Role,
Rig: roleInfo.Rig,
Polecat: roleInfo.Polecat,
TownRoot: townRoot,
WorkDir: cwd,
}
to = buildAgentIdentity(ctx)
if to == "" {
return fmt.Errorf("cannot determine identity (role: %s)", ctx.Role)
}
} else if len(args) > 0 {
to = args[0]
} else {
return fmt.Errorf("address required (or use --self)")
}
// All mail uses town beads (two-level architecture)
workDir, err := findMailWorkDir()
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 (--urgent overrides --priority)
if mailUrgent {
msg.Priority = mail.PriorityUrgent
} else {
msg.Priority = mail.PriorityFromInt(mailPriority)
}
if mailNotify && msg.Priority == mail.PriorityNormal {
msg.Priority = mail.PriorityHigh
}
// Set message type
msg.Type = mail.ParseMessageType(mailType)
// Set pinned flag
msg.Pinned = mailPinned
// Set wisp flag (ephemeral message) - default true, --permanent overrides
msg.Wisp = mailWisp && !mailPermanent
// Set CC recipients
msg.CC = mailCC
// Handle reply-to: auto-set type to reply and look up thread
if mailReplyTo != "" {
msg.ReplyTo = mailReplyTo
if msg.Type == mail.TypeNotification {
msg.Type = mail.TypeReply
}
// Look up original message to get thread ID
router := mail.NewRouter(workDir)
mailbox, err := router.GetMailbox(from)
if err == nil {
if original, err := mailbox.Get(mailReplyTo); err == nil {
msg.ThreadID = original.ThreadID
}
}
}
// Generate thread ID for new threads
if msg.ThreadID == "" {
msg.ThreadID = generateThreadID()
}
// Send via router
router := mail.NewRouter(workDir)
// Check if this is a list address to show fan-out details
var listRecipients []string
if strings.HasPrefix(to, "list:") {
var err error
listRecipients, err = router.ExpandListAddress(to)
if err != nil {
return fmt.Errorf("sending message: %w", err)
}
}
if err := router.Send(msg); err != nil {
return fmt.Errorf("sending message: %w", err)
}
// Log mail event to activity feed
_ = events.LogFeed(events.TypeMail, from, events.MailPayload(to, mailSubject))
fmt.Printf("%s Message sent to %s\n", style.Bold.Render("✓"), to)
fmt.Printf(" Subject: %s\n", mailSubject)
// Show fan-out recipients for list addresses
if len(listRecipients) > 0 {
fmt.Printf(" Recipients: %s\n", strings.Join(listRecipients, ", "))
}
if len(msg.CC) > 0 {
fmt.Printf(" CC: %s\n", strings.Join(msg.CC, ", "))
}
if msg.Type != mail.TypeNotification {
fmt.Printf(" Type: %s\n", msg.Type)
}
return nil
}
func runMailInbox(cmd *cobra.Command, args []string) error {
// Determine which inbox to check (priority: --identity flag, positional arg, auto-detect)
address := ""
if mailInboxIdentity != "" {
address = mailInboxIdentity
} else if len(args) > 0 {
address = args[0]
} else {
address = detectSender()
}
// All mail uses town beads (two-level architecture)
workDir, err := findMailWorkDir()
if err != nil {
return fmt.Errorf("not in a Gas Town workspace: %w", err)
}
// 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 = "○"
}
typeMarker := ""
if msg.Type != "" && msg.Type != mail.TypeNotification {
typeMarker = fmt.Sprintf(" [%s]", msg.Type)
}
priorityMarker := ""
if msg.Priority == mail.PriorityHigh || msg.Priority == mail.PriorityUrgent {
priorityMarker = " " + style.Bold.Render("!")
}
wispMarker := ""
if msg.Wisp {
wispMarker = " " + style.Dim.Render("(wisp)")
}
fmt.Printf(" %s %s%s%s%s\n", readMarker, msg.Subject, typeMarker, priorityMarker, wispMarker)
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]
// Determine which inbox
address := detectSender()
// All mail uses town beads (two-level architecture)
workDir, err := findMailWorkDir()
if err != nil {
return fmt.Errorf("not in a Gas Town workspace: %w", err)
}
// 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)
}
// Note: We intentionally do NOT mark as read/ack on read.
// User must explicitly delete/ack the message.
// This preserves handoff messages for reference.
// JSON output
if mailReadJSON {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(msg)
}
// Human-readable output
priorityStr := ""
if msg.Priority == mail.PriorityUrgent {
priorityStr = " " + style.Bold.Render("[URGENT]")
} else if msg.Priority == mail.PriorityHigh {
priorityStr = " " + style.Bold.Render("[HIGH PRIORITY]")
}
typeStr := ""
if msg.Type != "" && msg.Type != mail.TypeNotification {
typeStr = fmt.Sprintf(" [%s]", msg.Type)
}
fmt.Printf("%s %s%s%s\n\n", style.Bold.Render("Subject:"), msg.Subject, typeStr, 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.ThreadID != "" {
fmt.Printf("Thread: %s\n", style.Dim.Render(msg.ThreadID))
}
if msg.ReplyTo != "" {
fmt.Printf("Reply-To: %s\n", style.Dim.Render(msg.ReplyTo))
}
if msg.Body != "" {
fmt.Printf("\n%s\n", msg.Body)
}
return nil
}
func runMailPeek(cmd *cobra.Command, args []string) error {
// Determine which inbox
address := detectSender()
// All mail uses town beads (two-level architecture)
workDir, err := findMailWorkDir()
if err != nil {
return NewSilentExit(1) // Silent exit - no workspace
}
// Get mailbox
router := mail.NewRouter(workDir)
mailbox, err := router.GetMailbox(address)
if err != nil {
return NewSilentExit(1) // Silent exit - can't access mailbox
}
// Get unread messages
messages, err := mailbox.ListUnread()
if err != nil || len(messages) == 0 {
return NewSilentExit(1) // Silent exit - no unread
}
// Show first unread message
msg := messages[0]
// Header with priority indicator
priorityStr := ""
if msg.Priority == mail.PriorityUrgent {
priorityStr = " [URGENT]"
} else if msg.Priority == mail.PriorityHigh {
priorityStr = " [!]"
}
fmt.Printf("📬 %s%s\n", msg.Subject, priorityStr)
fmt.Printf("From: %s\n", msg.From)
fmt.Printf("ID: %s\n\n", msg.ID)
// Body preview (truncate long bodies)
if msg.Body != "" {
body := msg.Body
// Truncate to ~500 chars for popup display
if len(body) > 500 {
body = body[:500] + "\n..."
}
fmt.Print(body)
if !strings.HasSuffix(body, "\n") {
fmt.Println()
}
}
// Show count if more messages
if len(messages) > 1 {
fmt.Printf("\n%s\n", style.Dim.Render(fmt.Sprintf("(+%d more unread)", len(messages)-1)))
}
return nil
}
func runMailDelete(cmd *cobra.Command, args []string) error {
msgID := args[0]
// Determine which inbox
address := detectSender()
// All mail uses town beads (two-level architecture)
workDir, err := findMailWorkDir()
if err != nil {
return fmt.Errorf("not in a Gas Town workspace: %w", err)
}
// 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
}
func runMailArchive(cmd *cobra.Command, args []string) error {
msgID := args[0]
// Determine which inbox
address := detectSender()
// All mail uses town beads (two-level architecture)
workDir, err := findMailWorkDir()
if err != nil {
return fmt.Errorf("not in a Gas Town workspace: %w", err)
}
// 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("archiving message: %w", err)
}
fmt.Printf("%s Message archived\n", style.Bold.Render("✓"))
return nil
}
// findMailWorkDir returns the town root for all mail operations.
//
// Two-level beads architecture:
// - Town beads (~/gt/.beads/): ALL mail and coordination
// - Clone beads (<rig>/crew/*/.beads/): Project issues only
//
// Mail ALWAYS uses town beads, regardless of sender or recipient address.
// This ensures messages are visible to all agents in the town.
func findMailWorkDir() (string, error) {
return workspace.FindFromCwdOrError()
}
// findLocalBeadsDir finds the nearest .beads directory by walking up from CWD.
// Used for project work (molecules, issue creation) that uses clone beads.
func findLocalBeadsDir() (string, error) {
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.
// Priority:
// 1. GT_ROLE env var → use the role-based identity (agent session)
// 2. No GT_ROLE → try cwd-based detection (witness/refinery/polecat/crew directories)
// 3. No match → return "overseer" (human at terminal)
//
// All Gas Town agents run in tmux sessions with GT_ROLE set at spawn.
// However, cwd-based detection is also tried to support running commands
// from agent directories without GT_ROLE set (e.g., debugging sessions).
func detectSender() string {
// Check GT_ROLE first (authoritative for agent sessions)
role := os.Getenv("GT_ROLE")
if role != "" {
// Agent session - build address from role and context
return detectSenderFromRole(role)
}
// No GT_ROLE - try cwd-based detection, defaults to overseer if not in agent directory
return detectSenderFromCwd()
}
// detectSenderFromRole builds an address from the GT_ROLE and related env vars.
// GT_ROLE can be either a simple role name ("crew", "polecat") or a full address
// ("greenplace/crew/joe") depending on how the session was started.
//
// If GT_ROLE is a simple name but required env vars (GT_RIG, GT_POLECAT, etc.)
// are missing, falls back to cwd-based detection. This could return "overseer"
// if cwd doesn't match any known agent path - a misconfigured agent session.
func detectSenderFromRole(role string) string {
rig := os.Getenv("GT_RIG")
// Check if role is already a full address (contains /)
if strings.Contains(role, "/") {
// GT_ROLE is already a full address, use it directly
return role
}
// GT_ROLE is a simple role name, build the full address
switch role {
case "mayor":
return "mayor/"
case "deacon":
return "deacon/"
case "polecat":
polecat := os.Getenv("GT_POLECAT")
if rig != "" && polecat != "" {
return fmt.Sprintf("%s/%s", rig, polecat)
}
// Fallback to cwd detection for polecats
return detectSenderFromCwd()
case "crew":
crew := os.Getenv("GT_CREW")
if rig != "" && crew != "" {
return fmt.Sprintf("%s/crew/%s", rig, crew)
}
// Fallback to cwd detection for crew
return detectSenderFromCwd()
case "witness":
if rig != "" {
return fmt.Sprintf("%s/witness", rig)
}
return detectSenderFromCwd()
case "refinery":
if rig != "" {
return fmt.Sprintf("%s/refinery", rig)
}
return detectSenderFromCwd()
default:
// Unknown role, try cwd detection
return detectSenderFromCwd()
}
}
// detectSenderFromCwd is the legacy cwd-based detection for edge cases.
func detectSenderFromCwd() string {
cwd, err := os.Getwd()
if err != nil {
return "overseer"
}
// If in a rig's polecats directory, extract address (format: rig/polecats/name)
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/polecats/%s", rigName, polecatPath)
}
}
// If in a rig's crew directory, extract address (format: rig/crew/name)
if strings.Contains(cwd, "/crew/") {
parts := strings.Split(cwd, "/crew/")
if len(parts) >= 2 {
rigPath := parts[0]
crewName := strings.Split(parts[1], "/")[0]
rigName := filepath.Base(rigPath)
return fmt.Sprintf("%s/crew/%s", rigName, crewName)
}
}
// If in a rig's refinery directory, extract address (format: rig/refinery)
if strings.Contains(cwd, "/refinery") {
parts := strings.Split(cwd, "/refinery")
if len(parts) >= 1 {
rigName := filepath.Base(parts[0])
return fmt.Sprintf("%s/refinery", rigName)
}
}
// If in a rig's witness directory, extract address (format: rig/witness)
if strings.Contains(cwd, "/witness") {
parts := strings.Split(cwd, "/witness")
if len(parts) >= 1 {
rigName := filepath.Base(parts[0])
return fmt.Sprintf("%s/witness", rigName)
}
}
// Default to overseer (human)
return "overseer"
}
func runMailCheck(cmd *cobra.Command, args []string) error {
// Determine which inbox (priority: --identity flag, auto-detect)
address := ""
if mailCheckIdentity != "" {
address = mailCheckIdentity
} else {
address = detectSender()
}
// All mail uses town beads (two-level architecture)
workDir, err := findMailWorkDir()
if err != nil {
if mailCheckInject {
// Inject mode: always exit 0, silent on error
return nil
}
return fmt.Errorf("not in a Gas Town workspace: %w", err)
}
// Get mailbox
router := mail.NewRouter(workDir)
mailbox, err := router.GetMailbox(address)
if err != nil {
if mailCheckInject {
return nil
}
return fmt.Errorf("getting mailbox: %w", err)
}
// Count unread
_, unread, err := mailbox.Count()
if err != nil {
if mailCheckInject {
return nil
}
return fmt.Errorf("counting messages: %w", err)
}
// JSON output
if mailCheckJSON {
result := map[string]interface{}{
"address": address,
"unread": unread,
"has_new": unread > 0,
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(result)
}
// Inject mode: output system-reminder if mail exists
if mailCheckInject {
if unread > 0 {
// Get subjects for context
messages, _ := mailbox.ListUnread()
var subjects []string
for _, msg := range messages {
subjects = append(subjects, fmt.Sprintf("- %s from %s: %s", msg.ID, msg.From, msg.Subject))
}
fmt.Println("<system-reminder>")
fmt.Printf("You have %d unread message(s) in your inbox.\n\n", unread)
for _, s := range subjects {
fmt.Println(s)
}
fmt.Println()
fmt.Println("Run 'gt mail inbox' to see your messages, or 'gt mail read <id>' for a specific message.")
fmt.Println("</system-reminder>")
}
return nil
}
// Normal mode
if unread > 0 {
fmt.Printf("%s %d unread message(s)\n", style.Bold.Render("📬"), unread)
return NewSilentExit(0)
}
fmt.Println("No new mail")
return NewSilentExit(1)
}
func runMailThread(cmd *cobra.Command, args []string) error {
threadID := args[0]
// All mail uses town beads (two-level architecture)
workDir, err := findMailWorkDir()
if err != nil {
return fmt.Errorf("not in a Gas Town workspace: %w", err)
}
// Determine which inbox
address := detectSender()
// Get mailbox and thread messages
router := mail.NewRouter(workDir)
mailbox, err := router.GetMailbox(address)
if err != nil {
return fmt.Errorf("getting mailbox: %w", err)
}
messages, err := mailbox.ListByThread(threadID)
if err != nil {
return fmt.Errorf("getting thread: %w", err)
}
// JSON output
if mailThreadJSON {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(messages)
}
// Human-readable output
fmt.Printf("%s Thread: %s (%d messages)\n\n",
style.Bold.Render("🧵"), threadID, len(messages))
if len(messages) == 0 {
fmt.Printf(" %s\n", style.Dim.Render("(no messages in thread)"))
return nil
}
for i, msg := range messages {
typeMarker := ""
if msg.Type != "" && msg.Type != mail.TypeNotification {
typeMarker = fmt.Sprintf(" [%s]", msg.Type)
}
priorityMarker := ""
if msg.Priority == mail.PriorityHigh || msg.Priority == mail.PriorityUrgent {
priorityMarker = " " + style.Bold.Render("!")
}
if i > 0 {
fmt.Printf(" %s\n", style.Dim.Render("│"))
}
fmt.Printf(" %s %s%s%s\n", style.Bold.Render("●"), msg.Subject, typeMarker, priorityMarker)
fmt.Printf(" %s from %s to %s\n",
style.Dim.Render(msg.ID),
msg.From, msg.To)
fmt.Printf(" %s\n",
style.Dim.Render(msg.Timestamp.Format("2006-01-02 15:04")))
if msg.Body != "" {
fmt.Printf(" %s\n", msg.Body)
}
}
return nil
}
func runMailReply(cmd *cobra.Command, args []string) error {
msgID := args[0]
// All mail uses town beads (two-level architecture)
workDir, err := findMailWorkDir()
if err != nil {
return fmt.Errorf("not in a Gas Town workspace: %w", err)
}
// Determine current address
from := detectSender()
// Get the original message
router := mail.NewRouter(workDir)
mailbox, err := router.GetMailbox(from)
if err != nil {
return fmt.Errorf("getting mailbox: %w", err)
}
original, err := mailbox.Get(msgID)
if err != nil {
return fmt.Errorf("getting message: %w", err)
}
// Build reply subject
subject := mailReplySubject
if subject == "" {
if strings.HasPrefix(original.Subject, "Re: ") {
subject = original.Subject
} else {
subject = "Re: " + original.Subject
}
}
// Create reply message
reply := &mail.Message{
From: from,
To: original.From, // Reply to sender
Subject: subject,
Body: mailReplyMessage,
Type: mail.TypeReply,
Priority: mail.PriorityNormal,
ReplyTo: msgID,
ThreadID: original.ThreadID,
}
// If original has no thread ID, create one
if reply.ThreadID == "" {
reply.ThreadID = generateThreadID()
}
// Send the reply
if err := router.Send(reply); err != nil {
return fmt.Errorf("sending reply: %w", err)
}
fmt.Printf("%s Reply sent to %s\n", style.Bold.Render("✓"), original.From)
fmt.Printf(" Subject: %s\n", subject)
if original.ThreadID != "" {
fmt.Printf(" Thread: %s\n", style.Dim.Render(original.ThreadID))
}
return nil
}
// generateThreadID creates a random thread ID for new message threads.
func generateThreadID() string {
b := make([]byte, 6)
_, _ = rand.Read(b) // crypto/rand.Read only fails on broken system
return "thread-" + hex.EncodeToString(b)
}