Skip to content

Commit b6d4521

Browse files
committed
feat: support Claude Code Keychain OAuth credentials transfer
1 parent ee88bdd commit b6d4521

1 file changed

Lines changed: 73 additions & 19 deletions

File tree

pkg/cmd/open/open.go

Lines changed: 73 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -932,23 +932,47 @@ func isRemoteClaudeAuthenticated(sshAlias string) bool {
932932
return checkCmd.Run() == nil
933933
}
934934

935-
// tryTransferClaudeOAuthSession checks for a local ~/.claude/.credentials.json
936-
// and offers to transfer it to the remote instance. This is a session transfer:
937-
// the local file is removed after copying so the token is only active on
938-
// the remote machine.
935+
// tryTransferClaudeOAuthSession checks for Claude Code OAuth credentials
936+
// locally and offers to transfer them to the remote instance. It checks:
937+
// 1. ~/.claude/.credentials.json (file on disk)
938+
// 2. macOS Keychain entry "Claude Code-credentials" (Max subscription OAuth)
939+
//
940+
// This is a session transfer: the local credentials are removed after copying
941+
// so the token is only active on the remote machine.
939942
func tryTransferClaudeOAuthSession(t *terminal.Terminal, sshAlias string) {
940943
homeDir, err := os.UserHomeDir()
941944
if err != nil {
942945
return
943946
}
944947
localCredPath := homeDir + "/.claude/.credentials.json"
945948

946-
// Check if local credentials file exists
947-
if _, err := os.Stat(localCredPath); os.IsNotExist(err) {
949+
// Determine credential source: file on disk or macOS Keychain
950+
hasFile := false
951+
hasKeychain := false
952+
var keychainCreds string
953+
954+
if _, err := os.Stat(localCredPath); err == nil {
955+
hasFile = true
956+
}
957+
958+
if runtime.GOOS == "darwin" && !hasFile {
959+
creds, err := getClaudeCredentialsFromKeychain()
960+
if err == nil && creds != "" {
961+
hasKeychain = true
962+
keychainCreds = creds
963+
}
964+
}
965+
966+
if !hasFile && !hasKeychain {
948967
return
949968
}
950969

951-
t.Vprintf("%s", t.Yellow("\nFound Claude Code OAuth session in ~/.claude/.credentials.json\n"))
970+
source := "~/.claude/.credentials.json"
971+
if hasKeychain {
972+
source = "macOS Keychain (Claude Code-credentials)"
973+
}
974+
975+
t.Vprintf("%s", t.Yellow(fmt.Sprintf("\nFound Claude Code OAuth session in %s\n", source)))
952976
t.Vprintf("%s", t.Yellow("Transferring this session will move your auth to the remote instance\n"))
953977
t.Vprintf("%s", t.Yellow("and log you out locally (the token can only be active in one place).\n\n"))
954978

@@ -968,23 +992,53 @@ func tryTransferClaudeOAuthSession(t *terminal.Terminal, sshAlias string) {
968992
return
969993
}
970994

971-
// SCP the credentials file to remote
972-
scpCmd := exec.Command("scp", localCredPath, sshAlias+":~/.claude/.credentials.json") // #nosec G204
973-
output, err := scpCmd.CombinedOutput()
974-
if err != nil {
975-
t.Vprintf(t.Red("Failed to transfer credentials: %s\n%s\n"), err, string(output))
976-
return
977-
}
978-
979-
// Remove local credentials file
980-
if err := os.Remove(localCredPath); err != nil {
981-
t.Vprintf(t.Red("Transferred to remote but failed to remove local credentials: %v\n"), err)
982-
return
995+
if hasFile {
996+
// SCP the credentials file to remote
997+
scpCmd := exec.Command("scp", localCredPath, sshAlias+":~/.claude/.credentials.json") // #nosec G204
998+
output, err := scpCmd.CombinedOutput()
999+
if err != nil {
1000+
t.Vprintf(t.Red("Failed to transfer credentials: %s\n%s\n"), err, string(output))
1001+
return
1002+
}
1003+
// Remove local credentials file
1004+
if err := os.Remove(localCredPath); err != nil {
1005+
t.Vprintf(t.Red("Transferred to remote but failed to remove local credentials: %v\n"), err)
1006+
return
1007+
}
1008+
} else {
1009+
// Write keychain credentials to remote via SSH
1010+
writeCmd := exec.Command(
1011+
"ssh", sshAlias,
1012+
fmt.Sprintf(`cat > "$HOME/.claude/.credentials.json" << 'BREV_EOF'
1013+
%s
1014+
BREV_EOF`, keychainCreds),
1015+
) // #nosec G204
1016+
output, err := writeCmd.CombinedOutput()
1017+
if err != nil {
1018+
t.Vprintf(t.Red("Failed to transfer credentials: %s\n%s\n"), err, string(output))
1019+
return
1020+
}
1021+
// Delete the keychain entry locally
1022+
deleteCmd := exec.Command("security", "delete-generic-password", "-s", "Claude Code-credentials") // #nosec G204
1023+
if err := deleteCmd.Run(); err != nil {
1024+
t.Vprintf(t.Red("Transferred to remote but failed to remove local Keychain entry: %v\n"), err)
1025+
return
1026+
}
9831027
}
9841028

9851029
t.Vprintf("%s", t.Green("OAuth session transferred to remote instance. You are now logged out locally.\n"))
9861030
}
9871031

1032+
// getClaudeCredentialsFromKeychain reads the OAuth credentials stored by
1033+
// Claude Code in the macOS Keychain under "Claude Code-credentials".
1034+
func getClaudeCredentialsFromKeychain() (string, error) {
1035+
out, err := exec.Command("security", "find-generic-password", "-s", "Claude Code-credentials", "-w").Output() // #nosec G204
1036+
if err != nil {
1037+
return "", err
1038+
}
1039+
return strings.TrimSpace(string(out)), nil
1040+
}
1041+
9881042
// getClaudeKeyFromKeychain reads the API key stored by Claude Code in the
9891043
// macOS Keychain (security framework).
9901044
func getClaudeKeyFromKeychain() (string, error) {

0 commit comments

Comments
 (0)