Merge pull request #129 from dlukt/main

feat: Add gt config command for managing agent settings
This commit is contained in:
Steve Yegge
2026-01-04 23:53:43 -08:00
committed by GitHub
6 changed files with 1294 additions and 4 deletions

View File

@@ -235,6 +235,22 @@ gt <role> attach # Jump into any agent session
# e.g., gt mayor attach, gt witness attach
```
### Configuration
```bash
gt config agent list [--json] # List all agents (built-in + custom)
gt config agent get <name> # Show agent configuration
gt config agent set <name> <cmd> # Create or update custom agent
gt config agent remove <name> # Remove custom agent (built-ins protected)
gt config default-agent [name] # Get or set town default agent
```
**Example**: Use a cheaper model for most work:
```bash
gt config agent set claude-glm "claude-glm --model glm-4"
gt config default-agent claude-glm
```
Most other work happens through agents - just ask them.
### For Agents

View File

@@ -215,6 +215,28 @@ gt doctor # Health check
gt doctor --fix # Auto-repair
```
### Configuration
```bash
# Agent management
gt config agent list [--json] # List all agents (built-in + custom)
gt config agent get <name> # Show agent configuration
gt config agent set <name> <cmd> # Create or update custom agent
gt config agent remove <name> # Remove custom agent (built-ins protected)
# Default agent
gt config default-agent [name] # Get or set town default agent
```
**Built-in agents**: `claude`, `gemini`, `codex`
**Custom agents**: Define per-town in `mayor/town.json`:
```bash
gt config agent set claude-glm "claude-glm --model glm-4"
gt config agent set claude "claude-opus" # Override built-in
gt config default-agent claude-glm # Set default
```
### Rig Management
```bash

468
internal/cmd/config.go Normal file
View File

@@ -0,0 +1,468 @@
// Package cmd provides CLI commands for the gt tool.
package cmd
import (
"encoding/json"
"fmt"
"os"
"sort"
"strings"
"github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/config"
"github.com/steveyegge/gastown/internal/style"
"github.com/steveyegge/gastown/internal/workspace"
)
var configCmd = &cobra.Command{
Use: "config",
GroupID: GroupConfig,
Short: "Manage Gas Town configuration",
RunE: requireSubcommand,
Long: `Manage Gas Town configuration settings.
This command allows you to view and modify configuration settings
for your Gas Town workspace, including agent aliases and defaults.
Commands:
gt config agent list List all agents (built-in and custom)
gt config agent get <name> Show agent configuration
gt config agent set <name> <cmd> Set custom agent command
gt config agent remove <name> Remove custom agent
gt config default-agent [name] Get or set default agent`,
}
// Agent subcommands
var configAgentListCmd = &cobra.Command{
Use: "list",
Short: "List all agents",
Long: `List all available agents (built-in and custom).
Shows all built-in agent presets (claude, gemini, codex) and any
custom agents defined in your town settings.
Examples:
gt config agent list # Text output
gt config agent list --json # JSON output`,
RunE: runConfigAgentList,
}
var configAgentGetCmd = &cobra.Command{
Use: "get <name>",
Short: "Show agent configuration",
Long: `Show the configuration for a specific agent.
Displays the full configuration for an agent, including command,
arguments, and other settings. Works for both built-in and custom agents.
Examples:
gt config agent get claude
gt config agent get my-custom-agent`,
Args: cobra.ExactArgs(1),
RunE: runConfigAgentGet,
}
var configAgentSetCmd = &cobra.Command{
Use: "set <name> <command>",
Short: "Set custom agent command",
Long: `Set a custom agent command in town settings.
This creates or updates a custom agent definition that overrides
or extends the built-in presets. The custom agent will be available
to all rigs in the town.
The command can include arguments. Use quotes if the command or
arguments contain spaces.
Examples:
gt config agent set claude-glm \"claude-glm --model glm-4\"
gt config agent set gemini-custom gemini --approval-mode yolo
gt config agent set claude \"claude-glm\" # Override built-in claude`,
Args: cobra.ExactArgs(2),
RunE: runConfigAgentSet,
}
var configAgentRemoveCmd = &cobra.Command{
Use: "remove <name>",
Short: "Remove custom agent",
Long: `Remove a custom agent definition from town settings.
This removes a custom agent from your town settings. Built-in agents
(claude, gemini, codex) cannot be removed.
Examples:
gt config agent remove claude-glm`,
Args: cobra.ExactArgs(1),
RunE: runConfigAgentRemove,
}
// Default-agent subcommand
var configDefaultAgentCmd = &cobra.Command{
Use: "default-agent [name]",
Short: "Get or set default agent",
Long: `Get or set the default agent for the town.
With no arguments, shows the current default agent.
With an argument, sets the default agent to the specified name.
The default agent is used when a rig doesn't specify its own agent
setting. Can be a built-in preset (claude, gemini, codex) or a
custom agent name.
Examples:
gt config default-agent # Show current default
gt config default-agent claude # Set to claude
gt config default-agent gemini # Set to gemini
gt config default-agent my-custom # Set to custom agent`,
RunE: runConfigDefaultAgent,
}
// Flags
var (
configAgentListJSON bool
)
// AgentListItem represents an agent in list output.
type AgentListItem struct {
Name string `json:"name"`
Command string `json:"command"`
Args string `json:"args,omitempty"`
Type string `json:"type"` // "built-in" or "custom"
IsCustom bool `json:"is_custom"`
}
func runConfigAgentList(cmd *cobra.Command, args []string) error {
townRoot, err := workspace.FindFromCwd()
if err != nil {
return fmt.Errorf("finding town root: %w", err)
}
// Load town settings
settingsPath := config.TownSettingsPath(townRoot)
townSettings, err := config.LoadOrCreateTownSettings(settingsPath)
if err != nil {
return fmt.Errorf("loading town settings: %w", err)
}
// Load agent registry
registryPath := config.DefaultAgentRegistryPath(townRoot)
if err := config.LoadAgentRegistry(registryPath); err != nil {
return fmt.Errorf("loading agent registry: %w", err)
}
// Collect all agents
builtInAgents := []string{"claude", "gemini", "codex"}
customAgents := make(map[string]*config.RuntimeConfig)
if townSettings.Agents != nil {
for name, runtime := range townSettings.Agents {
customAgents[name] = runtime
}
}
// Build list items
var items []AgentListItem
for _, name := range builtInAgents {
preset := config.GetAgentPresetByName(name)
if preset != nil {
items = append(items, AgentListItem{
Name: name,
Command: preset.Command,
Args: strings.Join(preset.Args, " "),
Type: "built-in",
IsCustom: false,
})
}
}
for name, runtime := range customAgents {
argsStr := ""
if runtime.Args != nil {
argsStr = strings.Join(runtime.Args, " ")
}
items = append(items, AgentListItem{
Name: name,
Command: runtime.Command,
Args: argsStr,
Type: "custom",
IsCustom: true,
})
}
// Sort by name
sort.Slice(items, func(i, j int) bool {
return items[i].Name < items[j].Name
})
if configAgentListJSON {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(items)
}
// Text output
fmt.Printf("%s\n\n", style.Bold.Render("Available Agents"))
for _, item := range items {
typeLabel := style.Dim.Render("[" + item.Type + "]")
fmt.Printf(" %s %s %s", style.Bold.Render(item.Name), typeLabel, item.Command)
if item.Args != "" {
fmt.Printf(" %s", item.Args)
}
fmt.Println()
}
// Show default
defaultAgent := townSettings.DefaultAgent
if defaultAgent == "" {
defaultAgent = "claude"
}
fmt.Printf("\nDefault: %s\n", style.Bold.Render(defaultAgent))
return nil
}
func runConfigAgentGet(cmd *cobra.Command, args []string) error {
name := args[0]
townRoot, err := workspace.FindFromCwd()
if err != nil {
return fmt.Errorf("finding town root: %w", err)
}
// Load town settings for custom agents
settingsPath := config.TownSettingsPath(townRoot)
townSettings, err := config.LoadOrCreateTownSettings(settingsPath)
if err != nil {
return fmt.Errorf("loading town settings: %w", err)
}
// Load agent registry
registryPath := config.DefaultAgentRegistryPath(townRoot)
if err := config.LoadAgentRegistry(registryPath); err != nil {
return fmt.Errorf("loading agent registry: %w", err)
}
// Check custom agents first
if townSettings.Agents != nil {
if runtime, ok := townSettings.Agents[name]; ok {
displayAgentConfig(name, runtime, nil, true)
return nil
}
}
// Check built-in agents
preset := config.GetAgentPresetByName(name)
if preset != nil {
runtime := &config.RuntimeConfig{
Command: preset.Command,
Args: preset.Args,
}
displayAgentConfig(name, runtime, preset, false)
return nil
}
return fmt.Errorf("agent '%s' not found", name)
}
func displayAgentConfig(name string, runtime *config.RuntimeConfig, preset *config.AgentPresetInfo, isCustom bool) {
fmt.Printf("%s\n\n", style.Bold.Render("Agent: "+name))
typeLabel := "custom"
if !isCustom {
typeLabel = "built-in"
}
fmt.Printf("Type: %s\n", typeLabel)
fmt.Printf("Command: %s\n", runtime.Command)
if runtime.Args != nil && len(runtime.Args) > 0 {
fmt.Printf("Args: %s\n", strings.Join(runtime.Args, " "))
}
if preset != nil {
if preset.SessionIDEnv != "" {
fmt.Printf("Session ID Env: %s\n", preset.SessionIDEnv)
}
if preset.ResumeFlag != "" {
fmt.Printf("Resume Style: %s (%s)\n", preset.ResumeStyle, preset.ResumeFlag)
}
fmt.Printf("Supports Hooks: %v\n", preset.SupportsHooks)
}
}
func runConfigAgentSet(cmd *cobra.Command, args []string) error {
name := args[0]
commandLine := args[1]
townRoot, err := workspace.FindFromCwd()
if err != nil {
return fmt.Errorf("finding town root: %w", err)
}
// Load town settings
settingsPath := config.TownSettingsPath(townRoot)
townSettings, err := config.LoadOrCreateTownSettings(settingsPath)
if err != nil {
return fmt.Errorf("loading town settings: %w", err)
}
// Parse command line into command and args
parts := strings.Fields(commandLine)
if len(parts) == 0 {
return fmt.Errorf("command cannot be empty")
}
// Initialize agents map if needed
if townSettings.Agents == nil {
townSettings.Agents = make(map[string]*config.RuntimeConfig)
}
// Create or update the agent
townSettings.Agents[name] = &config.RuntimeConfig{
Command: parts[0],
Args: parts[1:],
}
// Save settings
if err := config.SaveTownSettings(settingsPath, townSettings); err != nil {
return fmt.Errorf("saving town settings: %w", err)
}
fmt.Printf("Agent '%s' set to: %s\n", style.Bold.Render(name), commandLine)
// Check if this overrides a built-in
builtInAgents := []string{"claude", "gemini", "codex"}
for _, builtin := range builtInAgents {
if name == builtin {
fmt.Printf("\n%s\n", style.Dim.Render("(overriding built-in '"+builtin+"' preset)"))
break
}
}
return nil
}
func runConfigAgentRemove(cmd *cobra.Command, args []string) error {
name := args[0]
townRoot, err := workspace.FindFromCwd()
if err != nil {
return fmt.Errorf("finding town root: %w", err)
}
// Check if trying to remove built-in
builtInAgents := []string{"claude", "gemini", "codex"}
for _, builtin := range builtInAgents {
if name == builtin {
return fmt.Errorf("cannot remove built-in agent '%s' (use 'gt config agent set' to override it)", name)
}
}
// Load town settings
settingsPath := config.TownSettingsPath(townRoot)
townSettings, err := config.LoadOrCreateTownSettings(settingsPath)
if err != nil {
return fmt.Errorf("loading town settings: %w", err)
}
// Check if agent exists
if townSettings.Agents == nil || townSettings.Agents[name] == nil {
return fmt.Errorf("custom agent '%s' not found", name)
}
// Remove the agent
delete(townSettings.Agents, name)
// Save settings
if err := config.SaveTownSettings(settingsPath, townSettings); err != nil {
return fmt.Errorf("saving town settings: %w", err)
}
fmt.Printf("Removed custom agent '%s'\n", style.Bold.Render(name))
return nil
}
func runConfigDefaultAgent(cmd *cobra.Command, args []string) error {
townRoot, err := workspace.FindFromCwd()
if err != nil {
return fmt.Errorf("finding town root: %w", err)
}
// Load town settings
settingsPath := config.TownSettingsPath(townRoot)
townSettings, err := config.LoadOrCreateTownSettings(settingsPath)
if err != nil {
return fmt.Errorf("loading town settings: %w", err)
}
// Load agent registry
registryPath := config.DefaultAgentRegistryPath(townRoot)
if err := config.LoadAgentRegistry(registryPath); err != nil {
return fmt.Errorf("loading agent registry: %w", err)
}
if len(args) == 0 {
// Show current default
defaultAgent := townSettings.DefaultAgent
if defaultAgent == "" {
defaultAgent = "claude"
}
fmt.Printf("Default agent: %s\n", style.Bold.Render(defaultAgent))
return nil
}
// Set new default
name := args[0]
// Verify agent exists
isValid := false
builtInAgents := []string{"claude", "gemini", "codex"}
for _, builtin := range builtInAgents {
if name == builtin {
isValid = true
break
}
}
if !isValid && townSettings.Agents != nil {
if _, ok := townSettings.Agents[name]; ok {
isValid = true
}
}
if !isValid {
return fmt.Errorf("agent '%s' not found (use 'gt config agent list' to see available agents)", name)
}
// Set default
townSettings.DefaultAgent = name
// Save settings
if err := config.SaveTownSettings(settingsPath, townSettings); err != nil {
return fmt.Errorf("saving town settings: %w", err)
}
fmt.Printf("Default agent set to '%s'\n", style.Bold.Render(name))
return nil
}
func init() {
// Add flags
configAgentListCmd.Flags().BoolVar(&configAgentListJSON, "json", false, "Output as JSON")
// Add agent subcommands
configAgentCmd := &cobra.Command{
Use: "agent",
Short: "Manage agent configuration",
RunE: requireSubcommand,
}
configAgentCmd.AddCommand(configAgentListCmd)
configAgentCmd.AddCommand(configAgentGetCmd)
configAgentCmd.AddCommand(configAgentSetCmd)
configAgentCmd.AddCommand(configAgentRemoveCmd)
// Add subcommands to config
configCmd.AddCommand(configAgentCmd)
configCmd.AddCommand(configDefaultAgentCmd)
// Register with root
rootCmd.AddCommand(configCmd)
}

