From 3e59d8fca546876d9465c9b08eb262cce63a9c19 Mon Sep 17 00:00:00 2001 From: maciborka Date: Thu, 16 Jul 2026 03:37:20 +0300 Subject: [PATCH] fix(store): bridge credentials I/O to macOS keychain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On macOS, Claude Code 2.x stores OAuth credentials in the login keychain (service "Claude Code-credentials") instead of the legacy ~/.claude/.credentials.json file. The switcher only read/wrote that file, so on a default macOS install it crashed with ENOENT and switches had no effect (Claude Code never reads the file it wrote). Bridge the credential I/O in lib/store/io.cjs: - readCredentials / writeCredentials detect the keychain case (darwin + credentials file absent) and use the "security" CLI, otherwise fall back to the existing file path — keeping full backward compatibility for older Claude Code / Linux / Windows. - writeLiveState now uses writeCredentials, so switching actually updates the keychain entry Claude Code reads. - backupKeychainCredentials dumps the current keychain value to the backup dir before overwriting (the file backupFile step was a no-op when no credentials file existed). cc-switch.cjs reads credentials via readCredentials instead of readJson. Verified on macOS with Claude Code 2.1.204: cc-switch, cc-sync-oauth, list and the no-op write path all work; the keychain entry stays stable and a backup is produced. Fixes #3 --- cc-switch.cjs | 3 +- lib/store/io.cjs | 78 ++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 78 insertions(+), 3 deletions(-) diff --git a/cc-switch.cjs b/cc-switch.cjs index e822dbc..c815e3a 100644 --- a/cc-switch.cjs +++ b/cc-switch.cjs @@ -24,6 +24,7 @@ const { ensureDir, readJson, readJsonIfExists, + readCredentials, deepCopy, writeLiveState, writeStore, @@ -184,7 +185,7 @@ async function main() { try { const config = readJson(options.configPath); - const credentials = readJson(options.credentialsPath); + const credentials = readCredentials(options.credentialsPath); const existingStore = normalizeStore(readJsonIfExists(options.storePath, { version: STORE_VERSION, accounts: [] }), STORE_VERSION); if (options.usageOnly) { diff --git a/lib/store/io.cjs b/lib/store/io.cjs index e93b071..f6b1324 100644 --- a/lib/store/io.cjs +++ b/lib/store/io.cjs @@ -1,6 +1,10 @@ const fs = require('fs'); const os = require('os'); const path = require('path'); +const { execFileSync } = require('child_process'); + +const KEYCHAIN_SERVICE = 'Claude Code-credentials'; +const isDarwin = process.platform === 'darwin'; function getDefaultConfigPath() { return path.join(os.homedir(), '.claude.json'); @@ -36,6 +40,75 @@ function writeJson(filePath, value) { fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8'); } +function credentialsUseKeychain(credentialsPath) { + return isDarwin && !fs.existsSync(credentialsPath); +} + +function keychainAccount() { + try { + const out = execFileSync('security', ['find-generic-password', '-s', KEYCHAIN_SERVICE], { encoding: 'utf8' }); + const match = out.match(/"acct"="([^"]*)"/); + return match ? match[1] : null; + } catch { + return null; + } +} + +function readKeychainCredentials() { + try { + const raw = execFileSync('security', ['find-generic-password', '-s', KEYCHAIN_SERVICE, '-w'], { encoding: 'utf8' }); + return JSON.parse(raw.trim()); + } catch (err) { + throw new Error(`Failed to read credentials from macOS keychain (service "${KEYCHAIN_SERVICE}"): ${err.message}`); + } +} + +function writeKeychainCredentials(value) { + const account = keychainAccount() || os.userInfo().username || 'Claude Code'; + execFileSync('security', [ + 'add-generic-password', '-U', + '-s', KEYCHAIN_SERVICE, + '-a', account, + '-w', JSON.stringify(value), + ]); +} + +function backupKeychainCredentials(backupDir) { + let value; + try { + value = readKeychainCredentials(); + } catch { + return; + } + ensureDir(backupDir); + const timestamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\..+/, '').replace('T', '-'); + fs.writeFileSync(path.join(backupDir, `credentials-keychain.${timestamp}.bak`), `${JSON.stringify(value, null, 2)}\n`, 'utf8'); + const backups = fs.readdirSync(backupDir) + .filter((name) => name.startsWith('credentials-keychain.') && name.endsWith('.bak')) + .sort() + .reverse(); + for (const stale of backups.slice(3)) { + fs.rmSync(path.join(backupDir, stale), { force: true }); + } +} + +function readCredentials(credentialsPath) { + if (credentialsUseKeychain(credentialsPath)) { + return readKeychainCredentials(); + } + return readJson(credentialsPath); +} + +function writeCredentials(credentialsPath, value, backupDir) { + if (credentialsUseKeychain(credentialsPath)) { + backupKeychainCredentials(backupDir); + writeKeychainCredentials(value); + return; + } + backupFile(credentialsPath, backupDir); + writeJson(credentialsPath, value); +} + function backupFile(filePath, backupDir) { if (!fs.existsSync(filePath)) return; ensureDir(backupDir); @@ -59,9 +132,8 @@ function deepCopy(value) { function writeLiveState(config, credentials, options) { backupFile(options.configPath, options.backupDir); - backupFile(options.credentialsPath, options.backupDir); writeJson(options.configPath, config); - writeJson(options.credentialsPath, credentials); + writeCredentials(options.credentialsPath, credentials, options.backupDir); } function writeStore(store, options) { @@ -79,6 +151,8 @@ module.exports = { readJsonIfExists, writeJson, backupFile, + readCredentials, + writeCredentials, deepCopy, writeLiveState, writeStore,