feat: add 'gt rig info' command for detailed rig status

Adds a new command `gt rig info <name>` that displays comprehensive
information about a rig including:
- Rig path and git URL
- Active polecats with their state and assigned issues
- Crew workers
- Refinery status (state, PID, current MR, stats)
- Witness and Mayor status with last activity times
- Beads summary (open/in_progress/closed/blocked counts)

Closes: gt-zko

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-12-17 22:29:40 -08:00
parent 3b1ce3afe1
commit 8696413e09

View File

@@ -2,14 +2,19 @@
package cmd
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/config"
"github.com/steveyegge/gastown/internal/git"
"github.com/steveyegge/gastown/internal/polecat"
"github.com/steveyegge/gastown/internal/refinery"
"github.com/steveyegge/gastown/internal/rig"
"github.com/steveyegge/gastown/internal/style"
"github.com/steveyegge/gastown/internal/workspace"
@@ -63,6 +68,24 @@ var rigRemoveCmd = &cobra.Command{
RunE: runRigRemove,
}
var rigInfoCmd = &cobra.Command{
Use: "info <name>",
Short: "Show detailed information about a rig",
Long: `Show detailed status information for a specific rig.
Displays:
- Rig path and git URL
- Active polecats with status
- Refinery status
- Witness status
- Beads summary (open issues count)
Example:
gt rig info gastown`,
Args: cobra.ExactArgs(1),
RunE: runRigInfo,
}
// Flags
var (
rigAddPrefix string
@@ -74,6 +97,7 @@ func init() {
rigCmd.AddCommand(rigAddCmd)
rigCmd.AddCommand(rigListCmd)
rigCmd.AddCommand(rigRemoveCmd)
rigCmd.AddCommand(rigInfoCmd)
rigAddCmd.Flags().StringVar(&rigAddPrefix, "prefix", "", "Beads issue prefix (default: derived from name)")
rigAddCmd.Flags().StringVar(&rigAddCrew, "crew", "main", "Default crew workspace name")
@@ -243,3 +267,327 @@ func pathExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
func runRigInfo(cmd *cobra.Command, args []string) error {
name := args[0]
// Find workspace
townRoot, err := workspace.FindFromCwdOrError()
if err != nil {
return fmt.Errorf("not in a Gas Town workspace: %w", err)
}
// Load rigs config
rigsPath := filepath.Join(townRoot, "mayor", "rigs.json")
rigsConfig, err := config.LoadRigsConfig(rigsPath)
if err != nil {
return fmt.Errorf("loading rigs config: %w", err)
}
// Create rig manager and get the rig
g := git.NewGit(townRoot)
mgr := rig.NewManager(townRoot, rigsConfig, g)
r, err := mgr.GetRig(name)
if err != nil {
return fmt.Errorf("rig not found: %s", name)
}
// Print rig header
fmt.Printf("%s\n", style.Bold.Render(r.Name))
fmt.Printf(" Path: %s\n", r.Path)
fmt.Printf(" Git: %s\n", r.GitURL)
if r.Config != nil && r.Config.Prefix != "" {
fmt.Printf(" Beads prefix: %s\n", r.Config.Prefix)
}
fmt.Println()
// Show polecats
fmt.Printf("%s\n", style.Bold.Render("Polecats"))
polecatMgr := polecat.NewManager(r, g)
polecats, err := polecatMgr.List()
if err != nil || len(polecats) == 0 {
fmt.Printf(" %s\n", style.Dim.Render("(none)"))
} else {
for _, p := range polecats {
stateStr := formatPolecatState(p.State)
if p.Issue != "" {
fmt.Printf(" %s %s %s\n", p.Name, stateStr, style.Dim.Render(p.Issue))
} else {
fmt.Printf(" %s %s\n", p.Name, stateStr)
}
}
}
fmt.Println()
// Show crew workers
fmt.Printf("%s\n", style.Bold.Render("Crew"))
if len(r.Crew) == 0 {
fmt.Printf(" %s\n", style.Dim.Render("(none)"))
} else {
for _, c := range r.Crew {
fmt.Printf(" %s\n", c)
}
}
fmt.Println()
// Show refinery status
fmt.Printf("%s\n", style.Bold.Render("Refinery"))
if r.HasRefinery {
refMgr := refinery.NewManager(r)
refStatus, err := refMgr.Status()
if err != nil {
fmt.Printf(" %s %s\n", style.Warning.Render("!"), "Error loading status")
} else {
stateStr := formatRefineryState(refStatus.State)
fmt.Printf(" Status: %s\n", stateStr)
if refStatus.State == refinery.StateRunning && refStatus.PID > 0 {
fmt.Printf(" PID: %d\n", refStatus.PID)
}
if refStatus.CurrentMR != nil {
fmt.Printf(" Current: %s (%s)\n", refStatus.CurrentMR.Branch, refStatus.CurrentMR.Worker)
}
if refStatus.Stats.TotalMerged > 0 || refStatus.Stats.TotalFailed > 0 {
fmt.Printf(" Stats: %d merged, %d failed\n", refStatus.Stats.TotalMerged, refStatus.Stats.TotalFailed)
}
}
} else {
fmt.Printf(" %s\n", style.Dim.Render("(not configured)"))
}
fmt.Println()
// Show witness status
fmt.Printf("%s\n", style.Bold.Render("Witness"))
if r.HasWitness {
witnessState := loadWitnessState(r.Path)
if witnessState != nil {
fmt.Printf(" Last active: %s\n", formatTimeAgo(witnessState.LastActive))
if witnessState.Session != "" {
fmt.Printf(" Session: %s\n", witnessState.Session)
}
} else {
fmt.Printf(" %s\n", style.Success.Render("configured"))
}
} else {
fmt.Printf(" %s\n", style.Dim.Render("(not configured)"))
}
fmt.Println()
// Show mayor status
fmt.Printf("%s\n", style.Bold.Render("Mayor"))
if r.HasMayor {
mayorState := loadMayorState(r.Path)
if mayorState != nil {
fmt.Printf(" Last active: %s\n", formatTimeAgo(mayorState.LastActive))
} else {
fmt.Printf(" %s\n", style.Success.Render("configured"))
}
} else {
fmt.Printf(" %s\n", style.Dim.Render("(not configured)"))
}
fmt.Println()
// Show beads summary
fmt.Printf("%s\n", style.Bold.Render("Beads"))
beadsStats := getBeadsSummary(r.Path)
if beadsStats != nil {
fmt.Printf(" Open: %d In Progress: %d Closed: %d\n",
beadsStats.Open, beadsStats.InProgress, beadsStats.Closed)
if beadsStats.Blocked > 0 {
fmt.Printf(" Blocked: %d\n", beadsStats.Blocked)
}
} else {
fmt.Printf(" %s\n", style.Dim.Render("(beads not initialized)"))
}
return nil
}
// formatPolecatState returns a styled string for polecat state.
func formatPolecatState(state polecat.State) string {
switch state {
case polecat.StateIdle:
return style.Dim.Render("idle")
case polecat.StateActive:
return style.Info.Render("active")
case polecat.StateWorking:
return style.Success.Render("working")
case polecat.StateDone:
return style.Success.Render("done")
case polecat.StateStuck:
return style.Warning.Render("stuck")
default:
return style.Dim.Render(string(state))
}
}
// formatRefineryState returns a styled string for refinery state.
func formatRefineryState(state refinery.State) string {
switch state {
case refinery.StateStopped:
return style.Dim.Render("stopped")
case refinery.StateRunning:
return style.Success.Render("running")
case refinery.StatePaused:
return style.Warning.Render("paused")
default:
return style.Dim.Render(string(state))
}
}
// loadWitnessState loads the witness state.json.
func loadWitnessState(rigPath string) *config.AgentState {
statePath := filepath.Join(rigPath, "witness", "state.json")
data, err := os.ReadFile(statePath)
if err != nil {
return nil
}
var state config.AgentState
if err := json.Unmarshal(data, &state); err != nil {
return nil
}
return &state
}
// loadMayorState loads the mayor state.json.
func loadMayorState(rigPath string) *config.AgentState {
statePath := filepath.Join(rigPath, "mayor", "state.json")
data, err := os.ReadFile(statePath)
if err != nil {
return nil
}
var state config.AgentState
if err := json.Unmarshal(data, &state); err != nil {
return nil
}
return &state
}
// formatTimeAgo formats a time as a human-readable "ago" string.
func formatTimeAgo(t time.Time) string {
if t.IsZero() {
return "never"
}
d := time.Since(t)
if d < time.Minute {
return "just now"
}
if d < time.Hour {
mins := int(d.Minutes())
if mins == 1 {
return "1 minute ago"
}
return fmt.Sprintf("%d minutes ago", mins)
}
if d < 24*time.Hour {
hours := int(d.Hours())
if hours == 1 {
return "1 hour ago"
}
return fmt.Sprintf("%d hours ago", hours)
}
days := int(d.Hours() / 24)
if days == 1 {
return "1 day ago"
}
return fmt.Sprintf("%d days ago", days)
}
// BeadsSummary contains counts of issues by status.
type BeadsSummary struct {
Open int
InProgress int
Closed int
Blocked int
}
// getBeadsSummary runs bd stats to get beads summary.
func getBeadsSummary(rigPath string) *BeadsSummary {
// Check if .beads directory exists
beadsDir := filepath.Join(rigPath, ".beads")
if _, err := os.Stat(beadsDir); os.IsNotExist(err) {
return nil
}
// Try running bd stats --json (it may exit with code 1 but still output JSON)
cmd := exec.Command("bd", "stats", "--json")
cmd.Dir = rigPath
output, _ := cmd.CombinedOutput()
// Parse JSON output (bd stats --json may exit with error but still produce valid JSON)
var stats struct {
Open int `json:"open_issues"`
InProgress int `json:"in_progress_issues"`
Closed int `json:"closed_issues"`
Blocked int `json:"blocked_issues"`
}
if err := json.Unmarshal(output, &stats); err != nil {
// JSON parsing failed, try fallback
return getBeadsSummaryFallback(rigPath)
}
return &BeadsSummary{
Open: stats.Open,
InProgress: stats.InProgress,
Closed: stats.Closed,
Blocked: stats.Blocked,
}
}
// getBeadsSummaryFallback counts issues by parsing bd list output.
func getBeadsSummaryFallback(rigPath string) *BeadsSummary {
summary := &BeadsSummary{}
// Count open issues
if count := countBeadsIssues(rigPath, "open"); count >= 0 {
summary.Open = count
}
// Count in_progress issues
if count := countBeadsIssues(rigPath, "in_progress"); count >= 0 {
summary.InProgress = count
}
// Count closed issues
if count := countBeadsIssues(rigPath, "closed"); count >= 0 {
summary.Closed = count
}
// Count blocked issues
cmd := exec.Command("bd", "blocked")
cmd.Dir = rigPath
output, err := cmd.Output()
if err == nil {
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
// Filter out empty lines and header
count := 0
for _, line := range lines {
line = strings.TrimSpace(line)
if line != "" && !strings.HasPrefix(line, "Blocked") && !strings.HasPrefix(line, "---") {
count++
}
}
summary.Blocked = count
}
return summary
}
// countBeadsIssues counts issues with a given status.
func countBeadsIssues(rigPath, status string) int {
cmd := exec.Command("bd", "list", "--status="+status)
cmd.Dir = rigPath
output, err := cmd.Output()
if err != nil {
return 0
}
// Count non-empty lines (each line is one issue)
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
count := 0
for _, line := range lines {
if strings.TrimSpace(line) != "" {
count++
}
}
return count
}