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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user