refactor(daemon): consolidate CLI to subcommands with semantic styling (#1006)

Merging with plan to address in follow-up commits:
- Deduplicate daemon_start.go validation logic (call startDaemon())
- Silence deprecation warnings in --json mode for agent ergonomics
- Consolidate DaemonStatusReport with DaemonHealthReport
- Remove unused outputStatusJSON()
- Add tests for new subcommands
This commit is contained in:
Ryan
2026-01-10 16:28:52 -08:00
committed by GitHub
parent 3f2b693bea
commit d8d4a7ed2d
3 changed files with 639 additions and 12 deletions

View File

@@ -34,14 +34,16 @@ The daemon will:
- Auto-import when remote changes detected
Common operations:
bd daemon --start Start the daemon (background)
bd daemon --start --foreground Start in foreground (for systemd/supervisord)
bd daemon --stop Stop a running daemon
bd daemon --stop-all Stop ALL running bd daemons
bd daemon --status Check if daemon is running
bd daemon --health Check daemon health and metrics
bd daemon start Start the daemon (background)
bd daemon start --foreground Start in foreground (for systemd/supervisord)
bd daemon stop Stop current workspace daemon
bd daemon status Show daemon status
bd daemon status --all Show all daemons with health check
bd daemon logs View daemon logs
bd daemon restart Restart daemon
bd daemon killall Stop all running daemons
Run 'bd daemon' with no flags to see available options.`,
Run 'bd daemon --help' to see all subcommands.`,
Run: func(cmd *cobra.Command, args []string) {
start, _ := cmd.Flags().GetBool("start")
stop, _ := cmd.Flags().GetBool("stop")
@@ -65,6 +67,23 @@ Run 'bd daemon' with no flags to see available options.`,
return
}
// Show deprecation warnings for flag-based actions
if start {
fmt.Fprintf(os.Stderr, "Warning: --start is deprecated, use 'bd daemon start' instead\n")
}
if stop {
fmt.Fprintf(os.Stderr, "Warning: --stop is deprecated, use 'bd daemon stop' instead\n")
}
if stopAll {
fmt.Fprintf(os.Stderr, "Warning: --stop-all is deprecated, use 'bd daemon killall' instead\n")
}
if status {
fmt.Fprintf(os.Stderr, "Warning: --status is deprecated, use 'bd daemon status' instead\n")
}
if health {
fmt.Fprintf(os.Stderr, "Warning: --health is deprecated, use 'bd daemon status --all' instead\n")
}
// If auto-commit/auto-push flags weren't explicitly provided, read from config
// GH#871: Read from config.yaml first (team-shared), then fall back to SQLite (legacy)
// (skip if --stop, --status, --health, --metrics)
@@ -206,16 +225,22 @@ Run 'bd daemon' with no flags to see available options.`,
}
func init() {
daemonCmd.Flags().Bool("start", false, "Start the daemon")
// Register subcommands (preferred interface)
daemonCmd.AddCommand(daemonStartCmd)
daemonCmd.AddCommand(daemonStatusCmd)
// Note: stop, restart, logs, killall, list, health subcommands are registered in daemons.go
// Legacy flags (deprecated - use subcommands instead)
daemonCmd.Flags().Bool("start", false, "Start the daemon (deprecated: use 'bd daemon start')")
daemonCmd.Flags().Duration("interval", 5*time.Second, "Sync check interval")
daemonCmd.Flags().Bool("auto-commit", false, "Automatically commit changes")
daemonCmd.Flags().Bool("auto-push", false, "Automatically push commits")
daemonCmd.Flags().Bool("auto-pull", false, "Automatically pull from remote (default: true when sync.branch configured)")
daemonCmd.Flags().Bool("local", false, "Run in local-only mode (no git required, no sync)")
daemonCmd.Flags().Bool("stop", false, "Stop running daemon")
daemonCmd.Flags().Bool("stop-all", false, "Stop all running bd daemons")
daemonCmd.Flags().Bool("status", false, "Show daemon status")
daemonCmd.Flags().Bool("health", false, "Check daemon health and metrics")
daemonCmd.Flags().Bool("stop", false, "Stop running daemon (deprecated: use 'bd daemon stop')")
daemonCmd.Flags().Bool("stop-all", false, "Stop all running bd daemons (deprecated: use 'bd daemon killall')")
daemonCmd.Flags().Bool("status", false, "Show daemon status (deprecated: use 'bd daemon status')")
daemonCmd.Flags().Bool("health", false, "Check daemon health (deprecated: use 'bd daemon status --all')")
daemonCmd.Flags().Bool("metrics", false, "Show detailed daemon metrics")
daemonCmd.Flags().String("log", "", "Log file path (default: .beads/daemon.log)")
daemonCmd.Flags().Bool("foreground", false, "Run in foreground (don't daemonize)")

151
cmd/bd/daemon_start.go Normal file
View File

@@ -0,0 +1,151 @@
package main
import (
"fmt"
"os"
"time"
"github.com/spf13/cobra"
"github.com/steveyegge/beads/internal/beads"
"github.com/steveyegge/beads/internal/rpc"
)
var daemonStartCmd = &cobra.Command{
Use: "start",
Short: "Start the background daemon",
Long: `Start the background daemon that automatically syncs issues with git remote.
The daemon will:
- Poll for changes at configurable intervals (default: 5 seconds)
- Export pending database changes to JSONL
- Auto-commit changes if --auto-commit flag set
- Auto-push commits if --auto-push flag set
- Pull remote changes periodically
- Auto-import when remote changes detected
Examples:
bd daemon start # Start with defaults
bd daemon start --auto-commit # Enable auto-commit
bd daemon start --auto-push # Enable auto-push (implies --auto-commit)
bd daemon start --foreground # Run in foreground (for systemd/supervisord)
bd daemon start --local # Local-only mode (no git sync)`,
Run: func(cmd *cobra.Command, args []string) {
interval, _ := cmd.Flags().GetDuration("interval")
autoCommit, _ := cmd.Flags().GetBool("auto-commit")
autoPush, _ := cmd.Flags().GetBool("auto-push")
autoPull, _ := cmd.Flags().GetBool("auto-pull")
localMode, _ := cmd.Flags().GetBool("local")
logFile, _ := cmd.Flags().GetString("log")
foreground, _ := cmd.Flags().GetBool("foreground")
logLevel, _ := cmd.Flags().GetString("log-level")
logJSON, _ := cmd.Flags().GetBool("log-json")
// Load auto-commit/push/pull defaults from env vars, config, or sync-branch
autoCommit, autoPush, autoPull = loadDaemonAutoSettings(cmd, autoCommit, autoPush, autoPull)
if interval <= 0 {
fmt.Fprintf(os.Stderr, "Error: interval must be positive (got %v)\n", interval)
os.Exit(1)
}
pidFile, err := getPIDFilePath()
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
// Skip daemon-running check if we're the forked child (BD_DAEMON_FOREGROUND=1)
if os.Getenv("BD_DAEMON_FOREGROUND") != "1" {
// Check if daemon is already running
if isRunning, pid := isDaemonRunning(pidFile); isRunning {
// Check if running daemon has compatible version
socketPath := getSocketPathForPID(pidFile)
if client, err := rpc.TryConnectWithTimeout(socketPath, 1*time.Second); err == nil && client != nil {
health, healthErr := client.Health()
_ = client.Close()
// If we can check version and it's compatible, exit
if healthErr == nil && health.Compatible {
fmt.Fprintf(os.Stderr, "Error: daemon already running (PID %d, version %s)\n", pid, health.Version)
fmt.Fprintf(os.Stderr, "Use 'bd daemon stop' to stop it first\n")
os.Exit(1)
}
// Version mismatch - auto-stop old daemon
if healthErr == nil && !health.Compatible {
fmt.Fprintf(os.Stderr, "Warning: daemon version mismatch (daemon: %s, client: %s)\n", health.Version, Version)
fmt.Fprintf(os.Stderr, "Stopping old daemon and starting new one...\n")
stopDaemon(pidFile)
}
} else {
fmt.Fprintf(os.Stderr, "Error: daemon already running (PID %d)\n", pid)
fmt.Fprintf(os.Stderr, "Use 'bd daemon stop' to stop it first\n")
os.Exit(1)
}
}
}
// Validate --local mode constraints
if localMode {
if autoCommit {
fmt.Fprintf(os.Stderr, "Error: --auto-commit cannot be used with --local mode\n")
fmt.Fprintf(os.Stderr, "Hint: --local mode runs without git, so commits are not possible\n")
os.Exit(1)
}
if autoPush {
fmt.Fprintf(os.Stderr, "Error: --auto-push cannot be used with --local mode\n")
fmt.Fprintf(os.Stderr, "Hint: --local mode runs without git, so pushes are not possible\n")
os.Exit(1)
}
}
// Validate we're in a git repo (skip in local mode)
if !localMode && !isGitRepo() {
fmt.Fprintf(os.Stderr, "Error: not in a git repository\n")
fmt.Fprintf(os.Stderr, "Hint: run 'git init' to initialize a repository, or use --local for local-only mode\n")
os.Exit(1)
}
// Check for upstream if auto-push enabled
if autoPush && !gitHasUpstream() {
fmt.Fprintf(os.Stderr, "Error: no upstream configured (required for --auto-push)\n")
fmt.Fprintf(os.Stderr, "Hint: git push -u origin <branch-name>\n")
os.Exit(1)
}
// Warn if starting daemon in a git worktree
if dbPath == "" {
if foundDB := beads.FindDatabasePath(); foundDB != "" {
dbPath = foundDB
}
}
if dbPath != "" {
warnWorktreeDaemon(dbPath)
}
// Start daemon
if localMode {
fmt.Printf("Starting bd daemon in LOCAL mode (interval: %v, no git sync)\n", interval)
} else {
fmt.Printf("Starting bd daemon (interval: %v, auto-commit: %v, auto-push: %v, auto-pull: %v)\n",
interval, autoCommit, autoPush, autoPull)
}
if logFile != "" {
fmt.Printf("Logging to: %s\n", logFile)
}
startDaemon(interval, autoCommit, autoPush, autoPull, localMode, foreground, logFile, pidFile, logLevel, logJSON)
},
}
func init() {
daemonStartCmd.Flags().Duration("interval", 5*time.Second, "Sync check interval")
daemonStartCmd.Flags().Bool("auto-commit", false, "Automatically commit changes")
daemonStartCmd.Flags().Bool("auto-push", false, "Automatically push commits")
daemonStartCmd.Flags().Bool("auto-pull", false, "Automatically pull from remote")
daemonStartCmd.Flags().Bool("local", false, "Run in local-only mode (no git required, no sync)")
daemonStartCmd.Flags().String("log", "", "Log file path (default: .beads/daemon.log)")
daemonStartCmd.Flags().Bool("foreground", false, "Run in foreground (don't daemonize)")
daemonStartCmd.Flags().String("log-level", "info", "Log level (debug, info, warn, error)")
daemonStartCmd.Flags().Bool("log-json", false, "Output logs in JSON format")
}

451
cmd/bd/daemon_status.go Normal file
View File

@@ -0,0 +1,451 @@
package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"text/tabwriter"
"time"
"github.com/spf13/cobra"
"github.com/steveyegge/beads/internal/daemon"
"github.com/steveyegge/beads/internal/rpc"
"github.com/steveyegge/beads/internal/ui"
)
// DaemonStatusReport is a single daemon status entry for JSON output
type DaemonStatusReport struct {
Workspace string `json:"workspace"`
PID int `json:"pid,omitempty"`
Version string `json:"version,omitempty"`
Status string `json:"status"`
Issue string `json:"issue,omitempty"`
Started string `json:"started,omitempty"`
UptimeSeconds float64 `json:"uptime_seconds,omitempty"`
AutoCommit bool `json:"auto_commit,omitempty"`
AutoPush bool `json:"auto_push,omitempty"`
AutoPull bool `json:"auto_pull,omitempty"`
LocalMode bool `json:"local_mode,omitempty"`
SyncInterval string `json:"sync_interval,omitempty"`
DaemonMode string `json:"daemon_mode,omitempty"`
LogPath string `json:"log_path,omitempty"`
VersionMismatch bool `json:"version_mismatch,omitempty"`
IsCurrent bool `json:"is_current,omitempty"`
}
// DaemonStatusAllResponse is returned for --all mode
type DaemonStatusAllResponse struct {
Total int `json:"total"`
Healthy int `json:"healthy"`
Outdated int `json:"outdated"`
Stale int `json:"stale"`
Unresponsive int `json:"unresponsive"`
Daemons []DaemonStatusReport `json:"daemons"`
}
var daemonStatusCmd = &cobra.Command{
Use: "status",
Short: "Show daemon status",
Long: `Show status of the current workspace's daemon, or all daemons with --all.
Examples:
bd daemon status # Current workspace daemon
bd daemon status --all # All running daemons`,
Run: func(cmd *cobra.Command, args []string) {
showAll, _ := cmd.Flags().GetBool("all")
if showAll {
showAllDaemonsStatus(cmd)
} else {
showCurrentDaemonStatus()
}
},
}
func init() {
daemonStatusCmd.Flags().Bool("all", false, "Show status of all daemons")
daemonStatusCmd.Flags().StringSlice("search", nil, "Directories to search for daemons (with --all)")
}
// shortenPath replaces home directory with ~ for display
func shortenPath(p string) string {
home, err := os.UserHomeDir()
if err != nil {
return p
}
if strings.HasPrefix(p, home) {
return "~" + p[len(home):]
}
return p
}
// formatRelativeTime formats a time as relative (e.g., "2h ago")
func formatRelativeTime(t time.Time) string {
d := time.Since(t)
if d < time.Minute {
return "just now"
} else if d < time.Hour {
mins := int(d.Minutes())
if mins == 1 {
return "1m ago"
}
return fmt.Sprintf("%dm ago", mins)
} else if d < 24*time.Hour {
hours := int(d.Hours())
if hours == 1 {
return "1h ago"
}
return fmt.Sprintf("%dh ago", hours)
}
days := int(d.Hours() / 24)
if days == 1 {
return "1d ago"
}
return fmt.Sprintf("%dd ago", days)
}
// formatBoolIcon returns a styled checkmark or dash for boolean values
func formatBoolIcon(enabled bool) string {
if enabled {
return ui.RenderPass(ui.IconPass)
}
return ui.RenderMuted("-")
}
// renderDaemonStatusIcon renders status with semantic styling
func renderDaemonStatusIcon(status string) string {
switch status {
case "healthy", "running":
return ui.RenderPass(ui.IconPass + " " + status)
case "outdated", "version_mismatch":
return ui.RenderWarn(ui.IconWarn + " outdated")
case "stale":
return ui.RenderWarn(ui.IconWarn + " stale")
case "unresponsive":
return ui.RenderFail(ui.IconFail + " unresponsive")
case "not_running":
return ui.RenderMuted("○ not running")
default:
return status
}
}
// showCurrentDaemonStatus shows detailed status for current workspace daemon
func showCurrentDaemonStatus() {
pidFile, err := getPIDFilePath()
if err != nil {
if jsonOutput {
outputJSON(map[string]string{"error": err.Error()})
} else {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
}
os.Exit(1)
}
beadsDir := filepath.Dir(pidFile)
socketPath := filepath.Join(beadsDir, "bd.sock")
workspacePath := filepath.Dir(beadsDir)
// Check if daemon is running
isRunning, pid := isDaemonRunning(pidFile)
if !isRunning {
if jsonOutput {
outputJSON(DaemonStatusReport{
Workspace: workspacePath,
Status: "not_running",
})
} else {
fmt.Printf("%s\n\n", renderDaemonStatusIcon("not_running"))
fmt.Printf(" Workspace: %s\n", shortenPath(workspacePath))
fmt.Printf("\n To start: bd daemon start\n")
}
return
}
// Get detailed status via RPC
var rpcStatus *rpc.StatusResponse
if client, err := rpc.TryConnectWithTimeout(socketPath, 1*time.Second); err == nil && client != nil {
if status, err := client.Status(); err == nil {
rpcStatus = status
}
_ = client.Close()
}
// Get started time from PID file
var startedTime time.Time
if info, err := os.Stat(pidFile); err == nil {
startedTime = info.ModTime()
}
// Determine daemon version and check for mismatch
daemonVersion := ""
versionMismatch := false
if rpcStatus != nil {
daemonVersion = rpcStatus.Version
if daemonVersion != "" && daemonVersion != Version {
versionMismatch = true
}
}
// Determine status
status := "running"
issue := ""
if versionMismatch {
status = "outdated"
issue = fmt.Sprintf("daemon %s != cli %s", daemonVersion, Version)
}
// Get log path
logPath := filepath.Join(beadsDir, "daemon.log")
if _, err := os.Stat(logPath); err != nil {
logPath = ""
}
if jsonOutput {
report := DaemonStatusReport{
Workspace: workspacePath,
PID: pid,
Version: daemonVersion,
Status: status,
Issue: issue,
LogPath: logPath,
VersionMismatch: versionMismatch,
IsCurrent: true,
}
if !startedTime.IsZero() {
report.Started = startedTime.Format(time.RFC3339)
}
if rpcStatus != nil {
report.UptimeSeconds = rpcStatus.UptimeSeconds
report.AutoCommit = rpcStatus.AutoCommit
report.AutoPush = rpcStatus.AutoPush
report.AutoPull = rpcStatus.AutoPull
report.LocalMode = rpcStatus.LocalMode
report.SyncInterval = rpcStatus.SyncInterval
report.DaemonMode = rpcStatus.DaemonMode
}
outputJSON(report)
return
}
// Human-readable output with semantic styling
// Status line
versionStr := ""
if daemonVersion != "" {
versionStr = fmt.Sprintf(", v%s", daemonVersion)
}
if versionMismatch {
fmt.Printf("%s (PID %d%s)\n", renderDaemonStatusIcon("outdated"), pid, versionStr)
fmt.Printf(" %s\n\n", ui.RenderWarn(fmt.Sprintf("CLI version: %s", Version)))
} else {
fmt.Printf("%s (PID %d%s)\n\n", renderDaemonStatusIcon("running"), pid, versionStr)
}
// Details
fmt.Printf(" Workspace: %s\n", shortenPath(workspacePath))
if !startedTime.IsZero() {
fmt.Printf(" Started: %s (%s)\n", startedTime.Format("2006-01-02 15:04:05"), formatRelativeTime(startedTime))
}
if rpcStatus != nil {
fmt.Printf(" Mode: %s\n", rpcStatus.DaemonMode)
fmt.Printf(" Interval: %s\n", rpcStatus.SyncInterval)
// Compact sync flags display
syncFlags := []string{}
if rpcStatus.AutoCommit {
syncFlags = append(syncFlags, ui.RenderPass(ui.IconPass)+" commit")
}
if rpcStatus.AutoPush {
syncFlags = append(syncFlags, ui.RenderPass(ui.IconPass)+" push")
}
if rpcStatus.AutoPull {
syncFlags = append(syncFlags, ui.RenderPass(ui.IconPass)+" pull")
}
if len(syncFlags) > 0 {
fmt.Printf(" Sync: %s\n", strings.Join(syncFlags, " "))
} else {
fmt.Printf(" Sync: %s\n", ui.RenderMuted("none"))
}
if rpcStatus.LocalMode {
fmt.Printf(" Local: %s\n", ui.RenderWarn("yes (no git sync)"))
}
}
if logPath != "" {
// Show relative path for log
relLog := ".beads/daemon.log"
fmt.Printf(" Log: %s\n", relLog)
}
// Show hint about other daemons
daemons, err := daemon.DiscoverDaemons(nil)
if err == nil {
aliveCount := 0
for _, d := range daemons {
if d.Alive {
aliveCount++
}
}
if aliveCount > 1 {
fmt.Printf("\n %s\n", ui.RenderMuted(fmt.Sprintf("%d other daemon(s) running (bd daemon status --all)", aliveCount-1)))
}
}
}
// showAllDaemonsStatus shows status of all daemons
func showAllDaemonsStatus(cmd *cobra.Command) {
searchRoots, _ := cmd.Flags().GetStringSlice("search")
// Discover daemons
daemons, err := daemon.DiscoverDaemons(searchRoots)
if err != nil {
if jsonOutput {
outputJSON(map[string]string{"error": err.Error()})
} else {
fmt.Fprintf(os.Stderr, "Error discovering daemons: %v\n", err)
}
os.Exit(1)
}
// Auto-cleanup stale sockets
if cleaned, err := daemon.CleanupStaleSockets(daemons); err == nil && cleaned > 0 && !jsonOutput {
fmt.Fprintf(os.Stderr, "Cleaned up %d stale socket(s)\n", cleaned)
}
// Get current workspace to mark it
currentWorkspace := ""
if pidFile, err := getPIDFilePath(); err == nil {
beadsDir := filepath.Dir(pidFile)
currentWorkspace = filepath.Dir(beadsDir)
}
currentVersion := Version
var reports []DaemonStatusReport
healthyCount := 0
outdatedCount := 0
staleCount := 0
unresponsiveCount := 0
for _, d := range daemons {
report := DaemonStatusReport{
Workspace: d.WorkspacePath,
PID: d.PID,
Version: d.Version,
IsCurrent: d.WorkspacePath == currentWorkspace,
}
if !d.Alive {
report.Status = "stale"
report.Issue = d.Error
staleCount++
} else if d.Version != "" && d.Version != currentVersion {
report.Status = "outdated"
report.Issue = fmt.Sprintf("daemon %s != cli %s", d.Version, currentVersion)
report.VersionMismatch = true
outdatedCount++
} else {
report.Status = "healthy"
healthyCount++
}
reports = append(reports, report)
}
if jsonOutput {
outputJSON(DaemonStatusAllResponse{
Total: len(reports),
Healthy: healthyCount,
Outdated: outdatedCount,
Stale: staleCount,
Unresponsive: unresponsiveCount,
Daemons: reports,
})
return
}
// Human-readable output
if len(reports) == 0 {
fmt.Println("No daemons found")
return
}
// Summary line
fmt.Printf("Daemons: %d total", len(reports))
if healthyCount > 0 {
fmt.Printf(", %s", ui.RenderPass(fmt.Sprintf("%d healthy", healthyCount)))
}
if outdatedCount > 0 {
fmt.Printf(", %s", ui.RenderWarn(fmt.Sprintf("%d outdated", outdatedCount)))
}
if staleCount > 0 {
fmt.Printf(", %s", ui.RenderWarn(fmt.Sprintf("%d stale", staleCount)))
}
if unresponsiveCount > 0 {
fmt.Printf(", %s", ui.RenderFail(fmt.Sprintf("%d unresponsive", unresponsiveCount)))
}
fmt.Println()
fmt.Println()
// Table
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
_, _ = fmt.Fprintln(w, " WORKSPACE\tPID\tVERSION\tSTATUS")
for _, r := range reports {
workspace := shortenPath(r.Workspace)
if workspace == "" {
workspace = "(unknown)"
}
// Add arrow for current workspace
prefix := " "
if r.IsCurrent {
prefix = ui.RenderAccent("→ ")
}
pidStr := "-"
if r.PID != 0 {
pidStr = fmt.Sprintf("%d", r.PID)
}
version := r.Version
if version == "" {
version = "-"
}
// Render status with icon and color
var statusDisplay string
switch r.Status {
case "healthy":
statusDisplay = ui.RenderPass(ui.IconPass + " healthy")
case "outdated":
statusDisplay = ui.RenderWarn(ui.IconWarn + " outdated")
// Add version hint
statusDisplay += ui.RenderMuted(fmt.Sprintf(" (cli: %s)", currentVersion))
case "stale":
statusDisplay = ui.RenderWarn(ui.IconWarn + " stale")
case "unresponsive":
statusDisplay = ui.RenderFail(ui.IconFail + " unresponsive")
default:
statusDisplay = r.Status
}
_, _ = fmt.Fprintf(w, "%s%s\t%s\t%s\t%s\n",
prefix, workspace, pidStr, version, statusDisplay)
}
_ = w.Flush()
// Exit with error if there are issues
if outdatedCount > 0 || staleCount > 0 || unresponsiveCount > 0 {
os.Exit(1)
}
}
// outputStatusJSON outputs the status as JSON (helper to avoid duplicating json.Marshal)
func outputStatusJSON(v interface{}) {
data, _ := json.MarshalIndent(v, "", " ")
fmt.Println(string(data))
}