feat(account): add gt account switch command

Adds the ability to switch between Claude Code accounts with a single command:
  gt account switch <handle>

The command:
1. Detects current account by checking ~/.claude symlink target
2. If ~/.claude is a real directory, moves it to the current account config_dir
3. Removes existing ~/.claude symlink (if any)
4. Creates symlink from ~/.claude to target account config_dir
5. Updates default account in accounts.json
6. Prints confirmation with restart reminder

Handles edge cases:
- Already on target account (no-op with message)
- Target account does not exist (error with list of valid accounts)
- ~/.claude is real directory (first-time setup scenario)

Closes gt-jd8m1

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
jack
2026-01-07 21:41:51 -08:00
committed by Steve Yegge
parent 19f4fa3ddb
commit 7f6fe53c6f
2 changed files with 435 additions and 0 deletions

View File

@@ -264,6 +264,25 @@ Examples:
RunE: runAccountStatus,
}
var accountSwitchCmd = &cobra.Command{
Use: "switch <handle>",
Short: "Switch to a different account",
Long: `Switch the active Claude Code account.
This command:
1. Backs up ~/.claude to the current account's config_dir (if needed)
2. Creates a symlink from ~/.claude to the target account's config_dir
3. Updates the default account in accounts.json
After switching, you must restart Claude Code for the change to take effect.
Examples:
gt account switch work # Switch to work account
gt account switch personal # Switch to personal account`,
Args: cobra.ExactArgs(1),
RunE: runAccountSwitch,
}
func runAccountStatus(cmd *cobra.Command, args []string) error {
townRoot, err := workspace.FindFromCwd()
if err != nil {
@@ -318,6 +337,122 @@ func runAccountStatus(cmd *cobra.Command, args []string) error {
return nil
}
func runAccountSwitch(cmd *cobra.Command, args []string) error {
targetHandle := 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 target account exists
targetAcct := cfg.GetAccount(targetHandle)
if targetAcct == nil {
// List available accounts
var handles []string
for h := range cfg.Accounts {
handles = append(handles, h)
}
sort.Strings(handles)
return fmt.Errorf("account '%s' not found. Available accounts: %v", targetHandle, handles)
}
// Get ~/.claude path
home, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("getting home directory: %w", err)
}
claudeDir := home + "/.claude"
// Check current state of ~/.claude
fileInfo, err := os.Lstat(claudeDir)
if err != nil && !os.IsNotExist(err) {
return fmt.Errorf("checking ~/.claude: %w", err)
}
// Determine current account (if any) by checking symlink target
var currentHandle string
if err == nil && fileInfo.Mode()&os.ModeSymlink != 0 {
// It's a symlink - find which account it points to
linkTarget, err := os.Readlink(claudeDir)
if err != nil {
return fmt.Errorf("reading symlink: %w", err)
}
for h, acct := range cfg.Accounts {
if acct.ConfigDir == linkTarget {
currentHandle = h
break
}
}
}
// Check if already on target account
if currentHandle == targetHandle {
fmt.Printf("Already on account '%s'\n", targetHandle)
return nil
}
// Handle the case where ~/.claude is a real directory (not a symlink)
if err == nil && fileInfo.Mode()&os.ModeSymlink == 0 && fileInfo.IsDir() {
// It's a real directory - need to move it
// Try to find which account it belongs to based on default
if currentHandle == "" && cfg.Default != "" {
currentHandle = cfg.Default
}
if currentHandle != "" {
currentAcct := cfg.GetAccount(currentHandle)
if currentAcct != nil {
// Move ~/.claude to the current account's config_dir
fmt.Printf("Moving ~/.claude to %s...\n", currentAcct.ConfigDir)
// Remove the target config dir if it exists (it might be empty from account add)
if _, err := os.Stat(currentAcct.ConfigDir); err == nil {
if err := os.RemoveAll(currentAcct.ConfigDir); err != nil {
return fmt.Errorf("removing existing config dir: %w", err)
}
}
if err := os.Rename(claudeDir, currentAcct.ConfigDir); err != nil {
return fmt.Errorf("moving ~/.claude to %s: %w", currentAcct.ConfigDir, err)
}
}
} else {
return fmt.Errorf("~/.claude is a directory but no default account is set. Please set a default account first with 'gt account default <handle>'")
}
} else if err == nil && fileInfo.Mode()&os.ModeSymlink != 0 {
// It's a symlink - remove it so we can create a new one
if err := os.Remove(claudeDir); err != nil {
return fmt.Errorf("removing existing symlink: %w", err)
}
}
// If ~/.claude doesn't exist, that's fine - we'll create the symlink
// Create symlink to target account
if err := os.Symlink(targetAcct.ConfigDir, claudeDir); err != nil {
return fmt.Errorf("creating symlink to %s: %w", targetAcct.ConfigDir, err)
}
// Update default account
cfg.Default = targetHandle
if err := config.SaveAccountsConfig(accountsPath, cfg); err != nil {
return fmt.Errorf("saving accounts config: %w", err)
}
fmt.Printf("Switched to account '%s'\n", targetHandle)
fmt.Printf("~/.claude -> %s\n", targetAcct.ConfigDir)
fmt.Println()
fmt.Println(style.Warning.Render("⚠️ Restart Claude Code for the change to take effect"))
return nil
}
func init() {
// Add flags
accountListCmd.Flags().BoolVar(&accountJSON, "json", false, "Output as JSON")
@@ -330,6 +465,7 @@ func init() {
accountCmd.AddCommand(accountAddCmd)
accountCmd.AddCommand(accountDefaultCmd)
accountCmd.AddCommand(accountStatusCmd)
accountCmd.AddCommand(accountSwitchCmd)
rootCmd.AddCommand(accountCmd)
}

View File

@@ -0,0 +1,299 @@
package cmd
import (
"os"
"path/filepath"
"testing"
"time"
"github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/config"
)
// setupTestTownForAccount creates a minimal Gas Town workspace with accounts.
func setupTestTownForAccount(t *testing.T) (townRoot string, accountsDir 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)
}
// Create accounts directory
accountsDir = filepath.Join(t.TempDir(), "claude-accounts")
if err := os.MkdirAll(accountsDir, 0755); err != nil {
t.Fatalf("mkdir accounts: %v", err)
}
return townRoot, accountsDir
}
func TestAccountSwitch(t *testing.T) {
t.Run("switch between accounts", func(t *testing.T) {
townRoot, accountsDir := setupTestTownForAccount(t)
// Create fake home directory for ~/.claude
fakeHome := t.TempDir()
originalHome := os.Getenv("HOME")
os.Setenv("HOME", fakeHome)
defer os.Setenv("HOME", originalHome)
// Create account config directories
workConfigDir := filepath.Join(accountsDir, "work")
personalConfigDir := filepath.Join(accountsDir, "personal")
if err := os.MkdirAll(workConfigDir, 0755); err != nil {
t.Fatalf("mkdir work config: %v", err)
}
if err := os.MkdirAll(personalConfigDir, 0755); err != nil {
t.Fatalf("mkdir personal config: %v", err)
}
// Create accounts.json with two accounts
accountsPath := filepath.Join(townRoot, "mayor", "accounts.json")
accountsCfg := config.NewAccountsConfig()
accountsCfg.Accounts["work"] = config.Account{
Email: "steve@work.com",
ConfigDir: workConfigDir,
}
accountsCfg.Accounts["personal"] = config.Account{
Email: "steve@personal.com",
ConfigDir: personalConfigDir,
}
accountsCfg.Default = "work"
if err := config.SaveAccountsConfig(accountsPath, accountsCfg); err != nil {
t.Fatalf("save accounts.json: %v", err)
}
// Create initial symlink to work account
claudeDir := filepath.Join(fakeHome, ".claude")
if err := os.Symlink(workConfigDir, claudeDir); err != nil {
t.Fatalf("create symlink: %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 switch to personal
cmd := &cobra.Command{}
err := runAccountSwitch(cmd, []string{"personal"})
if err != nil {
t.Fatalf("runAccountSwitch failed: %v", err)
}
// Verify symlink points to personal
target, err := os.Readlink(claudeDir)
if err != nil {
t.Fatalf("readlink: %v", err)
}
if target != personalConfigDir {
t.Errorf("symlink target = %q, want %q", target, personalConfigDir)
}
// Verify default was updated
loadedCfg, err := config.LoadAccountsConfig(accountsPath)
if err != nil {
t.Fatalf("load accounts: %v", err)
}
if loadedCfg.Default != "personal" {
t.Errorf("default = %q, want 'personal'", loadedCfg.Default)
}
})
t.Run("already on target account", func(t *testing.T) {
townRoot, accountsDir := setupTestTownForAccount(t)
fakeHome := t.TempDir()
originalHome := os.Getenv("HOME")
os.Setenv("HOME", fakeHome)
defer os.Setenv("HOME", originalHome)
workConfigDir := filepath.Join(accountsDir, "work")
if err := os.MkdirAll(workConfigDir, 0755); err != nil {
t.Fatalf("mkdir work config: %v", err)
}
accountsPath := filepath.Join(townRoot, "mayor", "accounts.json")
accountsCfg := config.NewAccountsConfig()
accountsCfg.Accounts["work"] = config.Account{
Email: "steve@work.com",
ConfigDir: workConfigDir,
}
accountsCfg.Default = "work"
if err := config.SaveAccountsConfig(accountsPath, accountsCfg); err != nil {
t.Fatalf("save accounts.json: %v", err)
}
// Create symlink already pointing to work
claudeDir := filepath.Join(fakeHome, ".claude")
if err := os.Symlink(workConfigDir, claudeDir); err != nil {
t.Fatalf("create symlink: %v", err)
}
originalWd, _ := os.Getwd()
defer os.Chdir(originalWd)
if err := os.Chdir(townRoot); err != nil {
t.Fatalf("chdir: %v", err)
}
// Switch to work (should be no-op)
cmd := &cobra.Command{}
err := runAccountSwitch(cmd, []string{"work"})
if err != nil {
t.Fatalf("runAccountSwitch failed: %v", err)
}
// Symlink should still point to work
target, err := os.Readlink(claudeDir)
if err != nil {
t.Fatalf("readlink: %v", err)
}
if target != workConfigDir {
t.Errorf("symlink target = %q, want %q", target, workConfigDir)
}
})
t.Run("nonexistent account", func(t *testing.T) {
townRoot, accountsDir := setupTestTownForAccount(t)
fakeHome := t.TempDir()
originalHome := os.Getenv("HOME")
os.Setenv("HOME", fakeHome)
defer os.Setenv("HOME", originalHome)
workConfigDir := filepath.Join(accountsDir, "work")
if err := os.MkdirAll(workConfigDir, 0755); err != nil {
t.Fatalf("mkdir work config: %v", err)
}
accountsPath := filepath.Join(townRoot, "mayor", "accounts.json")
accountsCfg := config.NewAccountsConfig()
accountsCfg.Accounts["work"] = config.Account{
Email: "steve@work.com",
ConfigDir: workConfigDir,
}
accountsCfg.Default = "work"
if err := config.SaveAccountsConfig(accountsPath, accountsCfg); err != nil {
t.Fatalf("save accounts.json: %v", err)
}
originalWd, _ := os.Getwd()
defer os.Chdir(originalWd)
if err := os.Chdir(townRoot); err != nil {
t.Fatalf("chdir: %v", err)
}
// Switch to nonexistent account
cmd := &cobra.Command{}
err := runAccountSwitch(cmd, []string{"nonexistent"})
if err == nil {
t.Fatal("expected error for nonexistent account")
}
})
t.Run("real directory gets moved", func(t *testing.T) {
townRoot, accountsDir := setupTestTownForAccount(t)
fakeHome := t.TempDir()
originalHome := os.Getenv("HOME")
os.Setenv("HOME", fakeHome)
defer os.Setenv("HOME", originalHome)
workConfigDir := filepath.Join(accountsDir, "work")
personalConfigDir := filepath.Join(accountsDir, "personal")
// Don't create workConfigDir - it will be created by moving ~/.claude
if err := os.MkdirAll(personalConfigDir, 0755); err != nil {
t.Fatalf("mkdir personal config: %v", err)
}
accountsPath := filepath.Join(townRoot, "mayor", "accounts.json")
accountsCfg := config.NewAccountsConfig()
accountsCfg.Accounts["work"] = config.Account{
Email: "steve@work.com",
ConfigDir: workConfigDir,
}
accountsCfg.Accounts["personal"] = config.Account{
Email: "steve@personal.com",
ConfigDir: personalConfigDir,
}
accountsCfg.Default = "work"
if err := config.SaveAccountsConfig(accountsPath, accountsCfg); err != nil {
t.Fatalf("save accounts.json: %v", err)
}
// Create ~/.claude as a real directory with a marker file
claudeDir := filepath.Join(fakeHome, ".claude")
if err := os.MkdirAll(claudeDir, 0755); err != nil {
t.Fatalf("mkdir .claude: %v", err)
}
markerFile := filepath.Join(claudeDir, "marker.txt")
if err := os.WriteFile(markerFile, []byte("test"), 0644); err != nil {
t.Fatalf("write marker: %v", err)
}
originalWd, _ := os.Getwd()
defer os.Chdir(originalWd)
if err := os.Chdir(townRoot); err != nil {
t.Fatalf("chdir: %v", err)
}
// Switch to personal
cmd := &cobra.Command{}
err := runAccountSwitch(cmd, []string{"personal"})
if err != nil {
t.Fatalf("runAccountSwitch failed: %v", err)
}
// Verify ~/.claude is now a symlink to personal
fileInfo, err := os.Lstat(claudeDir)
if err != nil {
t.Fatalf("lstat .claude: %v", err)
}
if fileInfo.Mode()&os.ModeSymlink == 0 {
t.Error("~/.claude is not a symlink")
}
target, err := os.Readlink(claudeDir)
if err != nil {
t.Fatalf("readlink: %v", err)
}
if target != personalConfigDir {
t.Errorf("symlink target = %q, want %q", target, personalConfigDir)
}
// Verify original content was moved to work config dir
movedMarker := filepath.Join(workConfigDir, "marker.txt")
if _, err := os.Stat(movedMarker); err != nil {
t.Errorf("marker file not moved to work config dir: %v", err)
}
})
}