refactor(cmd): split crew.go into focused modules
Break down 1184-line crew.go into 8 smaller, focused files: - crew.go: command definitions, flags, init (229 lines) - crew_helpers.go: shared utilities (235 lines) - crew_lifecycle.go: remove/refresh/restart (219 lines) - crew_status.go: status command (154 lines) - crew_at.go: session attachment (142 lines) - crew_maintenance.go: rename/pristine (121 lines) - crew_list.go: list command (89 lines) - crew_add.go: add command (74 lines) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 <name>", 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 <town>/<rig>/crew/<name>/ 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: <rig>/crew/<name>/...
|
||||
// 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
|
||||
}
|
||||
|
||||
74
internal/cmd/crew_add.go
Normal file
74
internal/cmd/crew_add.go
Normal file
@@ -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
|
||||
}
|
||||
142
internal/cmd/crew_at.go
Normal file
142
internal/cmd/crew_at.go
Normal file
@@ -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 <name>", 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)
|
||||
}
|
||||
235
internal/cmd/crew_helpers.go
Normal file
235
internal/cmd/crew_helpers.go
Normal file
@@ -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 <town>/<rig>/crew/<name>/ 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: <rig>/crew/<name>/...
|
||||
// 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
|
||||
}
|
||||
219
internal/cmd/crew_lifecycle.go
Normal file
219
internal/cmd/crew_lifecycle.go
Normal file
@@ -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
|
||||
}
|
||||
89
internal/cmd/crew_list.go
Normal file
89
internal/cmd/crew_list.go
Normal file
@@ -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
|
||||
}
|
||||
121
internal/cmd/crew_maintenance.go
Normal file
121
internal/cmd/crew_maintenance.go
Normal file
@@ -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
|
||||
}
|
||||
154
internal/cmd/crew_status.go
Normal file
154
internal/cmd/crew_status.go
Normal file
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user