diff --git a/README.md b/README.md index a7a01189..a0e40a5b 100644 --- a/README.md +++ b/README.md @@ -235,6 +235,22 @@ gt 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 # Show agent configuration +gt config agent set # Create or update custom agent +gt config agent remove # 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 diff --git a/docs/reference.md b/docs/reference.md index 1dede5d5..90cfa621 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -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 # Show agent configuration +gt config agent set # Create or update custom agent +gt config agent remove # 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 diff --git a/internal/cmd/config.go b/internal/cmd/config.go new file mode 100644 index 00000000..6a76df6d --- /dev/null +++ b/internal/cmd/config.go @@ -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 Show agent configuration + gt config agent set Set custom agent command + gt config agent remove 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 ", + 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 ", + 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 ", + 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) +} diff --git a/internal/cmd/config_test.go b/internal/cmd/config_test.go new file mode 100644 index 00000000..a6dca7e0 --- /dev/null +++ b/internal/cmd/config_test.go @@ -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) + } + }) +} diff --git a/internal/config/loader.go b/internal/config/loader.go index cfb53131..40bae010 100644 --- a/internal/config/loader.go +++ b/internal/config/loader.go @@ -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,7 +808,12 @@ 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 == "" { - return DefaultRuntimeConfig().BuildCommand() + // 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/) townRoot := filepath.Dir(rigPath) @@ -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 == "" { - return DefaultRuntimeConfig().BuildCommandWithPrompt(prompt) + // 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 { - rc = DefaultRuntimeConfig() + // 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 diff --git a/internal/config/loader_test.go b/internal/config/loader_test.go index 548b6d69..2dcb4510 100644 --- a/internal/config/loader_test.go +++ b/internal/config/loader_test.go @@ -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)) + } + }) +}