mat multi-account-tool

AI CLI account switcher

multi-account-tool (mat)

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] ✓

Why

How it works

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)

OAuth Rotation Safety Matrix

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 snapshotLiveToProfile then swaps; Discard skips the auto-snapshot (data loss); Cancel aborts. mat exec re-captures the live credentials on exit (PR-I*) so rotation triggered during the command is preserved in the swap-target profile before restore — protected against SIGINT/SIGTERM/SIGHUP (SIGKILL is OS-level untrappable and falls back to stale-recovery on the next mat call). Claude/Goose identity-aware adapters (PR-H) classify rotation vs identity change with high/medium confidence — no more [low conf] dialog noise on safe swaps.

Platform support

CLI macOS Linux Windows Override / known limits
Claude Code macOS Keychain only — no Linux/Windows credential-store backend yet
Codex CLI ⚠️ untested ~/.codex/auth.json (cross-platform file path)
Gemini / Antigravity ⚠️ untested ~/.gemini/oauth_creds.json + google_accounts.json
Aider ⚠️ untested env override: OPENAI_API_KEY / ANTHROPIC_API_KEY / OPENAI_API_BASE etc. bypass ~/.aider.conf.ymlmat cannot swap shell env
Kimi CLI ⚠️ untested env override: MOONSHOT_API_KEY and friends bypass ~/.kimi/config.toml
Qwen Code CLI ⚠️ untested Credential precedence: shell env > ~/.qwen/.env > ~/.qwen/settings.json. mat swaps both files but cannot affect shell env
Crush ⚠️ untested project-local override: ./.crush.json / ./crush.json in CWD takes precedence over ~/.config/crush/*; CRUSH_GLOBAL_* env vars also override
OpenCode ⚠️ untested 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

"⚠️ untested" = swap logic is platform-agnostic file I/O, but the project's CI runs macOS + Ubuntu only. Windows paths are inferred from each CLI's documentation, not exercised. Patches and bug reports welcome.

Switch flow (lossless)

  1. Pre-swap freshness check — if the live credentials drifted from the active profile (OAuth refresh-token rotation), mat shows a Recapture / Discard / Cancel dialog before steps 1–3 below. See "OAuth Rotation Safety Matrix" above for per-CLI classification.
  2. The current live credentials are snapshotted into the currently active profile (automatic backup).
  3. The target profile's stored credentials are atomically restored to the live location.
  4. 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.


Install

brew tap ictechgy/mat
brew install mat

npm

npm install -g multi-account-tool

From source

git clone https://github.com/ictechgy/multi-account-tool.git
cd multi-account-tool
npm install
npm run build
npm link

Verify the install

mat --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.


Usage

mat              # launch the TUI
mat --version    # print installed version
mat --help       # short usage summary (subcommands: exec, freshness)

The TUI opens with CLI → profile → switch.

First run

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).

Adding a new account

  1. mat → pick a CLI → press a → enter a profile name (e.g., work)
  2. Press Enter on the new profile to make it active. If the live credentials drifted from the active profile's stored snapshot (OAuth refresh-token rotation), mat shows a Recapture / Discard / Cancel dialog before swapping — see Switch flow + OAuth Rotation Safety Matrix above.
  3. In a separate terminal, log in to the CLI itself (claude, codex, gemini, …). This overwrites the live credentials with the new account.
  4. Back in mat, press c on the same profile to capture the new live credentials into it
  5. From now on, switch freely between profiles with Enter

Key bindings

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 — one-shot swap around a command

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" Enter

Behaviour:

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 — per-session isolation (different account per terminal, concurrently)

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 orphan

Unlike 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" account

Mechanism — 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):

mat freshness — pre-swap safety check

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 --json

Each 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 inflightfix 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.


Data layout

~/.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.


Security

Accepted trade-offs (by design)

Built-in safeguards


Adding a new CLI

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:

2. Built-in addition — requires mat repo PR

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.


Changelog

See CHANGELOG.md for release history and notable changes (Keep a Changelog format, Semantic Versioning).

Roadmap

See ROADMAP.md for v0.4+ plans:


License

MIT — LICENSE