From 59ffb3cc58cd1f448a1af95994f7a0fd94ac34d9 Mon Sep 17 00:00:00 2001 From: gastown/polecats/dementus Date: Tue, 30 Dec 2025 23:06:44 -0800 Subject: [PATCH] Add runtime configuration for LLM commands (gt-dc2fs) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add RuntimeConfig type to RigSettings allowing per-rig LLM runtime configuration. This moves hardcoded "claude --dangerously-skip-permissions" invocations to configurable settings. Changes: - Add RuntimeConfig type with Command, Args, InitialPrompt fields - Add BuildCommand() and BuildCommandWithPrompt() methods - Add helper functions: LoadRuntimeConfig, BuildAgentStartupCommand, BuildPolecatStartupCommand, BuildCrewStartupCommand - Update startup paths in up.go and mayor.go to use new config - Add comprehensive tests for RuntimeConfig functionality Remaining hardcoded invocations can be updated incrementally. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/cmd/mayor.go | 4 +- internal/cmd/up.go | 16 ++- internal/config/loader.go | 111 +++++++++++++++++++ internal/config/loader_test.go | 196 +++++++++++++++++++++++++++++++++ internal/config/types.go | 81 ++++++++++++++ 5 files changed, 401 insertions(+), 7 deletions(-) diff --git a/internal/cmd/mayor.go b/internal/cmd/mayor.go index 82557061..37374a89 100644 --- a/internal/cmd/mayor.go +++ b/internal/cmd/mayor.go @@ -6,6 +6,7 @@ import ( "time" "github.com/spf13/cobra" + "github.com/steveyegge/gastown/internal/config" "github.com/steveyegge/gastown/internal/constants" "github.com/steveyegge/gastown/internal/session" "github.com/steveyegge/gastown/internal/style" @@ -133,7 +134,8 @@ func startMayorSession(t *tmux.Tmux) error { // Launch Claude - the startup hook handles 'gt prime' automatically // Use SendKeysDelayed to allow shell initialization after NewSession // Export GT_ROLE and BD_ACTOR in the command since tmux SetEnvironment only affects new panes - claudeCmd := `export GT_ROLE=mayor BD_ACTOR=mayor GIT_AUTHOR_NAME=mayor && claude --dangerously-skip-permissions` + // Mayor uses default runtime config (empty rigPath) since it's not rig-specific + claudeCmd := config.BuildAgentStartupCommand("mayor", "mayor", "", "") if err := t.SendKeysDelayed(MayorSessionName, claudeCmd, 200); err != nil { return fmt.Errorf("sending command: %w", err) } diff --git a/internal/cmd/up.go b/internal/cmd/up.go index ea78aa49..5c06bde0 100644 --- a/internal/cmd/up.go +++ b/internal/cmd/up.go @@ -315,9 +315,9 @@ func ensureWitness(t *tmux.Tmux, sessionName, rigPath, rigName string) error { theme := tmux.AssignTheme(rigName) _ = t.ConfigureGasTownSession(sessionName, theme, "", "Witness", rigName) - // Launch Claude + // Launch Claude using runtime config // Export GT_ROLE and BD_ACTOR in the command since tmux SetEnvironment only affects new panes - claudeCmd := fmt.Sprintf(`export GT_ROLE=witness BD_ACTOR=%s GIT_AUTHOR_NAME=%s && claude --dangerously-skip-permissions`, bdActor, bdActor) + claudeCmd := config.BuildAgentStartupCommand("witness", bdActor, rigPath, "") if err := t.SendKeysDelayed(sessionName, claudeCmd, 200); err != nil { return err } @@ -538,8 +538,10 @@ func ensureCrewSession(t *tmux.Tmux, sessionName, crewPath, rigName, crewName st theme := tmux.AssignTheme(rigName) _ = t.ConfigureGasTownSession(sessionName, theme, "", "Crew", crewName) - // Launch Claude - claudeCmd := fmt.Sprintf(`export GT_ROLE=crew GT_RIG=%s GT_CREW=%s BD_ACTOR=%s GIT_AUTHOR_NAME=%s && claude --dangerously-skip-permissions`, rigName, crewName, bdActor, bdActor) + // Launch Claude using runtime config + // crewPath is like ~/gt/gastown/crew/max, so rig path is two dirs up + rigPath := filepath.Dir(filepath.Dir(crewPath)) + claudeCmd := config.BuildCrewStartupCommand(rigName, crewName, rigPath, "") if err := t.SendKeysDelayed(sessionName, claudeCmd, 200); err != nil { return err } @@ -637,8 +639,10 @@ func ensurePolecatSession(t *tmux.Tmux, sessionName, polecatPath, rigName, polec theme := tmux.AssignTheme(rigName) _ = t.ConfigureGasTownSession(sessionName, theme, "", "Polecat", polecatName) - // Launch Claude - claudeCmd := fmt.Sprintf(`export GT_ROLE=polecat GT_RIG=%s GT_POLECAT=%s BD_ACTOR=%s GIT_AUTHOR_NAME=%s && claude --dangerously-skip-permissions`, rigName, polecatName, bdActor, bdActor) + // Launch Claude using runtime config + // polecatPath is like ~/gt/gastown/polecats/toast, so rig path is two dirs up + rigPath := filepath.Dir(filepath.Dir(polecatPath)) + claudeCmd := config.BuildPolecatStartupCommand(rigName, polecatName, rigPath, "") if err := t.SendKeysDelayed(sessionName, claudeCmd, 200); err != nil { return err } diff --git a/internal/config/loader.go b/internal/config/loader.go index e3e43d02..74932fa0 100644 --- a/internal/config/loader.go +++ b/internal/config/loader.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "path/filepath" + "sort" "strings" "time" ) @@ -689,3 +690,113 @@ func LoadOrCreateMessagingConfig(path string) (*MessagingConfig, error) { } return config, nil } + +// LoadRuntimeConfig loads the RuntimeConfig from a rig's settings. +// Falls back to defaults if settings don't exist or don't specify runtime config. +// rigPath should be the path to the rig directory (e.g., ~/gt/gastown). +func LoadRuntimeConfig(rigPath string) *RuntimeConfig { + settingsPath := filepath.Join(rigPath, "settings", "config.json") + settings, err := LoadRigSettings(settingsPath) + if err != nil { + return DefaultRuntimeConfig() + } + if settings.Runtime == nil { + return DefaultRuntimeConfig() + } + // Fill in defaults for empty fields + rc := settings.Runtime + if rc.Command == "" { + rc.Command = "claude" + } + if rc.Args == nil { + rc.Args = []string{"--dangerously-skip-permissions"} + } + return rc +} + +// GetRuntimeCommand is a convenience function that returns the full command string +// for starting an LLM session. It loads the config and builds the command. +func GetRuntimeCommand(rigPath string) string { + return LoadRuntimeConfig(rigPath).BuildCommand() +} + +// GetRuntimeCommandWithPrompt returns the full command with an initial prompt. +func GetRuntimeCommandWithPrompt(rigPath, prompt string) string { + return LoadRuntimeConfig(rigPath).BuildCommandWithPrompt(prompt) +} + +// BuildStartupCommand builds a full startup command with environment exports. +// envVars is a map of environment variable names to values. +// rigPath is optional - if empty, uses defaults. +// prompt is optional - if provided, appended as the initial prompt. +func BuildStartupCommand(envVars map[string]string, rigPath, prompt string) string { + var rc *RuntimeConfig + if rigPath != "" { + rc = LoadRuntimeConfig(rigPath) + } else { + rc = DefaultRuntimeConfig() + } + + // Build environment export prefix + var exports []string + for k, v := range envVars { + exports = append(exports, fmt.Sprintf("%s=%s", k, v)) + } + + // Sort for deterministic output + sort.Strings(exports) + + var cmd string + if len(exports) > 0 { + cmd = "export " + strings.Join(exports, " ") + " && " + } + + // Add runtime command + if prompt != "" { + cmd += rc.BuildCommandWithPrompt(prompt) + } else { + cmd += rc.BuildCommand() + } + + return cmd +} + +// BuildAgentStartupCommand is a convenience function for starting agent sessions. +// It sets standard environment variables (GT_ROLE, BD_ACTOR, GIT_AUTHOR_NAME) +// and builds the full startup command. +func BuildAgentStartupCommand(role, bdActor, rigPath, prompt string) string { + envVars := map[string]string{ + "GT_ROLE": role, + "BD_ACTOR": bdActor, + "GIT_AUTHOR_NAME": bdActor, + } + return BuildStartupCommand(envVars, rigPath, prompt) +} + +// BuildPolecatStartupCommand builds the startup command for a polecat. +// Sets GT_ROLE, GT_RIG, GT_POLECAT, BD_ACTOR, and GIT_AUTHOR_NAME. +func BuildPolecatStartupCommand(rigName, polecatName, rigPath, prompt string) string { + bdActor := fmt.Sprintf("%s/polecats/%s", rigName, polecatName) + envVars := map[string]string{ + "GT_ROLE": "polecat", + "GT_RIG": rigName, + "GT_POLECAT": polecatName, + "BD_ACTOR": bdActor, + "GIT_AUTHOR_NAME": polecatName, + } + return BuildStartupCommand(envVars, rigPath, prompt) +} + +// BuildCrewStartupCommand builds the startup command for a crew member. +// Sets GT_ROLE, GT_RIG, GT_CREW, BD_ACTOR, and GIT_AUTHOR_NAME. +func BuildCrewStartupCommand(rigName, crewName, rigPath, prompt string) string { + bdActor := fmt.Sprintf("%s/crew/%s", rigName, crewName) + envVars := map[string]string{ + "GT_ROLE": "crew", + "GT_RIG": rigName, + "GT_CREW": crewName, + "BD_ACTOR": bdActor, + "GIT_AUTHOR_NAME": crewName, + } + return BuildStartupCommand(envVars, rigPath, prompt) +} diff --git a/internal/config/loader_test.go b/internal/config/loader_test.go index d74625c2..db559ccc 100644 --- a/internal/config/loader_test.go +++ b/internal/config/loader_test.go @@ -3,6 +3,7 @@ package config import ( "os" "path/filepath" + "strings" "testing" "time" ) @@ -802,3 +803,198 @@ func TestMessagingConfigPath(t *testing.T) { t.Errorf("MessagingConfigPath = %q, want %q", path, expected) } } + +func TestRuntimeConfigDefaults(t *testing.T) { + rc := DefaultRuntimeConfig() + if rc.Command != "claude" { + t.Errorf("Command = %q, want %q", rc.Command, "claude") + } + if len(rc.Args) != 1 || rc.Args[0] != "--dangerously-skip-permissions" { + t.Errorf("Args = %v, want [--dangerously-skip-permissions]", rc.Args) + } +} + +func TestRuntimeConfigBuildCommand(t *testing.T) { + tests := []struct { + name string + rc *RuntimeConfig + want string + }{ + { + name: "nil config uses defaults", + rc: nil, + want: "claude --dangerously-skip-permissions", + }, + { + name: "default config", + rc: DefaultRuntimeConfig(), + want: "claude --dangerously-skip-permissions", + }, + { + name: "custom command", + rc: &RuntimeConfig{Command: "aider", Args: []string{"--no-git"}}, + want: "aider --no-git", + }, + { + name: "multiple args", + rc: &RuntimeConfig{Command: "claude", Args: []string{"--model", "opus", "--no-confirm"}}, + want: "claude --model opus --no-confirm", + }, + { + name: "empty command uses default", + rc: &RuntimeConfig{Command: "", Args: nil}, + want: "claude --dangerously-skip-permissions", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.rc.BuildCommand() + if got != tt.want { + t.Errorf("BuildCommand() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestRuntimeConfigBuildCommandWithPrompt(t *testing.T) { + tests := []struct { + name string + rc *RuntimeConfig + prompt string + want string + }{ + { + name: "no prompt", + rc: DefaultRuntimeConfig(), + prompt: "", + want: "claude --dangerously-skip-permissions", + }, + { + name: "with prompt", + rc: DefaultRuntimeConfig(), + prompt: "gt prime", + want: `claude --dangerously-skip-permissions "gt prime"`, + }, + { + name: "prompt with quotes", + rc: DefaultRuntimeConfig(), + prompt: `Hello "world"`, + want: `claude --dangerously-skip-permissions "Hello \"world\""`, + }, + { + name: "config initial prompt used if no override", + rc: &RuntimeConfig{Command: "aider", Args: []string{}, InitialPrompt: "/help"}, + prompt: "", + want: `aider "/help"`, + }, + { + name: "override takes precedence over config", + rc: &RuntimeConfig{Command: "aider", Args: []string{}, InitialPrompt: "/help"}, + prompt: "custom prompt", + want: `aider "custom prompt"`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.rc.BuildCommandWithPrompt(tt.prompt) + if got != tt.want { + t.Errorf("BuildCommandWithPrompt(%q) = %q, want %q", tt.prompt, got, tt.want) + } + }) + } +} + +func TestBuildAgentStartupCommand(t *testing.T) { + // Test without rig config (uses defaults) + cmd := BuildAgentStartupCommand("witness", "gastown/witness", "", "") + + // Should contain environment exports and claude command + if !strings.Contains(cmd, "export") { + t.Error("expected export in command") + } + if !strings.Contains(cmd, "GT_ROLE=witness") { + t.Error("expected GT_ROLE=witness in command") + } + if !strings.Contains(cmd, "BD_ACTOR=gastown/witness") { + t.Error("expected BD_ACTOR in command") + } + if !strings.Contains(cmd, "claude --dangerously-skip-permissions") { + t.Error("expected claude command in output") + } +} + +func TestBuildPolecatStartupCommand(t *testing.T) { + cmd := BuildPolecatStartupCommand("gastown", "toast", "", "") + + if !strings.Contains(cmd, "GT_ROLE=polecat") { + t.Error("expected GT_ROLE=polecat in command") + } + if !strings.Contains(cmd, "GT_RIG=gastown") { + t.Error("expected GT_RIG=gastown in command") + } + if !strings.Contains(cmd, "GT_POLECAT=toast") { + t.Error("expected GT_POLECAT=toast in command") + } + if !strings.Contains(cmd, "BD_ACTOR=gastown/polecats/toast") { + t.Error("expected BD_ACTOR in command") + } +} + +func TestBuildCrewStartupCommand(t *testing.T) { + cmd := BuildCrewStartupCommand("gastown", "max", "", "") + + if !strings.Contains(cmd, "GT_ROLE=crew") { + t.Error("expected GT_ROLE=crew in command") + } + if !strings.Contains(cmd, "GT_RIG=gastown") { + t.Error("expected GT_RIG=gastown in command") + } + if !strings.Contains(cmd, "GT_CREW=max") { + t.Error("expected GT_CREW=max in command") + } + if !strings.Contains(cmd, "BD_ACTOR=gastown/crew/max") { + t.Error("expected BD_ACTOR in command") + } +} + +func TestLoadRuntimeConfigFromSettings(t *testing.T) { + // Create temp rig with custom runtime config + dir := t.TempDir() + settingsDir := filepath.Join(dir, "settings") + if err := os.MkdirAll(settingsDir, 0755); err != nil { + t.Fatalf("creating settings dir: %v", err) + } + + settings := NewRigSettings() + settings.Runtime = &RuntimeConfig{ + Command: "aider", + Args: []string{"--no-git", "--model", "claude-3"}, + } + if err := SaveRigSettings(filepath.Join(settingsDir, "config.json"), settings); err != nil { + t.Fatalf("saving settings: %v", err) + } + + // Load and verify + rc := LoadRuntimeConfig(dir) + if rc.Command != "aider" { + t.Errorf("Command = %q, want %q", rc.Command, "aider") + } + if len(rc.Args) != 3 { + t.Errorf("Args = %v, want 3 args", rc.Args) + } + + cmd := rc.BuildCommand() + if cmd != "aider --no-git --model claude-3" { + t.Errorf("BuildCommand() = %q, want %q", cmd, "aider --no-git --model claude-3") + } +} + +func TestLoadRuntimeConfigFallsBackToDefaults(t *testing.T) { + // Non-existent path should use defaults + rc := LoadRuntimeConfig("/nonexistent/path") + if rc.Command != "claude" { + t.Errorf("Command = %q, want %q (default)", rc.Command, "claude") + } +} diff --git a/internal/config/types.go b/internal/config/types.go index 66c05df2..f27de87d 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -3,6 +3,7 @@ package config import ( "os" + "strings" "time" ) @@ -103,6 +104,7 @@ type RigSettings struct { Theme *ThemeConfig `json:"theme,omitempty"` // tmux theme settings Namepool *NamepoolConfig `json:"namepool,omitempty"` // polecat name pool settings Crew *CrewConfig `json:"crew,omitempty"` // crew startup settings + Runtime *RuntimeConfig `json:"runtime,omitempty"` // LLM runtime settings } // CrewConfig represents crew workspace settings for a rig. @@ -119,6 +121,85 @@ type CrewConfig struct { Startup string `json:"startup,omitempty"` } +// RuntimeConfig represents LLM runtime configuration for agent sessions. +// This allows switching between different LLM backends (claude, aider, etc.) +// without modifying startup code. +type RuntimeConfig struct { + // Command is the CLI command to invoke (e.g., "claude", "aider"). + // Default: "claude" + Command string `json:"command,omitempty"` + + // Args are additional command-line arguments. + // Default: ["--dangerously-skip-permissions"] + Args []string `json:"args,omitempty"` + + // InitialPrompt is an optional first message to send after startup. + // For claude, this is passed as the prompt argument. + // Empty by default (hooks handle context). + InitialPrompt string `json:"initial_prompt,omitempty"` +} + +// DefaultRuntimeConfig returns a RuntimeConfig with sensible defaults. +func DefaultRuntimeConfig() *RuntimeConfig { + return &RuntimeConfig{ + Command: "claude", + Args: []string{"--dangerously-skip-permissions"}, + } +} + +// BuildCommand returns the full command line string. +// For use with tmux SendKeys. +func (rc *RuntimeConfig) BuildCommand() string { + if rc == nil { + return DefaultRuntimeConfig().BuildCommand() + } + + cmd := rc.Command + if cmd == "" { + cmd = "claude" + } + + // Build args + args := rc.Args + if args == nil { + args = []string{"--dangerously-skip-permissions"} + } + + // Combine command and args + if len(args) > 0 { + return cmd + " " + strings.Join(args, " ") + } + return cmd +} + +// BuildCommandWithPrompt returns the full command line with an initial prompt. +// If the config has an InitialPrompt, it's appended as a quoted argument. +// If prompt is provided, it overrides the config's InitialPrompt. +func (rc *RuntimeConfig) BuildCommandWithPrompt(prompt string) string { + base := rc.BuildCommand() + + // Use provided prompt or fall back to config + p := prompt + if p == "" && rc != nil { + p = rc.InitialPrompt + } + + if p == "" { + return base + } + + // Quote the prompt for shell safety + return base + " " + quoteForShell(p) +} + +// quoteForShell quotes a string for safe shell usage. +func quoteForShell(s string) string { + // Simple quoting: wrap in double quotes, escape internal quotes + escaped := strings.ReplaceAll(s, `\`, `\\`) + escaped = strings.ReplaceAll(escaped, `"`, `\"`) + return `"` + escaped + `"` +} + // ThemeConfig represents tmux theme settings for a rig. type ThemeConfig struct { // Name picks from the default palette (e.g., "ocean", "forest").