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
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"syscall"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"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
|
// Crew command flags
|
||||||
var (
|
var (
|
||||||
crewRig string
|
crewRig string
|
||||||
crewBranch bool
|
crewBranch bool
|
||||||
crewJSON bool
|
crewJSON bool
|
||||||
crewForce bool
|
crewForce bool
|
||||||
crewNoTmux bool
|
crewNoTmux bool
|
||||||
crewMessage string
|
crewMessage string
|
||||||
)
|
)
|
||||||
|
|
||||||
var crewCmd = &cobra.Command{
|
var crewCmd = &cobra.Command{
|
||||||
@@ -244,941 +227,3 @@ func init() {
|
|||||||
|
|
||||||
rootCmd.AddCommand(crewCmd)
|
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