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:
@@ -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)
|
||||
}
|
||||
|
||||
299
internal/cmd/account_test.go
Normal file
299
internal/cmd/account_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user