feat: add gt crew add command for user-managed workspaces

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 <rig>/crew/<name>/
- Create optional feature branch (crew/<name>)
- 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 <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-12-16 20:46:52 -08:00
parent 0c9c3f5563
commit 887c0f19d1
4 changed files with 761 additions and 0 deletions

156
internal/cmd/crew.go Normal file
View File

@@ -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 <name> Create a new crew workspace
gt crew list List crew workspaces
gt crew remove <name> Remove a crew workspace`,
}
var crewAddCmd = &cobra.Command{
Use: "add <name>",
Short: "Create a new crew workspace",
Long: `Create a new crew workspace with a clone of the rig repository.
The workspace is created at <rig>/crew/<name>/ with:
- A full git clone of the project repository
- Mail directory for message delivery
- CLAUDE.md with crew worker prompting
- Optional feature branch (crew/<name>)
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/<name>)")
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")
}

276
internal/crew/manager.go Normal file
View File

@@ -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 <recipient> -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 <id> # Review issue details
bd update <id> --status=in_progress # Claim it
bd close <id> # 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
}

View File

@@ -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()
}

39
internal/crew/types.go Normal file
View File

@@ -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,
}
}