diff --git a/internal/cmd/crew.go b/internal/cmd/crew.go index 4b318d9c..f239d298 100644 --- a/internal/cmd/crew.go +++ b/internal/cmd/crew.go @@ -1,34 +1,17 @@ package cmd import ( - "encoding/json" - "fmt" - "os" - "os/exec" - "path/filepath" - "strings" - "syscall" - "time" - "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 - crewJSON bool - crewForce bool - crewNoTmux bool - crewMessage string + crewRig string + crewBranch bool + crewJSON bool + crewForce bool + crewNoTmux bool + crewMessage string ) var crewCmd = &cobra.Command{ @@ -244,941 +227,3 @@ func init() { rootCmd.AddCommand(crewCmd) } - -func runCrewAdd(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 - rigsConfigPath := filepath.Join(townRoot, "mayor", "rigs.json") - rigsConfig, err := config.LoadRigsConfig(rigsConfigPath) - if err != nil { - rigsConfig = &config.RigsConfig{Rigs: make(map[string]config.RigEntry)} - } - - // Determine rig - rigName := crewRig - if rigName == "" { - // Try to infer from cwd - rigName, err = inferRigFromCwd(townRoot) - if err != nil { - return 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 fmt.Errorf("rig '%s' not found", rigName) - } - - // Create crew manager - crewGit := git.NewGit(r.Path) - crewMgr := crew.NewManager(r, crewGit) - - // Create crew workspace - fmt.Printf("Creating crew workspace %s in %s...\n", name, rigName) - - worker, err := crewMgr.Add(name, crewBranch) - if err != nil { - if err == crew.ErrCrewExists { - return fmt.Errorf("crew workspace '%s' already exists", name) - } - return fmt.Errorf("creating crew workspace: %w", err) - } - - fmt.Printf("%s Created crew workspace: %s/%s\n", - style.Bold.Render("✓"), rigName, name) - fmt.Printf(" Path: %s\n", worker.ClonePath) - fmt.Printf(" Branch: %s\n", worker.Branch) - fmt.Printf(" Mail: %s/mail/\n", worker.ClonePath) - - fmt.Printf("\n%s\n", style.Dim.Render("Start working with: cd "+worker.ClonePath)) - - return nil -} - -// inferRigFromCwd tries to determine the rig from the current directory. -func inferRigFromCwd(townRoot string) (string, error) { - cwd, err := filepath.Abs(".") - if err != nil { - return "", err - } - - // Check if cwd is within a rig - rel, err := filepath.Rel(townRoot, cwd) - if err != nil { - return "", fmt.Errorf("not in workspace") - } - - // Normalize and split path - first component is the rig name - rel = filepath.ToSlash(rel) - parts := strings.Split(rel, "/") - - if len(parts) > 0 && parts[0] != "" && parts[0] != "." { - return parts[0], nil - } - - 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, "mayor", "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 { - var name string - - // Determine crew name: from arg, or auto-detect from cwd - if len(args) > 0 { - name = args[0] - } else { - // Try to detect from current directory - detected, err := detectCrewFromCwd() - if err != nil { - return fmt.Errorf("could not detect crew workspace from current directory: %w\n\nUsage: gt crew at ", err) - } - name = detected.crewName - if crewRig == "" { - crewRig = detected.rigName - } - fmt.Printf("Detected crew workspace: %s/%s\n", detected.rigName, name) - } - - 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) - } - - // Ensure crew workspace is on main branch (persistent roles should not use feature branches) - ensureMainBranch(worker.ClonePath, fmt.Sprintf("Crew workspace %s/%s", r.Name, name)) - - // 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) - - // Apply rig-based theming (uses config if set, falls back to hash) - theme := getThemeForRig(r.Name) - _ = t.ConfigureGasTownSession(sessionID, theme, r.Name, name, "crew") - - // Wait for shell to be ready after session creation - if err := t.WaitForShellReady(sessionID, 5*time.Second); err != nil { - return fmt.Errorf("waiting for shell: %w", err) - } - - // Start claude with skip permissions (crew workers are trusted like Mayor) - if err := t.SendKeys(sessionID, "claude --dangerously-skip-permissions"); err != nil { - return fmt.Errorf("starting claude: %w", err) - } - - // Wait for Claude to start (pane command changes from shell to node) - shells := []string{"bash", "zsh", "sh", "fish", "tcsh", "ksh"} - if err := t.WaitForCommand(sessionID, shells, 15*time.Second); err != nil { - fmt.Printf("Warning: Timeout waiting for Claude to start: %v\n", err) - } - - // Give Claude time to initialize after process starts - time.Sleep(500 * time.Millisecond) - - // Send gt prime to initialize context - if err := t.SendKeys(sessionID, "gt prime"); err != nil { - // Non-fatal: Claude started but priming failed - fmt.Printf("Warning: Could not send prime command: %v\n", err) - } - - fmt.Printf("%s Created session for %s/%s\n", - style.Bold.Render("✓"), r.Name, name) - } else { - // Session exists - check if Claude is still running - // Uses both pane command check and UI marker detection to avoid - // restarting when user is in a subshell spawned from Claude - if !t.IsClaudeRunning(sessionID) { - // Claude has exited, restart it - fmt.Printf("Claude exited, restarting...\n") - if err := t.SendKeys(sessionID, "claude --dangerously-skip-permissions"); err != nil { - return fmt.Errorf("restarting claude: %w", err) - } - // Wait for Claude to start, then prime - shells := []string{"bash", "zsh", "sh", "fish", "tcsh", "ksh"} - if err := t.WaitForCommand(sessionID, shells, 15*time.Second); err != nil { - fmt.Printf("Warning: Timeout waiting for Claude to start: %v\n", err) - } - // Give Claude time to initialize after process starts - time.Sleep(500 * time.Millisecond) - if err := t.SendKeys(sessionID, "gt prime"); err != nil { - fmt.Printf("Warning: Could not send prime command: %v\n", err) - } - // Send crew resume prompt after prime completes - // Use longer debounce (300ms) to ensure paste completes before Enter - crewPrompt := "Run gt prime. Check your mail and in-progress issues. Act on anything urgent, else await instructions." - if err := t.SendKeysDelayedDebounced(sessionID, crewPrompt, 3000, 300); err != nil { - fmt.Printf("Warning: Could not send resume prompt: %v\n", err) - } - } - } - - // Check if we're already in the target session - if isInTmuxSession(sessionID) { - // We're in the session at a shell prompt - just start Claude directly - fmt.Printf("Starting Claude in current session...\n") - return execClaude() - } - - // Attach to session using exec to properly forward TTY - return attachToTmuxSession(sessionID) -} - -// isShellCommand checks if the command is a shell (meaning Claude has exited). -func isShellCommand(cmd string) bool { - shells := []string{"bash", "zsh", "sh", "fish", "tcsh", "ksh"} - for _, shell := range shells { - if cmd == shell { - return true - } - } - return false -} - -// execClaude execs claude, replacing the current process. -// Used when we're already in the target session and just need to start Claude. -func execClaude() error { - claudePath, err := exec.LookPath("claude") - if err != nil { - return fmt.Errorf("claude not found: %w", err) - } - - // exec replaces current process with claude - args := []string{"claude", "--dangerously-skip-permissions"} - return syscall.Exec(claudePath, args, os.Environ()) -} - -// isInTmuxSession checks if we're currently inside the target tmux session. -func isInTmuxSession(targetSession string) bool { - // TMUX env var format: /tmp/tmux-501/default,12345,0 - // We need to get the current session name via tmux display-message - tmuxEnv := os.Getenv("TMUX") - if tmuxEnv == "" { - return false // Not in tmux at all - } - - // Get current session name - cmd := exec.Command("tmux", "display-message", "-p", "#{session_name}") - out, err := cmd.Output() - if err != nil { - return false - } - - currentSession := strings.TrimSpace(string(out)) - return currentSession == targetSession -} - -// attachToTmuxSession attaches to a tmux session with proper TTY forwarding. -func attachToTmuxSession(sessionID string) error { - tmuxPath, err := exec.LookPath("tmux") - if err != nil { - return fmt.Errorf("tmux not found: %w", err) - } - - cmd := exec.Command(tmuxPath, "attach-session", "-t", sessionID) - cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - return cmd.Run() -} - -// crewDetection holds the result of detecting crew workspace from cwd. -type crewDetection struct { - rigName string - crewName string -} - -// detectCrewFromCwd attempts to detect the crew workspace from the current directory. -// It looks for the pattern //crew// in the current path. -func detectCrewFromCwd() (*crewDetection, error) { - cwd, err := os.Getwd() - if err != nil { - return nil, fmt.Errorf("getting cwd: %w", err) - } - - // Find town root - townRoot, err := workspace.FindFromCwd() - if err != nil { - return nil, fmt.Errorf("not in Gas Town workspace: %w", err) - } - if townRoot == "" { - return nil, fmt.Errorf("not in Gas Town workspace") - } - - // Get relative path from town root - relPath, err := filepath.Rel(townRoot, cwd) - if err != nil { - return nil, fmt.Errorf("getting relative path: %w", err) - } - - // Normalize and split path - relPath = filepath.ToSlash(relPath) - parts := strings.Split(relPath, "/") - - // Look for pattern: /crew//... - // Minimum: rig, crew, name = 3 parts - if len(parts) < 3 { - return nil, fmt.Errorf("not in a crew workspace (path too short)") - } - - rigName := parts[0] - if parts[1] != "crew" { - return nil, fmt.Errorf("not in a crew workspace (not in crew/ directory)") - } - crewName := parts[2] - - return &crewDetection{ - rigName: rigName, - crewName: crewName, - }, nil -} - -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) - - // Wait for shell to be ready - if err := t.WaitForShellReady(sessionID, 5*time.Second); err != nil { - return fmt.Errorf("waiting for shell: %w", err) - } - - // Start claude (refresh uses regular permissions, reads handoff mail) - 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 -} - -func runCrewRestart(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) - - // Kill existing session if running - if hasSession, _ := t.HasSession(sessionID); hasSession { - if err := t.KillSession(sessionID); err != nil { - return fmt.Errorf("killing old session: %w", err) - } - fmt.Printf("Killed 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) - - // Apply rig-based theming (uses config if set, falls back to hash) - theme := getThemeForRig(r.Name) - _ = t.ConfigureGasTownSession(sessionID, theme, r.Name, name, "crew") - - // Wait for shell to be ready - if err := t.WaitForShellReady(sessionID, 5*time.Second); err != nil { - return fmt.Errorf("waiting for shell: %w", err) - } - - // Start claude with skip permissions (crew workers are trusted) - if err := t.SendKeys(sessionID, "claude --dangerously-skip-permissions"); err != nil { - return fmt.Errorf("starting claude: %w", err) - } - - // Wait for Claude to start, then prime it - shells := []string{"bash", "zsh", "sh", "fish", "tcsh", "ksh"} - if err := t.WaitForCommand(sessionID, shells, 15*time.Second); err != nil { - fmt.Printf("Warning: Timeout waiting for Claude to start: %v\n", err) - } - // Give Claude time to initialize after process starts - time.Sleep(500 * time.Millisecond) - if err := t.SendKeys(sessionID, "gt prime"); err != nil { - // Non-fatal: Claude started but priming failed - fmt.Printf("Warning: Could not send prime command: %v\n", err) - } - - // Send crew resume prompt after prime completes - // Use longer debounce (300ms) to ensure paste completes before Enter - crewPrompt := "Read your mail, act on anything urgent, else await instructions." - if err := t.SendKeysDelayedDebounced(sessionID, crewPrompt, 3000, 300); err != nil { - fmt.Printf("Warning: Could not send resume prompt: %v\n", err) - } - - fmt.Printf("%s Restarted 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 -} - -func runCrewRename(cmd *cobra.Command, args []string) error { - oldName := args[0] - newName := args[1] - - crewMgr, r, err := getCrewManager(crewRig) - if err != nil { - return err - } - - // Kill any running session for the old name - t := tmux.NewTmux() - oldSessionID := crewSessionName(r.Name, oldName) - if hasSession, _ := t.HasSession(oldSessionID); hasSession { - if err := t.KillSession(oldSessionID); err != nil { - return fmt.Errorf("killing old session: %w", err) - } - fmt.Printf("Killed session %s\n", oldSessionID) - } - - // Perform the rename - if err := crewMgr.Rename(oldName, newName); err != nil { - if err == crew.ErrCrewNotFound { - return fmt.Errorf("crew workspace '%s' not found", oldName) - } - if err == crew.ErrCrewExists { - return fmt.Errorf("crew workspace '%s' already exists", newName) - } - return fmt.Errorf("renaming crew workspace: %w", err) - } - - fmt.Printf("%s Renamed crew workspace: %s/%s → %s/%s\n", - style.Bold.Render("✓"), r.Name, oldName, r.Name, newName) - fmt.Printf("New session will be: %s\n", style.Dim.Render(crewSessionName(r.Name, newName))) - - return nil -} - -func runCrewPristine(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 - } - - var results []*crew.PristineResult - - for _, w := range workers { - result, err := crewMgr.Pristine(w.Name) - if err != nil { - return fmt.Errorf("pristine %s: %w", w.Name, err) - } - results = append(results, result) - } - - if crewJSON { - enc := json.NewEncoder(os.Stdout) - enc.SetIndent("", " ") - return enc.Encode(results) - } - - // Text output - for _, result := range results { - fmt.Printf("%s %s/%s\n", style.Bold.Render("→"), r.Name, result.Name) - - if result.HadChanges { - fmt.Printf(" %s\n", style.Bold.Render("⚠ Has uncommitted changes")) - } - - if result.Pulled { - fmt.Printf(" %s git pull\n", style.Dim.Render("✓")) - } else if result.PullError != "" { - fmt.Printf(" %s git pull: %s\n", style.Bold.Render("✗"), result.PullError) - } - - if result.Synced { - fmt.Printf(" %s bd sync\n", style.Dim.Render("✓")) - } else if result.SyncError != "" { - fmt.Printf(" %s bd sync: %s\n", style.Bold.Render("✗"), result.SyncError) - } - } - - return nil -} - -// ensureMainBranch checks if a git directory is on main branch. -// If not, warns the user and offers to switch. -// Returns true if on main (or switched to main), false if user declined. -func ensureMainBranch(dir, roleName string) bool { - g := git.NewGit(dir) - - branch, err := g.CurrentBranch() - if err != nil { - // Not a git repo or other error, skip check - return true - } - - if branch == "main" || branch == "master" { - return true - } - - // Warn about wrong branch - fmt.Printf("\n%s %s is on branch '%s', not main\n", - style.Warning.Render("⚠"), - roleName, - branch) - fmt.Println(" Persistent roles should work on main to avoid orphaned work.") - fmt.Println() - - // Auto-switch to main - fmt.Printf(" Switching to main...\n") - if err := g.Checkout("main"); err != nil { - fmt.Printf(" %s Could not switch to main: %v\n", style.Error.Render("✗"), err) - fmt.Println(" Please manually run: git checkout main && git pull") - return false - } - - // Pull latest - if err := g.Pull("origin", "main"); err != nil { - fmt.Printf(" %s Pull failed (continuing anyway): %v\n", style.Warning.Render("⚠"), err) - } else { - fmt.Printf(" %s Switched to main and pulled latest\n", style.Success.Render("✓")) - } - - return true -} diff --git a/internal/cmd/crew_add.go b/internal/cmd/crew_add.go new file mode 100644 index 00000000..08308330 --- /dev/null +++ b/internal/cmd/crew_add.go @@ -0,0 +1,74 @@ +package cmd + +import ( + "fmt" + "path/filepath" + + "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/rig" + "github.com/steveyegge/gastown/internal/style" + "github.com/steveyegge/gastown/internal/workspace" +) + +func runCrewAdd(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 + rigsConfigPath := filepath.Join(townRoot, "mayor", "rigs.json") + rigsConfig, err := config.LoadRigsConfig(rigsConfigPath) + if err != nil { + rigsConfig = &config.RigsConfig{Rigs: make(map[string]config.RigEntry)} + } + + // Determine rig + rigName := crewRig + if rigName == "" { + // Try to infer from cwd + rigName, err = inferRigFromCwd(townRoot) + if err != nil { + return 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 fmt.Errorf("rig '%s' not found", rigName) + } + + // Create crew manager + crewGit := git.NewGit(r.Path) + crewMgr := crew.NewManager(r, crewGit) + + // Create crew workspace + fmt.Printf("Creating crew workspace %s in %s...\n", name, rigName) + + worker, err := crewMgr.Add(name, crewBranch) + if err != nil { + if err == crew.ErrCrewExists { + return fmt.Errorf("crew workspace '%s' already exists", name) + } + return fmt.Errorf("creating crew workspace: %w", err) + } + + fmt.Printf("%s Created crew workspace: %s/%s\n", + style.Bold.Render("✓"), rigName, name) + fmt.Printf(" Path: %s\n", worker.ClonePath) + fmt.Printf(" Branch: %s\n", worker.Branch) + fmt.Printf(" Mail: %s/mail/\n", worker.ClonePath) + + fmt.Printf("\n%s\n", style.Dim.Render("Start working with: cd "+worker.ClonePath)) + + return nil +} diff --git a/internal/cmd/crew_at.go b/internal/cmd/crew_at.go new file mode 100644 index 00000000..cfd52587 --- /dev/null +++ b/internal/cmd/crew_at.go @@ -0,0 +1,142 @@ +package cmd + +import ( + "fmt" + "time" + + "github.com/spf13/cobra" + "github.com/steveyegge/gastown/internal/crew" + "github.com/steveyegge/gastown/internal/style" + "github.com/steveyegge/gastown/internal/tmux" +) + +func runCrewAt(cmd *cobra.Command, args []string) error { + var name string + + // Determine crew name: from arg, or auto-detect from cwd + if len(args) > 0 { + name = args[0] + } else { + // Try to detect from current directory + detected, err := detectCrewFromCwd() + if err != nil { + return fmt.Errorf("could not detect crew workspace from current directory: %w\n\nUsage: gt crew at ", err) + } + name = detected.crewName + if crewRig == "" { + crewRig = detected.rigName + } + fmt.Printf("Detected crew workspace: %s/%s\n", detected.rigName, name) + } + + 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) + } + + // Ensure crew workspace is on main branch (persistent roles should not use feature branches) + ensureMainBranch(worker.ClonePath, fmt.Sprintf("Crew workspace %s/%s", r.Name, name)) + + // 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) + + // Apply rig-based theming (uses config if set, falls back to hash) + theme := getThemeForRig(r.Name) + _ = t.ConfigureGasTownSession(sessionID, theme, r.Name, name, "crew") + + // Wait for shell to be ready after session creation + if err := t.WaitForShellReady(sessionID, 5*time.Second); err != nil { + return fmt.Errorf("waiting for shell: %w", err) + } + + // Start claude with skip permissions (crew workers are trusted like Mayor) + if err := t.SendKeys(sessionID, "claude --dangerously-skip-permissions"); err != nil { + return fmt.Errorf("starting claude: %w", err) + } + + // Wait for Claude to start (pane command changes from shell to node) + shells := []string{"bash", "zsh", "sh", "fish", "tcsh", "ksh"} + if err := t.WaitForCommand(sessionID, shells, 15*time.Second); err != nil { + fmt.Printf("Warning: Timeout waiting for Claude to start: %v\n", err) + } + + // Give Claude time to initialize after process starts + time.Sleep(500 * time.Millisecond) + + // Send gt prime to initialize context + if err := t.SendKeys(sessionID, "gt prime"); err != nil { + // Non-fatal: Claude started but priming failed + fmt.Printf("Warning: Could not send prime command: %v\n", err) + } + + fmt.Printf("%s Created session for %s/%s\n", + style.Bold.Render("✓"), r.Name, name) + } else { + // Session exists - check if Claude is still running + // Uses both pane command check and UI marker detection to avoid + // restarting when user is in a subshell spawned from Claude + if !t.IsClaudeRunning(sessionID) { + // Claude has exited, restart it + fmt.Printf("Claude exited, restarting...\n") + if err := t.SendKeys(sessionID, "claude --dangerously-skip-permissions"); err != nil { + return fmt.Errorf("restarting claude: %w", err) + } + // Wait for Claude to start, then prime + shells := []string{"bash", "zsh", "sh", "fish", "tcsh", "ksh"} + if err := t.WaitForCommand(sessionID, shells, 15*time.Second); err != nil { + fmt.Printf("Warning: Timeout waiting for Claude to start: %v\n", err) + } + // Give Claude time to initialize after process starts + time.Sleep(500 * time.Millisecond) + if err := t.SendKeys(sessionID, "gt prime"); err != nil { + fmt.Printf("Warning: Could not send prime command: %v\n", err) + } + // Send crew resume prompt after prime completes + // Use longer debounce (300ms) to ensure paste completes before Enter + crewPrompt := "Run gt prime. Check your mail and in-progress issues. Act on anything urgent, else await instructions." + if err := t.SendKeysDelayedDebounced(sessionID, crewPrompt, 3000, 300); err != nil { + fmt.Printf("Warning: Could not send resume prompt: %v\n", err) + } + } + } + + // Check if we're already in the target session + if isInTmuxSession(sessionID) { + // We're in the session at a shell prompt - just start Claude directly + fmt.Printf("Starting Claude in current session...\n") + return execClaude() + } + + // Attach to session using exec to properly forward TTY + return attachToTmuxSession(sessionID) +} diff --git a/internal/cmd/crew_helpers.go b/internal/cmd/crew_helpers.go new file mode 100644 index 00000000..127edd34 --- /dev/null +++ b/internal/cmd/crew_helpers.go @@ -0,0 +1,235 @@ +package cmd + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" + + "github.com/steveyegge/gastown/internal/config" + "github.com/steveyegge/gastown/internal/crew" + "github.com/steveyegge/gastown/internal/git" + "github.com/steveyegge/gastown/internal/rig" + "github.com/steveyegge/gastown/internal/style" + "github.com/steveyegge/gastown/internal/workspace" +) + +// inferRigFromCwd tries to determine the rig from the current directory. +func inferRigFromCwd(townRoot string) (string, error) { + cwd, err := filepath.Abs(".") + if err != nil { + return "", err + } + + // Check if cwd is within a rig + rel, err := filepath.Rel(townRoot, cwd) + if err != nil { + return "", fmt.Errorf("not in workspace") + } + + // Normalize and split path - first component is the rig name + rel = filepath.ToSlash(rel) + parts := strings.Split(rel, "/") + + if len(parts) > 0 && parts[0] != "" && parts[0] != "." { + return parts[0], nil + } + + 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, "mayor", "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) +} + +// crewDetection holds the result of detecting crew workspace from cwd. +type crewDetection struct { + rigName string + crewName string +} + +// detectCrewFromCwd attempts to detect the crew workspace from the current directory. +// It looks for the pattern //crew// in the current path. +func detectCrewFromCwd() (*crewDetection, error) { + cwd, err := os.Getwd() + if err != nil { + return nil, fmt.Errorf("getting cwd: %w", err) + } + + // Find town root + townRoot, err := workspace.FindFromCwd() + if err != nil { + return nil, fmt.Errorf("not in Gas Town workspace: %w", err) + } + if townRoot == "" { + return nil, fmt.Errorf("not in Gas Town workspace") + } + + // Get relative path from town root + relPath, err := filepath.Rel(townRoot, cwd) + if err != nil { + return nil, fmt.Errorf("getting relative path: %w", err) + } + + // Normalize and split path + relPath = filepath.ToSlash(relPath) + parts := strings.Split(relPath, "/") + + // Look for pattern: /crew//... + // Minimum: rig, crew, name = 3 parts + if len(parts) < 3 { + return nil, fmt.Errorf("not in a crew workspace (path too short)") + } + + rigName := parts[0] + if parts[1] != "crew" { + return nil, fmt.Errorf("not in a crew workspace (not in crew/ directory)") + } + crewName := parts[2] + + return &crewDetection{ + rigName: rigName, + crewName: crewName, + }, nil +} + +// isShellCommand checks if the command is a shell (meaning Claude has exited). +func isShellCommand(cmd string) bool { + shells := []string{"bash", "zsh", "sh", "fish", "tcsh", "ksh"} + for _, shell := range shells { + if cmd == shell { + return true + } + } + return false +} + +// execClaude execs claude, replacing the current process. +// Used when we're already in the target session and just need to start Claude. +func execClaude() error { + claudePath, err := exec.LookPath("claude") + if err != nil { + return fmt.Errorf("claude not found: %w", err) + } + + // exec replaces current process with claude + args := []string{"claude", "--dangerously-skip-permissions"} + return syscall.Exec(claudePath, args, os.Environ()) +} + +// isInTmuxSession checks if we're currently inside the target tmux session. +func isInTmuxSession(targetSession string) bool { + // TMUX env var format: /tmp/tmux-501/default,12345,0 + // We need to get the current session name via tmux display-message + tmuxEnv := os.Getenv("TMUX") + if tmuxEnv == "" { + return false // Not in tmux at all + } + + // Get current session name + cmd := exec.Command("tmux", "display-message", "-p", "#{session_name}") + out, err := cmd.Output() + if err != nil { + return false + } + + currentSession := strings.TrimSpace(string(out)) + return currentSession == targetSession +} + +// attachToTmuxSession attaches to a tmux session with proper TTY forwarding. +func attachToTmuxSession(sessionID string) error { + tmuxPath, err := exec.LookPath("tmux") + if err != nil { + return fmt.Errorf("tmux not found: %w", err) + } + + cmd := exec.Command(tmuxPath, "attach-session", "-t", sessionID) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +// ensureMainBranch checks if a git directory is on main branch. +// If not, warns the user and offers to switch. +// Returns true if on main (or switched to main), false if user declined. +func ensureMainBranch(dir, roleName string) bool { + g := git.NewGit(dir) + + branch, err := g.CurrentBranch() + if err != nil { + // Not a git repo or other error, skip check + return true + } + + if branch == "main" || branch == "master" { + return true + } + + // Warn about wrong branch + fmt.Printf("\n%s %s is on branch '%s', not main\n", + style.Warning.Render("⚠"), + roleName, + branch) + fmt.Println(" Persistent roles should work on main to avoid orphaned work.") + fmt.Println() + + // Auto-switch to main + fmt.Printf(" Switching to main...\n") + if err := g.Checkout("main"); err != nil { + fmt.Printf(" %s Could not switch to main: %v\n", style.Error.Render("✗"), err) + fmt.Println(" Please manually run: git checkout main && git pull") + return false + } + + // Pull latest + if err := g.Pull("origin", "main"); err != nil { + fmt.Printf(" %s Pull failed (continuing anyway): %v\n", style.Warning.Render("⚠"), err) + } else { + fmt.Printf(" %s Switched to main and pulled latest\n", style.Success.Render("✓")) + } + + return true +} diff --git a/internal/cmd/crew_lifecycle.go b/internal/cmd/crew_lifecycle.go new file mode 100644 index 00000000..eb4770a5 --- /dev/null +++ b/internal/cmd/crew_lifecycle.go @@ -0,0 +1,219 @@ +package cmd + +import ( + "fmt" + "os" + "path/filepath" + "time" + + "github.com/spf13/cobra" + "github.com/steveyegge/gastown/internal/crew" + "github.com/steveyegge/gastown/internal/mail" + "github.com/steveyegge/gastown/internal/style" + "github.com/steveyegge/gastown/internal/tmux" +) + +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) + + // Wait for shell to be ready + if err := t.WaitForShellReady(sessionID, 5*time.Second); err != nil { + return fmt.Errorf("waiting for shell: %w", err) + } + + // Start claude (refresh uses regular permissions, reads handoff mail) + 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 +} + +func runCrewRestart(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) + + // Kill existing session if running + if hasSession, _ := t.HasSession(sessionID); hasSession { + if err := t.KillSession(sessionID); err != nil { + return fmt.Errorf("killing old session: %w", err) + } + fmt.Printf("Killed 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) + + // Apply rig-based theming (uses config if set, falls back to hash) + theme := getThemeForRig(r.Name) + _ = t.ConfigureGasTownSession(sessionID, theme, r.Name, name, "crew") + + // Wait for shell to be ready + if err := t.WaitForShellReady(sessionID, 5*time.Second); err != nil { + return fmt.Errorf("waiting for shell: %w", err) + } + + // Start claude with skip permissions (crew workers are trusted) + if err := t.SendKeys(sessionID, "claude --dangerously-skip-permissions"); err != nil { + return fmt.Errorf("starting claude: %w", err) + } + + // Wait for Claude to start, then prime it + shells := []string{"bash", "zsh", "sh", "fish", "tcsh", "ksh"} + if err := t.WaitForCommand(sessionID, shells, 15*time.Second); err != nil { + fmt.Printf("Warning: Timeout waiting for Claude to start: %v\n", err) + } + // Give Claude time to initialize after process starts + time.Sleep(500 * time.Millisecond) + if err := t.SendKeys(sessionID, "gt prime"); err != nil { + // Non-fatal: Claude started but priming failed + fmt.Printf("Warning: Could not send prime command: %v\n", err) + } + + // Send crew resume prompt after prime completes + // Use longer debounce (300ms) to ensure paste completes before Enter + crewPrompt := "Read your mail, act on anything urgent, else await instructions." + if err := t.SendKeysDelayedDebounced(sessionID, crewPrompt, 3000, 300); err != nil { + fmt.Printf("Warning: Could not send resume prompt: %v\n", err) + } + + fmt.Printf("%s Restarted 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 +} diff --git a/internal/cmd/crew_list.go b/internal/cmd/crew_list.go new file mode 100644 index 00000000..3eaf8c73 --- /dev/null +++ b/internal/cmd/crew_list.go @@ -0,0 +1,89 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/spf13/cobra" + "github.com/steveyegge/gastown/internal/git" + "github.com/steveyegge/gastown/internal/style" + "github.com/steveyegge/gastown/internal/tmux" +) + +// 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 +} diff --git a/internal/cmd/crew_maintenance.go b/internal/cmd/crew_maintenance.go new file mode 100644 index 00000000..709ea129 --- /dev/null +++ b/internal/cmd/crew_maintenance.go @@ -0,0 +1,121 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/spf13/cobra" + "github.com/steveyegge/gastown/internal/crew" + "github.com/steveyegge/gastown/internal/style" + "github.com/steveyegge/gastown/internal/tmux" +) + +func runCrewRename(cmd *cobra.Command, args []string) error { + oldName := args[0] + newName := args[1] + + crewMgr, r, err := getCrewManager(crewRig) + if err != nil { + return err + } + + // Kill any running session for the old name + t := tmux.NewTmux() + oldSessionID := crewSessionName(r.Name, oldName) + if hasSession, _ := t.HasSession(oldSessionID); hasSession { + if err := t.KillSession(oldSessionID); err != nil { + return fmt.Errorf("killing old session: %w", err) + } + fmt.Printf("Killed session %s\n", oldSessionID) + } + + // Perform the rename + if err := crewMgr.Rename(oldName, newName); err != nil { + if err == crew.ErrCrewNotFound { + return fmt.Errorf("crew workspace '%s' not found", oldName) + } + if err == crew.ErrCrewExists { + return fmt.Errorf("crew workspace '%s' already exists", newName) + } + return fmt.Errorf("renaming crew workspace: %w", err) + } + + fmt.Printf("%s Renamed crew workspace: %s/%s → %s/%s\n", + style.Bold.Render("✓"), r.Name, oldName, r.Name, newName) + fmt.Printf("New session will be: %s\n", style.Dim.Render(crewSessionName(r.Name, newName))) + + return nil +} + +func runCrewPristine(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 + } + + var results []*crew.PristineResult + + for _, w := range workers { + result, err := crewMgr.Pristine(w.Name) + if err != nil { + return fmt.Errorf("pristine %s: %w", w.Name, err) + } + results = append(results, result) + } + + if crewJSON { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(results) + } + + // Text output + for _, result := range results { + fmt.Printf("%s %s/%s\n", style.Bold.Render("→"), r.Name, result.Name) + + if result.HadChanges { + fmt.Printf(" %s\n", style.Bold.Render("⚠ Has uncommitted changes")) + } + + if result.Pulled { + fmt.Printf(" %s git pull\n", style.Dim.Render("✓")) + } else if result.PullError != "" { + fmt.Printf(" %s git pull: %s\n", style.Bold.Render("✗"), result.PullError) + } + + if result.Synced { + fmt.Printf(" %s bd sync\n", style.Dim.Render("✓")) + } else if result.SyncError != "" { + fmt.Printf(" %s bd sync: %s\n", style.Bold.Render("✗"), result.SyncError) + } + } + + return nil +} diff --git a/internal/cmd/crew_status.go b/internal/cmd/crew_status.go new file mode 100644 index 00000000..28379046 --- /dev/null +++ b/internal/cmd/crew_status.go @@ -0,0 +1,154 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/spf13/cobra" + "github.com/steveyegge/gastown/internal/crew" + "github.com/steveyegge/gastown/internal/git" + "github.com/steveyegge/gastown/internal/mail" + "github.com/steveyegge/gastown/internal/style" + "github.com/steveyegge/gastown/internal/tmux" +) + +// 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 +}