feat: Add gt config command for managing agent settings
Implements GitHub issue #127 - allow custom agent configuration through a CLI interface instead of command-line aliases. The gt config command provides: - gt config agent list [--json] List all agents - 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/set default agent Users can now define custom agents (e.g., claude-glm) and override built-in presets (claude, gemini, codex) through town settings instead of shell aliases. Changes: - Add SaveTownSettings() to internal/config/loader.go - Add internal/cmd/config.go with full config command implementation - Add comprehensive unit tests for both SaveTownSettings and all config subcommands (17 test cases covering success and error scenarios) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
468
internal/cmd/config.go
Normal file
468
internal/cmd/config.go
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user