Files
gastown/internal/config/env.go
Julian Knutsen 3caf32f9f7 fix(config): don't export empty GT_ROOT/BEADS_DIR in AgentEnv (#385)
* fix(config): don't export empty GT_ROOT/BEADS_DIR in AgentEnv

Fix polecats not having GT_ROOT environment variable set. The symptom was
polecat sessions showing GT_ROOT="" instead of the expected town root.

Root cause: AgentEnvSimple doesn't set TownRoot, but AgentEnv was always
setting env["GT_ROOT"] = cfg.TownRoot even when empty. This empty value
in export commands would override the tmux session environment.

Changes:
- Only set GT_ROOT and BEADS_DIR in env map if non-empty
- Refactor daemon.go to use AgentEnv with full AgentEnvConfig instead
  of AgentEnvSimple + manual additions
- Update test to verify keys are absent rather than empty

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(lint): silence unparam for unused executeExternalActions args

The external action params (beadID, severity, description) are reserved
for future email/SMS/slack implementations but currently unused.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: julianknutsen <julianknutsen@users.noreply.github>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: max <steve.yegge@gmail.com>
2026-01-12 02:45:03 -08:00

211 lines
5.7 KiB
Go

// 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
// BeadsDir is the resolved BEADS_DIR path.
// Callers should use beads.ResolveBeadsDir() to compute this.
BeadsDir string
// RuntimeConfigDir is the optional CLAUDE_CONFIG_DIR path
RuntimeConfigDir 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"
case "deacon":
env["BD_ACTOR"] = "deacon"
env["GIT_AUTHOR_NAME"] = "deacon"
case "boot":
env["BD_ACTOR"] = "deacon-boot"
env["GIT_AUTHOR_NAME"] = "boot"
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)
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)
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
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
}
// Only set GT_ROOT and BEADS_DIR if provided
// Empty values would override tmux session environment
if cfg.TownRoot != "" {
env["GT_ROOT"] = cfg.TownRoot
}
if cfg.BeadsDir != "" {
env["BEADS_DIR"] = cfg.BeadsDir
}
// 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
}
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,
})
}
// 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.
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, 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
}