From b56977218b6e587c216642ed9903527afb5ce9c2 Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Tue, 16 Dec 2025 20:57:26 -0800 Subject: [PATCH] feat: add gt crew commands (list, attach, remove, refresh, status) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add remaining crew subcommands for user-managed workspaces: - gt crew list: List crew workspaces with session/git status - gt crew at/attach: Start or attach to tmux session - gt crew remove: Remove workspace with safety checks - gt crew refresh: Context cycling with mail-to-self handoff - gt crew status: Show detailed workspace status with mail info All commands support --rig flag for explicit rig selection and --json output where appropriate. Closes: gt-cik.9 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/cmd/crew.go | 555 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 550 insertions(+), 5 deletions(-) diff --git a/internal/cmd/crew.go b/internal/cmd/crew.go index 4452f567..e0c10d9d 100644 --- a/internal/cmd/crew.go +++ b/internal/cmd/crew.go @@ -1,22 +1,31 @@ package cmd import ( + "encoding/json" "fmt" + "os" "path/filepath" + "strings" "github.com/spf13/cobra" "github.com/steveyegge/gastown/internal/config" "github.com/steveyegge/gastown/internal/crew" "github.com/steveyegge/gastown/internal/git" + "github.com/steveyegge/gastown/internal/mail" "github.com/steveyegge/gastown/internal/rig" "github.com/steveyegge/gastown/internal/style" + "github.com/steveyegge/gastown/internal/tmux" "github.com/steveyegge/gastown/internal/workspace" ) // Crew command flags var ( - crewRig string - crewBranch bool + crewRig string + crewBranch bool + crewJSON bool + crewForce bool + crewNoTmux bool + crewMessage string ) var crewCmd = &cobra.Command{ @@ -32,9 +41,12 @@ Unlike polecats which are witness-managed and ephemeral, crew workers are: - Tmux optional: Can work in terminal directly Commands: - gt crew add Create a new crew workspace - gt crew list List crew workspaces - gt crew remove Remove a crew workspace`, + gt crew add Create a new crew workspace + gt crew list List crew workspaces with status + gt crew at Attach to crew workspace session + gt crew remove Remove a crew workspace + gt crew refresh Context cycling with mail-to-self handoff + gt crew status [] Show detailed workspace status`, } var crewAddCmd = &cobra.Command{ @@ -56,11 +68,109 @@ Examples: RunE: runCrewAdd, } +var crewListCmd = &cobra.Command{ + Use: "list", + Short: "List crew workspaces with status", + Long: `List all crew workspaces in a rig with their status. + +Shows git branch, session state, and git status for each workspace. + +Examples: + gt crew list # List in current rig + gt crew list --rig gastown # List in specific rig + gt crew list --json # JSON output`, + RunE: runCrewList, +} + +var crewAtCmd = &cobra.Command{ + Use: "at ", + Aliases: []string{"attach"}, + Short: "Attach to crew workspace session", + Long: `Start or attach to a tmux session for a crew workspace. + +Creates a new tmux session if none exists, or attaches to existing. +Use --no-tmux to just print the directory path instead. + +Examples: + gt crew at dave # Attach to dave's session + gt crew at dave --no-tmux # Just print path`, + Args: cobra.ExactArgs(1), + RunE: runCrewAt, +} + +var crewRemoveCmd = &cobra.Command{ + Use: "remove ", + Short: "Remove a crew workspace", + Long: `Remove a crew workspace from the rig. + +Checks for uncommitted changes and running sessions before removing. +Use --force to skip checks and remove anyway. + +Examples: + gt crew remove dave # Remove with safety checks + gt crew remove dave --force # Force remove`, + Args: cobra.ExactArgs(1), + RunE: runCrewRemove, +} + +var crewRefreshCmd = &cobra.Command{ + Use: "refresh ", + Short: "Context cycling with mail-to-self handoff", + Long: `Cycle a crew workspace session with handoff. + +Sends a handoff mail to the workspace's own inbox, then restarts the session. +The new session reads the handoff mail and resumes work. + +Examples: + gt crew refresh dave # Refresh with auto-generated handoff + gt crew refresh dave -m "Working on gt-123" # Add custom message`, + Args: cobra.ExactArgs(1), + RunE: runCrewRefresh, +} + +var crewStatusCmd = &cobra.Command{ + Use: "status []", + Short: "Show detailed workspace status", + Long: `Show detailed status for crew workspace(s). + +Displays session state, git status, branch info, and mail inbox status. +If no name given, shows status for all crew workers. + +Examples: + gt crew status # Status of all crew workers + gt crew status dave # Status of specific worker + gt crew status --json # JSON output`, + RunE: runCrewStatus, +} + func init() { + // Add flags crewAddCmd.Flags().StringVar(&crewRig, "rig", "", "Rig to create crew workspace in") crewAddCmd.Flags().BoolVar(&crewBranch, "branch", false, "Create a feature branch (crew/)") + crewListCmd.Flags().StringVar(&crewRig, "rig", "", "Filter by rig name") + crewListCmd.Flags().BoolVar(&crewJSON, "json", false, "Output as JSON") + + crewAtCmd.Flags().StringVar(&crewRig, "rig", "", "Rig to use") + crewAtCmd.Flags().BoolVar(&crewNoTmux, "no-tmux", false, "Just print directory path") + + crewRemoveCmd.Flags().StringVar(&crewRig, "rig", "", "Rig to use") + crewRemoveCmd.Flags().BoolVar(&crewForce, "force", false, "Force remove (skip safety checks)") + + crewRefreshCmd.Flags().StringVar(&crewRig, "rig", "", "Rig to use") + crewRefreshCmd.Flags().StringVarP(&crewMessage, "message", "m", "", "Custom handoff message") + + crewStatusCmd.Flags().StringVar(&crewRig, "rig", "", "Filter by rig name") + crewStatusCmd.Flags().BoolVar(&crewJSON, "json", false, "Output as JSON") + + // Add subcommands crewCmd.AddCommand(crewAddCmd) + crewCmd.AddCommand(crewListCmd) + crewCmd.AddCommand(crewAtCmd) + crewCmd.AddCommand(crewRemoveCmd) + crewCmd.AddCommand(crewRefreshCmd) + crewCmd.AddCommand(crewStatusCmd) + rootCmd.AddCommand(crewCmd) } @@ -154,3 +264,438 @@ func inferRigFromCwd(townRoot string) (string, error) { return "", fmt.Errorf("could not infer rig from current directory") } + +// getCrewManager returns a crew manager for the specified or inferred rig. +func getCrewManager(rigName string) (*crew.Manager, *rig.Rig, error) { + // Find town root + townRoot, err := workspace.FindFromCwdOrError() + if err != nil { + return nil, nil, fmt.Errorf("not in a Gas Town workspace: %w", err) + } + + // Load rigs config + rigsConfigPath := filepath.Join(townRoot, "config", "rigs.json") + rigsConfig, err := config.LoadRigsConfig(rigsConfigPath) + if err != nil { + rigsConfig = &config.RigsConfig{Rigs: make(map[string]config.RigEntry)} + } + + // Determine rig + if rigName == "" { + rigName, err = inferRigFromCwd(townRoot) + if err != nil { + return nil, nil, fmt.Errorf("could not determine rig (use --rig flag): %w", err) + } + } + + // Get rig + g := git.NewGit(townRoot) + rigMgr := rig.NewManager(townRoot, rigsConfig, g) + r, err := rigMgr.GetRig(rigName) + if err != nil { + return nil, nil, fmt.Errorf("rig '%s' not found", rigName) + } + + // Create crew manager + crewGit := git.NewGit(r.Path) + crewMgr := crew.NewManager(r, crewGit) + + return crewMgr, r, nil +} + +// crewSessionName generates the tmux session name for a crew worker. +func crewSessionName(rigName, crewName string) string { + return fmt.Sprintf("gt-%s-crew-%s", rigName, crewName) +} + +// CrewListItem represents a crew worker in list output. +type CrewListItem struct { + Name string `json:"name"` + Rig string `json:"rig"` + Branch string `json:"branch"` + Path string `json:"path"` + HasSession bool `json:"has_session"` + GitClean bool `json:"git_clean"` +} + +func runCrewList(cmd *cobra.Command, args []string) error { + crewMgr, r, err := getCrewManager(crewRig) + if err != nil { + return err + } + + workers, err := crewMgr.List() + if err != nil { + return fmt.Errorf("listing crew workers: %w", err) + } + + if len(workers) == 0 { + fmt.Println("No crew workspaces found.") + return nil + } + + // Check session and git status for each worker + t := tmux.NewTmux() + var items []CrewListItem + + for _, w := range workers { + sessionID := crewSessionName(r.Name, w.Name) + hasSession, _ := t.HasSession(sessionID) + + crewGit := git.NewGit(w.ClonePath) + gitClean := true + if status, err := crewGit.Status(); err == nil { + gitClean = status.Clean + } + + items = append(items, CrewListItem{ + Name: w.Name, + Rig: r.Name, + Branch: w.Branch, + Path: w.ClonePath, + HasSession: hasSession, + GitClean: gitClean, + }) + } + + if crewJSON { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(items) + } + + // Text output + fmt.Printf("%s\n\n", style.Bold.Render("Crew Workspaces")) + for _, item := range items { + status := style.Dim.Render("○") + if item.HasSession { + status = style.Bold.Render("●") + } + + gitStatus := style.Dim.Render("clean") + if !item.GitClean { + gitStatus = style.Bold.Render("dirty") + } + + fmt.Printf(" %s %s/%s\n", status, item.Rig, item.Name) + fmt.Printf(" Branch: %s Git: %s\n", item.Branch, gitStatus) + fmt.Printf(" %s\n", style.Dim.Render(item.Path)) + } + + return nil +} + +func runCrewAt(cmd *cobra.Command, args []string) error { + name := args[0] + + crewMgr, r, err := getCrewManager(crewRig) + if err != nil { + return err + } + + // Get the crew worker + worker, err := crewMgr.Get(name) + if err != nil { + if err == crew.ErrCrewNotFound { + return fmt.Errorf("crew workspace '%s' not found", name) + } + return fmt.Errorf("getting crew worker: %w", err) + } + + // If --no-tmux, just print the path + if crewNoTmux { + fmt.Println(worker.ClonePath) + return nil + } + + // Check if session exists + t := tmux.NewTmux() + sessionID := crewSessionName(r.Name, name) + hasSession, err := t.HasSession(sessionID) + if err != nil { + return fmt.Errorf("checking session: %w", err) + } + + if !hasSession { + // Create new session + if err := t.NewSession(sessionID, worker.ClonePath); err != nil { + return fmt.Errorf("creating session: %w", err) + } + + // Set environment + t.SetEnvironment(sessionID, "GT_RIG", r.Name) + t.SetEnvironment(sessionID, "GT_CREW", name) + + // Start claude + if err := t.SendKeys(sessionID, "claude"); err != nil { + return fmt.Errorf("starting claude: %w", err) + } + + fmt.Printf("%s Created session for %s/%s\n", + style.Bold.Render("✓"), r.Name, name) + } + + // Attach to session + return t.AttachSession(sessionID) +} + +func runCrewRemove(cmd *cobra.Command, args []string) error { + name := args[0] + + crewMgr, r, err := getCrewManager(crewRig) + if err != nil { + return err + } + + // Check for running session (unless forced) + if !crewForce { + t := tmux.NewTmux() + sessionID := crewSessionName(r.Name, name) + hasSession, _ := t.HasSession(sessionID) + if hasSession { + return fmt.Errorf("session '%s' is running (use --force to kill and remove)", sessionID) + } + } + + // Kill session if it exists + t := tmux.NewTmux() + sessionID := crewSessionName(r.Name, name) + if hasSession, _ := t.HasSession(sessionID); hasSession { + if err := t.KillSession(sessionID); err != nil { + return fmt.Errorf("killing session: %w", err) + } + fmt.Printf("Killed session %s\n", sessionID) + } + + // Remove the crew workspace + if err := crewMgr.Remove(name, crewForce); err != nil { + if err == crew.ErrCrewNotFound { + return fmt.Errorf("crew workspace '%s' not found", name) + } + if err == crew.ErrHasChanges { + return fmt.Errorf("crew workspace has uncommitted changes (use --force to remove anyway)") + } + return fmt.Errorf("removing crew workspace: %w", err) + } + + fmt.Printf("%s Removed crew workspace: %s/%s\n", + style.Bold.Render("✓"), r.Name, name) + return nil +} + +func runCrewRefresh(cmd *cobra.Command, args []string) error { + name := args[0] + + crewMgr, r, err := getCrewManager(crewRig) + if err != nil { + return err + } + + // Get the crew worker + worker, err := crewMgr.Get(name) + if err != nil { + if err == crew.ErrCrewNotFound { + return fmt.Errorf("crew workspace '%s' not found", name) + } + return fmt.Errorf("getting crew worker: %w", err) + } + + t := tmux.NewTmux() + sessionID := crewSessionName(r.Name, name) + + // Check if session exists + hasSession, _ := t.HasSession(sessionID) + + // Create handoff message + handoffMsg := crewMessage + if handoffMsg == "" { + handoffMsg = fmt.Sprintf("Context refresh for %s. Check mail and beads for current work state.", name) + } + + // Send handoff mail to self + mailDir := filepath.Join(worker.ClonePath, "mail") + if _, err := os.Stat(mailDir); os.IsNotExist(err) { + if err := os.MkdirAll(mailDir, 0755); err != nil { + return fmt.Errorf("creating mail dir: %w", err) + } + } + + // Create and send mail + mailbox := mail.NewMailbox(mailDir) + msg := &mail.Message{ + From: fmt.Sprintf("%s/%s", r.Name, name), + To: fmt.Sprintf("%s/%s", r.Name, name), + Subject: "🤝 HANDOFF: Context Refresh", + Body: handoffMsg, + } + if err := mailbox.Append(msg); err != nil { + return fmt.Errorf("sending handoff mail: %w", err) + } + fmt.Printf("Sent handoff mail to %s/%s\n", r.Name, name) + + // Kill existing session if running + if hasSession { + if err := t.KillSession(sessionID); err != nil { + return fmt.Errorf("killing old session: %w", err) + } + fmt.Printf("Killed old session %s\n", sessionID) + } + + // Start new session + if err := t.NewSession(sessionID, worker.ClonePath); err != nil { + return fmt.Errorf("creating session: %w", err) + } + + // Set environment + t.SetEnvironment(sessionID, "GT_RIG", r.Name) + t.SetEnvironment(sessionID, "GT_CREW", name) + + // Start claude + if err := t.SendKeys(sessionID, "claude"); err != nil { + return fmt.Errorf("starting claude: %w", err) + } + + fmt.Printf("%s Refreshed crew workspace: %s/%s\n", + style.Bold.Render("✓"), r.Name, name) + fmt.Printf("Attach with: %s\n", style.Dim.Render(fmt.Sprintf("gt crew at %s", name))) + + return nil +} + +// CrewStatusItem represents detailed status for a crew worker. +type CrewStatusItem struct { + Name string `json:"name"` + Rig string `json:"rig"` + Path string `json:"path"` + Branch string `json:"branch"` + HasSession bool `json:"has_session"` + SessionID string `json:"session_id,omitempty"` + GitClean bool `json:"git_clean"` + GitModified []string `json:"git_modified,omitempty"` + GitUntracked []string `json:"git_untracked,omitempty"` + MailTotal int `json:"mail_total"` + MailUnread int `json:"mail_unread"` +} + +func runCrewStatus(cmd *cobra.Command, args []string) error { + crewMgr, r, err := getCrewManager(crewRig) + if err != nil { + return err + } + + var workers []*crew.CrewWorker + + if len(args) > 0 { + // Specific worker + name := args[0] + worker, err := crewMgr.Get(name) + if err != nil { + if err == crew.ErrCrewNotFound { + return fmt.Errorf("crew workspace '%s' not found", name) + } + return fmt.Errorf("getting crew worker: %w", err) + } + workers = []*crew.CrewWorker{worker} + } else { + // All workers + workers, err = crewMgr.List() + if err != nil { + return fmt.Errorf("listing crew workers: %w", err) + } + } + + if len(workers) == 0 { + fmt.Println("No crew workspaces found.") + return nil + } + + t := tmux.NewTmux() + var items []CrewStatusItem + + for _, w := range workers { + sessionID := crewSessionName(r.Name, w.Name) + hasSession, _ := t.HasSession(sessionID) + + // Git status + crewGit := git.NewGit(w.ClonePath) + gitStatus, _ := crewGit.Status() + branch, _ := crewGit.CurrentBranch() + + gitClean := true + var modified, untracked []string + if gitStatus != nil { + gitClean = gitStatus.Clean + modified = append(gitStatus.Modified, gitStatus.Added...) + modified = append(modified, gitStatus.Deleted...) + untracked = gitStatus.Untracked + } + + // Mail status + mailDir := filepath.Join(w.ClonePath, "mail") + mailTotal, mailUnread := 0, 0 + if _, err := os.Stat(mailDir); err == nil { + mailbox := mail.NewMailbox(mailDir) + mailTotal, mailUnread, _ = mailbox.Count() + } + + item := CrewStatusItem{ + Name: w.Name, + Rig: r.Name, + Path: w.ClonePath, + Branch: branch, + HasSession: hasSession, + GitClean: gitClean, + GitModified: modified, + GitUntracked: untracked, + MailTotal: mailTotal, + MailUnread: mailUnread, + } + if hasSession { + item.SessionID = sessionID + } + + items = append(items, item) + } + + if crewJSON { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(items) + } + + // Text output + for i, item := range items { + if i > 0 { + fmt.Println() + } + + sessionStatus := style.Dim.Render("○ stopped") + if item.HasSession { + sessionStatus = style.Bold.Render("● running") + } + + fmt.Printf("%s %s/%s\n", sessionStatus, item.Rig, item.Name) + fmt.Printf(" Path: %s\n", item.Path) + fmt.Printf(" Branch: %s\n", item.Branch) + + if item.GitClean { + fmt.Printf(" Git: %s\n", style.Dim.Render("clean")) + } else { + fmt.Printf(" Git: %s\n", style.Bold.Render("dirty")) + if len(item.GitModified) > 0 { + fmt.Printf(" Modified: %s\n", strings.Join(item.GitModified, ", ")) + } + if len(item.GitUntracked) > 0 { + fmt.Printf(" Untracked: %s\n", strings.Join(item.GitUntracked, ", ")) + } + } + + if item.MailUnread > 0 { + fmt.Printf(" Mail: %d unread / %d total\n", item.MailUnread, item.MailTotal) + } else { + fmt.Printf(" Mail: %s\n", style.Dim.Render(fmt.Sprintf("%d messages", item.MailTotal))) + } + } + + return nil +}