// Package config provides configuration loading and environment variable management. package config import ( "fmt" "os" "sort" "strings" ) // AgentEnvConfig specifies the configuration for generating agent environment variables. // This is the single source of truth for all agent environment configuration. type AgentEnvConfig struct { // Role is the agent role: mayor, deacon, witness, refinery, crew, polecat, boot Role string // Rig is the rig name (empty for town-level agents like mayor/deacon) Rig string // AgentName is the specific agent name (empty for singletons like witness/refinery) // For polecats, this is the polecat name. For crew, this is the crew member name. AgentName string // TownRoot is the root of the Gas Town workspace. // Sets GT_ROOT environment variable. TownRoot string // RuntimeConfigDir is the optional CLAUDE_CONFIG_DIR path RuntimeConfigDir string // SessionIDEnv is the environment variable name that holds the session ID. // Sets GT_SESSION_ID_ENV so the runtime knows where to find the session ID. SessionIDEnv string // BeadsNoDaemon sets BEADS_NO_DAEMON=1 if true // Used for polecats that should bypass the beads daemon BeadsNoDaemon bool } // AgentEnv returns all environment variables for an agent based on the config. // This is the single source of truth for agent environment variables. func AgentEnv(cfg AgentEnvConfig) map[string]string { env := make(map[string]string) env["GT_ROLE"] = cfg.Role // Set role-specific variables switch cfg.Role { case "mayor": env["BD_ACTOR"] = "mayor" env["GIT_AUTHOR_NAME"] = "mayor" env["GIT_AUTHOR_EMAIL"] = "mayor@gastown.local" case "deacon": env["BD_ACTOR"] = "deacon" env["GIT_AUTHOR_NAME"] = "deacon" env["GIT_AUTHOR_EMAIL"] = "deacon@gastown.local" case "boot": env["BD_ACTOR"] = "deacon-boot" env["GIT_AUTHOR_NAME"] = "boot" env["GIT_AUTHOR_EMAIL"] = "boot@gastown.local" case "dog": env["BD_ACTOR"] = fmt.Sprintf("deacon/dogs/%s", cfg.AgentName) env["GIT_AUTHOR_NAME"] = fmt.Sprintf("dog-%s", cfg.AgentName) env["GIT_AUTHOR_EMAIL"] = fmt.Sprintf("dog-%s@gastown.local", cfg.AgentName) case "witness": env["GT_RIG"] = cfg.Rig env["BD_ACTOR"] = fmt.Sprintf("%s/witness", cfg.Rig) env["GIT_AUTHOR_NAME"] = fmt.Sprintf("%s/witness", cfg.Rig) env["GIT_AUTHOR_EMAIL"] = fmt.Sprintf("%s-witness@gastown.local", cfg.Rig) case "refinery": env["GT_RIG"] = cfg.Rig env["BD_ACTOR"] = fmt.Sprintf("%s/refinery", cfg.Rig) env["GIT_AUTHOR_NAME"] = fmt.Sprintf("%s/refinery", cfg.Rig) env["GIT_AUTHOR_EMAIL"] = fmt.Sprintf("%s-refinery@gastown.local", cfg.Rig) case "polecat": env["GT_RIG"] = cfg.Rig env["GT_POLECAT"] = cfg.AgentName env["BD_ACTOR"] = fmt.Sprintf("%s/polecats/%s", cfg.Rig, cfg.AgentName) env["GIT_AUTHOR_NAME"] = cfg.AgentName env["GIT_AUTHOR_EMAIL"] = fmt.Sprintf("%s-polecat-%s@gastown.local", cfg.Rig, cfg.AgentName) case "crew": env["GT_RIG"] = cfg.Rig env["GT_CREW"] = cfg.AgentName env["BD_ACTOR"] = fmt.Sprintf("%s/crew/%s", cfg.Rig, cfg.AgentName) env["GIT_AUTHOR_NAME"] = cfg.AgentName env["GIT_AUTHOR_EMAIL"] = fmt.Sprintf("%s-crew-%s@gastown.local", cfg.Rig, cfg.AgentName) } // Only set GT_ROOT if provided // Empty values would override tmux session environment if cfg.TownRoot != "" { env["GT_ROOT"] = cfg.TownRoot } // Set BEADS_AGENT_NAME for polecat/crew (uses same format as BD_ACTOR) if cfg.Role == "polecat" || cfg.Role == "crew" { env["BEADS_AGENT_NAME"] = fmt.Sprintf("%s/%s", cfg.Rig, cfg.AgentName) } if cfg.BeadsNoDaemon { env["BEADS_NO_DAEMON"] = "1" } // Add optional runtime config directory if cfg.RuntimeConfigDir != "" { env["CLAUDE_CONFIG_DIR"] = cfg.RuntimeConfigDir } // Add session ID env var name if provided if cfg.SessionIDEnv != "" { env["GT_SESSION_ID_ENV"] = cfg.SessionIDEnv } return env } // AgentEnvSimple is a convenience function for simple role-based env var lookup. // Use this when you only need role, rig, and agentName without advanced options. func AgentEnvSimple(role, rig, agentName string) map[string]string { return AgentEnv(AgentEnvConfig{ Role: role, Rig: rig, AgentName: agentName, }) } // ShellQuote returns a shell-safe quoted string. // Values containing special characters are wrapped in single quotes. // Single quotes within the value are escaped using the '\” idiom. func ShellQuote(s string) string { // Check if quoting is needed (contains shell special chars) needsQuoting := false for _, c := range s { switch c { case ' ', '\t', '\n', '"', '\'', '`', '$', '\\', '!', '*', '?', '[', ']', '{', '}', '(', ')', '<', '>', '|', '&', ';', '#': needsQuoting = true } if needsQuoting { break } } if !needsQuoting { return s } // Use single quotes, escaping any embedded single quotes // 'foo'\''bar' means: 'foo' + escaped-single-quote + 'bar' return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'" } // ExportPrefix builds an export statement prefix for shell commands. // Returns a string like "export GT_ROLE=mayor BD_ACTOR=mayor && " // The keys are sorted for deterministic output. // Values containing special characters are properly shell-quoted. func ExportPrefix(env map[string]string) string { if len(env) == 0 { return "" } // Sort keys for deterministic output keys := make([]string, 0, len(env)) for k := range env { keys = append(keys, k) } sort.Strings(keys) var parts []string for _, k := range keys { parts = append(parts, fmt.Sprintf("%s=%s", k, ShellQuote(env[k]))) } return "export " + strings.Join(parts, " ") + " && " } // BuildStartupCommandWithEnv builds a startup command with the given environment variables. // This combines the export prefix with the agent command and optional prompt. func BuildStartupCommandWithEnv(env map[string]string, agentCmd, prompt string) string { prefix := ExportPrefix(env) if prompt != "" { // Include prompt as argument to agent command return fmt.Sprintf("%s%s %q", prefix, agentCmd, prompt) } return prefix + agentCmd } // MergeEnv merges multiple environment maps, with later maps taking precedence. func MergeEnv(maps ...map[string]string) map[string]string { result := make(map[string]string) for _, m := range maps { for k, v := range m { result[k] = v } } return result } // FilterEnv returns a new map with only the specified keys. func FilterEnv(env map[string]string, keys ...string) map[string]string { result := make(map[string]string) for _, k := range keys { if v, ok := env[k]; ok { result[k] = v } } return result } // WithoutEnv returns a new map without the specified keys. func WithoutEnv(env map[string]string, keys ...string) map[string]string { result := make(map[string]string) exclude := make(map[string]bool) for _, k := range keys { exclude[k] = true } for k, v := range env { if !exclude[k] { result[k] = v } } return result } // EnvForExecCommand returns os.Environ() with the given env vars appended. // This is useful for setting cmd.Env on exec.Command. func EnvForExecCommand(env map[string]string) []string { result := os.Environ() for k, v := range env { result = append(result, k+"="+v) } return result } // EnvToSlice converts an env map to a slice of "K=V" strings. // Useful for appending to os.Environ() manually. func EnvToSlice(env map[string]string) []string { result := make([]string, 0, len(env)) for k, v := range env { result = append(result, k+"="+v) } return result }