package cmd import ( "fmt" "os" "path/filepath" "strings" "github.com/spf13/cobra" "github.com/steveyegge/gastown/internal/style" "github.com/steveyegge/gastown/internal/workspace" ) // Environment variables for role detection const ( EnvGTRole = "GT_ROLE" EnvGTRoleHome = "GT_ROLE_HOME" ) // RoleInfo contains information about a role and its detection source. // This is the canonical struct for role detection - used by both GetRole() // and detectRole() functions. type RoleInfo struct { Role Role `json:"role"` Source string `json:"source"` // "env", "cwd", or "explicit" Home string `json:"home"` Rig string `json:"rig,omitempty"` Polecat string `json:"polecat,omitempty"` EnvRole string `json:"env_role,omitempty"` // Value of GT_ROLE if set CwdRole Role `json:"cwd_role,omitempty"` // Role detected from cwd Mismatch bool `json:"mismatch,omitempty"` // True if env != cwd detection TownRoot string `json:"town_root,omitempty"` WorkDir string `json:"work_dir,omitempty"` // Current working directory } var roleCmd = &cobra.Command{ Use: "role", GroupID: GroupAgents, Short: "Show or manage agent role", Long: `Display the current agent role and its detection source. Role is determined by: 1. GT_ROLE environment variable (authoritative if set) 2. Current working directory (fallback) If both are available and disagree, a warning is shown.`, RunE: runRoleShow, } var roleShowCmd = &cobra.Command{ Use: "show", Short: "Show current role", RunE: runRoleShow, } var roleHomeCmd = &cobra.Command{ Use: "home [ROLE]", Short: "Show home directory for a role", Long: `Show the canonical home directory for a role. If no role is specified, shows the home for the current role. Examples: gt role home # Home for current role gt role home mayor # Home for mayor gt role home witness # Home for witness (requires --rig)`, Args: cobra.MaximumNArgs(1), RunE: runRoleHome, } var roleDetectCmd = &cobra.Command{ Use: "detect", Short: "Force cwd-based role detection (debugging)", Long: `Detect role from current working directory, ignoring GT_ROLE env var. This is useful for debugging role detection issues.`, RunE: runRoleDetect, } var roleListCmd = &cobra.Command{ Use: "list", Short: "List all known roles", RunE: runRoleList, } var roleEnvCmd = &cobra.Command{ Use: "env", Short: "Print export statements for current role", Long: `Print shell export statements to set GT_ROLE and GT_ROLE_HOME. Usage: eval $(gt role env) # Set role env vars in current shell`, RunE: runRoleEnv, } // Flags var ( roleRig string rolePolecat string ) func init() { rootCmd.AddCommand(roleCmd) roleCmd.AddCommand(roleShowCmd) roleCmd.AddCommand(roleHomeCmd) roleCmd.AddCommand(roleDetectCmd) roleCmd.AddCommand(roleListCmd) roleCmd.AddCommand(roleEnvCmd) // Add --rig flag to home command for witness/refinery/polecat roleHomeCmd.Flags().StringVar(&roleRig, "rig", "", "Rig name (required for rig-specific roles)") roleHomeCmd.Flags().StringVar(&rolePolecat, "polecat", "", "Polecat/crew member name") } // GetRole returns the current role, checking GT_ROLE first then falling back to cwd. // This is the canonical function for role detection. func GetRole() (RoleInfo, error) { cwd, err := os.Getwd() if err != nil { return RoleInfo{}, fmt.Errorf("getting current directory: %w", err) } townRoot, err := workspace.FindFromCwd() if err != nil { return RoleInfo{}, fmt.Errorf("finding workspace: %w", err) } if townRoot == "" { return RoleInfo{}, fmt.Errorf("not in a Gas Town workspace") } return GetRoleWithContext(cwd, townRoot) } // GetRoleWithContext returns role info given explicit cwd and town root. func GetRoleWithContext(cwd, townRoot string) (RoleInfo, error) { info := RoleInfo{ TownRoot: townRoot, WorkDir: cwd, } // Check environment variable first envRole := os.Getenv(EnvGTRole) info.EnvRole = envRole // Always detect from cwd for comparison/fallback cwdCtx := detectRole(cwd, townRoot) info.CwdRole = cwdCtx.Role // Determine authoritative role if envRole != "" { // Parse env role - it might be simple ("mayor") or compound ("gastown/witness") parsedRole, rig, polecat := parseRoleString(envRole) info.Role = parsedRole info.Rig = rig info.Polecat = polecat info.Source = "env" // Check for mismatch with cwd detection if cwdCtx.Role != RoleUnknown && cwdCtx.Role != parsedRole { info.Mismatch = true } } else { // Fall back to cwd detection - copy all fields from cwdCtx info.Role = cwdCtx.Role info.Rig = cwdCtx.Rig info.Polecat = cwdCtx.Polecat info.Source = "cwd" } // Determine home directory info.Home = getRoleHome(info.Role, info.Rig, info.Polecat, townRoot) return info, nil } // parseRoleString parses a role string like "mayor", "gastown/witness", or "gastown/polecats/alpha". func parseRoleString(s string) (Role, string, string) { s = strings.TrimSpace(s) // Simple roles switch s { case "mayor": return RoleMayor, "", "" case "deacon": return RoleDeacon, "", "" } // Compound roles: rig/role or rig/polecats/name or rig/crew/name parts := strings.Split(s, "/") if len(parts) < 2 { // Unknown format, try to match as simple role return Role(s), "", "" } rig := parts[0] switch parts[1] { case "witness": return RoleWitness, rig, "" case "refinery": return RoleRefinery, rig, "" case "polecats": if len(parts) >= 3 { return RolePolecat, rig, parts[2] } return RolePolecat, rig, "" case "crew": if len(parts) >= 3 { return RoleCrew, rig, parts[2] } return RoleCrew, rig, "" default: // Might be rig/polecatName format return RolePolecat, rig, parts[1] } } // getRoleHome returns the canonical home directory for a role. func getRoleHome(role Role, rig, polecat, townRoot string) string { switch role { case RoleMayor: return townRoot case RoleDeacon: return filepath.Join(townRoot, "deacon") case RoleWitness: if rig == "" { return "" } return filepath.Join(townRoot, rig, "witness", "rig") case RoleRefinery: if rig == "" { return "" } return filepath.Join(townRoot, rig, "refinery", "rig") case RolePolecat: if rig == "" || polecat == "" { return "" } return filepath.Join(townRoot, rig, "polecats", polecat) case RoleCrew: if rig == "" || polecat == "" { return "" } return filepath.Join(townRoot, rig, "crew", polecat) default: return "" } } func runRoleShow(cmd *cobra.Command, args []string) error { info, err := GetRole() if err != nil { return err } // Header fmt.Printf("%s\n", style.Bold.Render(string(info.Role))) fmt.Printf("Source: %s\n", info.Source) if info.Home != "" { fmt.Printf("Home: %s\n", info.Home) } if info.Rig != "" { fmt.Printf("Rig: %s\n", info.Rig) } if info.Polecat != "" { fmt.Printf("Worker: %s\n", info.Polecat) } // Show mismatch warning if info.Mismatch { fmt.Println() fmt.Printf("%s\n", style.Bold.Render("⚠️ ROLE MISMATCH")) fmt.Printf(" GT_ROLE=%s (authoritative)\n", info.EnvRole) fmt.Printf(" cwd suggests: %s\n", info.CwdRole) fmt.Println() fmt.Println("The GT_ROLE env var takes precedence, but you may be in the wrong directory.") fmt.Printf("Expected home: %s\n", info.Home) } return nil } func runRoleHome(cmd *cobra.Command, args []string) error { townRoot, err := workspace.FindFromCwd() if err != nil { return fmt.Errorf("finding workspace: %w", err) } if townRoot == "" { return fmt.Errorf("not in a Gas Town workspace") } var role Role var rig, polecat string if len(args) > 0 { // Explicit role provided role, rig, polecat = parseRoleString(args[0]) // Override with flags if provided if roleRig != "" { rig = roleRig } if rolePolecat != "" { polecat = rolePolecat } } else { // Use current role info, err := GetRole() if err != nil { return err } role = info.Role rig = info.Rig polecat = info.Polecat } home := getRoleHome(role, rig, polecat, townRoot) if home == "" { return fmt.Errorf("cannot determine home for role %s (rig=%q, polecat=%q)", role, rig, polecat) } fmt.Println(home) return nil } func runRoleDetect(cmd *cobra.Command, args []string) error { cwd, err := os.Getwd() if err != nil { return fmt.Errorf("getting current directory: %w", err) } townRoot, err := workspace.FindFromCwd() if err != nil { return fmt.Errorf("finding workspace: %w", err) } if townRoot == "" { return fmt.Errorf("not in a Gas Town workspace") } ctx := detectRole(cwd, townRoot) fmt.Printf("%s (from cwd)\n", style.Bold.Render(string(ctx.Role))) fmt.Printf("Directory: %s\n", cwd) if ctx.Rig != "" { fmt.Printf("Rig: %s\n", ctx.Rig) } if ctx.Polecat != "" { fmt.Printf("Worker: %s\n", ctx.Polecat) } // Check if env var disagrees envRole := os.Getenv(EnvGTRole) if envRole != "" { parsedRole, _, _ := parseRoleString(envRole) if parsedRole != ctx.Role { fmt.Println() fmt.Printf("%s\n", style.Bold.Render("⚠️ Mismatch with $GT_ROLE")) fmt.Printf(" $GT_ROLE=%s\n", envRole) fmt.Println(" The env var takes precedence in normal operation.") } } return nil } func runRoleList(cmd *cobra.Command, args []string) error { roles := []struct { name Role desc string }{ {RoleMayor, "Global coordinator at town root"}, {RoleDeacon, "Background supervisor daemon"}, {RoleWitness, "Per-rig polecat lifecycle manager"}, {RoleRefinery, "Per-rig merge queue processor"}, {RolePolecat, "Ephemeral worker with own worktree"}, {RoleCrew, "Persistent worker with own worktree"}, } fmt.Println("Available roles:") fmt.Println() for _, r := range roles { fmt.Printf(" %-10s %s\n", style.Bold.Render(string(r.name)), r.desc) } return nil } func runRoleEnv(cmd *cobra.Command, args []string) error { info, err := GetRole() if err != nil { return err } // Build the role string for GT_ROLE var roleStr string switch info.Role { case RoleMayor: roleStr = "mayor" case RoleDeacon: roleStr = "deacon" case RoleWitness: roleStr = fmt.Sprintf("%s/witness", info.Rig) case RoleRefinery: roleStr = fmt.Sprintf("%s/refinery", info.Rig) case RolePolecat: roleStr = fmt.Sprintf("%s/polecats/%s", info.Rig, info.Polecat) case RoleCrew: roleStr = fmt.Sprintf("%s/crew/%s", info.Rig, info.Polecat) default: roleStr = string(info.Role) } fmt.Printf("export %s=%s\n", EnvGTRole, roleStr) if info.Home != "" { fmt.Printf("export %s=%s\n", EnvGTRoleHome, info.Home) } return nil }