diff --git a/install.cjs b/install.cjs index 125758c..ac43b98 100644 --- a/install.cjs +++ b/install.cjs @@ -77,6 +77,7 @@ function installCommands() { const actionRemoveSource = path.join(repoRoot, 'lib', 'actions', 'remove.cjs'); const actionListSource = path.join(repoRoot, 'lib', 'actions', 'list.cjs'); const actionSwitchSource = path.join(repoRoot, 'lib', 'actions', 'switch.cjs'); + const actionRenameSource = path.join(repoRoot, 'lib', 'actions', 'rename.cjs'); const sessionStartSource = path.join(repoRoot, 'session-start.cjs'); const statuslineSource = path.join(repoRoot, 'statusline.cjs'); const cliTarget = path.join(binDir, 'cc-switch.cjs'); @@ -118,6 +119,7 @@ function installCommands() { fs.copyFileSync(actionRemoveSource, path.join(binLibActionsDir, 'remove.cjs')); fs.copyFileSync(actionListSource, path.join(binLibActionsDir, 'list.cjs')); fs.copyFileSync(actionSwitchSource, path.join(binLibActionsDir, 'switch.cjs')); + fs.copyFileSync(actionRenameSource, path.join(binLibActionsDir, 'rename.cjs')); fs.copyFileSync(sessionStartSource, sessionStartTarget); fs.copyFileSync(statuslineSource, statuslineTarget); diff --git a/lib/store/io.cjs b/lib/store/io.cjs index e93b071..598b6f9 100644 --- a/lib/store/io.cjs +++ b/lib/store/io.cjs @@ -1,6 +1,85 @@ const fs = require('fs'); const os = require('os'); const path = require('path'); +const { execFileSync } = require('child_process'); + +// --- macOS keychain bridge ------------------------------------------------- +// On macOS, Claude Code stores OAuth credentials in the login keychain +// (service "Claude Code-credentials") instead of ~/.claude/.credentials.json. +// This bridge transparently routes credential reads/writes to the keychain +// when the file is absent, so the multi-account switcher works on macOS. + +const KEYCHAIN_SERVICE = 'Claude Code-credentials'; + +function isMac() { + return process.platform === 'darwin'; +} + +function keychainRead() { + try { + return execFileSync('security', ['find-generic-password', '-s', KEYCHAIN_SERVICE, '-w'], { + encoding: 'utf8', + }); + } catch (err) { + return null; + } +} + +function keychainAccount() { + try { + const out = execFileSync('security', ['find-generic-password', '-s', KEYCHAIN_SERVICE], { + encoding: 'utf8', + }); + const match = out.match(/"acct"="([^"]*)"/); + if (match) return match[1]; + } catch (err) { + /* fall through to default account */ + } + return os.userInfo().username; +} + +function keychainWrite(jsonString) { + const account = keychainAccount(); + execFileSync('security', [ + 'add-generic-password', + '-U', + '-s', + KEYCHAIN_SERVICE, + '-a', + account, + '-w', + jsonString, + ]); +} + +// Active only for the credentials file, on macOS, when no plaintext +// credentials file exists (i.e. the install relies on the keychain). +function keychainCredentialsActive(filePath) { + return ( + isMac() && + path.basename(filePath) === '.credentials.json' && + !fs.existsSync(filePath) && + keychainRead() !== null + ); +} + +function backupKeychainCredentials(backupDir) { + const raw = keychainRead(); + if (raw === null) return; + ensureDir(backupDir); + const timestamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\..+/, '').replace('T', '-'); + fs.writeFileSync(path.join(backupDir, `keychain-credentials.${timestamp}.bak`), raw, 'utf8'); + + const backups = fs.readdirSync(backupDir) + .filter((name) => name.startsWith('keychain-credentials.') && name.endsWith('.bak')) + .sort() + .reverse(); + + for (const stale of backups.slice(3)) { + fs.rmSync(path.join(backupDir, stale), { force: true }); + } +} +// --------------------------------------------------------------------------- function getDefaultConfigPath() { return path.join(os.homedir(), '.claude.json'); @@ -23,15 +102,25 @@ function ensureDir(dirPath) { } function readJson(filePath) { + if (keychainCredentialsActive(filePath)) { + return JSON.parse(keychainRead()); + } return JSON.parse(fs.readFileSync(filePath, 'utf8')); } function readJsonIfExists(filePath, fallback) { + if (keychainCredentialsActive(filePath)) { + return JSON.parse(keychainRead()); + } if (!fs.existsSync(filePath)) return fallback; return readJson(filePath); } function writeJson(filePath, value) { + if (keychainCredentialsActive(filePath)) { + keychainWrite(JSON.stringify(value)); + return; + } ensureDir(path.dirname(filePath)); fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8'); } @@ -59,7 +148,11 @@ function deepCopy(value) { function writeLiveState(config, credentials, options) { backupFile(options.configPath, options.backupDir); - backupFile(options.credentialsPath, options.backupDir); + if (keychainCredentialsActive(options.credentialsPath)) { + backupKeychainCredentials(options.backupDir); + } else { + backupFile(options.credentialsPath, options.backupDir); + } writeJson(options.configPath, config); writeJson(options.credentialsPath, credentials); } diff --git a/package.json b/package.json index 2898bf4..5902238 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "claude-code-multi-accounts", - "version": "0.3.9", + "version": "0.4.0", "description": "Installer and CLI for switching Claude Code oauthAccount entries across Windows, WSL, and Unix shells.", "license": "MIT", "type": "commonjs", @@ -27,6 +27,7 @@ "lib/actions/remove.cjs", "lib/actions/list.cjs", "lib/actions/switch.cjs", + "lib/actions/rename.cjs", "README.md", "session-start.cjs", "statusline.cjs",