583
internal/cmd/config_test.go Normal file
View File

@@ -0,0 +1,583 @@
package cmd
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/config"
)
// setupTestTown creates a minimal Gas Town workspace for testing.
func setupTestTownForConfig(t *testing.T) string {
t.Helper()
townRoot := t.TempDir()
// Create mayor directory with required files
mayorDir := filepath.Join(townRoot, "mayor")
if err := os.MkdirAll(mayorDir, 0755); err != nil {
t.Fatalf("mkdir mayor: %v", err)
}
// Create town.json
townConfig := &config.TownConfig{
Type: "town",
Version: config.CurrentTownVersion,
Name: "test-town",
PublicName: "Test Town",
CreatedAt: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC),
}
townConfigPath := filepath.Join(mayorDir, "town.json")
if err := config.SaveTownConfig(townConfigPath, townConfig); err != nil {
t.Fatalf("save town.json: %v", err)
}
// Create empty rigs.json
rigsConfig := &config.RigsConfig{
Version: 1,
Rigs: make(map[string]config.RigEntry),
}
rigsPath := filepath.Join(mayorDir, "rigs.json")
if err := config.SaveRigsConfig(rigsPath, rigsConfig); err != nil {
t.Fatalf("save rigs.json: %v", err)
}
return townRoot
}
func TestConfigAgentList(t *testing.T) {
t.Run("lists built-in agents", func(t *testing.T) {
townRoot := setupTestTownForConfig(t)
settingsPath := config.TownSettingsPath(townRoot)
// Change to town root so workspace.FindFromCwd works
originalWd, _ := os.Getwd()
defer os.Chdir(originalWd)
if err := os.Chdir(townRoot); err != nil {
t.Fatalf("chdir: %v", err)
}
// Run the command
cmd := &cobra.Command{}
args := []string{}
err := runConfigAgentList(cmd, args)
if err != nil {
t.Fatalf("runConfigAgentList failed: %v", err)
}
// Verify settings file was created (LoadOrCreate creates it)
if _, err := os.Stat(settingsPath); err != nil {
// This is OK - list command works without settings file
}
})
t.Run("lists built-in and custom agents", func(t *testing.T) {
townRoot := setupTestTownForConfig(t)
settingsPath := config.TownSettingsPath(townRoot)
// Create settings with custom agent
settings := &config.TownSettings{
Type: "town-settings",
Version: config.CurrentTownSettingsVersion,
DefaultAgent: "claude",
Agents: map[string]*config.RuntimeConfig{
"my-custom": {
Command: "my-agent",
Args: []string{"--flag"},
},
},
}
if err := config.SaveTownSettings(settingsPath, settings); err != nil {
t.Fatalf("save settings: %v", err)
}
// Change to town root
originalWd, _ := os.Getwd()
defer os.Chdir(originalWd)
if err := os.Chdir(townRoot); err != nil {
t.Fatalf("chdir: %v", err)
}
// Load agent registry
registryPath := config.DefaultAgentRegistryPath(townRoot)
if err := config.LoadAgentRegistry(registryPath); err != nil {
t.Fatalf("load agent registry: %v", err)
}
// Run the command
cmd := &cobra.Command{}
args := []string{}
err := runConfigAgentList(cmd, args)
if err != nil {
t.Fatalf("runConfigAgentList failed: %v", err)
}
})
t.Run("JSON output", func(t *testing.T) {
townRoot := setupTestTownForConfig(t)
// Change to town root
originalWd, _ := os.Getwd()
defer os.Chdir(originalWd)
if err := os.Chdir(townRoot); err != nil {
t.Fatalf("chdir: %v", err)
}
// Set JSON flag
configAgentListJSON = true
defer func() { configAgentListJSON = false }()
// Load agent registry
registryPath := config.DefaultAgentRegistryPath(townRoot)
if err := config.LoadAgentRegistry(registryPath); err != nil {
t.Fatalf("load agent registry: %v", err)
}
// Capture output
// Note: This test verifies the command runs without error
// Full JSON validation would require capturing stdout
cmd := &cobra.Command{}
args := []string{}
err := runConfigAgentList(cmd, args)
if err != nil {
t.Fatalf("runConfigAgentList failed: %v", err)
}
})
}
func TestConfigAgentGet(t *testing.T) {
t.Run("gets built-in agent", func(t *testing.T) {
townRoot := setupTestTownForConfig(t)
// Change to town root
originalWd, _ := os.Getwd()
defer os.Chdir(originalWd)
if err := os.Chdir(townRoot); err != nil {
t.Fatalf("chdir: %v", err)
}
// Load agent registry
registryPath := config.DefaultAgentRegistryPath(townRoot)
if err := config.LoadAgentRegistry(registryPath); err != nil {
t.Fatalf("load agent registry: %v", err)
}
// Run the command
cmd := &cobra.Command{}
args := []string{"claude"}
err := runConfigAgentGet(cmd, args)
if err != nil {
t.Fatalf("runConfigAgentGet failed: %v", err)
}
})
t.Run("gets custom agent", func(t *testing.T) {
townRoot := setupTestTownForConfig(t)
settingsPath := config.TownSettingsPath(townRoot)
// Create settings with custom agent
settings := &config.TownSettings{
Type: "town-settings",
Version: config.CurrentTownSettingsVersion,
DefaultAgent: "claude",
Agents: map[string]*config.RuntimeConfig{
"my-custom": {
Command: "my-agent",
Args: []string{"--flag1", "--flag2"},
},
},
}
if err := config.SaveTownSettings(settingsPath, settings); err != nil {
t.Fatalf("save settings: %v", err)
}
// Change to town root
originalWd, _ := os.Getwd()
defer os.Chdir(originalWd)
if err := os.Chdir(townRoot); err != nil {
t.Fatalf("chdir: %v", err)
}
// Load agent registry
registryPath := config.DefaultAgentRegistryPath(townRoot)
if err := config.LoadAgentRegistry(registryPath); err != nil {
t.Fatalf("load agent registry: %v", err)
}
// Run the command
cmd := &cobra.Command{}
args := []string{"my-custom"}
err := runConfigAgentGet(cmd, args)
if err != nil {
t.Fatalf("runConfigAgentGet failed: %v", err)
}
})
t.Run("returns error for unknown agent", func(t *testing.T) {
townRoot := setupTestTownForConfig(t)
// Change to town root
originalWd, _ := os.Getwd()
defer os.Chdir(originalWd)
if err := os.Chdir(townRoot); err != nil {
t.Fatalf("chdir: %v", err)
}
// Load agent registry
registryPath := config.DefaultAgentRegistryPath(townRoot)
if err := config.LoadAgentRegistry(registryPath); err != nil {
t.Fatalf("load agent registry: %v", err)
}
// Run the command with unknown agent
cmd := &cobra.Command{}
args := []string{"unknown-agent"}
err := runConfigAgentGet(cmd, args)
if err == nil {
t.Fatal("expected error for unknown agent")
}
if !strings.Contains(err.Error(), "not found") {
t.Errorf("error = %v, want 'not found'", err)
}
})
}
func TestConfigAgentSet(t *testing.T) {
t.Run("sets custom agent", func(t *testing.T) {
townRoot := setupTestTownForConfig(t)
settingsPath := config.TownSettingsPath(townRoot)
// Change to town root
originalWd, _ := os.Getwd()
defer os.Chdir(originalWd)
if err := os.Chdir(townRoot); err != nil {
t.Fatalf("chdir: %v", err)
}
// Run the command
cmd := &cobra.Command{}
args := []string{"my-agent", "my-agent --arg1 --arg2"}
err := runConfigAgentSet(cmd, args)
if err != nil {
t.Fatalf("runConfigAgentSet failed: %v", err)
}
// Verify settings were saved
loaded, err := config.LoadOrCreateTownSettings(settingsPath)
if err != nil {
t.Fatalf("load settings: %v", err)
}
if loaded.Agents == nil {
t.Fatal("Agents map is nil")
}
agent, ok := loaded.Agents["my-agent"]
if !ok {
t.Fatal("custom agent not found in settings")
}
if agent.Command != "my-agent" {
t.Errorf("Command = %q, want 'my-agent'", agent.Command)
}
if len(agent.Args) != 2 {
t.Errorf("Args count = %d, want 2", len(agent.Args))
}
})
t.Run("sets agent with single command (no args)", func(t *testing.T) {
townRoot := setupTestTownForConfig(t)
settingsPath := config.TownSettingsPath(townRoot)
// Change to town root
originalWd, _ := os.Getwd()
defer os.Chdir(originalWd)
if err := os.Chdir(townRoot); err != nil {
t.Fatalf("chdir: %v", err)
}
// Run the command
cmd := &cobra.Command{}
args := []string{"simple-agent", "simple-agent"}
err := runConfigAgentSet(cmd, args)
if err != nil {
t.Fatalf("runConfigAgentSet failed: %v", err)
}
// Verify settings were saved
loaded, err := config.LoadOrCreateTownSettings(settingsPath)
if err != nil {
t.Fatalf("load settings: %v", err)
}
agent := loaded.Agents["simple-agent"]
if agent.Command != "simple-agent" {
t.Errorf("Command = %q, want 'simple-agent'", agent.Command)
}
if len(agent.Args) != 0 {
t.Errorf("Args count = %d, want 0", len(agent.Args))
}
})
t.Run("overrides existing agent", func(t *testing.T) {
townRoot := setupTestTownForConfig(t)
settingsPath := config.TownSettingsPath(townRoot)
// Create initial settings
settings := &config.TownSettings{
Type: "town-settings",
Version: config.CurrentTownSettingsVersion,
DefaultAgent: "claude",
Agents: map[string]*config.RuntimeConfig{
"my-agent": {
Command: "old-command",
Args: []string{"--old"},
},
},
}
if err := config.SaveTownSettings(settingsPath, settings); err != nil {
t.Fatalf("save initial settings: %v", err)
}
// Change to town root
originalWd, _ := os.Getwd()
defer os.Chdir(originalWd)
if err := os.Chdir(townRoot); err != nil {
t.Fatalf("chdir: %v", err)
}
// Run the command to override
cmd := &cobra.Command{}
args := []string{"my-agent", "new-command --new"}
err := runConfigAgentSet(cmd, args)
if err != nil {
t.Fatalf("runConfigAgentSet failed: %v", err)
}
// Verify settings were updated
loaded, err := config.LoadOrCreateTownSettings(settingsPath)
if err != nil {
t.Fatalf("load settings: %v", err)
}
agent := loaded.Agents["my-agent"]
if agent.Command != "new-command" {
t.Errorf("Command = %q, want 'new-command'", agent.Command)
}
})
}
func TestConfigAgentRemove(t *testing.T) {
t.Run("removes custom agent", func(t *testing.T) {
townRoot := setupTestTownForConfig(t)
settingsPath := config.TownSettingsPath(townRoot)
// Create settings with custom agent
settings := &config.TownSettings{
Type: "town-settings",
Version: config.CurrentTownSettingsVersion,
DefaultAgent: "claude",
Agents: map[string]*config.RuntimeConfig{
"my-agent": {
Command: "my-agent",
Args: []string{"--flag"},
},
},
}
if err := config.SaveTownSettings(settingsPath, settings); err != nil {
t.Fatalf("save settings: %v", err)
}
// Change to town root
originalWd, _ := os.Getwd()
defer os.Chdir(originalWd)
if err := os.Chdir(townRoot); err != nil {
t.Fatalf("chdir: %v", err)
}
// Run the command
cmd := &cobra.Command{}
args := []string{"my-agent"}
err := runConfigAgentRemove(cmd, args)
if err != nil {
t.Fatalf("runConfigAgentRemove failed: %v", err)
}
// Verify agent was removed
loaded, err := config.LoadOrCreateTownSettings(settingsPath)
if err != nil {
t.Fatalf("load settings: %v", err)
}
if loaded.Agents != nil {
if _, ok := loaded.Agents["my-agent"]; ok {
t.Error("agent still exists after removal")
}
}
})
t.Run("rejects removing built-in agent", func(t *testing.T) {
townRoot := setupTestTownForConfig(t)
// Change to town root
originalWd, _ := os.Getwd()
defer os.Chdir(originalWd)
if err := os.Chdir(townRoot); err != nil {
t.Fatalf("chdir: %v", err)
}
// Try to remove a built-in agent
cmd := &cobra.Command{}
args := []string{"claude"}
err := runConfigAgentRemove(cmd, args)
if err == nil {
t.Fatal("expected error when removing built-in agent")
}
if !strings.Contains(err.Error(), "cannot remove built-in") {
t.Errorf("error = %v, want 'cannot remove built-in'", err)
}
})
t.Run("returns error for non-existent custom agent", func(t *testing.T) {
townRoot := setupTestTownForConfig(t)
// Change to town root
originalWd, _ := os.Getwd()
defer os.Chdir(originalWd)
if err := os.Chdir(townRoot); err != nil {
t.Fatalf("chdir: %v", err)
}
// Try to remove a non-existent agent
cmd := &cobra.Command{}
args := []string{"non-existent"}
err := runConfigAgentRemove(cmd, args)
if err == nil {
t.Fatal("expected error for non-existent agent")
}
if !strings.Contains(err.Error(), "not found") {
t.Errorf("error = %v, want 'not found'", err)
}
})
}
func TestConfigDefaultAgent(t *testing.T) {
t.Run("gets default agent (shows current)", func(t *testing.T) {
townRoot := setupTestTownForConfig(t)
// Change to town root
originalWd, _ := os.Getwd()
defer os.Chdir(originalWd)
if err := os.Chdir(townRoot); err != nil {
t.Fatalf("chdir: %v", err)
}
// Run the command with no args (should show current default)
cmd := &cobra.Command{}
args := []string{}
err := runConfigDefaultAgent(cmd, args)
if err != nil {
t.Fatalf("runConfigDefaultAgent failed: %v", err)
}
})
t.Run("sets default agent to built-in", func(t *testing.T) {
townRoot := setupTestTownForConfig(t)
settingsPath := config.TownSettingsPath(townRoot)
// Change to town root
originalWd, _ := os.Getwd()
defer os.Chdir(originalWd)
if err := os.Chdir(townRoot); err != nil {
t.Fatalf("chdir: %v", err)
}
// Set default to gemini
cmd := &cobra.Command{}
args := []string{"gemini"}
err := runConfigDefaultAgent(cmd, args)
if err != nil {
t.Fatalf("runConfigDefaultAgent failed: %v", err)
}
// Verify settings were saved
loaded, err := config.LoadOrCreateTownSettings(settingsPath)
if err != nil {
t.Fatalf("load settings: %v", err)
}
if loaded.DefaultAgent != "gemini" {
t.Errorf("DefaultAgent = %q, want 'gemini'", loaded.DefaultAgent)
}
})
t.Run("sets default agent to custom", func(t *testing.T) {
townRoot := setupTestTownForConfig(t)
settingsPath := config.TownSettingsPath(townRoot)
// Create settings with custom agent
settings := &config.TownSettings{
Type: "town-settings",
Version: config.CurrentTownSettingsVersion,
DefaultAgent: "claude",
Agents: map[string]*config.RuntimeConfig{
"my-custom": {
Command: "my-agent",
Args: []string{},
},
},
}
if err := config.SaveTownSettings(settingsPath, settings); err != nil {
t.Fatalf("save settings: %v", err)
}
// Change to town root
originalWd, _ := os.Getwd()
defer os.Chdir(originalWd)
if err := os.Chdir(townRoot); err != nil {
t.Fatalf("chdir: %v", err)
}
// Set default to custom agent
cmd := &cobra.Command{}
args := []string{"my-custom"}
err := runConfigDefaultAgent(cmd, args)
if err != nil {
t.Fatalf("runConfigDefaultAgent failed: %v", err)
}
// Verify settings were saved
loaded, err := config.LoadOrCreateTownSettings(settingsPath)
if err != nil {
t.Fatalf("load settings: %v", err)
}
if loaded.DefaultAgent != "my-custom" {
t.Errorf("DefaultAgent = %q, want 'my-custom'", loaded.DefaultAgent)
}
})
t.Run("returns error for unknown agent", func(t *testing.T) {
townRoot := setupTestTownForConfig(t)
// Change to town root
originalWd, _ := os.Getwd()
defer os.Chdir(originalWd)
if err := os.Chdir(townRoot); err != nil {
t.Fatalf("chdir: %v", err)
}
// Try to set default to unknown agent
cmd := &cobra.Command{}
args := []string{"unknown-agent"}
err := runConfigDefaultAgent(cmd, args)
if err == nil {
t.Fatal("expected error for unknown agent")
}
if !strings.Contains(err.Error(), "not found") {
t.Errorf("error = %v, want 'not found'", err)
}
})
}

