Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions install.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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);

Expand Down
95 changes: 94 additions & 1 deletion lib/store/io.cjs
Original file line number Diff line number Diff line change
@@ -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"<blob>="([^"]*)"/);
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');
Expand All @@ -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');
}
Expand Down Expand Up @@ -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);
}
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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",
Expand Down