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
+136
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)
}