Files
gastown/internal/cmd/crew_helpers.go
Steve Yegge e9587bf045 Smart attach: link window when inside tmux
When 'gt X attach' is run from inside a tmux session, link the target
session's window as a new tab instead of switching sessions entirely.
Use C-b n/p to navigate between tabs.

Outside tmux: unchanged behavior (full attach)
Inside tmux: links window as tab for convenient multi-session viewing

- Add tmux.LinkWindow() and tmux.IsInsideTmux()
- Update attachToTmuxSession() with smart detection
- Update mayor, deacon, crew, refinery attach commands
2025-12-22 15:42:37 -08:00

240 lines
6.6 KiB
Go

package cmd
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"syscall"
"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/tmux"
"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) {
// Handle optional rig inference from cwd
if rigName == "" {
townRoot, err := workspace.FindFromCwdOrError()
if err != nil {
return nil, nil, fmt.Errorf("not in a Gas Town workspace: %w", err)
}
rigName, err = inferRigFromCwd(townRoot)
if err != nil {
return nil, nil, fmt.Errorf("could not determine rig (use --rig flag): %w", err)
}
}
_, r, err := getRig(rigName)
if err != nil {
return nil, nil, err
}
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.
// If prompt is provided, it's passed as the initial prompt to Claude.
func execClaude(prompt string) 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"}
if prompt != "" {
args = append(args, prompt)
}
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 smart behavior:
// - If already inside tmux: links the session as a new tab (use C-b n/p to switch)
// - If outside tmux: attaches normally (takes over terminal)
func attachToTmuxSession(sessionID string) error {
// If already inside tmux, link the window as a tab instead of switching sessions
if tmux.IsInsideTmux() {
t := tmux.NewTmux()
if err := t.LinkWindow(sessionID, 0); err != nil {
return fmt.Errorf("linking window: %w", err)
}
fmt.Printf("Linked %s as a new tab. Use C-b n to switch to it.\n", sessionID)
return nil
}
// Outside tmux: attach normally
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
}