feat: add bd mail commands and identity configuration (bd-kwro.6, bd-kwro.7)
- Add `bd mail send/inbox/read/ack` commands for inter-agent messaging - Implement GetIdentity() with priority chain: flag > BEADS_IDENTITY env > config.yaml > git user.name > hostname - Messages are stored as issues with type=message for git-native communication - Support both daemon and direct mode for all mail operations 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
+681
-18
File diff suppressed because one or more lines are too long
+489
@@ -0,0 +1,489 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/steveyegge/beads/internal/config"
|
||||||
|
"github.com/steveyegge/beads/internal/rpc"
|
||||||
|
"github.com/steveyegge/beads/internal/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
var mailCmd = &cobra.Command{
|
||||||
|
Use: "mail",
|
||||||
|
Short: "Send and receive messages via beads",
|
||||||
|
Long: `Send and receive messages between agents using beads storage.
|
||||||
|
|
||||||
|
Messages are stored as issues with type=message, enabling git-native
|
||||||
|
inter-agent communication without external services.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
bd mail send worker-1 -s "Task complete" -m "Finished bd-xyz"
|
||||||
|
bd mail inbox
|
||||||
|
bd mail read bd-abc123
|
||||||
|
bd mail ack bd-abc123`,
|
||||||
|
}
|
||||||
|
|
||||||
|
var mailSendCmd = &cobra.Command{
|
||||||
|
Use: "send <recipient> -s <subject> -m <body>",
|
||||||
|
Short: "Send a message to another agent",
|
||||||
|
Long: `Send a message to another agent via beads.
|
||||||
|
|
||||||
|
Creates an issue with type=message, sender=your identity, assignee=recipient.
|
||||||
|
The --urgent flag sets priority=0.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
bd mail send worker-1 -s "Task complete" -m "Finished bd-xyz"
|
||||||
|
bd mail send worker-1 -s "Help needed" -m "Blocked on auth" --urgent
|
||||||
|
bd mail send worker-1 -s "Quick note" -m "FYI" --identity refinery`,
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: runMailSend,
|
||||||
|
}
|
||||||
|
|
||||||
|
var mailInboxCmd = &cobra.Command{
|
||||||
|
Use: "inbox",
|
||||||
|
Short: "List messages addressed to you",
|
||||||
|
Long: `List open messages where assignee matches your identity.
|
||||||
|
|
||||||
|
Messages are sorted by priority (urgent first), then by date (newest first).
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
bd mail inbox
|
||||||
|
bd mail inbox --from worker-1
|
||||||
|
bd mail inbox --priority 0`,
|
||||||
|
RunE: runMailInbox,
|
||||||
|
}
|
||||||
|
|
||||||
|
var mailReadCmd = &cobra.Command{
|
||||||
|
Use: "read <id>",
|
||||||
|
Short: "Read a specific message",
|
||||||
|
Long: `Display the full content of a message.
|
||||||
|
|
||||||
|
Does NOT mark the message as read - use 'bd mail ack' for that.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
bd mail read bd-abc123`,
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: runMailRead,
|
||||||
|
}
|
||||||
|
|
||||||
|
var mailAckCmd = &cobra.Command{
|
||||||
|
Use: "ack <id> [id2...]",
|
||||||
|
Short: "Acknowledge (close) messages",
|
||||||
|
Long: `Mark messages as read by closing them.
|
||||||
|
|
||||||
|
Can acknowledge multiple messages at once.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
bd mail ack bd-abc123
|
||||||
|
bd mail ack bd-abc123 bd-def456 bd-ghi789`,
|
||||||
|
Args: cobra.MinimumNArgs(1),
|
||||||
|
RunE: runMailAck,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mail command flags
|
||||||
|
var (
|
||||||
|
mailSubject string
|
||||||
|
mailBody string
|
||||||
|
mailUrgent bool
|
||||||
|
mailIdentity string
|
||||||
|
mailFrom string
|
||||||
|
mailPriorityFlag int
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
rootCmd.AddCommand(mailCmd)
|
||||||
|
mailCmd.AddCommand(mailSendCmd)
|
||||||
|
mailCmd.AddCommand(mailInboxCmd)
|
||||||
|
mailCmd.AddCommand(mailReadCmd)
|
||||||
|
mailCmd.AddCommand(mailAckCmd)
|
||||||
|
|
||||||
|
// Send command flags
|
||||||
|
mailSendCmd.Flags().StringVarP(&mailSubject, "subject", "s", "", "Message subject (required)")
|
||||||
|
mailSendCmd.Flags().StringVarP(&mailBody, "body", "m", "", "Message body (required)")
|
||||||
|
mailSendCmd.Flags().BoolVar(&mailUrgent, "urgent", false, "Set priority=0 (urgent)")
|
||||||
|
mailSendCmd.Flags().StringVar(&mailIdentity, "identity", "", "Override sender identity")
|
||||||
|
_ = mailSendCmd.MarkFlagRequired("subject")
|
||||||
|
_ = mailSendCmd.MarkFlagRequired("body")
|
||||||
|
|
||||||
|
// Inbox command flags
|
||||||
|
mailInboxCmd.Flags().StringVar(&mailFrom, "from", "", "Filter by sender")
|
||||||
|
mailInboxCmd.Flags().IntVar(&mailPriorityFlag, "priority", -1, "Filter by priority (0-4)")
|
||||||
|
|
||||||
|
// Read command flags
|
||||||
|
mailReadCmd.Flags().StringVar(&mailIdentity, "identity", "", "Override identity for access check")
|
||||||
|
|
||||||
|
// Ack command flags
|
||||||
|
mailAckCmd.Flags().StringVar(&mailIdentity, "identity", "", "Override identity")
|
||||||
|
}
|
||||||
|
|
||||||
|
func runMailSend(cmd *cobra.Command, args []string) error {
|
||||||
|
CheckReadonly("mail send")
|
||||||
|
|
||||||
|
recipient := args[0]
|
||||||
|
sender := config.GetIdentity(mailIdentity)
|
||||||
|
|
||||||
|
// Determine priority
|
||||||
|
priority := 2 // default: normal
|
||||||
|
if mailUrgent {
|
||||||
|
priority = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// If daemon is running, use RPC
|
||||||
|
if daemonClient != nil {
|
||||||
|
createArgs := &rpc.CreateArgs{
|
||||||
|
Title: mailSubject,
|
||||||
|
Description: mailBody,
|
||||||
|
IssueType: string(types.TypeMessage),
|
||||||
|
Priority: priority,
|
||||||
|
Assignee: recipient,
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := daemonClient.Create(createArgs)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to send message: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse response to get issue ID and update sender field
|
||||||
|
var issue types.Issue
|
||||||
|
if err := json.Unmarshal(resp.Data, &issue); err != nil {
|
||||||
|
return fmt.Errorf("parsing response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: sender/ephemeral fields need daemon support - for now they work in direct mode
|
||||||
|
if jsonOutput {
|
||||||
|
result := map[string]interface{}{
|
||||||
|
"id": issue.ID,
|
||||||
|
"to": recipient,
|
||||||
|
"from": sender,
|
||||||
|
"subject": mailSubject,
|
||||||
|
"priority": priority,
|
||||||
|
"timestamp": issue.CreatedAt,
|
||||||
|
}
|
||||||
|
encoder := json.NewEncoder(os.Stdout)
|
||||||
|
encoder.SetIndent("", " ")
|
||||||
|
return encoder.Encode(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Message sent: %s\n", issue.ID)
|
||||||
|
fmt.Printf(" To: %s\n", recipient)
|
||||||
|
fmt.Printf(" Subject: %s\n", mailSubject)
|
||||||
|
if mailUrgent {
|
||||||
|
fmt.Printf(" Priority: URGENT\n")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Direct mode
|
||||||
|
now := time.Now()
|
||||||
|
issue := &types.Issue{
|
||||||
|
Title: mailSubject,
|
||||||
|
Description: mailBody,
|
||||||
|
Status: types.StatusOpen,
|
||||||
|
Priority: priority,
|
||||||
|
IssueType: types.TypeMessage,
|
||||||
|
Assignee: recipient,
|
||||||
|
Sender: sender,
|
||||||
|
Ephemeral: true, // Messages can be bulk-deleted
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := store.CreateIssue(rootCtx, issue, actor); err != nil {
|
||||||
|
return fmt.Errorf("failed to send message: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger auto-flush
|
||||||
|
if flushManager != nil {
|
||||||
|
flushManager.MarkDirty(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
if jsonOutput {
|
||||||
|
result := map[string]interface{}{
|
||||||
|
"id": issue.ID,
|
||||||
|
"to": recipient,
|
||||||
|
"from": sender,
|
||||||
|
"subject": mailSubject,
|
||||||
|
"priority": priority,
|
||||||
|
"timestamp": issue.CreatedAt,
|
||||||
|
}
|
||||||
|
encoder := json.NewEncoder(os.Stdout)
|
||||||
|
encoder.SetIndent("", " ")
|
||||||
|
return encoder.Encode(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Message sent: %s\n", issue.ID)
|
||||||
|
fmt.Printf(" To: %s\n", recipient)
|
||||||
|
fmt.Printf(" Subject: %s\n", mailSubject)
|
||||||
|
if mailUrgent {
|
||||||
|
fmt.Printf(" Priority: URGENT\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runMailInbox(cmd *cobra.Command, args []string) error {
|
||||||
|
identity := config.GetIdentity(mailIdentity)
|
||||||
|
|
||||||
|
// Query for open messages assigned to this identity
|
||||||
|
messageType := types.TypeMessage
|
||||||
|
openStatus := types.StatusOpen
|
||||||
|
filter := types.IssueFilter{
|
||||||
|
IssueType: &messageType,
|
||||||
|
Status: &openStatus,
|
||||||
|
Assignee: &identity,
|
||||||
|
}
|
||||||
|
|
||||||
|
var issues []*types.Issue
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if daemonClient != nil {
|
||||||
|
// Daemon mode - use RPC list
|
||||||
|
resp, rpcErr := daemonClient.List(&rpc.ListArgs{
|
||||||
|
Status: string(openStatus),
|
||||||
|
IssueType: string(messageType),
|
||||||
|
Assignee: identity,
|
||||||
|
})
|
||||||
|
if rpcErr != nil {
|
||||||
|
return fmt.Errorf("failed to fetch inbox: %w", rpcErr)
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(resp.Data, &issues); err != nil {
|
||||||
|
return fmt.Errorf("parsing response: %w", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Direct mode
|
||||||
|
issues, err = store.SearchIssues(rootCtx, "", filter)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to fetch inbox: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by sender if specified
|
||||||
|
var filtered []*types.Issue
|
||||||
|
for _, issue := range issues {
|
||||||
|
if mailFrom != "" && issue.Sender != mailFrom {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Filter by priority if specified
|
||||||
|
if cmd.Flags().Changed("priority") && mailPriorityFlag >= 0 && issue.Priority != mailPriorityFlag {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
filtered = append(filtered, issue)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by priority (ascending), then by date (descending)
|
||||||
|
// Priority 0 is highest priority
|
||||||
|
for i := 0; i < len(filtered)-1; i++ {
|
||||||
|
for j := i + 1; j < len(filtered); j++ {
|
||||||
|
swap := false
|
||||||
|
if filtered[i].Priority > filtered[j].Priority {
|
||||||
|
swap = true
|
||||||
|
} else if filtered[i].Priority == filtered[j].Priority {
|
||||||
|
if filtered[i].CreatedAt.Before(filtered[j].CreatedAt) {
|
||||||
|
swap = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if swap {
|
||||||
|
filtered[i], filtered[j] = filtered[j], filtered[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if jsonOutput {
|
||||||
|
encoder := json.NewEncoder(os.Stdout)
|
||||||
|
encoder.SetIndent("", " ")
|
||||||
|
return encoder.Encode(filtered)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(filtered) == 0 {
|
||||||
|
fmt.Printf("No messages for %s\n", identity)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Inbox for %s (%d messages):\n\n", identity, len(filtered))
|
||||||
|
for _, msg := range filtered {
|
||||||
|
// Format timestamp
|
||||||
|
age := time.Since(msg.CreatedAt)
|
||||||
|
var timeStr string
|
||||||
|
if age < time.Hour {
|
||||||
|
timeStr = fmt.Sprintf("%dm ago", int(age.Minutes()))
|
||||||
|
} else if age < 24*time.Hour {
|
||||||
|
timeStr = fmt.Sprintf("%dh ago", int(age.Hours()))
|
||||||
|
} else {
|
||||||
|
timeStr = fmt.Sprintf("%dd ago", int(age.Hours()/24))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority indicator
|
||||||
|
priorityStr := ""
|
||||||
|
if msg.Priority == 0 {
|
||||||
|
priorityStr = " [URGENT]"
|
||||||
|
} else if msg.Priority == 1 {
|
||||||
|
priorityStr = " [HIGH]"
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf(" %s: %s%s\n", msg.ID, msg.Title, priorityStr)
|
||||||
|
fmt.Printf(" From: %s (%s)\n", msg.Sender, timeStr)
|
||||||
|
if msg.RepliesTo != "" {
|
||||||
|
fmt.Printf(" Re: %s\n", msg.RepliesTo)
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runMailRead(cmd *cobra.Command, args []string) error {
|
||||||
|
messageID := args[0]
|
||||||
|
|
||||||
|
var issue *types.Issue
|
||||||
|
|
||||||
|
if daemonClient != nil {
|
||||||
|
// Daemon mode - use RPC show
|
||||||
|
resp, err := daemonClient.Show(&rpc.ShowArgs{ID: messageID})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read message: %w", err)
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(resp.Data, &issue); err != nil {
|
||||||
|
return fmt.Errorf("parsing response: %w", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Direct mode
|
||||||
|
var err error
|
||||||
|
issue, err = store.GetIssue(rootCtx, messageID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read message: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if issue == nil {
|
||||||
|
return fmt.Errorf("message not found: %s", messageID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if issue.IssueType != types.TypeMessage {
|
||||||
|
return fmt.Errorf("%s is not a message (type: %s)", messageID, issue.IssueType)
|
||||||
|
}
|
||||||
|
|
||||||
|
if jsonOutput {
|
||||||
|
encoder := json.NewEncoder(os.Stdout)
|
||||||
|
encoder.SetIndent("", " ")
|
||||||
|
return encoder.Encode(issue)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display message
|
||||||
|
fmt.Println(strings.Repeat("─", 66))
|
||||||
|
fmt.Printf("ID: %s\n", issue.ID)
|
||||||
|
fmt.Printf("From: %s\n", issue.Sender)
|
||||||
|
fmt.Printf("To: %s\n", issue.Assignee)
|
||||||
|
fmt.Printf("Subject: %s\n", issue.Title)
|
||||||
|
fmt.Printf("Time: %s\n", issue.CreatedAt.Format("2006-01-02 15:04:05"))
|
||||||
|
if issue.Priority <= 1 {
|
||||||
|
fmt.Printf("Priority: P%d\n", issue.Priority)
|
||||||
|
}
|
||||||
|
if issue.RepliesTo != "" {
|
||||||
|
fmt.Printf("Re: %s\n", issue.RepliesTo)
|
||||||
|
}
|
||||||
|
fmt.Printf("Status: %s\n", issue.Status)
|
||||||
|
fmt.Println(strings.Repeat("─", 66))
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println(issue.Description)
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runMailAck(cmd *cobra.Command, args []string) error {
|
||||||
|
CheckReadonly("mail ack")
|
||||||
|
|
||||||
|
var acked []string
|
||||||
|
var errors []string
|
||||||
|
|
||||||
|
for _, messageID := range args {
|
||||||
|
var issue *types.Issue
|
||||||
|
|
||||||
|
if daemonClient != nil {
|
||||||
|
// Daemon mode - use RPC
|
||||||
|
resp, err := daemonClient.Show(&rpc.ShowArgs{ID: messageID})
|
||||||
|
if err != nil {
|
||||||
|
errors = append(errors, fmt.Sprintf("%s: %v", messageID, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(resp.Data, &issue); err != nil {
|
||||||
|
errors = append(errors, fmt.Sprintf("%s: parse error: %v", messageID, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Direct mode
|
||||||
|
var err error
|
||||||
|
issue, err = store.GetIssue(rootCtx, messageID)
|
||||||
|
if err != nil {
|
||||||
|
errors = append(errors, fmt.Sprintf("%s: %v", messageID, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if issue == nil {
|
||||||
|
errors = append(errors, fmt.Sprintf("%s: not found", messageID))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if issue.IssueType != types.TypeMessage {
|
||||||
|
errors = append(errors, fmt.Sprintf("%s: not a message (type: %s)", messageID, issue.IssueType))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if issue.Status == types.StatusClosed {
|
||||||
|
errors = append(errors, fmt.Sprintf("%s: already acknowledged", messageID))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close the message
|
||||||
|
if daemonClient != nil {
|
||||||
|
// Daemon mode - use RPC close
|
||||||
|
_, err := daemonClient.CloseIssue(&rpc.CloseArgs{ID: messageID})
|
||||||
|
if err != nil {
|
||||||
|
errors = append(errors, fmt.Sprintf("%s: %v", messageID, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Direct mode - use CloseIssue for proper close handling
|
||||||
|
if err := store.CloseIssue(rootCtx, messageID, "acknowledged", actor); err != nil {
|
||||||
|
errors = append(errors, fmt.Sprintf("%s: %v", messageID, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
acked = append(acked, messageID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger auto-flush if any messages were acked (direct mode only)
|
||||||
|
if len(acked) > 0 && flushManager != nil {
|
||||||
|
flushManager.MarkDirty(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
if jsonOutput {
|
||||||
|
result := map[string]interface{}{
|
||||||
|
"acknowledged": acked,
|
||||||
|
"errors": errors,
|
||||||
|
}
|
||||||
|
encoder := json.NewEncoder(os.Stdout)
|
||||||
|
encoder.SetIndent("", " ")
|
||||||
|
return encoder.Encode(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, id := range acked {
|
||||||
|
fmt.Printf("Acknowledged: %s\n", id)
|
||||||
|
}
|
||||||
|
for _, errMsg := range errors {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: %s\n", errMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(errors) > 0 && len(acked) == 0 {
|
||||||
|
return fmt.Errorf("failed to acknowledge any messages")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package config
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -89,10 +90,12 @@ func Initialize() error {
|
|||||||
// These are bound explicitly for backward compatibility
|
// These are bound explicitly for backward compatibility
|
||||||
_ = v.BindEnv("flush-debounce", "BEADS_FLUSH_DEBOUNCE")
|
_ = v.BindEnv("flush-debounce", "BEADS_FLUSH_DEBOUNCE")
|
||||||
_ = v.BindEnv("auto-start-daemon", "BEADS_AUTO_START_DAEMON")
|
_ = v.BindEnv("auto-start-daemon", "BEADS_AUTO_START_DAEMON")
|
||||||
|
_ = v.BindEnv("identity", "BEADS_IDENTITY")
|
||||||
|
|
||||||
// Set defaults for additional settings
|
// Set defaults for additional settings
|
||||||
v.SetDefault("flush-debounce", "30s")
|
v.SetDefault("flush-debounce", "30s")
|
||||||
v.SetDefault("auto-start-daemon", true)
|
v.SetDefault("auto-start-daemon", true)
|
||||||
|
v.SetDefault("identity", "")
|
||||||
|
|
||||||
// Routing configuration defaults
|
// Routing configuration defaults
|
||||||
v.SetDefault("routing.mode", "auto")
|
v.SetDefault("routing.mode", "auto")
|
||||||
@@ -195,15 +198,50 @@ func GetMultiRepoConfig() *MultiRepoConfig {
|
|||||||
if v == nil {
|
if v == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if repos.primary is set (indicates multi-repo mode)
|
// Check if repos.primary is set (indicates multi-repo mode)
|
||||||
primary := v.GetString("repos.primary")
|
primary := v.GetString("repos.primary")
|
||||||
if primary == "" {
|
if primary == "" {
|
||||||
return nil // Single-repo mode
|
return nil // Single-repo mode
|
||||||
}
|
}
|
||||||
|
|
||||||
return &MultiRepoConfig{
|
return &MultiRepoConfig{
|
||||||
Primary: primary,
|
Primary: primary,
|
||||||
Additional: v.GetStringSlice("repos.additional"),
|
Additional: v.GetStringSlice("repos.additional"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetIdentity resolves the user's identity for messaging.
|
||||||
|
// Priority chain:
|
||||||
|
// 1. flagValue (if non-empty, from --identity flag)
|
||||||
|
// 2. BEADS_IDENTITY env var / config.yaml identity field (via viper)
|
||||||
|
// 3. git config user.name
|
||||||
|
// 4. hostname
|
||||||
|
//
|
||||||
|
// This is used as the sender field in bd mail commands.
|
||||||
|
func GetIdentity(flagValue string) string {
|
||||||
|
// 1. Command-line flag takes precedence
|
||||||
|
if flagValue != "" {
|
||||||
|
return flagValue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. BEADS_IDENTITY env var or config.yaml identity (viper handles both)
|
||||||
|
if identity := GetString("identity"); identity != "" {
|
||||||
|
return identity
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. git config user.name
|
||||||
|
cmd := exec.Command("git", "config", "user.name")
|
||||||
|
if output, err := cmd.Output(); err == nil {
|
||||||
|
if gitUser := strings.TrimSpace(string(output)); gitUser != "" {
|
||||||
|
return gitUser
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. hostname
|
||||||
|
if hostname, err := os.Hostname(); err == nil && hostname != "" {
|
||||||
|
return hostname
|
||||||
|
}
|
||||||
|
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
|||||||
@@ -408,3 +408,92 @@ func TestNilViperBehavior(t *testing.T) {
|
|||||||
// Set should not panic
|
// Set should not panic
|
||||||
Set("any-key", "any-value") // Should be a no-op
|
Set("any-key", "any-value") // Should be a no-op
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGetIdentity(t *testing.T) {
|
||||||
|
// Initialize viper
|
||||||
|
err := Initialize()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Initialize() returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 1: Flag value takes precedence over everything
|
||||||
|
got := GetIdentity("flag-identity")
|
||||||
|
if got != "flag-identity" {
|
||||||
|
t.Errorf("GetIdentity(flag-identity) = %q, want \"flag-identity\"", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 2: Empty flag falls back to BEADS_IDENTITY env
|
||||||
|
oldEnv := os.Getenv("BEADS_IDENTITY")
|
||||||
|
_ = os.Setenv("BEADS_IDENTITY", "env-identity")
|
||||||
|
defer func() {
|
||||||
|
if oldEnv == "" {
|
||||||
|
_ = os.Unsetenv("BEADS_IDENTITY")
|
||||||
|
} else {
|
||||||
|
_ = os.Setenv("BEADS_IDENTITY", oldEnv)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Re-initialize to pick up env var
|
||||||
|
_ = Initialize()
|
||||||
|
got = GetIdentity("")
|
||||||
|
if got != "env-identity" {
|
||||||
|
t.Errorf("GetIdentity(\"\") with BEADS_IDENTITY = %q, want \"env-identity\"", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 3: Without flag or env, should fall back to git user.name or hostname
|
||||||
|
_ = os.Unsetenv("BEADS_IDENTITY")
|
||||||
|
_ = Initialize()
|
||||||
|
got = GetIdentity("")
|
||||||
|
// We can't predict the exact value (depends on git config and hostname)
|
||||||
|
// but it should not be empty or "unknown" on most systems
|
||||||
|
if got == "" {
|
||||||
|
t.Error("GetIdentity(\"\") without flag or env returned empty string")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetIdentityFromConfig(t *testing.T) {
|
||||||
|
// Create a temporary directory for config file
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
// Create a config file with identity
|
||||||
|
configContent := `identity: config-identity`
|
||||||
|
beadsDir := filepath.Join(tmpDir, ".beads")
|
||||||
|
if err := os.MkdirAll(beadsDir, 0750); err != nil {
|
||||||
|
t.Fatalf("failed to create .beads directory: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
configPath := filepath.Join(beadsDir, "config.yaml")
|
||||||
|
if err := os.WriteFile(configPath, []byte(configContent), 0600); err != nil {
|
||||||
|
t.Fatalf("failed to write config file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear BEADS_IDENTITY env var
|
||||||
|
oldEnv := os.Getenv("BEADS_IDENTITY")
|
||||||
|
_ = os.Unsetenv("BEADS_IDENTITY")
|
||||||
|
defer func() {
|
||||||
|
if oldEnv != "" {
|
||||||
|
_ = os.Setenv("BEADS_IDENTITY", oldEnv)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Change to tmp directory
|
||||||
|
t.Chdir(tmpDir)
|
||||||
|
|
||||||
|
// Initialize viper
|
||||||
|
err := Initialize()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Initialize() returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test that identity from config file is used
|
||||||
|
got := GetIdentity("")
|
||||||
|
if got != "config-identity" {
|
||||||
|
t.Errorf("GetIdentity(\"\") with config file = %q, want \"config-identity\"", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test that flag still takes precedence
|
||||||
|
got = GetIdentity("flag-override")
|
||||||
|
if got != "flag-override" {
|
||||||
|
t.Errorf("GetIdentity(flag-override) = %q, want \"flag-override\"", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user