Add runtime configuration for LLM commands (gt-dc2fs)
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 <noreply@anthropic.com>
This commit is contained in:
committed by
Steve Yegge
parent
0da29050dd
commit
59ffb3cc58
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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").
|
||||
|
||||
Reference in New Issue
Block a user