한국어 | English
📖 Documentation: ictechgy.github.io/multi-account-tool
Switch between multiple AI CLI accounts (Claude Code, Codex, Gemini / Antigravity, Aider, Kimi, Qwen, Crush, OpenCode, Goose) from a single TUI. No more logout / login shuffles — keep one profile per account and swap in a keystroke. Safe by default: macOS Keychain backups with automatic rollback, atomic file writes, plaintext-credential exclusion paths, OAuth refresh-token rotation awareness with TUI dialog (recapture / discard / cancel).
╭ Multi-Account Tool ────────────────────────────────╮
│ AI CLI account switcher │
╰─────────────────────────────────────────────────────╯
> Claude Code [active: personal] ✓
Codex CLI [active: work] ✓
Gemini / Antigravity [active: personal] ✓
- You use Claude Code, Codex, Gemini, and friends, each with multiple accounts (personal / work / team)
- You're tired of running
logout→loginevery time you change context - You forget which account is currently active
mat swaps only the credentials. Everything else — hooks, agents, CLAUDE.md, conversation history, settings — stays untouched.
| CLI | Credential location | Swap strategy |
|---|---|---|
| Claude Code | macOS Keychain (Claude Code-credentials) |
Keychain entry swap |
| Codex CLI | ~/.codex/auth.json |
File swap |
| Gemini / Antigravity | ~/.gemini/oauth_creds.json, google_accounts.json |
File swap |
| Aider | ~/.aider.conf.yml |
File swap |
| Kimi CLI | ~/.kimi/config.toml |
File swap |
| Qwen Code CLI | ~/.qwen/settings.json, ~/.qwen/.env |
File swap |
| Crush | ~/.config/crush/crush.json, ~/.local/share/crush/crush.json |
File swap |
| OpenCode | ~/.local/share/opencode/auth.json (OS-agnostic, XDG standard) |
File swap |
| Goose | macOS Keychain / Linux Secret Service (service goose, account secrets) + ~/.config/goose/secrets.yaml + config.yaml |
Multi-source (account-scoped Keychain/os-keyring; Linux swaps via secret-tool — see below) |
Some CLIs use OAuth refresh-token rotation (RFC 6749 best practice): a refresh token may only be used once, after which the provider invalidates it. If mat restores an older snapshot of such a token, the provider rejects it as "already used" and the user is forced to re-login. The table below summarises which mat-supported CLIs are affected.
| CLI | Auth type | Rotation risk | mat safe modes |
|---|---|---|---|
| Codex CLI | OAuth (tokens.refresh_token, tokens.account_id) |
🔴 High — confirmed token revocation after stale restore | mat freshness codex before swap; mat exec for one-shot sessions |
| Gemini / Antigravity | OAuth (refresh_token + google_accounts.json.active) |
🔴 High | Same as Codex |
| OpenCode | OAuth per provider (provider.refresh, provider.accountId) |
🔴 High | Same as Codex |
| Claude Code | macOS Keychain (Anthropic OAuth) | 🟢 Mitigated — identity-aware adapter (subscriptionType + macOS keychain account) |
mat exec, and mat freshness claude (PR-H adapter, high-confidence rotation classification) |
| Goose | macOS Keychain + secrets.yaml / config.yaml (provider-routed) |
🟢 Mitigated — identity-aware adapter (provider key matrix + keychain account) | mat freshness goose reports per-source result, identity-aware |
| Aider / Kimi / Qwen / Crush | Static API key | 🟢 None | Standard swap suffices — but environment variables or project-local config can bypass mat (see "Platform support" below) |
Use mat freshness [<cli>] [--profile <name>] [--json] to inspect the live credentials versus the active profile before you swap. Exit code 0 means safe, exit code 1 means mat detected stale (identity changed or profile missing). For long-running sessions prefer mat exec, which automatically restores the previous profile after the command finishes — note that a SIGKILL to mat itself bypasses restore (see Security section).
OAuth rotation handling (PR-G/PR-I*/PR-H all landed): the TUI swap path detects freshness drift before swapping and shows an interactive Recapture / Discard / Cancel dialog (PR-G). Recapture saves the live credentials into the active profile via
snapshotLiveToProfilethen swaps; Discard skips the auto-snapshot (data loss); Cancel aborts.mat execre-captures the live credentials on exit (PR-I*) so rotation triggered during the command is preserved in the swap-target profile before restore — protected againstSIGINT/SIGTERM/SIGHUP(SIGKILLis OS-level untrappable and falls back to stale-recovery on the nextmatcall). Claude/Goose identity-aware adapters (PR-H) classify rotation vs identity change withhigh/mediumconfidence — no more[low conf]dialog noise on safe swaps.
| CLI | macOS | Linux | Windows | Override / known limits |
|---|---|---|---|---|
| Claude Code | ✅ | ❌ | ❌ | macOS Keychain only — no Linux/Windows credential-store backend yet |
| Codex CLI | ✅ | ✅ | ~/.codex/auth.json (cross-platform file path) |
|
| Gemini / Antigravity | ✅ | ✅ | ~/.gemini/oauth_creds.json + google_accounts.json |
|
| Aider | ✅ | ✅ | env override: OPENAI_API_KEY / ANTHROPIC_API_KEY / OPENAI_API_BASE etc. bypass ~/.aider.conf.yml — mat cannot swap shell env |
|
| Kimi CLI | ✅ | ✅ | env override: MOONSHOT_API_KEY and friends bypass ~/.kimi/config.toml |
|
| Qwen Code CLI | ✅ | ✅ | Credential precedence: shell env > ~/.qwen/.env > ~/.qwen/settings.json. mat swaps both files but cannot affect shell env |
|
| Crush | ✅ | ✅ | project-local override: ./.crush.json / ./crush.json in CWD takes precedence over ~/.config/crush/*; CRUSH_GLOBAL_* env vars also override |
|
| OpenCode | ✅ | ✅ | OS-agnostic XDG path (~/.local/share/opencode/auth.json on every OS via xdg-basedir) |
|
| Goose | ✅ | ✅ os-keyring | ❌ | macOS Keychain / Linux Secret Service (goose/secrets via secret-tool) + ~/.config/goose/*.yaml. On Linux mat includes the os-keyring source by default and requires secret-tool (libsecret-tools) + a keyring daemon — a missing tool or down daemon errors out rather than silently swapping stale YAML (Goose reaches the keyring via the libsecret library, so a missing secret-tool CLI does not prove the keyring is unused). Set GOOSE_DISABLE_KEYRING=1 if you use the file backend; mat then omits os-keyring and swaps secrets.yaml. Windows Credential Manager not yet supported |
"
- Pre-swap freshness check — if the live credentials drifted from the active profile (OAuth refresh-token rotation),
matshows a Recapture / Discard / Cancel dialog before steps 1–3 below. See "OAuth Rotation Safety Matrix" above for per-CLI classification. - The current live credentials are snapshotted into the currently active profile (automatic backup).
- The target profile's stored credentials are atomically restored to the live location.
- The active-profile pointer is updated.
Multi-source CLIs (e.g., Gemini with two files) get partial-failure rollback: if one source fails to restore, already-restored sources are reverted to the live backup to prevent split-state.
brew tap ictechgy/mat
brew install matnpm install -g multi-account-toolgit clone https://github.com/ictechgy/multi-account-tool.git
cd multi-account-tool
npm install
npm run build
npm linkmat --version # prints the installed semver
mat --help # subcommand list (TUI flags + `mat exec` / `mat freshness`)
node scripts/smoke-test.mjs # source-checkout only — read-only smoke test (CLI defs load + paths resolve, never touches credentials)The smoke test is read-only and safe to run on a machine with active mat profiles.
mat # launch the TUI
mat --version # print installed version
mat --help # short usage summary (subcommands: exec, freshness)The TUI opens with CLI → profile → switch.
If the CLI's live credentials are already present, mat offers to import them as a default profile. The prompt is shown once and never auto-pops again (you can always capture manually later).
mat→ pick a CLI → pressa→ enter a profile name (e.g.,work)- Press
Enteron the new profile to make it active. If the live credentials drifted from the active profile's stored snapshot (OAuth refresh-token rotation),matshows a Recapture / Discard / Cancel dialog before swapping — see Switch flow + OAuth Rotation Safety Matrix above. - In a separate terminal, log in to the CLI itself (
claude,codex,gemini, …). This overwrites the live credentials with the new account. - Back in
mat, presscon the same profile to capture the new live credentials into it - From now on, switch freely between profiles with
Enter
| Screen | Key | Action |
|---|---|---|
| Anywhere | q / Ctrl+C |
Quit |
| Anywhere | Esc |
Back |
| Home / Profiles | ↑ ↓ |
Move |
| Home / Profiles | Enter |
Select / Switch |
| Profiles | a |
Add profile |
| Profiles | c |
Capture live credentials into the focused profile |
| Profiles | r |
Rename |
| Profiles | d |
Delete |
| Freshness dialog | r / Enter |
Recapture (save live into active profile before swap) |
| Freshness dialog | d |
Discard (skip auto-snapshot — data loss) |
| Freshness dialog | c / Esc |
Cancel swap |
mat exec <cli> <profile> -- <cmd...>Temporarily swap to <profile>, run <cmd>, then restore the previously active profile when the command exits.
# Run a single Claude session as the "work" profile, then restore "personal"
mat exec claude work -- claude
# Pair with lterm (optional — install with `npm install -g @ictechgy/lterm` first)
lterm send-keys "mat exec claude work -- claude" EnterBehaviour:
- Requires an active profile for
<cli>already set (use the TUI to capture live credentials first). - A per-CLI lockfile (
~/.multi-account-tool/locks/<cli>.lock) prevents twomat execruns from racing on the same CLI. Stale locks from crashed processes are auto-recovered. - Signals (
SIGINT/SIGTERM/SIGHUP) are forwarded to the child; the child's exit code and signal are propagated back. - On exit,
matre-captures the live credentials into<profile>first (so rotation triggered by<cmd>is preserved), then restores the previous active profile. The recapture has a default 10s timeout (MAT_EXEC_RECAPTURE_TIMEOUT_MSenv override) to bound keychain-prompt hangs. - The restore step runs in a
finallyblock so normal exit, errors, and forwarded signals all trigger it. ASIGKILL(or other untrappable signal:SIGSEGV/SIGBUS) tomatitself bypasses restore — on the nextmatinvocation, the stale lock is auto-recovered andmatwrites a stderr warning indicating the live credentials may still belong to<profile>rather than the previous active profile (policy B: warn + drop).
This is temporal isolation, not session isolation: while the child runs, the OS-global credentials are the <profile> ones. Two terminals running different mat exec commands serialise via the lock; true per-session isolation is on the roadmap.
Exit codes:
| Code | Meaning |
|---|---|
0 |
Child exited 0 (and restore succeeded) |
2 |
Usage error (UsageError — pre-spawn validation) |
74 |
mat-side restore failed (restoreError set) — child result preserved on stdout/stderr |
75 |
Another mat exec holds the per-CLI lock (LockHeldError — pre-spawn) |
128+N |
Child terminated by signal N (e.g., 130 for SIGINT) |
1 |
Either: child exited non-zero with code 1, OR mat itself hit an unexpected error before/after child execution |
other (e.g., 3, 42) |
Child's own non-zero exit code is propagated as-is |
Note: 2 / 74 / 75 are reserved by mat's own error model (pre-spawn validation, lock contention, post-spawn restore failure). Any other non-zero code below 128 is the child's own exit code propagated transparently. Use restoreError log lines on stderr to distinguish 74 from a child exit 74 (unlikely but possible).
mat session start <cli> <profile> # launch an isolated subshell on <profile>
mat session list # running / orphan sessions
mat session stop <id> # terminate a session or reap an orphanUnlike mat exec (temporal isolation, serialized by a lock), mat session gives true concurrent isolation — two terminals can use different accounts of the same CLI at the same time:
# terminal A
mat session start codex work # CODEX_HOME points at an isolated dir → "work" account
# terminal B (simultaneously)
mat session start codex personal # independent isolated dir → "personal" accountMechanism — env injection + copy-isolate. mat session start spawns your $SHELL with the CLI's config-dir env var (e.g. CODEX_HOME) pointed at a fresh per-session directory under ~/.multi-account-tool/sessions/<id>/. The profile's credentials are copied (0600) into that directory, so the CLI inside the subshell reads the isolated account. On exit, mat re-captures the (possibly OAuth-rotated) credentials back into the profile and removes the session directory. The OS-global credentials and mat exec's lock are never touched — so sessions run concurrently without interference.
Supported CLIs (those that relocate their credential directory via an env var):
| CLI | env var |
|---|---|
| Codex | CODEX_HOME |
| Qwen Code | QWEN_HOME |
| Kimi | KIMI_SHARE_DIR |
| Crush | CRUSH_GLOBAL_CONFIG + CRUSH_GLOBAL_DATA |
Not supported (no credential-relocating env var; mat session start errors out): gemini (no env override — gemini-cli#2815), claude (macOS Keychain service name is not env-overridable), aider (credentials are provider env vars, not a file), opencode, goose, and any user plugin CLI (1st iteration is built-in only).
Exit codes mirror mat exec: 0 success, 2 usage error, 74 re-capture failed, 128+N child signal N (self-raised), child's own non-zero code propagated otherwise.
Limitations (read before relying on it):
- Credentials are isolated; non-secret config is not shared (1st iteration). Anything other than the credential file (model config, history, sessions, caches) is not materialized into the session — the CLI falls back to its defaults and anything it creates inside the session is discarded on exit (only the credential file is re-captured). This is a deliberate, fail-closed default. Contrast with
mat exec, where the CLI uses the real config dir so history is preserved: prefermat execfor long single-account work,mat sessionfor concurrent multi-account. (An allow-list to share read-mostly config like Codexconfig.tomlis implemented but disabled until its contents are verified — a follow-up.) SIGKILLorphans the session directory (trap-impossible, same asmat exec); the nextmat sessioncall reaps it (owning process gone — PID-reuse-aware via the process start-time signature — and both the session's start time and its directory mtime older than 1h).- Parent-trust assumption: isolation assumes
~/.multi-account-tooland its parent are trusted; a symlinked~/.multi-account-toolis out of scope. - Same profile, two concurrent sessions: re-capture is unsynchronized — single-credential CLIs are last-writer-wins; multi-credential CLIs (Qwen/Crush) may end up with files from different sessions. Both are always valid credentials of the same account (never wrong-account, never corrupted) and self-heal on next use. Prefer a distinct profile per terminal; per-profile locking is a follow-up.
mat session stopsendsSIGTERMonly when it can confirm the owning process's identity (PID + start-time signature). If that can't be verified (rare — e.g.psunavailable), it leaves the session untouched and asks you to retry, rather than risk killing an unrelated process that reused the PID.
mat freshness [<cli>] [--profile <name>] [--json]Compare live credentials with the active (or specified) profile snapshot and report drift before you swap. Useful in CI chains (mat freshness && deploy.sh) to block stale-restore incidents (e.g., OAuth refresh_token revocation after wrong-profile restore).
# Quick safety check before a long Claude session
mat freshness claude
# Inspect a specific profile (machine-readable JSON for CI)
mat freshness codex --profile work --jsonEach source is classified into one of four states — fresh (byte-identical), rotated (token rotated but identity preserved; safe to swap), stale (identity changed — a different account; swap will revoke), inflight (multi-source CLI partially updated — retry shortly).
Exit codes:
| Code | Meaning |
|---|---|
0 |
All sources are fresh or high-confidence rotated — safe to swap |
1 |
One or more sources are stale, low-confidence rotated, or inflight — fix before swap |
2 |
Usage error |
74 |
Internal check failed (e.g., source read error) |
See the OAuth Rotation Safety Matrix at the top of this README for per-CLI classification confidence.
~/.multi-account-tool/
├── config.json # active profile pointer + flags
├── cli-defs/ # optional user plugins — see "Adding a new CLI"
│ └── <id>.json
├── locks/ # per-CLI `mat exec` lock dirs (auto-recovered on stale)
│ └── <cli>.lock/
└── profiles/
├── claude/ # credentials.json (macOS Keychain backup, plaintext)
│ ├── personal/
│ │ ├── credentials.json
│ │ └── meta.json
│ └── work/...
├── codex/ # auth.json
├── gemini/ # oauth_creds.json + google_accounts.json
├── aider/ # aider.yml
├── kimi/ # config.toml
├── qwen/ # qwen-settings.json + qwen.env (prefixed saveAs to disambiguate)
├── crush/ # crush-config.json + crush-data.json (config + data layers)
├── opencode/ # auth.json (OS-agnostic XDG)
└── goose/ # goose-keyring.json (macOS Keychain / Linux Secret Service) + goose-secrets.yaml + goose-config.yaml
Files are created with 0600, directories with 0700.
-
Keychain ACL relaxation — All Keychain-backed sources (Claude Code credentials, Goose
goose/secretsentry) are normally protected by a Keychain ACL that limits access to specific binaries. To avoid breaking the upstream CLI after a swap,matrewrites the entry withsecurity add-generic-password -A, which allows any process running as the same user to read it. Any process under your UID (including a maliciousnpm postinstall) could then read it silently. An opt-in-T <path>whitelist mode is planned for a future release. -
Plaintext credential backups — OAuth tokens are stored as plaintext JSON under
~/.multi-account-tool/profiles/. Files are0600and directories0700, but they can still be picked up by disk backups. Exclude the data directory from Time Machine / iCloud / cloud-synced folders:xattr -w com.apple.metadata:com_apple_backup_excludeItem true ~/.multi-account-tool
-
argvexposure —security add-generic-password -w <value>passes the OAuth token as an argv parameter (a limitation of thesecurityCLI itself). It is briefly visible tops -ef, BSM audit, and EDR logs. Not recommended on machines with audit / EDR enabled.
- All external commands use
spawn(argv)only — no shell, no injection surface securityis invoked only via the absolute path/usr/bin/security(defends against PATH-shim attacks)- All file writes go through a single atomic helper (
.tmp → rename,O_EXCL + O_NOFOLLOW,0600) - Config mutations are funneled through
mutateConfig(in-process serialization) - Profile names:
[a-zA-Z0-9가-힣_.-]{1,40}+ NFC normalization + explicit rejection of./..///\/ NUL - Keychain swap: backup → exact-acct delete → add. If
addfails, the backup is auto-restored; if the rollback also fails, both errors surface together. - Restore is rollback-safe for multi-source CLIs (already-restored sources are reverted to the live backup on partial failure)
- Error messages are redacted (JWT pattern + 50+ char base64-like sequences →
[redacted]) - Dependencies:
npm auditclean
- Shared workstations
- Multi-user hosts
- Managed / audit-enabled enterprise devices
- Home directories synced to a cloud folder
Two options.
Drop a JSON file at ~/.multi-account-tool/cli-defs/<id>.json. Example template for an arbitrary CLI:
{
"id": "my-cli",
"name": "My CLI",
"sources": [
{ "type": "file", "path": "~/.config/my-cli/credentials.json", "saveAs": "credentials.json" }
]
}mat loads every *.json in that directory at startup. Invalid plugins are warned and skipped — mat keeps working. Built-in CLIs (claude, codex, gemini, aider, kimi, qwen, crush, opencode, goose) cannot be overridden — id collision is rejected.
Field rules:
id: ASCII letter start, then letters/digits/_/-, 1~32 chars (must not collide with built-ins).name: any non-empty string (display label).sources[].type:'file'or'keychain'(keychain is macOS-only).sources[].saveAs: ASCII filename, 1~64 chars ([a-zA-Z0-9._-]).sources[].path(file): any non-empty string (your filesystem path,~/expanded).sources[].service(keychain): any non-empty string (Keychain service name).sources[].account(keychain, optional): scopematto a specific-s <service> -a <account>entry. Required for generic / multi-account services (e.g., Goose'sgoose/secretsor any CLI with multiple Keychain entries under the same service) — without it,matmay match the wrong account. Validation: non-empty string, no NUL chars. Omit for single-account services (default behaviour preserved).
Add an entry to src/core/cli-defs.ts:
{
id: 'foo',
name: 'Foo CLI',
sources: [
{ type: 'file', path: '~/.foo/credentials.json', saveAs: 'credentials.json' }
]
}Use this for community-shared CLIs that should ship with mat. PRs welcome.
See CHANGELOG.md for release history and notable changes (Keep a Changelog format, Semantic Versioning).
See ROADMAP.md for v0.4+ plans:
Plugin mechanism for community-contributed CLI definitions✅ (v0.3)Aider built-in support✅ (v0.3) +Kimi / Qwen / Crush / OpenCode✅ (v0.3.x)Session-scoped credential isolation✅ (v0.4.x —mat session start/list/stop: env-injection + copy-isolate, concurrent multi-account; theltermshim integration below is still pending)- More built-in CLIs —
Goose✅ (v0.4.0 account-scoped Keychain; Linux Secret Service added via theos-keyringsource type). Copilot / Amp remain deferred — Copilot needs multi-account/user switchapplication-state swap, and Windows Credential Manager support is still pending (a separate follow-up). Cursor Agent: plugin recommended (keychain service name not publicly documented). - Goose Linux: on Linux, mat swaps Goose's default
secret-servicebackend (libsecret, GNOME Keyring/KWallet) through theos-keyringsource (secret-toolCLI,goose/secrets) plus the~/.config/goose/*.yamlfiles. Behavior by configuration:- Default (keyring): the os-keyring source is included and requires
secret-tool(libsecret-tools) + a running keyring daemon. A missing tool or a down/denied daemon produces an explicit error — mat does not silently fall back to YAML, because Goose accesses the keyring through the libsecret library (a separate package from thesecret-toolCLI), so a missing CLI does not prove the keyring is unused. Silently swappingsecrets.yamlfor an active keyring user would be a wrong-account write. An absent keyring entry (vs. a missing tool) is a normal "not found" and skips to the YAML files. - File backend: set
GOOSE_DISABLE_KEYRING. mat treats the env var as present-means-disabled (any value, including0/false/empty) — matching Goose's ownenv::var(...).is_ok()check — and then omits the keyring source (os-keyring on Linux, Keychain on macOS), swapping onlysecrets.yaml+config.yaml. Aconfig.yaml-onlykeyring: falsesetting is not auto-detected, so set the env var too.
- Default (keyring): the os-keyring source is included and requires
lterm claude --profile <name>shim wrapper
MIT — LICENSE