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:
@@ -34,24 +34,27 @@ Each registered account gets its own config directory:
|
|||||||
|
|
||||||
### Town Configuration
|
### Town Configuration
|
||||||
|
|
||||||
File: `~/gt/mayor/accounts.yaml`
|
File: `~/gt/mayor/accounts.json`
|
||||||
|
|
||||||
This follows the existing pattern where town-level config lives in `mayor/`.
|
This follows the existing pattern where town-level config lives in `mayor/`.
|
||||||
|
|
||||||
```yaml
|
```json
|
||||||
accounts:
|
{
|
||||||
yegge:
|
"version": 1,
|
||||||
email: steve.yegge@gmail.com
|
"accounts": {
|
||||||
description: "Personal/Gmail account"
|
"yegge": {
|
||||||
config_dir: ~/.claude-accounts/yegge
|
"email": "steve.yegge@gmail.com",
|
||||||
|
"description": "Personal/Gmail account",
|
||||||
ghosttrack:
|
"config_dir": "~/.claude-accounts/yegge"
|
||||||
email: steve@ghosttrack.com
|
},
|
||||||
description: "Ghost Track business account"
|
"ghosttrack": {
|
||||||
config_dir: ~/.claude-accounts/ghosttrack
|
"email": "steve@ghosttrack.com",
|
||||||
|
"description": "Ghost Track business account",
|
||||||
# Global default used when no override specified
|
"config_dir": "~/.claude-accounts/ghosttrack"
|
||||||
default: ghosttrack
|
}
|
||||||
|
},
|
||||||
|
"default": "ghosttrack"
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Environment Variable: GT_ACCOUNT
|
### Environment Variable: GT_ACCOUNT
|
||||||
@@ -196,7 +199,7 @@ claude # Login as ghosttrack
|
|||||||
|
|
||||||
### Phase 1: Basic Support
|
### Phase 1: Basic Support
|
||||||
|
|
||||||
- Add `accounts.yaml` parsing
|
- Add `accounts.json` parsing
|
||||||
- Add `gt account` subcommands
|
- Add `gt account` subcommands
|
||||||
- Wire up `GT_ACCOUNT` env var in spawn
|
- Wire up `GT_ACCOUNT` env var in spawn
|
||||||
|
|
||||||
|
|||||||
263
internal/cmd/account.go
Normal file
263
internal/cmd/account.go
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"sort"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/steveyegge/gastown/internal/config"
|
||||||
|
"github.com/steveyegge/gastown/internal/constants"
|
||||||
|
"github.com/steveyegge/gastown/internal/style"
|
||||||
|
"github.com/steveyegge/gastown/internal/workspace"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Account command flags
|
||||||
|
var (
|
||||||
|
accountJSON bool
|
||||||
|
accountEmail string
|
||||||
|
accountDescription string
|
||||||
|
)
|
||||||
|
|
||||||
|
var accountCmd = &cobra.Command{
|
||||||
|
Use: "account",
|
||||||
|
Short: "Manage Claude Code accounts",
|
||||||
|
Long: `Manage multiple Claude Code accounts for Gas Town.
|
||||||
|
|
||||||
|
This enables switching between accounts (e.g., personal vs work) with
|
||||||
|
easy account selection per spawn or globally.
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
gt account list List registered accounts
|
||||||
|
gt account add <handle> Add a new account
|
||||||
|
gt account default <handle> Set the default account
|
||||||
|
gt account status Show current account info`,
|
||||||
|
}
|
||||||
|
|
||||||
|
var accountListCmd = &cobra.Command{
|
||||||
|
Use: "list",
|
||||||
|
Short: "List registered accounts",
|
||||||
|
Long: `List all registered Claude Code accounts.
|
||||||
|
|
||||||
|
Shows account handles, emails, and which is the default.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
gt account list # Text output
|
||||||
|
gt account list --json # JSON output`,
|
||||||
|
RunE: runAccountList,
|
||||||
|
}
|
||||||
|
|
||||||
|
var accountAddCmd = &cobra.Command{
|
||||||
|
Use: "add <handle>",
|
||||||
|
Short: "Add a new account",
|
||||||
|
Long: `Add a new Claude Code account.
|
||||||
|
|
||||||
|
Creates a config directory at ~/.claude-accounts/<handle> and registers
|
||||||
|
the account. You'll need to run 'claude' with CLAUDE_CONFIG_DIR set to
|
||||||
|
that directory to complete the login.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
gt account add work
|
||||||
|
gt account add work --email steve@company.com
|
||||||
|
gt account add work --email steve@company.com --desc "Work account"`,
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: runAccountAdd,
|
||||||
|
}
|
||||||
|
|
||||||
|
var accountDefaultCmd = &cobra.Command{
|
||||||
|
Use: "default <handle>",
|
||||||
|
Short: "Set the default account",
|
||||||
|
Long: `Set the default Claude Code account.
|
||||||
|
|
||||||
|
The default account is used when no --account flag or GT_ACCOUNT env var
|
||||||
|
is specified during spawn or attach.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
gt account default work
|
||||||
|
gt account default personal`,
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: runAccountDefault,
|
||||||
|
}
|
||||||
|
|
||||||
|
// AccountListItem represents an account in list output.
|
||||||
|
type AccountListItem struct {
|
||||||
|
Handle string `json:"handle"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
ConfigDir string `json:"config_dir"`
|
||||||
|
IsDefault bool `json:"is_default"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func runAccountList(cmd *cobra.Command, args []string) error {
|
||||||
|
townRoot, err := workspace.FindFromCwd()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("finding town root: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
accountsPath := constants.MayorAccountsPath(townRoot)
|
||||||
|
cfg, err := config.LoadAccountsConfig(accountsPath)
|
||||||
|
if err != nil {
|
||||||
|
// If file doesn't exist, show empty message
|
||||||
|
fmt.Println("No accounts configured.")
|
||||||
|
fmt.Println("\nTo add an account:")
|
||||||
|
fmt.Println(" gt account add <handle>")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(cfg.Accounts) == 0 {
|
||||||
|
fmt.Println("No accounts configured.")
|
||||||
|
fmt.Println("\nTo add an account:")
|
||||||
|
fmt.Println(" gt account add <handle>")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build list items
|
||||||
|
var items []AccountListItem
|
||||||
|
for handle, acct := range cfg.Accounts {
|
||||||
|
items = append(items, AccountListItem{
|
||||||
|
Handle: handle,
|
||||||
|
Email: acct.Email,
|
||||||
|
Description: acct.Description,
|
||||||
|
ConfigDir: acct.ConfigDir,
|
||||||
|
IsDefault: handle == cfg.Default,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by handle for consistent output
|
||||||
|
sort.Slice(items, func(i, j int) bool {
|
||||||
|
return items[i].Handle < items[j].Handle
|
||||||
|
})
|
||||||
|
|
||||||
|
if accountJSON {
|
||||||
|
enc := json.NewEncoder(os.Stdout)
|
||||||
|
enc.SetIndent("", " ")
|
||||||
|
return enc.Encode(items)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Text output
|
||||||
|
fmt.Printf("%s\n\n", style.Bold.Render("Claude Code Accounts"))
|
||||||
|
for _, item := range items {
|
||||||
|
marker := " "
|
||||||
|
if item.IsDefault {
|
||||||
|
marker = "* "
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("%s%s", marker, style.Bold.Render(item.Handle))
|
||||||
|
if item.Email != "" {
|
||||||
|
fmt.Printf(" %s", item.Email)
|
||||||
|
}
|
||||||
|
if item.IsDefault {
|
||||||
|
fmt.Printf(" %s", style.Dim.Render("(default)"))
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
if item.Description != "" {
|
||||||
|
fmt.Printf(" %s\n", style.Dim.Render(item.Description))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runAccountAdd(cmd *cobra.Command, args []string) error {
|
||||||
|
handle := args[0]
|
||||||
|
|
||||||
|
townRoot, err := workspace.FindFromCwd()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("finding town root: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
accountsPath := constants.MayorAccountsPath(townRoot)
|
||||||
|
|
||||||
|
// Load existing config or create new
|
||||||
|
cfg, err := config.LoadAccountsConfig(accountsPath)
|
||||||
|
if err != nil {
|
||||||
|
cfg = config.NewAccountsConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if account already exists
|
||||||
|
if _, exists := cfg.Accounts[handle]; exists {
|
||||||
|
return fmt.Errorf("account '%s' already exists", handle)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build config directory path
|
||||||
|
configDir := config.DefaultAccountsConfigDir() + "/" + handle
|
||||||
|
|
||||||
|
// Create the config directory
|
||||||
|
if err := os.MkdirAll(configDir, 0755); err != nil {
|
||||||
|
return fmt.Errorf("creating config directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add account
|
||||||
|
cfg.Accounts[handle] = config.Account{
|
||||||
|
Email: accountEmail,
|
||||||
|
Description: accountDescription,
|
||||||
|
ConfigDir: configDir,
|
||||||
|
}
|
||||||
|
|
||||||
|
// If this is the first account, make it default
|
||||||
|
if cfg.Default == "" {
|
||||||
|
cfg.Default = handle
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save config
|
||||||
|
if err := config.SaveAccountsConfig(accountsPath, cfg); err != nil {
|
||||||
|
return fmt.Errorf("saving accounts config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Added account '%s'\n", handle)
|
||||||
|
fmt.Printf("Config directory: %s\n", configDir)
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("To complete login, run:")
|
||||||
|
fmt.Printf(" CLAUDE_CONFIG_DIR=%s claude\n", configDir)
|
||||||
|
fmt.Println("Then use /login to authenticate.")
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runAccountDefault(cmd *cobra.Command, args []string) error {
|
||||||
|
handle := args[0]
|
||||||
|
|
||||||
|
townRoot, err := workspace.FindFromCwd()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("finding town root: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
accountsPath := constants.MayorAccountsPath(townRoot)
|
||||||
|
cfg, err := config.LoadAccountsConfig(accountsPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("loading accounts config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if account exists
|
||||||
|
if _, exists := cfg.Accounts[handle]; !exists {
|
||||||
|
return fmt.Errorf("account '%s' not found", handle)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update default
|
||||||
|
cfg.Default = handle
|
||||||
|
|
||||||
|
// Save config
|
||||||
|
if err := config.SaveAccountsConfig(accountsPath, cfg); err != nil {
|
||||||
|
return fmt.Errorf("saving accounts config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Default account set to '%s'\n", handle)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
// Add flags
|
||||||
|
accountListCmd.Flags().BoolVar(&accountJSON, "json", false, "Output as JSON")
|
||||||
|
|
||||||
|
accountAddCmd.Flags().StringVar(&accountEmail, "email", "", "Account email address")
|
||||||
|
accountAddCmd.Flags().StringVar(&accountDescription, "desc", "", "Account description")
|
||||||
|
|
||||||
|
// Add subcommands
|
||||||
|
accountCmd.AddCommand(accountListCmd)
|
||||||
|
accountCmd.AddCommand(accountAddCmd)
|
||||||
|
accountCmd.AddCommand(accountDefaultCmd)
|
||||||
|
|
||||||
|
rootCmd.AddCommand(accountCmd)
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ var (
|
|||||||
crewForce bool
|
crewForce bool
|
||||||
crewNoTmux bool
|
crewNoTmux bool
|
||||||
crewMessage string
|
crewMessage string
|
||||||
|
crewAccount string
|
||||||
)
|
)
|
||||||
|
|
||||||
var crewCmd = &cobra.Command{
|
var crewCmd = &cobra.Command{
|
||||||
@@ -197,6 +198,7 @@ func init() {
|
|||||||
|
|
||||||
crewAtCmd.Flags().StringVar(&crewRig, "rig", "", "Rig to use")
|
crewAtCmd.Flags().StringVar(&crewRig, "rig", "", "Rig to use")
|
||||||
crewAtCmd.Flags().BoolVar(&crewNoTmux, "no-tmux", false, "Just print directory path")
|
crewAtCmd.Flags().BoolVar(&crewNoTmux, "no-tmux", false, "Just print directory path")
|
||||||
|
crewAtCmd.Flags().StringVar(&crewAccount, "account", "", "Claude Code account handle to use (overrides default)")
|
||||||
|
|
||||||
crewRemoveCmd.Flags().StringVar(&crewRig, "rig", "", "Rig to use")
|
crewRemoveCmd.Flags().StringVar(&crewRig, "rig", "", "Rig to use")
|
||||||
crewRemoveCmd.Flags().BoolVar(&crewForce, "force", false, "Force remove (skip safety checks)")
|
crewRemoveCmd.Flags().BoolVar(&crewForce, "force", false, "Force remove (skip safety checks)")
|
||||||
|
|||||||
@@ -5,9 +5,12 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/steveyegge/gastown/internal/config"
|
||||||
|
"github.com/steveyegge/gastown/internal/constants"
|
||||||
"github.com/steveyegge/gastown/internal/crew"
|
"github.com/steveyegge/gastown/internal/crew"
|
||||||
"github.com/steveyegge/gastown/internal/style"
|
"github.com/steveyegge/gastown/internal/style"
|
||||||
"github.com/steveyegge/gastown/internal/tmux"
|
"github.com/steveyegge/gastown/internal/tmux"
|
||||||
|
"github.com/steveyegge/gastown/internal/workspace"
|
||||||
)
|
)
|
||||||
|
|
||||||
func runCrewAt(cmd *cobra.Command, args []string) error {
|
func runCrewAt(cmd *cobra.Command, args []string) error {
|
||||||
@@ -52,6 +55,20 @@ func runCrewAt(cmd *cobra.Command, args []string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolve account for Claude config
|
||||||
|
townRoot, err := workspace.FindFromCwd()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("finding town root: %w", err)
|
||||||
|
}
|
||||||
|
accountsPath := constants.MayorAccountsPath(townRoot)
|
||||||
|
claudeConfigDir, accountHandle, err := config.ResolveAccountConfigDir(accountsPath, crewAccount)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("resolving account: %w", err)
|
||||||
|
}
|
||||||
|
if accountHandle != "" {
|
||||||
|
fmt.Printf("Using account: %s\n", accountHandle)
|
||||||
|
}
|
||||||
|
|
||||||
// Check if session exists
|
// Check if session exists
|
||||||
t := tmux.NewTmux()
|
t := tmux.NewTmux()
|
||||||
sessionID := crewSessionName(r.Name, name)
|
sessionID := crewSessionName(r.Name, name)
|
||||||
@@ -70,6 +87,11 @@ func runCrewAt(cmd *cobra.Command, args []string) error {
|
|||||||
_ = t.SetEnvironment(sessionID, "GT_RIG", r.Name)
|
_ = t.SetEnvironment(sessionID, "GT_RIG", r.Name)
|
||||||
_ = t.SetEnvironment(sessionID, "GT_CREW", name)
|
_ = t.SetEnvironment(sessionID, "GT_CREW", name)
|
||||||
|
|
||||||
|
// Set CLAUDE_CONFIG_DIR for account selection
|
||||||
|
if claudeConfigDir != "" {
|
||||||
|
_ = t.SetEnvironment(sessionID, "CLAUDE_CONFIG_DIR", claudeConfigDir)
|
||||||
|
}
|
||||||
|
|
||||||
// Apply rig-based theming (uses config if set, falls back to hash)
|
// Apply rig-based theming (uses config if set, falls back to hash)
|
||||||
theme := getThemeForRig(r.Name)
|
theme := getThemeForRig(r.Name)
|
||||||
_ = t.ConfigureGasTownSession(sessionID, theme, r.Name, name, "crew")
|
_ = t.ConfigureGasTownSession(sessionID, theme, r.Name, name, "crew")
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/steveyegge/gastown/internal/config"
|
"github.com/steveyegge/gastown/internal/config"
|
||||||
|
"github.com/steveyegge/gastown/internal/constants"
|
||||||
"github.com/steveyegge/gastown/internal/git"
|
"github.com/steveyegge/gastown/internal/git"
|
||||||
"github.com/steveyegge/gastown/internal/mail"
|
"github.com/steveyegge/gastown/internal/mail"
|
||||||
"github.com/steveyegge/gastown/internal/polecat"
|
"github.com/steveyegge/gastown/internal/polecat"
|
||||||
@@ -32,6 +33,7 @@ var (
|
|||||||
spawnRig string
|
spawnRig string
|
||||||
spawnMolecule string
|
spawnMolecule string
|
||||||
spawnForce bool
|
spawnForce bool
|
||||||
|
spawnAccount string
|
||||||
)
|
)
|
||||||
|
|
||||||
var spawnCmd = &cobra.Command{
|
var spawnCmd = &cobra.Command{
|
||||||
@@ -71,6 +73,7 @@ func init() {
|
|||||||
spawnCmd.Flags().StringVar(&spawnRig, "rig", "", "Rig name (defaults to current directory's rig)")
|
spawnCmd.Flags().StringVar(&spawnRig, "rig", "", "Rig name (defaults to current directory's rig)")
|
||||||
spawnCmd.Flags().StringVar(&spawnMolecule, "molecule", "", "Molecule ID to instantiate on the issue")
|
spawnCmd.Flags().StringVar(&spawnMolecule, "molecule", "", "Molecule ID to instantiate on the issue")
|
||||||
spawnCmd.Flags().BoolVar(&spawnForce, "force", false, "Force spawn even if polecat has unread mail")
|
spawnCmd.Flags().BoolVar(&spawnForce, "force", false, "Force spawn even if polecat has unread mail")
|
||||||
|
spawnCmd.Flags().StringVar(&spawnAccount, "account", "", "Claude Code account handle to use (overrides default)")
|
||||||
|
|
||||||
rootCmd.AddCommand(spawnCmd)
|
rootCmd.AddCommand(spawnCmd)
|
||||||
}
|
}
|
||||||
@@ -348,6 +351,16 @@ func runSpawn(cmd *cobra.Command, args []string) error {
|
|||||||
}
|
}
|
||||||
fmt.Printf("%s Work assignment sent\n", style.Bold.Render("✓"))
|
fmt.Printf("%s Work assignment sent\n", style.Bold.Render("✓"))
|
||||||
|
|
||||||
|
// Resolve account for Claude config
|
||||||
|
accountsPath := constants.MayorAccountsPath(townRoot)
|
||||||
|
claudeConfigDir, accountHandle, err := config.ResolveAccountConfigDir(accountsPath, spawnAccount)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("resolving account: %w", err)
|
||||||
|
}
|
||||||
|
if accountHandle != "" {
|
||||||
|
fmt.Printf("Using account: %s\n", accountHandle)
|
||||||
|
}
|
||||||
|
|
||||||
// Start session
|
// Start session
|
||||||
t := tmux.NewTmux()
|
t := tmux.NewTmux()
|
||||||
sessMgr := session.NewManager(t, r)
|
sessMgr := session.NewManager(t, r)
|
||||||
@@ -359,7 +372,10 @@ func runSpawn(cmd *cobra.Command, args []string) error {
|
|||||||
} else {
|
} else {
|
||||||
// Start new session
|
// Start new session
|
||||||
fmt.Printf("Starting session for %s/%s...\n", rigName, polecatName)
|
fmt.Printf("Starting session for %s/%s...\n", rigName, polecatName)
|
||||||
if err := sessMgr.Start(polecatName, session.StartOptions{}); err != nil {
|
startOpts := session.StartOptions{
|
||||||
|
ClaudeConfigDir: claudeConfigDir,
|
||||||
|
}
|
||||||
|
if err := sessMgr.Start(polecatName, startOpts); err != nil {
|
||||||
return fmt.Errorf("starting session: %w", err)
|
return fmt.Errorf("starting session: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -417,3 +418,150 @@ func NewMayorConfig() *MayorConfig {
|
|||||||
Version: CurrentMayorConfigVersion,
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -434,3 +434,118 @@ func TestLoadMayorConfigNotFound(t *testing.T) {
|
|||||||
t.Fatal("expected error for nonexistent file")
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
// Package config provides configuration types and serialization for Gas Town.
|
// Package config provides configuration types and serialization for Gas Town.
|
||||||
package config
|
package config
|
||||||
|
|
||||||
import "time"
|
import (
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
// TownConfig represents the main town identity (mayor/town.json).
|
// TownConfig represents the main town identity (mayor/town.json).
|
||||||
type TownConfig struct {
|
type TownConfig struct {
|
||||||
@@ -208,3 +211,27 @@ func DefaultNamepoolConfig() *NamepoolConfig {
|
|||||||
MaxBeforeNumbering: 50,
|
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"
|
||||||
|
}
|
||||||
|
|||||||
@@ -48,6 +48,9 @@ const (
|
|||||||
|
|
||||||
// FileConfigYAML is the beads config file.
|
// FileConfigYAML is the beads config file.
|
||||||
FileConfigYAML = "config.yaml"
|
FileConfigYAML = "config.yaml"
|
||||||
|
|
||||||
|
// FileAccountsJSON is the accounts configuration file in mayor/.
|
||||||
|
FileAccountsJSON = "accounts.json"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Git branch names.
|
// Git branch names.
|
||||||
@@ -154,3 +157,8 @@ func RigRuntimePath(rigPath string) string {
|
|||||||
func RigSettingsPath(rigPath string) string {
|
func RigSettingsPath(rigPath string) string {
|
||||||
return rigPath + "/" + DirSettings
|
return rigPath + "/" + DirSettings
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MayorAccountsPath returns the path to mayor/accounts.json within a town root.
|
||||||
|
func MayorAccountsPath(townRoot string) string {
|
||||||
|
return townRoot + "/" + DirMayor + "/" + FileAccountsJSON
|
||||||
|
}
|
||||||
|
|||||||
@@ -46,6 +46,13 @@ type StartOptions struct {
|
|||||||
|
|
||||||
// Command overrides the default "claude" command.
|
// Command overrides the default "claude" command.
|
||||||
Command string
|
Command string
|
||||||
|
|
||||||
|
// Account specifies the account handle to use (overrides default).
|
||||||
|
Account string
|
||||||
|
|
||||||
|
// ClaudeConfigDir is resolved CLAUDE_CONFIG_DIR for the account.
|
||||||
|
// If set, this is injected as an environment variable.
|
||||||
|
ClaudeConfigDir string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Info contains information about a running session.
|
// Info contains information about a running session.
|
||||||
@@ -133,6 +140,11 @@ func (m *Manager) Start(polecat string, opts StartOptions) error {
|
|||||||
_ = m.tmux.SetEnvironment(sessionID, "GT_RIG", m.rig.Name)
|
_ = m.tmux.SetEnvironment(sessionID, "GT_RIG", m.rig.Name)
|
||||||
_ = m.tmux.SetEnvironment(sessionID, "GT_POLECAT", polecat)
|
_ = m.tmux.SetEnvironment(sessionID, "GT_POLECAT", polecat)
|
||||||
|
|
||||||
|
// Set CLAUDE_CONFIG_DIR for account selection
|
||||||
|
if opts.ClaudeConfigDir != "" {
|
||||||
|
_ = m.tmux.SetEnvironment(sessionID, "CLAUDE_CONFIG_DIR", opts.ClaudeConfigDir)
|
||||||
|
}
|
||||||
|
|
||||||
// CRITICAL: Set beads environment for worktree polecats
|
// CRITICAL: Set beads environment for worktree polecats
|
||||||
// Polecats share the rig's beads directory (at rig root, not mayor/rig)
|
// Polecats share the rig's beads directory (at rig root, not mayor/rig)
|
||||||
// BEADS_NO_DAEMON=1 prevents daemon from committing to wrong branch
|
// BEADS_NO_DAEMON=1 prevents daemon from committing to wrong branch
|
||||||
|
|||||||
Reference in New Issue
Block a user