From 887c0f19d1411685a6d267f85007696602edb3ae Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Tue, 16 Dec 2025 20:46:52 -0800 Subject: [PATCH] feat: add gt crew add command for user-managed workspaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements gt-cik.2: Create crew workspace command with: - internal/crew/types.go: CrewWorker type definition - internal/crew/manager.go: Manager for crew lifecycle - internal/crew/manager_test.go: Unit tests - internal/cmd/crew.go: CLI command with --rig and --branch flags Crew workers are user-managed persistent workspaces that: - Clone repo into /crew// - Create optional feature branch (crew/) - Set up mail directory for delivery - Initialize CLAUDE.md with crew worker prompting - Are NOT registered with witness (user-managed) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/cmd/crew.go | 156 ++++++++++++++++++ internal/crew/manager.go | 276 ++++++++++++++++++++++++++++++++ internal/crew/manager_test.go | 290 ++++++++++++++++++++++++++++++++++ internal/crew/types.go | 39 +++++ 4 files changed, 761 insertions(+) create mode 100644 internal/cmd/crew.go create mode 100644 internal/crew/manager.go create mode 100644 internal/crew/manager_test.go create mode 100644 internal/crew/types.go diff --git a/internal/cmd/crew.go b/internal/cmd/crew.go new file mode 100644 index 00000000..4452f567 --- /dev/null +++ b/internal/cmd/crew.go @@ -0,0 +1,156 @@ +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" +) + +// Crew command flags +var ( + crewRig string + crewBranch bool +) + +var crewCmd = &cobra.Command{ + Use: "crew", + Short: "Manage crew workspaces (user-managed persistent workspaces)", + Long: `Crew workers are user-managed persistent workspaces within a rig. + +Unlike polecats which are witness-managed and ephemeral, crew workers are: +- Persistent: Not auto-garbage-collected +- User-managed: Overseer controls lifecycle +- Long-lived identities: recognizable names like dave, emma, fred +- Gas Town integrated: Mail, handoff mechanics work +- Tmux optional: Can work in terminal directly + +Commands: + gt crew add Create a new crew workspace + gt crew list List crew workspaces + gt crew remove Remove a crew workspace`, +} + +var crewAddCmd = &cobra.Command{ + Use: "add ", + Short: "Create a new crew workspace", + Long: `Create a new crew workspace with a clone of the rig repository. + +The workspace is created at /crew// with: +- A full git clone of the project repository +- Mail directory for message delivery +- CLAUDE.md with crew worker prompting +- Optional feature branch (crew/) + +Examples: + gt crew add dave # Create in current rig + gt crew add emma --rig gastown # Create in specific rig + gt crew add fred --branch # Create with feature branch`, + Args: cobra.ExactArgs(1), + RunE: runCrewAdd, +} + +func init() { + crewAddCmd.Flags().StringVar(&crewRig, "rig", "", "Rig to create crew workspace in") + crewAddCmd.Flags().BoolVar(&crewBranch, "branch", false, "Create a feature branch (crew/)") + + crewCmd.AddCommand(crewAddCmd) + 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, "config", "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") + } + + // First component should be the rig name + parts := filepath.SplitList(rel) + if len(parts) == 0 { + // Split on path separator instead + for i := 0; i < len(rel); i++ { + if rel[i] == filepath.Separator { + return rel[:i], nil + } + } + // No separator found, entire rel is the rig name + if rel != "" && rel != "." { + return rel, nil + } + } + + return "", fmt.Errorf("could not infer rig from current directory") +} diff --git a/internal/crew/manager.go b/internal/crew/manager.go new file mode 100644 index 00000000..e9927e65 --- /dev/null +++ b/internal/crew/manager.go @@ -0,0 +1,276 @@ +package crew + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/steveyegge/gastown/internal/git" + "github.com/steveyegge/gastown/internal/rig" +) + +// Common errors +var ( + ErrCrewExists = errors.New("crew worker already exists") + ErrCrewNotFound = errors.New("crew worker not found") + ErrHasChanges = errors.New("crew worker has uncommitted changes") +) + +// Manager handles crew worker lifecycle. +type Manager struct { + rig *rig.Rig + git *git.Git +} + +// NewManager creates a new crew manager. +func NewManager(r *rig.Rig, g *git.Git) *Manager { + return &Manager{ + rig: r, + git: g, + } +} + +// crewDir returns the directory for a crew worker. +func (m *Manager) crewDir(name string) string { + return filepath.Join(m.rig.Path, "crew", name) +} + +// stateFile returns the state file path for a crew worker. +func (m *Manager) stateFile(name string) string { + return filepath.Join(m.crewDir(name), "state.json") +} + +// mailDir returns the mail directory path for a crew worker. +func (m *Manager) mailDir(name string) string { + return filepath.Join(m.crewDir(name), "mail") +} + +// exists checks if a crew worker exists. +func (m *Manager) exists(name string) bool { + _, err := os.Stat(m.crewDir(name)) + return err == nil +} + +// Add creates a new crew worker with a clone of the rig. +func (m *Manager) Add(name string, createBranch bool) (*CrewWorker, error) { + if m.exists(name) { + return nil, ErrCrewExists + } + + crewPath := m.crewDir(name) + + // Create crew directory if needed + crewBaseDir := filepath.Join(m.rig.Path, "crew") + if err := os.MkdirAll(crewBaseDir, 0755); err != nil { + return nil, fmt.Errorf("creating crew dir: %w", err) + } + + // Clone the rig repo + if err := m.git.Clone(m.rig.GitURL, crewPath); err != nil { + return nil, fmt.Errorf("cloning rig: %w", err) + } + + crewGit := git.NewGit(crewPath) + branchName := "main" + + // Optionally create a working branch + if createBranch { + branchName = fmt.Sprintf("crew/%s", name) + if err := crewGit.CreateBranch(branchName); err != nil { + os.RemoveAll(crewPath) + return nil, fmt.Errorf("creating branch: %w", err) + } + if err := crewGit.Checkout(branchName); err != nil { + os.RemoveAll(crewPath) + return nil, fmt.Errorf("checking out branch: %w", err) + } + } + + // Create mail directory for mail delivery + mailPath := m.mailDir(name) + if err := os.MkdirAll(mailPath, 0755); err != nil { + os.RemoveAll(crewPath) + return nil, fmt.Errorf("creating mail dir: %w", err) + } + + // Create CLAUDE.md with crew worker prompting + if err := m.createClaudeMD(name, crewPath); err != nil { + os.RemoveAll(crewPath) + return nil, fmt.Errorf("creating CLAUDE.md: %w", err) + } + + // Create crew worker state + now := time.Now() + crew := &CrewWorker{ + Name: name, + Rig: m.rig.Name, + ClonePath: crewPath, + Branch: branchName, + CreatedAt: now, + UpdatedAt: now, + } + + // Save state + if err := m.saveState(crew); err != nil { + os.RemoveAll(crewPath) + return nil, fmt.Errorf("saving state: %w", err) + } + + return crew, nil +} + +// createClaudeMD creates the CLAUDE.md file for crew worker prompting. +func (m *Manager) createClaudeMD(name, crewPath string) error { + content := fmt.Sprintf(`# Claude: Crew Worker - %s + +You are a **crew worker** in the %s rig. Crew workers are user-managed persistent workspaces. + +## Key Differences from Polecats + +- **User-managed**: You are NOT managed by the Witness daemon +- **Persistent**: Your workspace is not automatically cleaned up +- **Optional issue assignment**: You can work without a beads issue +- **Mail enabled**: You can send and receive mail + +## Commands + +Check mail: +`+"```bash"+` +town mail inbox --as %s/%s +`+"```"+` + +Send mail: +`+"```bash"+` +town mail send -s "Subject" -m "Message" --as %s/%s +`+"```"+` + +## Session Cycling (Handoff) + +When your context fills up, use mail-to-self for handoff: + +1. Compose a handoff note with current state +2. Send to yourself: `+"```"+`town mail send %s/%s -s "Handoff" -m "..."--as %s/%s`+"```"+` +3. Exit cleanly +4. New session reads handoff from inbox + +## Beads + +If using beads for task tracking: +`+"```bash"+` +bd ready # Find available work +bd show # Review issue details +bd update --status=in_progress # Claim it +bd close # Mark complete +`+"```"+` +`, name, m.rig.Name, + m.rig.Name, name, + m.rig.Name, name, + m.rig.Name, name, m.rig.Name, name) + + claudePath := filepath.Join(crewPath, "CLAUDE.md") + return os.WriteFile(claudePath, []byte(content), 0644) +} + +// Remove deletes a crew worker. +func (m *Manager) Remove(name string, force bool) error { + if !m.exists(name) { + return ErrCrewNotFound + } + + crewPath := m.crewDir(name) + + if !force { + crewGit := git.NewGit(crewPath) + hasChanges, err := crewGit.HasUncommittedChanges() + if err == nil && hasChanges { + return ErrHasChanges + } + } + + // Remove directory + if err := os.RemoveAll(crewPath); err != nil { + return fmt.Errorf("removing crew dir: %w", err) + } + + return nil +} + +// List returns all crew workers in the rig. +func (m *Manager) List() ([]*CrewWorker, error) { + crewBaseDir := filepath.Join(m.rig.Path, "crew") + + entries, err := os.ReadDir(crewBaseDir) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("reading crew dir: %w", err) + } + + var workers []*CrewWorker + for _, entry := range entries { + if !entry.IsDir() { + continue + } + + worker, err := m.Get(entry.Name()) + if err != nil { + continue // Skip invalid workers + } + workers = append(workers, worker) + } + + return workers, nil +} + +// Get returns a specific crew worker by name. +func (m *Manager) Get(name string) (*CrewWorker, error) { + if !m.exists(name) { + return nil, ErrCrewNotFound + } + + return m.loadState(name) +} + +// saveState persists crew worker state to disk. +func (m *Manager) saveState(crew *CrewWorker) error { + data, err := json.MarshalIndent(crew, "", " ") + if err != nil { + return fmt.Errorf("marshaling state: %w", err) + } + + stateFile := m.stateFile(crew.Name) + if err := os.WriteFile(stateFile, data, 0644); err != nil { + return fmt.Errorf("writing state: %w", err) + } + + return nil +} + +// loadState reads crew worker state from disk. +func (m *Manager) loadState(name string) (*CrewWorker, error) { + stateFile := m.stateFile(name) + + data, err := os.ReadFile(stateFile) + if err != nil { + if os.IsNotExist(err) { + // Return minimal crew worker if state file missing + return &CrewWorker{ + Name: name, + Rig: m.rig.Name, + ClonePath: m.crewDir(name), + }, nil + } + return nil, fmt.Errorf("reading state: %w", err) + } + + var crew CrewWorker + if err := json.Unmarshal(data, &crew); err != nil { + return nil, fmt.Errorf("parsing state: %w", err) + } + + return &crew, nil +} diff --git a/internal/crew/manager_test.go b/internal/crew/manager_test.go new file mode 100644 index 00000000..f5bea974 --- /dev/null +++ b/internal/crew/manager_test.go @@ -0,0 +1,290 @@ +package crew + +import ( + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/steveyegge/gastown/internal/git" + "github.com/steveyegge/gastown/internal/rig" +) + +func TestManagerAddAndGet(t *testing.T) { + // Create temp directory for test + tmpDir, err := os.MkdirTemp("", "crew-test-*") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + // Create a mock rig + rigPath := filepath.Join(tmpDir, "test-rig") + if err := os.MkdirAll(rigPath, 0755); err != nil { + t.Fatalf("failed to create rig dir: %v", err) + } + + // Initialize git repo for the rig + g := git.NewGit(rigPath) + + // For testing, we need a git URL - use a local bare repo + bareRepoPath := filepath.Join(tmpDir, "bare-repo.git") + cmd := []string{"git", "init", "--bare", bareRepoPath} + if err := runCmd(cmd[0], cmd[1:]...); err != nil { + t.Fatalf("failed to create bare repo: %v", err) + } + + r := &rig.Rig{ + Name: "test-rig", + Path: rigPath, + GitURL: bareRepoPath, + } + + mgr := NewManager(r, g) + + // Test Add + worker, err := mgr.Add("dave", false) + if err != nil { + t.Fatalf("Add failed: %v", err) + } + + if worker.Name != "dave" { + t.Errorf("expected name 'dave', got '%s'", worker.Name) + } + if worker.Rig != "test-rig" { + t.Errorf("expected rig 'test-rig', got '%s'", worker.Rig) + } + if worker.Branch != "main" { + t.Errorf("expected branch 'main', got '%s'", worker.Branch) + } + + // Verify directory structure + crewDir := filepath.Join(rigPath, "crew", "dave") + if _, err := os.Stat(crewDir); os.IsNotExist(err) { + t.Error("crew directory was not created") + } + + mailDir := filepath.Join(crewDir, "mail") + if _, err := os.Stat(mailDir); os.IsNotExist(err) { + t.Error("mail directory was not created") + } + + claudeMD := filepath.Join(crewDir, "CLAUDE.md") + if _, err := os.Stat(claudeMD); os.IsNotExist(err) { + t.Error("CLAUDE.md was not created") + } + + stateFile := filepath.Join(crewDir, "state.json") + if _, err := os.Stat(stateFile); os.IsNotExist(err) { + t.Error("state.json was not created") + } + + // Test Get + retrieved, err := mgr.Get("dave") + if err != nil { + t.Fatalf("Get failed: %v", err) + } + if retrieved.Name != "dave" { + t.Errorf("expected name 'dave', got '%s'", retrieved.Name) + } + + // Test duplicate Add + _, err = mgr.Add("dave", false) + if err != ErrCrewExists { + t.Errorf("expected ErrCrewExists, got %v", err) + } + + // Test Get non-existent + _, err = mgr.Get("nonexistent") + if err != ErrCrewNotFound { + t.Errorf("expected ErrCrewNotFound, got %v", err) + } +} + +func TestManagerAddWithBranch(t *testing.T) { + // Create temp directory for test + tmpDir, err := os.MkdirTemp("", "crew-test-branch-*") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + // Create a mock rig + rigPath := filepath.Join(tmpDir, "test-rig") + if err := os.MkdirAll(rigPath, 0755); err != nil { + t.Fatalf("failed to create rig dir: %v", err) + } + + g := git.NewGit(rigPath) + + // Create a local repo with initial commit for branch testing + sourceRepoPath := filepath.Join(tmpDir, "source-repo") + if err := os.MkdirAll(sourceRepoPath, 0755); err != nil { + t.Fatalf("failed to create source repo dir: %v", err) + } + + // Initialize source repo with a commit + cmds := [][]string{ + {"git", "-C", sourceRepoPath, "init"}, + {"git", "-C", sourceRepoPath, "config", "user.email", "test@test.com"}, + {"git", "-C", sourceRepoPath, "config", "user.name", "Test"}, + } + for _, cmd := range cmds { + if err := runCmd(cmd[0], cmd[1:]...); err != nil { + t.Fatalf("failed to run %v: %v", cmd, err) + } + } + + // Create initial file and commit + testFile := filepath.Join(sourceRepoPath, "README.md") + if err := os.WriteFile(testFile, []byte("# Test"), 0644); err != nil { + t.Fatalf("failed to write test file: %v", err) + } + + cmds = [][]string{ + {"git", "-C", sourceRepoPath, "add", "."}, + {"git", "-C", sourceRepoPath, "commit", "-m", "Initial commit"}, + } + for _, cmd := range cmds { + if err := runCmd(cmd[0], cmd[1:]...); err != nil { + t.Fatalf("failed to run %v: %v", cmd, err) + } + } + + r := &rig.Rig{ + Name: "test-rig", + Path: rigPath, + GitURL: sourceRepoPath, + } + + mgr := NewManager(r, g) + + // Test Add with branch + worker, err := mgr.Add("emma", true) + if err != nil { + t.Fatalf("Add with branch failed: %v", err) + } + + if worker.Branch != "crew/emma" { + t.Errorf("expected branch 'crew/emma', got '%s'", worker.Branch) + } +} + +func TestManagerList(t *testing.T) { + // Create temp directory for test + tmpDir, err := os.MkdirTemp("", "crew-test-list-*") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + // Create a mock rig + rigPath := filepath.Join(tmpDir, "test-rig") + if err := os.MkdirAll(rigPath, 0755); err != nil { + t.Fatalf("failed to create rig dir: %v", err) + } + + g := git.NewGit(rigPath) + + // Create a bare repo for cloning + bareRepoPath := filepath.Join(tmpDir, "bare-repo.git") + if err := runCmd("git", "init", "--bare", bareRepoPath); err != nil { + t.Fatalf("failed to create bare repo: %v", err) + } + + r := &rig.Rig{ + Name: "test-rig", + Path: rigPath, + GitURL: bareRepoPath, + } + + mgr := NewManager(r, g) + + // Initially empty + workers, err := mgr.List() + if err != nil { + t.Fatalf("List failed: %v", err) + } + if len(workers) != 0 { + t.Errorf("expected 0 workers, got %d", len(workers)) + } + + // Add some workers + _, err = mgr.Add("alice", false) + if err != nil { + t.Fatalf("Add alice failed: %v", err) + } + _, err = mgr.Add("bob", false) + if err != nil { + t.Fatalf("Add bob failed: %v", err) + } + + workers, err = mgr.List() + if err != nil { + t.Fatalf("List failed: %v", err) + } + if len(workers) != 2 { + t.Errorf("expected 2 workers, got %d", len(workers)) + } +} + +func TestManagerRemove(t *testing.T) { + // Create temp directory for test + tmpDir, err := os.MkdirTemp("", "crew-test-remove-*") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + // Create a mock rig + rigPath := filepath.Join(tmpDir, "test-rig") + if err := os.MkdirAll(rigPath, 0755); err != nil { + t.Fatalf("failed to create rig dir: %v", err) + } + + g := git.NewGit(rigPath) + + // Create a bare repo for cloning + bareRepoPath := filepath.Join(tmpDir, "bare-repo.git") + if err := runCmd("git", "init", "--bare", bareRepoPath); err != nil { + t.Fatalf("failed to create bare repo: %v", err) + } + + r := &rig.Rig{ + Name: "test-rig", + Path: rigPath, + GitURL: bareRepoPath, + } + + mgr := NewManager(r, g) + + // Add a worker + _, err = mgr.Add("charlie", false) + if err != nil { + t.Fatalf("Add failed: %v", err) + } + + // Remove it (with force since CLAUDE.md is uncommitted) + err = mgr.Remove("charlie", true) + if err != nil { + t.Fatalf("Remove failed: %v", err) + } + + // Verify it's gone + _, err = mgr.Get("charlie") + if err != ErrCrewNotFound { + t.Errorf("expected ErrCrewNotFound, got %v", err) + } + + // Remove non-existent + err = mgr.Remove("nonexistent", false) + if err != ErrCrewNotFound { + t.Errorf("expected ErrCrewNotFound, got %v", err) + } +} + +// Helper to run commands +func runCmd(name string, args ...string) error { + cmd := exec.Command(name, args...) + return cmd.Run() +} diff --git a/internal/crew/types.go b/internal/crew/types.go new file mode 100644 index 00000000..2fefb5a1 --- /dev/null +++ b/internal/crew/types.go @@ -0,0 +1,39 @@ +// Package crew provides crew workspace management for overseer workspaces. +package crew + +import "time" + +// CrewWorker represents a user-managed workspace in a rig. +type CrewWorker struct { + // Name is the crew worker identifier. + Name string `json:"name"` + + // Rig is the rig this crew worker belongs to. + Rig string `json:"rig"` + + // ClonePath is the path to the crew worker's clone of the rig. + ClonePath string `json:"clone_path"` + + // Branch is the current git branch. + Branch string `json:"branch"` + + // CreatedAt is when the crew worker was created. + CreatedAt time.Time `json:"created_at"` + + // UpdatedAt is when the crew worker was last updated. + UpdatedAt time.Time `json:"updated_at"` +} + +// Summary provides a concise view of crew worker status. +type Summary struct { + Name string `json:"name"` + Branch string `json:"branch"` +} + +// Summary returns a Summary for this crew worker. +func (c *CrewWorker) Summary() Summary { + return Summary{ + Name: c.Name, + Branch: c.Branch, + } +}