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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user