diff --git a/internal/mail/bd.go b/internal/mail/bd.go new file mode 100644 index 00000000..eefb8520 --- /dev/null +++ b/internal/mail/bd.go @@ -0,0 +1,62 @@ +package mail + +import ( + "bytes" + "os/exec" + "strings" +) + +// bdError represents an error from running a bd command. +// It wraps the underlying error and includes the stderr output for inspection. +type bdError struct { + Err error + Stderr string +} + +// Error implements the error interface. +func (e *bdError) Error() string { + if e.Stderr != "" { + return e.Stderr + } + if e.Err != nil { + return e.Err.Error() + } + return "unknown bd error" +} + +// Unwrap returns the underlying error for errors.Is/As compatibility. +func (e *bdError) Unwrap() error { + return e.Err +} + +// ContainsError checks if the stderr message contains the given substring. +func (e *bdError) ContainsError(substr string) bool { + return strings.Contains(e.Stderr, substr) +} + +// runBdCommand executes a bd command with proper environment setup. +// workDir is the directory to run the command in. +// beadsDir is the BEADS_DIR environment variable value. +// extraEnv contains additional environment variables to set (e.g., "BD_IDENTITY=..."). +// Returns stdout bytes on success, or a *bdError on failure. +func runBdCommand(args []string, workDir, beadsDir string, extraEnv ...string) ([]byte, error) { + cmd := exec.Command("bd", args...) //nolint:gosec // G204: bd is a trusted internal tool + cmd.Dir = workDir + + env := append(cmd.Environ(), "BEADS_DIR="+beadsDir) + env = append(env, extraEnv...) + cmd.Env = env + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + return nil, &bdError{ + Err: err, + Stderr: strings.TrimSpace(stderr.String()), + } + } + + return stdout.Bytes(), nil +} diff --git a/internal/mail/mailbox.go b/internal/mail/mailbox.go index 31dd7463..ce69a523 100644 --- a/internal/mail/mailbox.go +++ b/internal/mail/mailbox.go @@ -2,16 +2,13 @@ package mail import ( "bufio" - "bytes" "encoding/json" "errors" "fmt" "os" - "os/exec" "path/filepath" "regexp" "sort" - "strings" "time" "github.com/steveyegge/gastown/internal/beads" @@ -168,34 +165,23 @@ func (m *Mailbox) identityVariants() []string { // queryMessages runs a bd list query with the given filter flag and value. func (m *Mailbox) queryMessages(beadsDir, filterFlag, filterValue, status string) ([]*Message, error) { - cmd := exec.Command("bd", "list", + args := []string{"list", "--type", "message", filterFlag, filterValue, "--status", status, "--json", - ) - cmd.Dir = m.workDir - cmd.Env = append(cmd.Environ(), - "BEADS_DIR="+beadsDir, - ) + } - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - - if err := cmd.Run(); err != nil { - errMsg := strings.TrimSpace(stderr.String()) - if errMsg != "" { - return nil, errors.New(errMsg) - } + stdout, err := runBdCommand(args, m.workDir, beadsDir) + if err != nil { return nil, err } // Parse JSON output var beadsMsgs []BeadsMessage - if err := json.Unmarshal(stdout.Bytes(), &beadsMsgs); err != nil { + if err := json.Unmarshal(stdout, &beadsMsgs); err != nil { // Empty inbox returns empty array or nothing - if len(stdout.Bytes()) == 0 || stdout.String() == "null" { + if len(stdout) == 0 || string(stdout) == "null" { return nil, nil } return nil, err @@ -281,28 +267,19 @@ func (m *Mailbox) getBeads(id string) (*Message, error) { // getFromDir retrieves a message from a beads directory. func (m *Mailbox) getFromDir(id, beadsDir string) (*Message, error) { - cmd := exec.Command("bd", "show", id, "--json") - cmd.Dir = m.workDir - cmd.Env = append(cmd.Environ(), "BEADS_DIR="+beadsDir) + args := []string{"show", id, "--json"} - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - - if err := cmd.Run(); err != nil { - errMsg := strings.TrimSpace(stderr.String()) - if strings.Contains(errMsg, "not found") { + stdout, err := runBdCommand(args, m.workDir, beadsDir) + if err != nil { + if bdErr, ok := err.(*bdError); ok && bdErr.ContainsError("not found") { return nil, ErrMessageNotFound } - if errMsg != "" { - return nil, errors.New(errMsg) - } return nil, err } // bd show --json returns an array var bms []BeadsMessage - if err := json.Unmarshal(stdout.Bytes(), &bms); err != nil { + if err := json.Unmarshal(stdout, &bms); err != nil { return nil, err } if len(bms) == 0 { @@ -346,21 +323,12 @@ func (m *Mailbox) closeInDir(id, beadsDir string) error { if sessionID := os.Getenv("CLAUDE_SESSION_ID"); sessionID != "" { args = append(args, "--session="+sessionID) } - cmd := exec.Command("bd", args...) //nolint:gosec // G204: bd is a trusted internal tool - cmd.Dir = m.workDir - cmd.Env = append(cmd.Environ(), "BEADS_DIR="+beadsDir) - var stderr bytes.Buffer - cmd.Stderr = &stderr - - if err := cmd.Run(); err != nil { - errMsg := strings.TrimSpace(stderr.String()) - if strings.Contains(errMsg, "not found") { + _, err := runBdCommand(args, m.workDir, beadsDir) + if err != nil { + if bdErr, ok := err.(*bdError); ok && bdErr.ContainsError("not found") { return ErrMessageNotFound } - if errMsg != "" { - return errors.New(errMsg) - } return err } @@ -397,21 +365,13 @@ func (m *Mailbox) MarkUnread(id string) error { } func (m *Mailbox) markUnreadBeads(id string) error { - cmd := exec.Command("bd", "reopen", id) - cmd.Dir = m.workDir - cmd.Env = append(cmd.Environ(), "BEADS_DIR="+m.beadsDir) + args := []string{"reopen", id} - var stderr bytes.Buffer - cmd.Stderr = &stderr - - if err := cmd.Run(); err != nil { - errMsg := strings.TrimSpace(stderr.String()) - if strings.Contains(errMsg, "not found") { + _, err := runBdCommand(args, m.workDir, m.beadsDir) + if err != nil { + if bdErr, ok := err.(*bdError); ok && bdErr.ContainsError("not found") { return ErrMessageNotFound } - if errMsg != "" { - return errors.New(errMsg) - } return err } @@ -797,29 +757,16 @@ func (m *Mailbox) ListByThread(threadID string) ([]*Message, error) { } func (m *Mailbox) listByThreadBeads(threadID string) ([]*Message, error) { - // bd message thread --json - cmd := exec.Command("bd", "message", "thread", threadID, "--json") - cmd.Dir = m.workDir - cmd.Env = append(cmd.Environ(), - "BD_IDENTITY="+m.identity, - "BEADS_DIR="+m.beadsDir, - ) + args := []string{"message", "thread", threadID, "--json"} - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - - if err := cmd.Run(); err != nil { - errMsg := strings.TrimSpace(stderr.String()) - if errMsg != "" { - return nil, errors.New(errMsg) - } + stdout, err := runBdCommand(args, m.workDir, m.beadsDir, "BD_IDENTITY="+m.identity) + if err != nil { return nil, err } var beadsMsgs []BeadsMessage - if err := json.Unmarshal(stdout.Bytes(), &beadsMsgs); err != nil { - if len(stdout.Bytes()) == 0 || stdout.String() == "null" { + if err := json.Unmarshal(stdout, &beadsMsgs); err != nil { + if len(stdout) == 0 || string(stdout) == "null" { return nil, nil } return nil, err diff --git a/internal/mail/router.go b/internal/mail/router.go index 0f38892b..a7278126 100644 --- a/internal/mail/router.go +++ b/internal/mail/router.go @@ -1,12 +1,10 @@ package mail import ( - "bytes" "encoding/json" "errors" "fmt" "os" - "os/exec" "path/filepath" "strings" @@ -453,24 +451,13 @@ func (r *Router) queryAgents(descContains string) ([]*agentBead, error) { args = append(args, "--desc-contains="+descContains) } - cmd := exec.Command("bd", args...) - cmd.Env = append(cmd.Environ(), "BEADS_DIR="+beadsDir) - cmd.Dir = filepath.Dir(beadsDir) - - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - - if err := cmd.Run(); err != nil { - errMsg := strings.TrimSpace(stderr.String()) - if errMsg != "" { - return nil, errors.New(errMsg) - } + stdout, err := runBdCommand(args, filepath.Dir(beadsDir), beadsDir) + if err != nil { return nil, fmt.Errorf("querying agents: %w", err) } var agents []*agentBead - if err := json.Unmarshal(stdout.Bytes(), &agents); err != nil { + if err := json.Unmarshal(stdout, &agents); err != nil { return nil, fmt.Errorf("parsing agent query result: %w", err) } @@ -622,20 +609,8 @@ func (r *Router) sendToSingle(msg *Message) error { } beadsDir := r.resolveBeadsDir(msg.To) - cmd := exec.Command("bd", args...) //nolint:gosec // G204: bd is a trusted internal tool - cmd.Env = append(cmd.Environ(), - "BEADS_DIR="+beadsDir, - ) - cmd.Dir = filepath.Dir(beadsDir) // Run in parent of .beads - - var stderr bytes.Buffer - cmd.Stderr = &stderr - - if err := cmd.Run(); err != nil { - errMsg := strings.TrimSpace(stderr.String()) - if errMsg != "" { - return errors.New(errMsg) - } + _, err := runBdCommand(args, filepath.Dir(beadsDir), beadsDir) + if err != nil { return fmt.Errorf("sending message: %w", err) } @@ -744,20 +719,8 @@ func (r *Router) sendToQueue(msg *Message) error { // Queue messages go to town-level beads (shared location) beadsDir := r.resolveBeadsDir("") - cmd := exec.Command("bd", args...) //nolint:gosec // G204: args are constructed internally, not from user input - cmd.Env = append(cmd.Environ(), - "BEADS_DIR="+beadsDir, - ) - cmd.Dir = filepath.Dir(beadsDir) // Run in parent of .beads - - var stderr bytes.Buffer - cmd.Stderr = &stderr - - if err := cmd.Run(); err != nil { - errMsg := strings.TrimSpace(stderr.String()) - if errMsg != "" { - return errors.New(errMsg) - } + _, err = runBdCommand(args, filepath.Dir(beadsDir), beadsDir) + if err != nil { return fmt.Errorf("sending to queue %s: %w", queueName, err) } @@ -827,20 +790,8 @@ func (r *Router) sendToAnnounce(msg *Message) error { // Announce messages go to town-level beads (shared location) beadsDir := r.resolveBeadsDir("") - cmd := exec.Command("bd", args...) //nolint:gosec // G204: args are constructed internally, not from user input - cmd.Env = append(cmd.Environ(), - "BEADS_DIR="+beadsDir, - ) - cmd.Dir = filepath.Dir(beadsDir) // Run in parent of .beads - - var stderr bytes.Buffer - cmd.Stderr = &stderr - - if err := cmd.Run(); err != nil { - errMsg := strings.TrimSpace(stderr.String()) - if errMsg != "" { - return errors.New(errMsg) - } + _, err = runBdCommand(args, filepath.Dir(beadsDir), beadsDir) + if err != nil { return fmt.Errorf("sending to announce %s: %w", announceName, err) } @@ -869,19 +820,8 @@ func (r *Router) pruneAnnounce(announceName string, retainCount int) error { "--asc", // Oldest first } - cmd := exec.Command("bd", args...) //nolint:gosec // G204: args are constructed internally - cmd.Env = append(cmd.Environ(), "BEADS_DIR="+beadsDir) - cmd.Dir = filepath.Dir(beadsDir) - - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - - if err := cmd.Run(); err != nil { - errMsg := strings.TrimSpace(stderr.String()) - if errMsg != "" { - return errors.New(errMsg) - } + stdout, err := runBdCommand(args, filepath.Dir(beadsDir), beadsDir) + if err != nil { return fmt.Errorf("querying announce messages: %w", err) } @@ -889,7 +829,7 @@ func (r *Router) pruneAnnounce(announceName string, retainCount int) error { var messages []struct { ID string `json:"id"` } - if err := json.Unmarshal(stdout.Bytes(), &messages); err != nil { + if err := json.Unmarshal(stdout, &messages); err != nil { return fmt.Errorf("parsing announce messages: %w", err) } @@ -904,12 +844,8 @@ func (r *Router) pruneAnnounce(announceName string, retainCount int) error { // Delete oldest messages for i := 0; i < toDelete && i < len(messages); i++ { deleteArgs := []string{"close", messages[i].ID, "--reason=retention pruning"} - deleteCmd := exec.Command("bd", deleteArgs...) //nolint:gosec // G204: args are constructed internally - deleteCmd.Env = append(deleteCmd.Environ(), "BEADS_DIR="+beadsDir) - deleteCmd.Dir = filepath.Dir(beadsDir) - // Best-effort deletion - don't fail if one delete fails - _ = deleteCmd.Run() + _, _ = runBdCommand(deleteArgs, filepath.Dir(beadsDir), beadsDir) } return nil