When attaching to a session from within tmux, use 'tmux switch-client' instead of 'tmux attach-session' to avoid the nested session error. Fixes #603 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
343 lines
10 KiB
Go
343 lines
10 KiB
Go
package cmd
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"syscall"
|
|
|
|
"github.com/steveyegge/gastown/internal/config"
|
|
"github.com/steveyegge/gastown/internal/constants"
|
|
"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) {
|
|
// 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)
|
|
}
|
|
|
|
// parseRigSlashName parses "rig/name" format into separate rig and name parts.
|
|
// Returns (rig, name, true) if the format matches, or ("", original, false) if not.
|
|
// Examples:
|
|
// - "beads/emma" -> ("beads", "emma", true)
|
|
// - "emma" -> ("", "emma", false)
|
|
// - "beads/crew/emma" -> ("beads", "crew/emma", true) - only first slash splits
|
|
func parseRigSlashName(input string) (rig, name string, ok bool) {
|
|
// Only split on first slash to handle edge cases
|
|
idx := strings.Index(input, "/")
|
|
if idx == -1 {
|
|
return "", input, false
|
|
}
|
|
return input[:idx], input[idx+1:], true
|
|
}
|
|
|
|
// 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 inside a crew workspace - specify the crew name or cd into a crew directory (e.g., gastown/crew/max)")
|
|
}
|
|
|
|
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 the runtime has exited).
|
|
func isShellCommand(cmd string) bool {
|
|
shells := constants.SupportedShells
|
|
for _, shell := range shells {
|
|
if cmd == shell {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// execAgent execs the configured agent, replacing the current process.
|
|
// Used when we're already in the target session and just need to start the agent.
|
|
// If prompt is provided, it's passed as the initial prompt.
|
|
func execAgent(cfg *config.RuntimeConfig, prompt string) error {
|
|
if cfg == nil {
|
|
cfg = config.DefaultRuntimeConfig()
|
|
}
|
|
|
|
agentPath, err := exec.LookPath(cfg.Command)
|
|
if err != nil {
|
|
return fmt.Errorf("%s not found: %w", cfg.Command, err)
|
|
}
|
|
|
|
// exec replaces current process with agent
|
|
// args[0] must be the command name (convention for exec)
|
|
args := append([]string{cfg.Command}, cfg.Args...)
|
|
if prompt != "" {
|
|
args = append(args, prompt)
|
|
}
|
|
return syscall.Exec(agentPath, args, os.Environ())
|
|
}
|
|
|
|
// execRuntime execs the runtime CLI, replacing the current process.
|
|
// Used when we're already in the target session and just need to start the runtime.
|
|
// If prompt is provided, it's passed according to the runtime's prompt mode.
|
|
func execRuntime(prompt, rigPath, configDir string) error {
|
|
runtimeConfig := config.LoadRuntimeConfig(rigPath)
|
|
args := runtimeConfig.BuildArgsWithPrompt(prompt)
|
|
if len(args) == 0 {
|
|
return fmt.Errorf("runtime command not configured")
|
|
}
|
|
|
|
binPath, err := exec.LookPath(args[0])
|
|
if err != nil {
|
|
return fmt.Errorf("runtime command not found: %w", err)
|
|
}
|
|
|
|
env := os.Environ()
|
|
if runtimeConfig.Session != nil && runtimeConfig.Session.ConfigDirEnv != "" && configDir != "" {
|
|
env = append(env, fmt.Sprintf("%s=%s", runtimeConfig.Session.ConfigDirEnv, configDir))
|
|
}
|
|
|
|
return syscall.Exec(binPath, args, env)
|
|
}
|
|
|
|
// 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.
|
|
// If already inside tmux, uses switch-client instead of attach-session.
|
|
func attachToTmuxSession(sessionID string) error {
|
|
tmuxPath, err := exec.LookPath("tmux")
|
|
if err != nil {
|
|
return fmt.Errorf("tmux not found: %w", err)
|
|
}
|
|
|
|
// Check if we're already inside a tmux session
|
|
var cmd *exec.Cmd
|
|
if os.Getenv("TMUX") != "" {
|
|
// Inside tmux: switch to the target session
|
|
cmd = exec.Command(tmuxPath, "switch-client", "-t", sessionID)
|
|
} else {
|
|
// Outside tmux: attach to the session
|
|
cmd = exec.Command(tmuxPath, "attach-session", "-t", sessionID)
|
|
}
|
|
cmd.Stdin = os.Stdin
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
return cmd.Run()
|
|
}
|
|
|
|
// ensureDefaultBranch checks if a git directory is on the default branch.
|
|
// If not, warns the user and offers to switch.
|
|
// Returns true if on default branch (or switched to it), false if user declined.
|
|
// The rigPath parameter is used to look up the configured default branch.
|
|
func ensureDefaultBranch(dir, roleName, rigPath string) bool { //nolint:unparam // bool return kept for future callers to check
|
|
g := git.NewGit(dir)
|
|
|
|
branch, err := g.CurrentBranch()
|
|
if err != nil {
|
|
// Not a git repo or other error, skip check
|
|
return true
|
|
}
|
|
|
|
// Get configured default branch for this rig
|
|
defaultBranch := "main" // fallback
|
|
if rigCfg, err := rig.LoadRigConfig(rigPath); err == nil && rigCfg.DefaultBranch != "" {
|
|
defaultBranch = rigCfg.DefaultBranch
|
|
}
|
|
|
|
if branch == defaultBranch || branch == "master" {
|
|
return true
|
|
}
|
|
|
|
// Warn about wrong branch
|
|
fmt.Printf("\n%s %s is on branch '%s', not %s\n",
|
|
style.Warning.Render("⚠"),
|
|
roleName,
|
|
branch,
|
|
defaultBranch)
|
|
fmt.Printf(" Persistent roles should work on %s to avoid orphaned work.\n", defaultBranch)
|
|
fmt.Println()
|
|
|
|
// Auto-switch to default branch
|
|
fmt.Printf(" Switching to %s...\n", defaultBranch)
|
|
if err := g.Checkout(defaultBranch); err != nil {
|
|
fmt.Printf(" %s Could not switch to %s: %v\n", style.Error.Render("✗"), defaultBranch, err)
|
|
fmt.Printf(" Please manually run: git checkout %s && git pull\n", defaultBranch)
|
|
return false
|
|
}
|
|
|
|
// Pull latest
|
|
if err := g.Pull("origin", defaultBranch); err != nil {
|
|
fmt.Printf(" %s Pull failed (continuing anyway): %v\n", style.Warning.Render("⚠"), err)
|
|
} else {
|
|
fmt.Printf(" %s Switched to %s and pulled latest\n", style.Success.Render("✓"), defaultBranch)
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// parseCrewSessionName extracts rig and crew name from a tmux session name.
|
|
// Format: gt-<rig>-crew-<name>
|
|
// Returns empty strings and false if the format doesn't match.
|
|
func parseCrewSessionName(sessionName string) (rigName, crewName string, ok bool) {
|
|
// Must start with "gt-" and contain "-crew-"
|
|
if !strings.HasPrefix(sessionName, "gt-") {
|
|
return "", "", false
|
|
}
|
|
|
|
// Remove "gt-" prefix
|
|
rest := sessionName[3:]
|
|
|
|
// Find "-crew-" separator
|
|
idx := strings.Index(rest, "-crew-")
|
|
if idx == -1 {
|
|
return "", "", false
|
|
}
|
|
|
|
rigName = rest[:idx]
|
|
crewName = rest[idx+6:] // len("-crew-") = 6
|
|
|
|
if rigName == "" || crewName == "" {
|
|
return "", "", false
|
|
}
|
|
|
|
return rigName, crewName, true
|
|
}
|
|
|
|
// findRigCrewSessions returns all crew sessions for a given rig, sorted alphabetically.
|
|
// Uses tmux list-sessions to find sessions matching gt-<rig>-crew-* pattern.
|
|
func findRigCrewSessions(rigName string) ([]string, error) { //nolint:unparam // error return kept for future use
|
|
cmd := exec.Command("tmux", "list-sessions", "-F", "#{session_name}")
|
|
out, err := cmd.Output()
|
|
if err != nil {
|
|
// No tmux server or no sessions
|
|
return nil, nil
|
|
}
|
|
|
|
prefix := fmt.Sprintf("gt-%s-crew-", rigName)
|
|
var sessions []string
|
|
|
|
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
|
if line == "" {
|
|
continue
|
|
}
|
|
if strings.HasPrefix(line, prefix) {
|
|
sessions = append(sessions, line)
|
|
}
|
|
}
|
|
|
|
// Sessions are already sorted by tmux, but sort explicitly for consistency
|
|
// (alphabetical by session name means alphabetical by crew name)
|
|
return sessions, nil
|
|
}
|