Parent commands (mol, mail, crew, polecat, etc.) previously showed help and exited 0 for unknown subcommands like "gt mol foobar". This masked errors in scripts and confused users. Added requireSubcommand() helper to root.go and applied it to all parent commands. Now unknown subcommands properly error with exit code 1. Example before: gt mol unhook → shows help, exits 0 Example after: gt mol unhook → "Error: unknown command "unhook"", exits 1 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1298 lines
34 KiB
Go
1298 lines
34 KiB
Go
package cmd
|
||
|
||
import (
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"os"
|
||
"os/exec"
|
||
"path/filepath"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/spf13/cobra"
|
||
"github.com/steveyegge/gastown/internal/beads"
|
||
"github.com/steveyegge/gastown/internal/git"
|
||
"github.com/steveyegge/gastown/internal/polecat"
|
||
"github.com/steveyegge/gastown/internal/rig"
|
||
"github.com/steveyegge/gastown/internal/session"
|
||
"github.com/steveyegge/gastown/internal/style"
|
||
"github.com/steveyegge/gastown/internal/tmux"
|
||
)
|
||
|
||
// Polecat command flags
|
||
var (
|
||
polecatListJSON bool
|
||
polecatListAll bool
|
||
polecatForce bool
|
||
polecatRemoveAll bool
|
||
)
|
||
|
||
var polecatCmd = &cobra.Command{
|
||
Use: "polecat",
|
||
Aliases: []string{"cat", "polecats"},
|
||
GroupID: GroupAgents,
|
||
Short: "Manage polecats in rigs",
|
||
RunE: requireSubcommand,
|
||
Long: `Manage polecat lifecycle in rigs.
|
||
|
||
Polecats are worker agents that operate in their own git worktrees.
|
||
Use the subcommands to add, remove, list, wake, and sleep polecats.`,
|
||
}
|
||
|
||
var polecatListCmd = &cobra.Command{
|
||
Use: "list [rig]",
|
||
Short: "List polecats in a rig",
|
||
Long: `List polecats in a rig or all rigs.
|
||
|
||
In the transient model, polecats exist only while working. The list shows
|
||
all currently active polecats with their states:
|
||
- working: Actively working on an issue
|
||
- done: Completed work, waiting for cleanup
|
||
- stuck: Needs assistance
|
||
|
||
Examples:
|
||
gt polecat list greenplace
|
||
gt polecat list --all
|
||
gt polecat list greenplace --json`,
|
||
RunE: runPolecatList,
|
||
}
|
||
|
||
var polecatAddCmd = &cobra.Command{
|
||
Use: "add <rig> <name>",
|
||
Short: "Add a new polecat to a rig",
|
||
Long: `Add a new polecat to a rig.
|
||
|
||
Creates a polecat directory, clones the rig repo, creates a work branch,
|
||
and initializes state.
|
||
|
||
Example:
|
||
gt polecat add greenplace Toast`,
|
||
Args: cobra.ExactArgs(2),
|
||
RunE: runPolecatAdd,
|
||
}
|
||
|
||
var polecatRemoveCmd = &cobra.Command{
|
||
Use: "remove <rig>/<polecat>... | <rig> --all",
|
||
Short: "Remove polecats from a rig",
|
||
Long: `Remove one or more polecats from a rig.
|
||
|
||
Fails if session is running (stop first).
|
||
Warns if uncommitted changes exist.
|
||
Use --force to bypass checks.
|
||
|
||
Examples:
|
||
gt polecat remove greenplace/Toast
|
||
gt polecat remove greenplace/Toast greenplace/Furiosa
|
||
gt polecat remove greenplace --all
|
||
gt polecat remove greenplace --all --force`,
|
||
Args: cobra.MinimumNArgs(1),
|
||
RunE: runPolecatRemove,
|
||
}
|
||
|
||
var polecatWakeCmd = &cobra.Command{
|
||
Use: "wake <rig>/<polecat>",
|
||
Short: "(Deprecated) Resume a polecat to working state",
|
||
Long: `Resume a polecat to working state.
|
||
|
||
DEPRECATED: In the transient model, polecats are created fresh for each task
|
||
via 'gt sling'. This command is kept for backward compatibility.
|
||
|
||
Transitions: done → working
|
||
|
||
Example:
|
||
gt polecat wake greenplace/Toast`,
|
||
Args: cobra.ExactArgs(1),
|
||
RunE: runPolecatWake,
|
||
}
|
||
|
||
var polecatSleepCmd = &cobra.Command{
|
||
Use: "sleep <rig>/<polecat>",
|
||
Short: "(Deprecated) Mark polecat as done",
|
||
Long: `Mark polecat as done.
|
||
|
||
DEPRECATED: In the transient model, polecats use 'gt handoff' when complete,
|
||
which triggers automatic cleanup by the Witness. This command is kept for
|
||
backward compatibility.
|
||
|
||
Transitions: working → done
|
||
|
||
Example:
|
||
gt polecat sleep greenplace/Toast`,
|
||
Args: cobra.ExactArgs(1),
|
||
RunE: runPolecatSleep,
|
||
}
|
||
|
||
var polecatDoneCmd = &cobra.Command{
|
||
Use: "done <rig>/<polecat>",
|
||
Aliases: []string{"finish"},
|
||
Short: "Mark polecat as done with work and return to idle",
|
||
Long: `Mark polecat as done with work and return to idle.
|
||
|
||
Transitions: working/done/stuck → idle
|
||
Clears the assigned issue.
|
||
Fails if session is running (stop first).
|
||
|
||
Example:
|
||
gt polecat done greenplace/Toast
|
||
gt polecat finish greenplace/Toast`,
|
||
Args: cobra.ExactArgs(1),
|
||
RunE: runPolecatDone,
|
||
}
|
||
|
||
var polecatResetCmd = &cobra.Command{
|
||
Use: "reset <rig>/<polecat>",
|
||
Short: "Force reset polecat to idle state",
|
||
Long: `Force reset polecat to idle state.
|
||
|
||
Transitions: any state → idle
|
||
Clears the assigned issue.
|
||
Use when polecat is stuck in an unexpected state.
|
||
Fails if session is running (stop first).
|
||
|
||
Example:
|
||
gt polecat reset greenplace/Toast`,
|
||
Args: cobra.ExactArgs(1),
|
||
RunE: runPolecatReset,
|
||
}
|
||
|
||
var polecatSyncCmd = &cobra.Command{
|
||
Use: "sync <rig>/<polecat>",
|
||
Short: "Sync beads for a polecat",
|
||
Long: `Sync beads for a polecat's worktree.
|
||
|
||
Runs 'bd sync' in the polecat's worktree to push local beads changes
|
||
to the shared sync branch and pull remote changes.
|
||
|
||
Use --all to sync all polecats in a rig.
|
||
Use --from-main to only pull (no push).
|
||
|
||
Examples:
|
||
gt polecat sync greenplace/Toast
|
||
gt polecat sync greenplace --all
|
||
gt polecat sync greenplace/Toast --from-main`,
|
||
Args: cobra.MaximumNArgs(1),
|
||
RunE: runPolecatSync,
|
||
}
|
||
|
||
var polecatStatusCmd = &cobra.Command{
|
||
Use: "status <rig>/<polecat>",
|
||
Short: "Show detailed status for a polecat",
|
||
Long: `Show detailed status for a polecat.
|
||
|
||
Displays comprehensive information including:
|
||
- Current lifecycle state (working, done, stuck, idle)
|
||
- Assigned issue (if any)
|
||
- Session status (running/stopped, attached/detached)
|
||
- Session creation time
|
||
- Last activity time
|
||
|
||
Examples:
|
||
gt polecat status greenplace/Toast
|
||
gt polecat status greenplace/Toast --json`,
|
||
Args: cobra.ExactArgs(1),
|
||
RunE: runPolecatStatus,
|
||
}
|
||
|
||
var (
|
||
polecatSyncAll bool
|
||
polecatSyncFromMain bool
|
||
polecatStatusJSON bool
|
||
polecatGitStateJSON bool
|
||
polecatGCDryRun bool
|
||
polecatNukeAll bool
|
||
polecatNukeDryRun bool
|
||
)
|
||
|
||
var polecatGCCmd = &cobra.Command{
|
||
Use: "gc <rig>",
|
||
Short: "Garbage collect stale polecat branches",
|
||
Long: `Garbage collect stale polecat branches in a rig.
|
||
|
||
Polecats use unique timestamped branches (polecat/<name>-<timestamp>) to
|
||
prevent drift issues. Over time, these branches accumulate as polecats
|
||
are recreated.
|
||
|
||
This command removes orphaned branches:
|
||
- Branches for polecats that no longer exist
|
||
- Old timestamped branches (keeps only the current one per polecat)
|
||
|
||
Examples:
|
||
gt polecat gc greenplace
|
||
gt polecat gc greenplace --dry-run`,
|
||
Args: cobra.ExactArgs(1),
|
||
RunE: runPolecatGC,
|
||
}
|
||
|
||
var polecatNukeCmd = &cobra.Command{
|
||
Use: "nuke <rig>/<polecat>... | <rig> --all",
|
||
Short: "Completely destroy a polecat (session, worktree, branch, agent bead)",
|
||
Long: `Completely destroy a polecat and all its artifacts.
|
||
|
||
This is the nuclear option for post-merge cleanup. It:
|
||
1. Kills the Claude session (if running)
|
||
2. Deletes the git worktree (bypassing all safety checks)
|
||
3. Deletes the polecat branch
|
||
4. Closes the agent bead (if exists)
|
||
|
||
Use this after the Refinery has merged the polecat's work.
|
||
|
||
Examples:
|
||
gt polecat nuke greenplace/Toast
|
||
gt polecat nuke greenplace/Toast greenplace/Furiosa
|
||
gt polecat nuke greenplace --all
|
||
gt polecat nuke greenplace --all --dry-run`,
|
||
Args: cobra.MinimumNArgs(1),
|
||
RunE: runPolecatNuke,
|
||
}
|
||
|
||
var polecatGitStateCmd = &cobra.Command{
|
||
Use: "git-state <rig>/<polecat>",
|
||
Short: "Show git state for pre-kill verification",
|
||
Long: `Show git state for a polecat's worktree.
|
||
|
||
Used by the Witness for pre-kill verification to ensure no work is lost.
|
||
Returns whether the worktree is clean (safe to kill) or dirty (needs cleanup).
|
||
|
||
Checks:
|
||
- Working tree: uncommitted changes
|
||
- Unpushed commits: commits ahead of origin/main
|
||
- Stashes: stashed changes
|
||
|
||
Examples:
|
||
gt polecat git-state greenplace/Toast
|
||
gt polecat git-state greenplace/Toast --json`,
|
||
Args: cobra.ExactArgs(1),
|
||
RunE: runPolecatGitState,
|
||
}
|
||
|
||
func init() {
|
||
// List flags
|
||
polecatListCmd.Flags().BoolVar(&polecatListJSON, "json", false, "Output as JSON")
|
||
polecatListCmd.Flags().BoolVar(&polecatListAll, "all", false, "List polecats in all rigs")
|
||
|
||
// Remove flags
|
||
polecatRemoveCmd.Flags().BoolVarP(&polecatForce, "force", "f", false, "Force removal, bypassing checks")
|
||
polecatRemoveCmd.Flags().BoolVar(&polecatRemoveAll, "all", false, "Remove all polecats in the rig")
|
||
|
||
// Sync flags
|
||
polecatSyncCmd.Flags().BoolVar(&polecatSyncAll, "all", false, "Sync all polecats in the rig")
|
||
polecatSyncCmd.Flags().BoolVar(&polecatSyncFromMain, "from-main", false, "Pull only, no push")
|
||
|
||
// Status flags
|
||
polecatStatusCmd.Flags().BoolVar(&polecatStatusJSON, "json", false, "Output as JSON")
|
||
|
||
// Git-state flags
|
||
polecatGitStateCmd.Flags().BoolVar(&polecatGitStateJSON, "json", false, "Output as JSON")
|
||
|
||
// GC flags
|
||
polecatGCCmd.Flags().BoolVar(&polecatGCDryRun, "dry-run", false, "Show what would be deleted without deleting")
|
||
|
||
// Nuke flags
|
||
polecatNukeCmd.Flags().BoolVar(&polecatNukeAll, "all", false, "Nuke all polecats in the rig")
|
||
polecatNukeCmd.Flags().BoolVar(&polecatNukeDryRun, "dry-run", false, "Show what would be nuked without doing it")
|
||
|
||
// Add subcommands
|
||
polecatCmd.AddCommand(polecatListCmd)
|
||
polecatCmd.AddCommand(polecatAddCmd)
|
||
polecatCmd.AddCommand(polecatRemoveCmd)
|
||
polecatCmd.AddCommand(polecatWakeCmd)
|
||
polecatCmd.AddCommand(polecatSleepCmd)
|
||
polecatCmd.AddCommand(polecatDoneCmd)
|
||
polecatCmd.AddCommand(polecatResetCmd)
|
||
polecatCmd.AddCommand(polecatSyncCmd)
|
||
polecatCmd.AddCommand(polecatStatusCmd)
|
||
polecatCmd.AddCommand(polecatGitStateCmd)
|
||
polecatCmd.AddCommand(polecatGCCmd)
|
||
polecatCmd.AddCommand(polecatNukeCmd)
|
||
|
||
rootCmd.AddCommand(polecatCmd)
|
||
}
|
||
|
||
// PolecatListItem represents a polecat in list output.
|
||
type PolecatListItem struct {
|
||
Rig string `json:"rig"`
|
||
Name string `json:"name"`
|
||
State polecat.State `json:"state"`
|
||
Issue string `json:"issue,omitempty"`
|
||
SessionRunning bool `json:"session_running"`
|
||
}
|
||
|
||
// getPolecatManager creates a polecat manager for the given rig.
|
||
func getPolecatManager(rigName string) (*polecat.Manager, *rig.Rig, error) {
|
||
_, r, err := getRig(rigName)
|
||
if err != nil {
|
||
return nil, nil, err
|
||
}
|
||
|
||
polecatGit := git.NewGit(r.Path)
|
||
mgr := polecat.NewManager(r, polecatGit)
|
||
|
||
return mgr, r, nil
|
||
}
|
||
|
||
func runPolecatList(cmd *cobra.Command, args []string) error {
|
||
var rigs []*rig.Rig
|
||
|
||
if polecatListAll {
|
||
// List all rigs
|
||
allRigs, _, err := getAllRigs()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
rigs = allRigs
|
||
} else {
|
||
// Need a rig name
|
||
if len(args) < 1 {
|
||
return fmt.Errorf("rig name required (or use --all)")
|
||
}
|
||
_, r, err := getPolecatManager(args[0])
|
||
if err != nil {
|
||
return err
|
||
}
|
||
rigs = []*rig.Rig{r}
|
||
}
|
||
|
||
// Collect polecats from all rigs
|
||
t := tmux.NewTmux()
|
||
var allPolecats []PolecatListItem
|
||
|
||
for _, r := range rigs {
|
||
polecatGit := git.NewGit(r.Path)
|
||
mgr := polecat.NewManager(r, polecatGit)
|
||
sessMgr := session.NewManager(t, r)
|
||
|
||
polecats, err := mgr.List()
|
||
if err != nil {
|
||
fmt.Fprintf(os.Stderr, "warning: failed to list polecats in %s: %v\n", r.Name, err)
|
||
continue
|
||
}
|
||
|
||
for _, p := range polecats {
|
||
running, _ := sessMgr.IsRunning(p.Name)
|
||
allPolecats = append(allPolecats, PolecatListItem{
|
||
Rig: r.Name,
|
||
Name: p.Name,
|
||
State: p.State,
|
||
Issue: p.Issue,
|
||
SessionRunning: running,
|
||
})
|
||
}
|
||
}
|
||
|
||
// Output
|
||
if polecatListJSON {
|
||
enc := json.NewEncoder(os.Stdout)
|
||
enc.SetIndent("", " ")
|
||
return enc.Encode(allPolecats)
|
||
}
|
||
|
||
if len(allPolecats) == 0 {
|
||
fmt.Println("No active polecats found.")
|
||
return nil
|
||
}
|
||
|
||
fmt.Printf("%s\n\n", style.Bold.Render("Active Polecats"))
|
||
for _, p := range allPolecats {
|
||
// Session indicator
|
||
sessionStatus := style.Dim.Render("○")
|
||
if p.SessionRunning {
|
||
sessionStatus = style.Success.Render("●")
|
||
}
|
||
|
||
// Normalize state for display (legacy idle/active → working)
|
||
displayState := p.State
|
||
if p.State == polecat.StateIdle || p.State == polecat.StateActive {
|
||
displayState = polecat.StateWorking
|
||
}
|
||
|
||
// State color
|
||
stateStr := string(displayState)
|
||
switch displayState {
|
||
case polecat.StateWorking:
|
||
stateStr = style.Info.Render(stateStr)
|
||
case polecat.StateStuck:
|
||
stateStr = style.Warning.Render(stateStr)
|
||
case polecat.StateDone:
|
||
stateStr = style.Success.Render(stateStr)
|
||
default:
|
||
stateStr = style.Dim.Render(stateStr)
|
||
}
|
||
|
||
fmt.Printf(" %s %s/%s %s\n", sessionStatus, p.Rig, p.Name, stateStr)
|
||
if p.Issue != "" {
|
||
fmt.Printf(" %s\n", style.Dim.Render(p.Issue))
|
||
}
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
func runPolecatAdd(cmd *cobra.Command, args []string) error {
|
||
rigName := args[0]
|
||
polecatName := args[1]
|
||
|
||
mgr, _, err := getPolecatManager(rigName)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
fmt.Printf("Adding polecat %s to rig %s...\n", polecatName, rigName)
|
||
|
||
p, err := mgr.Add(polecatName)
|
||
if err != nil {
|
||
return fmt.Errorf("adding polecat: %w", err)
|
||
}
|
||
|
||
fmt.Printf("%s Polecat %s added.\n", style.SuccessPrefix, p.Name)
|
||
fmt.Printf(" %s\n", style.Dim.Render(p.ClonePath))
|
||
fmt.Printf(" Branch: %s\n", style.Dim.Render(p.Branch))
|
||
|
||
return nil
|
||
}
|
||
|
||
func runPolecatRemove(cmd *cobra.Command, args []string) error {
|
||
// Build list of polecats to remove
|
||
type polecatToRemove struct {
|
||
rigName string
|
||
polecatName string
|
||
mgr *polecat.Manager
|
||
r *rig.Rig
|
||
}
|
||
var toRemove []polecatToRemove
|
||
|
||
if polecatRemoveAll {
|
||
// --all flag: first arg is just the rig name
|
||
rigName := args[0]
|
||
// Check if it looks like rig/polecat format
|
||
if _, _, err := parseAddress(rigName); err == nil {
|
||
return fmt.Errorf("with --all, provide just the rig name (e.g., 'gt polecat remove greenplace --all')")
|
||
}
|
||
|
||
mgr, r, err := getPolecatManager(rigName)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
polecats, err := mgr.List()
|
||
if err != nil {
|
||
return fmt.Errorf("listing polecats: %w", err)
|
||
}
|
||
|
||
if len(polecats) == 0 {
|
||
fmt.Println("No polecats to remove.")
|
||
return nil
|
||
}
|
||
|
||
for _, p := range polecats {
|
||
toRemove = append(toRemove, polecatToRemove{
|
||
rigName: rigName,
|
||
polecatName: p.Name,
|
||
mgr: mgr,
|
||
r: r,
|
||
})
|
||
}
|
||
} else {
|
||
// Multiple rig/polecat arguments
|
||
for _, arg := range args {
|
||
rigName, polecatName, err := parseAddress(arg)
|
||
if err != nil {
|
||
return fmt.Errorf("invalid address '%s': %w", arg, err)
|
||
}
|
||
|
||
mgr, r, err := getPolecatManager(rigName)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
toRemove = append(toRemove, polecatToRemove{
|
||
rigName: rigName,
|
||
polecatName: polecatName,
|
||
mgr: mgr,
|
||
r: r,
|
||
})
|
||
}
|
||
}
|
||
|
||
// Remove each polecat
|
||
t := tmux.NewTmux()
|
||
var removeErrors []string
|
||
removed := 0
|
||
|
||
for _, p := range toRemove {
|
||
// Check if session is running
|
||
if !polecatForce {
|
||
sessMgr := session.NewManager(t, p.r)
|
||
running, _ := sessMgr.IsRunning(p.polecatName)
|
||
if running {
|
||
removeErrors = append(removeErrors, fmt.Sprintf("%s/%s: session is running (stop first or use --force)", p.rigName, p.polecatName))
|
||
continue
|
||
}
|
||
}
|
||
|
||
fmt.Printf("Removing polecat %s/%s...\n", p.rigName, p.polecatName)
|
||
|
||
if err := p.mgr.Remove(p.polecatName, polecatForce); err != nil {
|
||
if errors.Is(err, polecat.ErrHasChanges) {
|
||
removeErrors = append(removeErrors, fmt.Sprintf("%s/%s: has uncommitted changes (use --force)", p.rigName, p.polecatName))
|
||
} else {
|
||
removeErrors = append(removeErrors, fmt.Sprintf("%s/%s: %v", p.rigName, p.polecatName, err))
|
||
}
|
||
continue
|
||
}
|
||
|
||
fmt.Printf(" %s removed\n", style.Success.Render("✓"))
|
||
removed++
|
||
}
|
||
|
||
// Report results
|
||
if len(removeErrors) > 0 {
|
||
fmt.Printf("\n%s Some removals failed:\n", style.Warning.Render("Warning:"))
|
||
for _, e := range removeErrors {
|
||
fmt.Printf(" - %s\n", e)
|
||
}
|
||
}
|
||
|
||
if removed > 0 {
|
||
fmt.Printf("\n%s Removed %d polecat(s).\n", style.SuccessPrefix, removed)
|
||
}
|
||
|
||
if len(removeErrors) > 0 {
|
||
return fmt.Errorf("%d removal(s) failed", len(removeErrors))
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
func runPolecatWake(cmd *cobra.Command, args []string) error {
|
||
fmt.Println(style.Warning.Render("DEPRECATED: Use 'gt sling' to create fresh polecats instead"))
|
||
fmt.Println()
|
||
|
||
rigName, polecatName, err := parseAddress(args[0])
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
mgr, _, err := getPolecatManager(rigName)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
if err := mgr.Wake(polecatName); err != nil {
|
||
return fmt.Errorf("waking polecat: %w", err)
|
||
}
|
||
|
||
fmt.Printf("%s Polecat %s is now working.\n", style.SuccessPrefix, polecatName)
|
||
return nil
|
||
}
|
||
|
||
func runPolecatSleep(cmd *cobra.Command, args []string) error {
|
||
fmt.Println(style.Warning.Render("DEPRECATED: Use 'gt handoff' from within a polecat session instead"))
|
||
fmt.Println()
|
||
|
||
rigName, polecatName, err := parseAddress(args[0])
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
mgr, r, err := getPolecatManager(rigName)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
// Check if session is running
|
||
t := tmux.NewTmux()
|
||
sessMgr := session.NewManager(t, r)
|
||
running, _ := sessMgr.IsRunning(polecatName)
|
||
if running {
|
||
return fmt.Errorf("session is running. Use 'gt handoff' from the polecat session, or stop it with: gt session stop %s/%s", rigName, polecatName)
|
||
}
|
||
|
||
if err := mgr.Sleep(polecatName); err != nil {
|
||
return fmt.Errorf("marking polecat as done: %w", err)
|
||
}
|
||
|
||
fmt.Printf("%s Polecat %s is now done.\n", style.SuccessPrefix, polecatName)
|
||
return nil
|
||
}
|
||
|
||
func runPolecatDone(cmd *cobra.Command, args []string) error {
|
||
rigName, polecatName, err := parseAddress(args[0])
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
mgr, r, err := getPolecatManager(rigName)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
// Check if session is running
|
||
t := tmux.NewTmux()
|
||
sessMgr := session.NewManager(t, r)
|
||
running, _ := sessMgr.IsRunning(polecatName)
|
||
if running {
|
||
return fmt.Errorf("session is running. Stop it first with: gt session stop %s/%s", rigName, polecatName)
|
||
}
|
||
|
||
if err := mgr.Finish(polecatName); err != nil {
|
||
return fmt.Errorf("finishing polecat: %w", err)
|
||
}
|
||
|
||
fmt.Printf("%s Polecat %s is now idle.\n", style.SuccessPrefix, polecatName)
|
||
return nil
|
||
}
|
||
|
||
func runPolecatReset(cmd *cobra.Command, args []string) error {
|
||
rigName, polecatName, err := parseAddress(args[0])
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
mgr, r, err := getPolecatManager(rigName)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
// Check if session is running
|
||
t := tmux.NewTmux()
|
||
sessMgr := session.NewManager(t, r)
|
||
running, _ := sessMgr.IsRunning(polecatName)
|
||
if running {
|
||
return fmt.Errorf("session is running. Stop it first with: gt session stop %s/%s", rigName, polecatName)
|
||
}
|
||
|
||
if err := mgr.Reset(polecatName); err != nil {
|
||
return fmt.Errorf("resetting polecat: %w", err)
|
||
}
|
||
|
||
fmt.Printf("%s Polecat %s has been reset to idle.\n", style.SuccessPrefix, polecatName)
|
||
return nil
|
||
}
|
||
|
||
func runPolecatSync(cmd *cobra.Command, args []string) error {
|
||
if len(args) < 1 {
|
||
return fmt.Errorf("rig or rig/polecat address required")
|
||
}
|
||
|
||
// Parse address - could be "rig" or "rig/polecat"
|
||
rigName, polecatName, err := parseAddress(args[0])
|
||
if err != nil {
|
||
// Might just be a rig name
|
||
rigName = args[0]
|
||
polecatName = ""
|
||
}
|
||
|
||
mgr, r, err := getPolecatManager(rigName)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
// Get list of polecats to sync
|
||
var polecatsToSync []string
|
||
if polecatSyncAll || polecatName == "" {
|
||
polecats, err := mgr.List()
|
||
if err != nil {
|
||
return fmt.Errorf("listing polecats: %w", err)
|
||
}
|
||
for _, p := range polecats {
|
||
polecatsToSync = append(polecatsToSync, p.Name)
|
||
}
|
||
} else {
|
||
polecatsToSync = []string{polecatName}
|
||
}
|
||
|
||
if len(polecatsToSync) == 0 {
|
||
fmt.Println("No polecats to sync.")
|
||
return nil
|
||
}
|
||
|
||
// Sync each polecat
|
||
var syncErrors []string
|
||
for _, name := range polecatsToSync {
|
||
polecatDir := filepath.Join(r.Path, "polecats", name)
|
||
|
||
// Check directory exists
|
||
if _, err := os.Stat(polecatDir); os.IsNotExist(err) {
|
||
syncErrors = append(syncErrors, fmt.Sprintf("%s: directory not found", name))
|
||
continue
|
||
}
|
||
|
||
// Build sync command
|
||
syncArgs := []string{"sync"}
|
||
if polecatSyncFromMain {
|
||
syncArgs = append(syncArgs, "--from-main")
|
||
}
|
||
|
||
fmt.Printf("Syncing %s/%s...\n", rigName, name)
|
||
|
||
syncCmd := exec.Command("bd", syncArgs...)
|
||
syncCmd.Dir = polecatDir
|
||
output, err := syncCmd.CombinedOutput()
|
||
if err != nil {
|
||
syncErrors = append(syncErrors, fmt.Sprintf("%s: %v", name, err))
|
||
if len(output) > 0 {
|
||
fmt.Printf(" %s\n", style.Dim.Render(string(output)))
|
||
}
|
||
} else {
|
||
fmt.Printf(" %s\n", style.Success.Render("✓ synced"))
|
||
}
|
||
}
|
||
|
||
if len(syncErrors) > 0 {
|
||
fmt.Printf("\n%s Some syncs failed:\n", style.Warning.Render("Warning:"))
|
||
for _, e := range syncErrors {
|
||
fmt.Printf(" - %s\n", e)
|
||
}
|
||
return fmt.Errorf("%d sync(s) failed", len(syncErrors))
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// PolecatStatus represents detailed polecat status for JSON output.
|
||
type PolecatStatus struct {
|
||
Rig string `json:"rig"`
|
||
Name string `json:"name"`
|
||
State polecat.State `json:"state"`
|
||
Issue string `json:"issue,omitempty"`
|
||
ClonePath string `json:"clone_path"`
|
||
Branch string `json:"branch"`
|
||
SessionRunning bool `json:"session_running"`
|
||
SessionID string `json:"session_id,omitempty"`
|
||
Attached bool `json:"attached,omitempty"`
|
||
Windows int `json:"windows,omitempty"`
|
||
CreatedAt string `json:"created_at,omitempty"`
|
||
LastActivity string `json:"last_activity,omitempty"`
|
||
}
|
||
|
||
func runPolecatStatus(cmd *cobra.Command, args []string) error {
|
||
rigName, polecatName, err := parseAddress(args[0])
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
mgr, r, err := getPolecatManager(rigName)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
// Get polecat info
|
||
p, err := mgr.Get(polecatName)
|
||
if err != nil {
|
||
return fmt.Errorf("polecat '%s' not found in rig '%s'", polecatName, rigName)
|
||
}
|
||
|
||
// Get session info
|
||
t := tmux.NewTmux()
|
||
sessMgr := session.NewManager(t, r)
|
||
sessInfo, err := sessMgr.Status(polecatName)
|
||
if err != nil {
|
||
// Non-fatal - continue without session info
|
||
sessInfo = &session.Info{
|
||
Polecat: polecatName,
|
||
Running: false,
|
||
}
|
||
}
|
||
|
||
// JSON output
|
||
if polecatStatusJSON {
|
||
status := PolecatStatus{
|
||
Rig: rigName,
|
||
Name: polecatName,
|
||
State: p.State,
|
||
Issue: p.Issue,
|
||
ClonePath: p.ClonePath,
|
||
Branch: p.Branch,
|
||
SessionRunning: sessInfo.Running,
|
||
SessionID: sessInfo.SessionID,
|
||
Attached: sessInfo.Attached,
|
||
Windows: sessInfo.Windows,
|
||
}
|
||
if !sessInfo.Created.IsZero() {
|
||
status.CreatedAt = sessInfo.Created.Format("2006-01-02 15:04:05")
|
||
}
|
||
if !sessInfo.LastActivity.IsZero() {
|
||
status.LastActivity = sessInfo.LastActivity.Format("2006-01-02 15:04:05")
|
||
}
|
||
enc := json.NewEncoder(os.Stdout)
|
||
enc.SetIndent("", " ")
|
||
return enc.Encode(status)
|
||
}
|
||
|
||
// Human-readable output
|
||
fmt.Printf("%s\n\n", style.Bold.Render(fmt.Sprintf("Polecat: %s/%s", rigName, polecatName)))
|
||
|
||
// State with color
|
||
stateStr := string(p.State)
|
||
switch p.State {
|
||
case polecat.StateWorking:
|
||
stateStr = style.Info.Render(stateStr)
|
||
case polecat.StateStuck:
|
||
stateStr = style.Warning.Render(stateStr)
|
||
case polecat.StateDone:
|
||
stateStr = style.Success.Render(stateStr)
|
||
default:
|
||
stateStr = style.Dim.Render(stateStr)
|
||
}
|
||
fmt.Printf(" State: %s\n", stateStr)
|
||
|
||
// Issue
|
||
if p.Issue != "" {
|
||
fmt.Printf(" Issue: %s\n", p.Issue)
|
||
} else {
|
||
fmt.Printf(" Issue: %s\n", style.Dim.Render("(none)"))
|
||
}
|
||
|
||
// Clone path and branch
|
||
fmt.Printf(" Clone: %s\n", style.Dim.Render(p.ClonePath))
|
||
fmt.Printf(" Branch: %s\n", style.Dim.Render(p.Branch))
|
||
|
||
// Session info
|
||
fmt.Println()
|
||
fmt.Printf("%s\n", style.Bold.Render("Session"))
|
||
|
||
if sessInfo.Running {
|
||
fmt.Printf(" Status: %s\n", style.Success.Render("running"))
|
||
fmt.Printf(" Session ID: %s\n", style.Dim.Render(sessInfo.SessionID))
|
||
|
||
if sessInfo.Attached {
|
||
fmt.Printf(" Attached: %s\n", style.Info.Render("yes"))
|
||
} else {
|
||
fmt.Printf(" Attached: %s\n", style.Dim.Render("no"))
|
||
}
|
||
|
||
if sessInfo.Windows > 0 {
|
||
fmt.Printf(" Windows: %d\n", sessInfo.Windows)
|
||
}
|
||
|
||
if !sessInfo.Created.IsZero() {
|
||
fmt.Printf(" Created: %s\n", sessInfo.Created.Format("2006-01-02 15:04:05"))
|
||
}
|
||
|
||
if !sessInfo.LastActivity.IsZero() {
|
||
// Show relative time for activity
|
||
ago := formatActivityTime(sessInfo.LastActivity)
|
||
fmt.Printf(" Last Activity: %s (%s)\n",
|
||
sessInfo.LastActivity.Format("15:04:05"),
|
||
style.Dim.Render(ago))
|
||
}
|
||
} else {
|
||
fmt.Printf(" Status: %s\n", style.Dim.Render("not running"))
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// formatActivityTime returns a human-readable relative time string.
|
||
func formatActivityTime(t time.Time) string {
|
||
d := time.Since(t)
|
||
switch {
|
||
case d < time.Minute:
|
||
return fmt.Sprintf("%d seconds ago", int(d.Seconds()))
|
||
case d < time.Hour:
|
||
return fmt.Sprintf("%d minutes ago", int(d.Minutes()))
|
||
case d < 24*time.Hour:
|
||
return fmt.Sprintf("%d hours ago", int(d.Hours()))
|
||
default:
|
||
return fmt.Sprintf("%d days ago", int(d.Hours()/24))
|
||
}
|
||
}
|
||
|
||
// GitState represents the git state of a polecat's worktree.
|
||
type GitState struct {
|
||
Clean bool `json:"clean"`
|
||
UncommittedFiles []string `json:"uncommitted_files"`
|
||
UnpushedCommits int `json:"unpushed_commits"`
|
||
StashCount int `json:"stash_count"`
|
||
}
|
||
|
||
func runPolecatGitState(cmd *cobra.Command, args []string) error {
|
||
rigName, polecatName, err := parseAddress(args[0])
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
mgr, r, err := getPolecatManager(rigName)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
// Verify polecat exists
|
||
p, err := mgr.Get(polecatName)
|
||
if err != nil {
|
||
return fmt.Errorf("polecat '%s' not found in rig '%s'", polecatName, rigName)
|
||
}
|
||
|
||
// Get git state from the polecat's worktree
|
||
state, err := getGitState(p.ClonePath)
|
||
if err != nil {
|
||
return fmt.Errorf("getting git state: %w", err)
|
||
}
|
||
|
||
// JSON output
|
||
if polecatGitStateJSON {
|
||
enc := json.NewEncoder(os.Stdout)
|
||
enc.SetIndent("", " ")
|
||
return enc.Encode(state)
|
||
}
|
||
|
||
// Human-readable output
|
||
fmt.Printf("%s\n\n", style.Bold.Render(fmt.Sprintf("Git State: %s/%s", r.Name, polecatName)))
|
||
|
||
// Working tree status
|
||
if len(state.UncommittedFiles) == 0 {
|
||
fmt.Printf(" Working Tree: %s\n", style.Success.Render("clean"))
|
||
} else {
|
||
fmt.Printf(" Working Tree: %s\n", style.Warning.Render("dirty"))
|
||
fmt.Printf(" Uncommitted: %s\n", style.Warning.Render(fmt.Sprintf("%d files", len(state.UncommittedFiles))))
|
||
for _, f := range state.UncommittedFiles {
|
||
fmt.Printf(" %s\n", style.Dim.Render(f))
|
||
}
|
||
}
|
||
|
||
// Unpushed commits
|
||
if state.UnpushedCommits == 0 {
|
||
fmt.Printf(" Unpushed: %s\n", style.Success.Render("0 commits"))
|
||
} else {
|
||
fmt.Printf(" Unpushed: %s\n", style.Warning.Render(fmt.Sprintf("%d commits ahead", state.UnpushedCommits)))
|
||
}
|
||
|
||
// Stashes
|
||
if state.StashCount == 0 {
|
||
fmt.Printf(" Stashes: %s\n", style.Dim.Render("0"))
|
||
} else {
|
||
fmt.Printf(" Stashes: %s\n", style.Warning.Render(fmt.Sprintf("%d", state.StashCount)))
|
||
}
|
||
|
||
// Verdict
|
||
fmt.Println()
|
||
if state.Clean {
|
||
fmt.Printf(" Verdict: %s\n", style.Success.Render("CLEAN (safe to kill)"))
|
||
} else {
|
||
fmt.Printf(" Verdict: %s\n", style.Error.Render("DIRTY (needs cleanup)"))
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// getGitState checks the git state of a worktree.
|
||
func getGitState(worktreePath string) (*GitState, error) {
|
||
state := &GitState{
|
||
Clean: true,
|
||
UncommittedFiles: []string{},
|
||
}
|
||
|
||
// Check for uncommitted changes (git status --porcelain)
|
||
statusCmd := exec.Command("git", "status", "--porcelain")
|
||
statusCmd.Dir = worktreePath
|
||
output, err := statusCmd.Output()
|
||
if err != nil {
|
||
return nil, fmt.Errorf("git status: %w", err)
|
||
}
|
||
if len(output) > 0 {
|
||
lines := splitLines(string(output))
|
||
for _, line := range lines {
|
||
if line != "" {
|
||
// Extract filename (skip the status prefix)
|
||
if len(line) > 3 {
|
||
state.UncommittedFiles = append(state.UncommittedFiles, line[3:])
|
||
} else {
|
||
state.UncommittedFiles = append(state.UncommittedFiles, line)
|
||
}
|
||
}
|
||
}
|
||
state.Clean = false
|
||
}
|
||
|
||
// Check for unpushed commits (git log origin/main..HEAD)
|
||
logCmd := exec.Command("git", "log", "origin/main..HEAD", "--oneline")
|
||
logCmd.Dir = worktreePath
|
||
output, err = logCmd.Output()
|
||
if err != nil {
|
||
// origin/main might not exist - try origin/master
|
||
logCmd = exec.Command("git", "log", "origin/master..HEAD", "--oneline")
|
||
logCmd.Dir = worktreePath
|
||
output, _ = logCmd.Output() // non-fatal: might be a new repo without remote tracking
|
||
}
|
||
if len(output) > 0 {
|
||
lines := splitLines(string(output))
|
||
count := 0
|
||
for _, line := range lines {
|
||
if line != "" {
|
||
count++
|
||
}
|
||
}
|
||
state.UnpushedCommits = count
|
||
if count > 0 {
|
||
state.Clean = false
|
||
}
|
||
}
|
||
|
||
// Check for stashes (git stash list)
|
||
stashCmd := exec.Command("git", "stash", "list")
|
||
stashCmd.Dir = worktreePath
|
||
output, err = stashCmd.Output()
|
||
if err != nil {
|
||
// Ignore stash errors
|
||
output = nil
|
||
}
|
||
if len(output) > 0 {
|
||
lines := splitLines(string(output))
|
||
count := 0
|
||
for _, line := range lines {
|
||
if line != "" {
|
||
count++
|
||
}
|
||
}
|
||
state.StashCount = count
|
||
if count > 0 {
|
||
state.Clean = false
|
||
}
|
||
}
|
||
|
||
return state, nil
|
||
}
|
||
|
||
func runPolecatGC(cmd *cobra.Command, args []string) error {
|
||
rigName := args[0]
|
||
|
||
mgr, r, err := getPolecatManager(rigName)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
fmt.Printf("Garbage collecting stale polecat branches in %s...\n\n", r.Name)
|
||
|
||
if polecatGCDryRun {
|
||
// Dry run - list branches that would be deleted
|
||
repoGit := git.NewGit(r.Path)
|
||
|
||
// List all polecat branches
|
||
branches, err := repoGit.ListBranches("polecat/*")
|
||
if err != nil {
|
||
return fmt.Errorf("listing branches: %w", err)
|
||
}
|
||
|
||
if len(branches) == 0 {
|
||
fmt.Println("No polecat branches found.")
|
||
return nil
|
||
}
|
||
|
||
// Get current branches
|
||
polecats, err := mgr.List()
|
||
if err != nil {
|
||
return fmt.Errorf("listing polecats: %w", err)
|
||
}
|
||
|
||
currentBranches := make(map[string]bool)
|
||
for _, p := range polecats {
|
||
currentBranches[p.Branch] = true
|
||
}
|
||
|
||
// Show what would be deleted
|
||
toDelete := 0
|
||
for _, branch := range branches {
|
||
if !currentBranches[branch] {
|
||
fmt.Printf(" Would delete: %s\n", style.Dim.Render(branch))
|
||
toDelete++
|
||
} else {
|
||
fmt.Printf(" Keep (in use): %s\n", style.Success.Render(branch))
|
||
}
|
||
}
|
||
|
||
fmt.Printf("\nWould delete %d branch(es), keep %d\n", toDelete, len(branches)-toDelete)
|
||
return nil
|
||
}
|
||
|
||
// Actually clean up
|
||
deleted, err := mgr.CleanupStaleBranches()
|
||
if err != nil {
|
||
return fmt.Errorf("cleanup failed: %w", err)
|
||
}
|
||
|
||
if deleted == 0 {
|
||
fmt.Println("No stale branches to clean up.")
|
||
} else {
|
||
fmt.Printf("%s Deleted %d stale branch(es).\n", style.SuccessPrefix, deleted)
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// splitLines splits a string into non-empty lines.
|
||
func splitLines(s string) []string {
|
||
var lines []string
|
||
for _, line := range filepath.SplitList(s) {
|
||
if line != "" {
|
||
lines = append(lines, line)
|
||
}
|
||
}
|
||
// filepath.SplitList doesn't work for newlines, use strings.Split instead
|
||
lines = nil
|
||
for _, line := range strings.Split(s, "\n") {
|
||
lines = append(lines, line)
|
||
}
|
||
return lines
|
||
}
|
||
|
||
func runPolecatNuke(cmd *cobra.Command, args []string) error {
|
||
// Build list of polecats to nuke
|
||
type polecatToNuke struct {
|
||
rigName string
|
||
polecatName string
|
||
mgr *polecat.Manager
|
||
r *rig.Rig
|
||
}
|
||
var toNuke []polecatToNuke
|
||
|
||
if polecatNukeAll {
|
||
// --all flag: first arg is just the rig name
|
||
rigName := args[0]
|
||
// Check if it looks like rig/polecat format
|
||
if _, _, err := parseAddress(rigName); err == nil {
|
||
return fmt.Errorf("with --all, provide just the rig name (e.g., 'gt polecat nuke greenplace --all')")
|
||
}
|
||
|
||
mgr, r, err := getPolecatManager(rigName)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
polecats, err := mgr.List()
|
||
if err != nil {
|
||
return fmt.Errorf("listing polecats: %w", err)
|
||
}
|
||
|
||
if len(polecats) == 0 {
|
||
fmt.Println("No polecats to nuke.")
|
||
return nil
|
||
}
|
||
|
||
for _, p := range polecats {
|
||
toNuke = append(toNuke, polecatToNuke{
|
||
rigName: rigName,
|
||
polecatName: p.Name,
|
||
mgr: mgr,
|
||
r: r,
|
||
})
|
||
}
|
||
} else {
|
||
// Multiple rig/polecat arguments
|
||
for _, arg := range args {
|
||
rigName, polecatName, err := parseAddress(arg)
|
||
if err != nil {
|
||
return fmt.Errorf("invalid address '%s': %w", arg, err)
|
||
}
|
||
|
||
mgr, r, err := getPolecatManager(rigName)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
toNuke = append(toNuke, polecatToNuke{
|
||
rigName: rigName,
|
||
polecatName: polecatName,
|
||
mgr: mgr,
|
||
r: r,
|
||
})
|
||
}
|
||
}
|
||
|
||
// Nuke each polecat
|
||
t := tmux.NewTmux()
|
||
var nukeErrors []string
|
||
nuked := 0
|
||
|
||
for _, p := range toNuke {
|
||
if polecatNukeDryRun {
|
||
fmt.Printf("Would nuke %s/%s:\n", p.rigName, p.polecatName)
|
||
fmt.Printf(" - Kill session: gt-%s-%s\n", p.rigName, p.polecatName)
|
||
fmt.Printf(" - Delete worktree: %s/polecats/%s\n", p.r.Path, p.polecatName)
|
||
fmt.Printf(" - Delete branch (if exists)\n")
|
||
fmt.Printf(" - Close agent bead: %s\n", beads.PolecatBeadID(p.rigName, p.polecatName))
|
||
continue
|
||
}
|
||
|
||
fmt.Printf("Nuking %s/%s...\n", p.rigName, p.polecatName)
|
||
|
||
// Step 1: Kill session (force mode - no graceful shutdown)
|
||
sessMgr := session.NewManager(t, p.r)
|
||
running, _ := sessMgr.IsRunning(p.polecatName)
|
||
if running {
|
||
if err := sessMgr.Stop(p.polecatName, true); err != nil {
|
||
fmt.Printf(" %s session kill failed: %v\n", style.Warning.Render("⚠"), err)
|
||
// Continue anyway - worktree removal will still work
|
||
} else {
|
||
fmt.Printf(" %s killed session\n", style.Success.Render("✓"))
|
||
}
|
||
}
|
||
|
||
// Step 2: Get polecat info before deletion (for branch name)
|
||
polecatInfo, err := p.mgr.Get(p.polecatName)
|
||
var branchToDelete string
|
||
if err == nil && polecatInfo != nil {
|
||
branchToDelete = polecatInfo.Branch
|
||
}
|
||
|
||
// Step 3: Delete worktree (nuclear mode - bypass all safety checks)
|
||
if err := p.mgr.RemoveWithOptions(p.polecatName, true, true); err != nil {
|
||
if errors.Is(err, polecat.ErrPolecatNotFound) {
|
||
fmt.Printf(" %s worktree already gone\n", style.Dim.Render("○"))
|
||
} else {
|
||
nukeErrors = append(nukeErrors, fmt.Sprintf("%s/%s: worktree removal failed: %v", p.rigName, p.polecatName, err))
|
||
continue
|
||
}
|
||
} else {
|
||
fmt.Printf(" %s deleted worktree\n", style.Success.Render("✓"))
|
||
}
|
||
|
||
// Step 4: Delete branch (if we know it)
|
||
if branchToDelete != "" {
|
||
repoGit := git.NewGit(filepath.Join(p.r.Path, "mayor", "rig"))
|
||
if err := repoGit.DeleteBranch(branchToDelete, true); err != nil {
|
||
// Non-fatal - branch might already be gone
|
||
fmt.Printf(" %s branch delete: %v\n", style.Dim.Render("○"), err)
|
||
} else {
|
||
fmt.Printf(" %s deleted branch %s\n", style.Success.Render("✓"), branchToDelete)
|
||
}
|
||
}
|
||
|
||
// Step 5: Close agent bead (if exists)
|
||
agentBeadID := beads.PolecatBeadID(p.rigName, p.polecatName)
|
||
closeCmd := exec.Command("bd", "close", agentBeadID, "--reason=nuked")
|
||
closeCmd.Dir = filepath.Join(p.r.Path, "mayor", "rig")
|
||
if err := closeCmd.Run(); err != nil {
|
||
// Non-fatal - agent bead might not exist
|
||
fmt.Printf(" %s agent bead not found or already closed\n", style.Dim.Render("○"))
|
||
} else {
|
||
fmt.Printf(" %s closed agent bead %s\n", style.Success.Render("✓"), agentBeadID)
|
||
}
|
||
|
||
nuked++
|
||
}
|
||
|
||
// Report results
|
||
if polecatNukeDryRun {
|
||
fmt.Printf("\n%s Would nuke %d polecat(s).\n", style.Info.Render("ℹ"), len(toNuke))
|
||
return nil
|
||
}
|
||
|
||
if len(nukeErrors) > 0 {
|
||
fmt.Printf("\n%s Some nukes failed:\n", style.Warning.Render("Warning:"))
|
||
for _, e := range nukeErrors {
|
||
fmt.Printf(" - %s\n", e)
|
||
}
|
||
}
|
||
|
||
if nuked > 0 {
|
||
fmt.Printf("\n%s Nuked %d polecat(s).\n", style.SuccessPrefix, nuked)
|
||
}
|
||
|
||
if len(nukeErrors) > 0 {
|
||
return fmt.Errorf("%d nuke(s) failed", len(nukeErrors))
|
||
}
|
||
|
||
return nil
|
||
}
|