diff --git a/internal/cmd/rig.go b/internal/cmd/rig.go index 6d9d65f1..a6e8adfd 100644 --- a/internal/cmd/rig.go +++ b/internal/cmd/rig.go @@ -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 ", + 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 +}