View File

@@ -691,6 +691,31 @@ func LoadOrCreateTownSettings(path string) (*TownSettings, error) {
return &settings, nil
}
// SaveTownSettings saves town settings to a file.
func SaveTownSettings(path string, settings *TownSettings) error {
if settings.Type != "town-settings" && settings.Type != "" {
return fmt.Errorf("%w: expected type 'town-settings', got '%s'", ErrInvalidType, settings.Type)
}
if settings.Version > CurrentTownSettingsVersion {
return fmt.Errorf("%w: got %d, max supported %d", ErrInvalidVersion, settings.Version, CurrentTownSettingsVersion)
}
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return fmt.Errorf("creating directory: %w", err)
}
data, err := json.MarshalIndent(settings, "", " ")
if err != nil {
return fmt.Errorf("encoding settings: %w", err)
}
if err := os.WriteFile(path, data, 0644); err != nil { //nolint:gosec // G306: settings files don't contain secrets
return fmt.Errorf("writing settings: %w", err)
}
return nil
}
// ResolveAgentConfig resolves the agent configuration for a rig.
// It looks up the agent by name in town settings (custom agents) and built-in presets.
//
@@ -783,8 +808,13 @@ func fillRuntimeDefaults(rc *RuntimeConfig) *RuntimeConfig {
// for starting an LLM session. It resolves the agent config and builds the command.
func GetRuntimeCommand(rigPath string) string {
if rigPath == "" {
// Try to detect town root from cwd for town-level agents (mayor, deacon)
townRoot, err := findTownRootFromCwd()
if err != nil {
return DefaultRuntimeConfig().BuildCommand()
}
return ResolveAgentConfig(townRoot, "").BuildCommand()
}
// Derive town root from rig path (rig is typically ~/gt/<rigname>)
townRoot := filepath.Dir(rigPath)
return ResolveAgentConfig(townRoot, rigPath).BuildCommand()
@@ -793,15 +823,50 @@ func GetRuntimeCommand(rigPath string) string {
// GetRuntimeCommandWithPrompt returns the full command with an initial prompt.
func GetRuntimeCommandWithPrompt(rigPath, prompt string) string {
if rigPath == "" {
// Try to detect town root from cwd for town-level agents (mayor, deacon)
townRoot, err := findTownRootFromCwd()
if err != nil {
return DefaultRuntimeConfig().BuildCommandWithPrompt(prompt)
}
return ResolveAgentConfig(townRoot, "").BuildCommandWithPrompt(prompt)
}
townRoot := filepath.Dir(rigPath)
return ResolveAgentConfig(townRoot, rigPath).BuildCommandWithPrompt(prompt)
}
// findTownRootFromCwd locates the town root by walking up from cwd.
// It looks for the mayor/town.json marker file.
// Returns empty string and no error if not found (caller should use defaults).
func findTownRootFromCwd() (string, error) {
cwd, err := os.Getwd()
if err != nil {
return "", fmt.Errorf("getting cwd: %w", err)
}
absDir, err := filepath.Abs(cwd)
if err != nil {
return "", fmt.Errorf("resolving path: %w", err)
}
const marker = "mayor/town.json"
current := absDir
for {
if _, err := os.Stat(filepath.Join(current, marker)); err == nil {
return current, nil
}
parent := filepath.Dir(current)
if parent == current {
return "", fmt.Errorf("town root not found (no %s marker)", marker)
}
current = parent
}
}
// 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.
// rigPath is optional - if empty, tries to detect town root from cwd.
// prompt is optional - if provided, appended as the initial prompt.
func BuildStartupCommand(envVars map[string]string, rigPath, prompt string) string {
var rc *RuntimeConfig
@@ -810,7 +875,13 @@ func BuildStartupCommand(envVars map[string]string, rigPath, prompt string) stri
townRoot := filepath.Dir(rigPath)
rc = ResolveAgentConfig(townRoot, rigPath)
} else {
// Try to detect town root from cwd for town-level agents (mayor, deacon)
townRoot, err := findTownRootFromCwd()
if err != nil {
rc = DefaultRuntimeConfig()
} else {
rc = ResolveAgentConfig(townRoot, "")
}
}
// Build environment export prefix

View File

@@ -970,3 +970,133 @@ func TestLoadRuntimeConfigFallsBackToDefaults(t *testing.T) {
t.Errorf("Command = %q, want %q (default)", rc.Command, "claude")
}
}
func TestSaveTownSettings(t *testing.T) {
t.Run("saves valid town settings", func(t *testing.T) {
tmpDir := t.TempDir()
settingsPath := filepath.Join(tmpDir, "settings", "config.json")
settings := &TownSettings{
Type: "town-settings",
Version: CurrentTownSettingsVersion,
DefaultAgent: "gemini",
Agents: map[string]*RuntimeConfig{
"my-agent": {
Command: "my-agent",
Args: []string{"--arg1", "--arg2"},
},
},
}
err := SaveTownSettings(settingsPath, settings)
if err != nil {
t.Fatalf("SaveTownSettings failed: %v", err)
}
// Verify file exists
data, err := os.ReadFile(settingsPath)
if err != nil {
t.Fatalf("reading settings file: %v", err)
}
// Verify it contains expected content
content := string(data)
if !strings.Contains(content, `"type": "town-settings"`) {
t.Errorf("missing type field")
}
if !strings.Contains(content, `"default_agent": "gemini"`) {
t.Errorf("missing default_agent field")
}
if !strings.Contains(content, `"my-agent"`) {
t.Errorf("missing custom agent")
}
})
t.Run("creates parent directories", func(t *testing.T) {
tmpDir := t.TempDir()
settingsPath := filepath.Join(tmpDir, "deeply", "nested", "settings", "config.json")
settings := NewTownSettings()
err := SaveTownSettings(settingsPath, settings)
if err != nil {
t.Fatalf("SaveTownSettings failed: %v", err)
}
// Verify file exists
if _, err := os.Stat(settingsPath); err != nil {
t.Errorf("settings file not created: %v", err)
}
})
t.Run("rejects invalid type", func(t *testing.T) {
tmpDir := t.TempDir()
settingsPath := filepath.Join(tmpDir, "config.json")
settings := &TownSettings{
Type: "invalid-type",
Version: CurrentTownSettingsVersion,
}
err := SaveTownSettings(settingsPath, settings)
if err == nil {
t.Error("expected error for invalid type")
}
})
t.Run("rejects unsupported version", func(t *testing.T) {
tmpDir := t.TempDir()
settingsPath := filepath.Join(tmpDir, "config.json")
settings := &TownSettings{
Type: "town-settings",
Version: CurrentTownSettingsVersion + 100,
}
err := SaveTownSettings(settingsPath, settings)
if err == nil {
t.Error("expected error for unsupported version")
}
})
t.Run("roundtrip save and load", func(t *testing.T) {
tmpDir := t.TempDir()
settingsPath := filepath.Join(tmpDir, "config.json")
original := &TownSettings{
Type: "town-settings",
Version: CurrentTownSettingsVersion,
DefaultAgent: "codex",
Agents: map[string]*RuntimeConfig{
"custom-1": {
Command: "custom-agent",
Args: []string{"--flag"},
},
},
}
err := SaveTownSettings(settingsPath, original)
if err != nil {
t.Fatalf("SaveTownSettings failed: %v", err)
}
loaded, err := LoadOrCreateTownSettings(settingsPath)
if err != nil {
t.Fatalf("LoadOrCreateTownSettings failed: %v", err)
}
if loaded.Type != original.Type {
t.Errorf("Type = %q, want %q", loaded.Type, original.Type)
}
if loaded.Version != original.Version {
t.Errorf("Version = %d, want %d", loaded.Version, original.Version)
}
if loaded.DefaultAgent != original.DefaultAgent {
t.Errorf("DefaultAgent = %q, want %q", loaded.DefaultAgent, original.DefaultAgent)
}
if len(loaded.Agents) != len(original.Agents) {
t.Errorf("Agents count = %d, want %d", len(loaded.Agents), len(original.Agents))
}
})
}