feat: implement account management for multi-account Claude Code (gt-3133)

Adds support for managing multiple Claude Code accounts in Gas Town:

- accounts.json config parsing in mayor/ directory
- gt account list/add/default commands
- GT_ACCOUNT env var support with priority resolution
- --account flag on gt spawn and gt crew at commands
- CLAUDE_CONFIG_DIR injection into tmux sessions

Priority order: GT_ACCOUNT env var > --account flag > default from config

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-12-23 04:04:59 -08:00
parent a9ee102606
commit ba2db2bc11
10 changed files with 634 additions and 18 deletions

View File

@@ -6,6 +6,7 @@ import (
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
@@ -417,3 +418,150 @@ func NewMayorConfig() *MayorConfig {
Version: CurrentMayorConfigVersion,
}
}
// LoadAccountsConfig loads and validates an accounts configuration file.
func LoadAccountsConfig(path string) (*AccountsConfig, error) {
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil, fmt.Errorf("%w: %s", ErrNotFound, path)
}
return nil, fmt.Errorf("reading accounts config: %w", err)
}
var config AccountsConfig
if err := json.Unmarshal(data, &config); err != nil {
return nil, fmt.Errorf("parsing accounts config: %w", err)
}
if err := validateAccountsConfig(&config); err != nil {
return nil, err
}
return &config, nil
}
// SaveAccountsConfig saves an accounts configuration to a file.
func SaveAccountsConfig(path string, config *AccountsConfig) error {
if err := validateAccountsConfig(config); err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return fmt.Errorf("creating directory: %w", err)
}
data, err := json.MarshalIndent(config, "", " ")
if err != nil {
return fmt.Errorf("encoding accounts config: %w", err)
}
if err := os.WriteFile(path, data, 0644); err != nil {
return fmt.Errorf("writing accounts config: %w", err)
}
return nil
}
// validateAccountsConfig validates an AccountsConfig.
func validateAccountsConfig(c *AccountsConfig) error {
if c.Version > CurrentAccountsVersion {
return fmt.Errorf("%w: got %d, max supported %d", ErrInvalidVersion, c.Version, CurrentAccountsVersion)
}
if c.Accounts == nil {
c.Accounts = make(map[string]Account)
}
// Validate default refers to an existing account (if set and accounts exist)
if c.Default != "" && len(c.Accounts) > 0 {
if _, ok := c.Accounts[c.Default]; !ok {
return fmt.Errorf("%w: default account '%s' not found in accounts", ErrMissingField, c.Default)
}
}
// Validate each account has required fields
for handle, acct := range c.Accounts {
if acct.ConfigDir == "" {
return fmt.Errorf("%w: config_dir for account '%s'", ErrMissingField, handle)
}
}
return nil
}
// NewAccountsConfig creates a new AccountsConfig with defaults.
func NewAccountsConfig() *AccountsConfig {
return &AccountsConfig{
Version: CurrentAccountsVersion,
Accounts: make(map[string]Account),
}
}
// GetAccount returns an account by handle, or nil if not found.
func (c *AccountsConfig) GetAccount(handle string) *Account {
if acct, ok := c.Accounts[handle]; ok {
return &acct
}
return nil
}
// GetDefaultAccount returns the default account, or nil if not set.
func (c *AccountsConfig) GetDefaultAccount() *Account {
if c.Default == "" {
return nil
}
return c.GetAccount(c.Default)
}
// ResolveAccountConfigDir resolves the CLAUDE_CONFIG_DIR for account selection.
// Priority order:
// 1. GT_ACCOUNT environment variable
// 2. accountFlag (from --account command flag)
// 3. Default account from config
//
// Returns empty string if no account configured or resolved.
// Returns the handle that was resolved as second value.
func ResolveAccountConfigDir(accountsPath, accountFlag string) (configDir, handle string, err error) {
// Load accounts config
cfg, loadErr := LoadAccountsConfig(accountsPath)
if loadErr != nil {
// No accounts configured - that's OK, return empty
return "", "", nil
}
// Priority 1: GT_ACCOUNT env var
if envAccount := os.Getenv("GT_ACCOUNT"); envAccount != "" {
acct := cfg.GetAccount(envAccount)
if acct == nil {
return "", "", fmt.Errorf("GT_ACCOUNT '%s' not found in accounts config", envAccount)
}
return expandPath(acct.ConfigDir), envAccount, nil
}
// Priority 2: --account flag
if accountFlag != "" {
acct := cfg.GetAccount(accountFlag)
if acct == nil {
return "", "", fmt.Errorf("account '%s' not found in accounts config", accountFlag)
}
return expandPath(acct.ConfigDir), accountFlag, nil
}
// Priority 3: Default account
if cfg.Default != "" {
acct := cfg.GetDefaultAccount()
if acct != nil {
return expandPath(acct.ConfigDir), cfg.Default, nil
}
}
return "", "", nil
}
// expandPath expands ~ to home directory.
func expandPath(path string) string {
if strings.HasPrefix(path, "~/") {
home, err := os.UserHomeDir()
if err == nil {
return filepath.Join(home, path[2:])
}
}
return path
}

View File

@@ -434,3 +434,118 @@ func TestLoadMayorConfigNotFound(t *testing.T) {
t.Fatal("expected error for nonexistent file")
}
}
func TestAccountsConfigRoundTrip(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "mayor", "accounts.json")
original := NewAccountsConfig()
original.Accounts["yegge"] = Account{
Email: "steve.yegge@gmail.com",
Description: "Personal account",
ConfigDir: "~/.claude-accounts/yegge",
}
original.Accounts["ghosttrack"] = Account{
Email: "steve@ghosttrack.com",
Description: "Business account",
ConfigDir: "~/.claude-accounts/ghosttrack",
}
original.Default = "ghosttrack"
if err := SaveAccountsConfig(path, original); err != nil {
t.Fatalf("SaveAccountsConfig: %v", err)
}
loaded, err := LoadAccountsConfig(path)
if err != nil {
t.Fatalf("LoadAccountsConfig: %v", err)
}
if loaded.Version != CurrentAccountsVersion {
t.Errorf("Version = %d, want %d", loaded.Version, CurrentAccountsVersion)
}
if len(loaded.Accounts) != 2 {
t.Errorf("Accounts count = %d, want 2", len(loaded.Accounts))
}
if loaded.Default != "ghosttrack" {
t.Errorf("Default = %q, want 'ghosttrack'", loaded.Default)
}
yegge := loaded.GetAccount("yegge")
if yegge == nil {
t.Fatal("GetAccount('yegge') returned nil")
}
if yegge.Email != "steve.yegge@gmail.com" {
t.Errorf("yegge.Email = %q, want 'steve.yegge@gmail.com'", yegge.Email)
}
defAcct := loaded.GetDefaultAccount()
if defAcct == nil {
t.Fatal("GetDefaultAccount() returned nil")
}
if defAcct.Email != "steve@ghosttrack.com" {
t.Errorf("default.Email = %q, want 'steve@ghosttrack.com'", defAcct.Email)
}
}
func TestAccountsConfigValidation(t *testing.T) {
tests := []struct {
name string
config *AccountsConfig
wantErr bool
}{
{
name: "valid empty config",
config: NewAccountsConfig(),
wantErr: false,
},
{
name: "valid config with accounts",
config: &AccountsConfig{
Version: 1,
Accounts: map[string]Account{
"test": {Email: "test@example.com", ConfigDir: "~/.claude-accounts/test"},
},
Default: "test",
},
wantErr: false,
},
{
name: "default refers to nonexistent account",
config: &AccountsConfig{
Version: 1,
Accounts: map[string]Account{
"test": {Email: "test@example.com", ConfigDir: "~/.claude-accounts/test"},
},
Default: "nonexistent",
},
wantErr: true,
},
{
name: "account missing config_dir",
config: &AccountsConfig{
Version: 1,
Accounts: map[string]Account{
"test": {Email: "test@example.com"},
},
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateAccountsConfig(tt.config)
if (err != nil) != tt.wantErr {
t.Errorf("validateAccountsConfig() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
func TestLoadAccountsConfigNotFound(t *testing.T) {
_, err := LoadAccountsConfig("/nonexistent/path.json")
if err == nil {
t.Fatal("expected error for nonexistent file")
}
}

View File

@@ -1,7 +1,10 @@
// Package config provides configuration types and serialization for Gas Town.
package config
import "time"
import (
"os"
"time"
)
// TownConfig represents the main town identity (mayor/town.json).
type TownConfig struct {
@@ -208,3 +211,27 @@ func DefaultNamepoolConfig() *NamepoolConfig {
MaxBeforeNumbering: 50,
}
}
// AccountsConfig represents Claude Code account configuration (mayor/accounts.json).
// This enables Gas Town to manage multiple Claude Code accounts with easy switching.
type AccountsConfig struct {
Version int `json:"version"` // schema version
Accounts map[string]Account `json:"accounts"` // handle -> account details
Default string `json:"default"` // default account handle
}
// Account represents a single Claude Code account.
type Account struct {
Email string `json:"email"` // account email
Description string `json:"description,omitempty"` // human description
ConfigDir string `json:"config_dir"` // path to CLAUDE_CONFIG_DIR
}
// CurrentAccountsVersion is the current schema version for AccountsConfig.
const CurrentAccountsVersion = 1
// DefaultAccountsConfigDir returns the default base directory for account configs.
func DefaultAccountsConfigDir() string {
home, _ := os.UserHomeDir()
return home + "/.claude-accounts"
}