mat multi-account-tool

AI CLI account switcher

multi-account-tool (mat)

Use one TUI to switch between multiple AI CLI accounts (Claude Code, Codex, Gemini CLI, Aider, Kimi, Qwen, Crush, OpenCode, Goose, Grok Build). Store one profile per account and switch with a keystroke instead of cycling through logoutlogin.

By default, mat takes the conservative path: it backs up macOS Keychain entries, rolls back partial failures, writes files atomically, calls out plaintext-credential backup risks, and checks credential freshness (including OAuth refresh-token rotation) before a swap. When live credentials have drifted, the TUI asks whether to Recapture, Discard, or Cancel before it swaps.

╭ Multi-Account Tool ────────────────────────────────╮
│  AI CLI account switcher                           │
╰─────────────────────────────────────────────────────╯

  > Claude Code            [active: personal] ✓
    Codex CLI              [active: work]     ✓
    Gemini CLI              [active: personal] ✓

Why

How it works

mat swaps only 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 CLI ~/.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 Existing Keychain/Secret Service + YAML artifacts, five fixed provider files and two bounded provider cache directories under ~/.config/goose/providers/ Profile swap only; fixed v1.43 paths, no discovery or session isolation
Grok Build ~/.grok/auth.json File swap (profile-swap-only)

OAuth Rotation Safety Matrix

Some CLIs use OAuth refresh-token rotation (RFC 6749 best practice): a refresh token may be single-use, so the provider invalidates it after the next successful refresh. Restoring an older snapshot can then make the provider reject the token as "already used", forcing a re-login. The table below summarizes the risk for CLIs that mat supports.

CLI Auth type Rotation risk mat safe workflows
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 commands
Gemini CLI 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 (high-confidence rotation classification)
Goose Existing backend/YAML plus seven fixed v1.43 provider cache artifacts ⚠️ Provider cache diffs are opaque low-confidence rotation; known YAML/keyring paths remain identity-aware mat freshness goose reports every fixed source; opaque diffs require attention
Crush Hyper/Copilot OAuth (providers.*.oauth) plus mirrored/static API keys ⚠️ No stored account identity — adapter-backed conservative low-confidence byte-diff only (not confirmed rotation) mat freshness crush before swap; project/CRUSH_*/XDG/provider-env overrides can bypass swapped globals (see Platform support)
Grok Build Browser/OIDC ~/.grok/auth.json ⚠️ Unknown — fallback byte-diff only, no identity adapter yet Use only the TUI profile switch (select grok, then the profile); review config/env/project overrides before relying on the selected profile
Aider / Kimi / Qwen 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: every source is fresh or adapter-confirmed rotated with high/medium confidence. Exit code 1 means mat found an unsafe pre-swap state: stale, low-confidence rotated from fallback/byte-diff classification, inflight, or a missing profile/source. 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: the TUI swap path checks freshness before swapping and shows an interactive Recapture / Discard / Cancel dialog when it detects drift. Recapture saves the live credentials into the active profile via snapshotLiveToProfile before the swap; Discard skips the auto-snapshot (data loss); Cancel aborts. mat exec re-captures live credentials on exit 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 classify rotation vs identity change with high/medium confidence, removing [low conf] dialog noise on safe swaps.

Platform support

CLI macOS Linux Windows Overrides / known limits
Claude Code macOS Keychain on macOS; ~/.claude/.credentials.json on Linux. mat session supports Linux via CLAUDE_CONFIG_DIR; macOS Keychain cannot be session-isolated
Codex CLI ⚠️ untested ~/.codex/auth.json (cross-platform file path)
Gemini CLI ⚠️ untested ~/.gemini/oauth_creds.json + google_accounts.json; mat session uses GEMINI_CLI_HOME with .gemini envSubdir. Google moved personal/free-tier CLI access toward Antigravity on 2026-06-18; enterprise/Cloud/API-key paths remain separate, and this availability change does not alter mat's credential boundary.
Google Antigravity (agy) ❌ blocked ❌ blocked ❌ blocked Rechecked at 1.1.2. It is not a Gemini CLI credential source. Public docs describe system-keyring auth with Google Sign-In fallback, but no stable keyring service/account, token profile, credential redirect, or recapture contract. Settings/cache under ~/.gemini/antigravity-cli/ and any observed antigravity-oauth-token file are not enough for safe support. See the auth-store research note.
Aider ⚠️ untested mat session start remains unsupported (no credential-dir env). mat session run aider is partial support: mat forces --config <session>/command/aider.yml + --env-file <session>/command/.env and hard-stops known argv/env/dotenv/OAuth-key/model-sidecar/provider-chain bypasses
Kimi CLI ⚠️ untested env override: MOONSHOT_API_KEY and friends bypass ~/.kimi/config.toml
Qwen Code CLI ⚠️ untested Rechecked at v0.19.10. Profile swap and mat session start qwen redirect QWEN_HOME, but are advisory only: Qwen can still use shell/project/ancestor/home configuration and custom modelProviders[].envKey sources. mat session run qwen remains intentionally unsupported until the complete auth/source contract can be fail-closed.
Crush ⚠️ untested Hyper/Copilot login may persist OAuth plus mirrored api_key under providers.* (pin charmbracelet/crush@7b24cc09…, retrieved 2026-07-15 KST). No stable account identity is stored, so freshness diffs stay low-confidence attention-required. project-local / env override: ./.crush.json / ./crush.json, CRUSH_GLOBAL_*, XDG roots, and provider API-key env can bypass swapped globals
OpenCode ⚠️ untested Rechecked against canonical anomalyco/opencode v1.18.1. OS-agnostic XDG path ($XDG_DATA_HOME/opencode/auth.json, default ~/.local/share/opencode/auth.json). mat session start is EXPERIMENTAL via broad XDG_DATA_HOME; mat session run opencode is command-scoped and hard-stops known local env/config bypasses
Goose ✅ os-keyring Existing goose/secrets backend + YAML behavior is unchanged. On Linux the default keyring path requires secret-tool (libsecret-tools) and a running keyring daemon; missing/denied tooling is an explicit error, not a stale-YAML fallback, because a missing CLI does not prove Goose's libsecret keyring is unused. Use GOOSE_DISABLE_KEYRING=1 only for Goose's file backend (presence-only; any value disables keyring). MAT swaps exactly five reviewed files and two bounded directories below ~/.config/goose/providers/, verified against Goose v1.43.0 source commit 5a9eb7e; the seventh artifact is Hugging Face oauth/tokens.json. HF_TOKEN can bypass that cache and is reported as an ambient warning. MAT never scans provider directories or enables session/run. Provider cache schemas remain opaque until redacted fixtures admit fields. Windows is unsupported.
Grok Build ⚠️ untested Current support swaps only the primary signed-in browser/OIDC token file ~/.grok/auth.json as grok-auth.json. mat session start/run grok is unsupported. ~/.grok/config.toml model api_key/env_key, XAI_API_KEY, GROK_* auth/model env, GROK_HOME, project .grok/config.toml, and MCP credentials can override or bypass auth.json; unset/review those before relying on a selected profile.

"⚠️ 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.

For the exact support boundary of one CLI, run mat support <cli> (or mat explain <cli>). The report shows the current swap, freshness, and session support; caveats; ambient override risks; and the last upstream assumptions mat verified.

During foreground profile switching and mat exec, mat warns about high-confidence ambient bypass channels such as provider API-key env vars or project-local config files. The warning is informational: mat does not block or scrub those channels yet. If the override is intentional, continue; otherwise unset or remove the named env/config source before relying on the selected profile.

Why Grok session isolation is not enabled yet

Grok Build support is intentionally profile-swap-only for now. xAI's public Build docs (Getting Started, Enterprise Deployments) describe multiple credential, config, and account-selection channels: browser OIDC/device auth, external auth-provider commands, direct API-key auth via XAI_API_KEY, model-level api_key / env_key in ~/.grok/config.toml, managed/requirements config layers, project-visible instructions/plugins/hooks/MCP servers, and grok inspect for the combined discovery view. Because ~/.grok/auth.json is only one credential channel, copying or redirecting that file alone would not prove that a mat session child is using only the selected profile.

A future mat session run grok needs a separate design that either (1) creates an API-key-only boundary and hard-stops browser/OIDC, config, env, project, plugin, hook, and MCP override channels, or (2) relies on an upstream-supported Grok credential/config-root redirect with clear recapture semantics. Until then, switch Grok profiles through the TUI (select grok, then the target profile) and unset/review the listed override sources before trusting the active profile.

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 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 session` / `mat plugin` / `mat freshness` / `mat doctor`)
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, session, plugin, freshness, doctor)

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 appears only once and is not shown automatically 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

Behavior:

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. Use mat session when you need true per-terminal isolation with different accounts running concurrently.

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 run <cli> <profile> -- [cli-args...]
                                  # run the builtin CLI executable directly in isolation
mat session run <cli> <profile> --check|--explain [--json] -- [cli-args...]
                                  # dry-run the exact session-run validators without spawning
mat session list [--json]           # running / orphan sessions
mat session stop <id>               # terminate a session or reap an orphan
mat status [--json]                 # active-profile + session summary for dashboards/statuslines

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-isolation. mat session start launches your $SHELL with the CLI's config-directory env var (for example, CODEX_HOME) pointing at a fresh directory under ~/.multi-account-tool/sessions/<id>/. mat copies the selected profile's credentials there with 0600 permissions, so CLI processes inside the subshell read only that isolated account.

A small allow-list of non-credential data may also be copied as session-local snapshots. For Codex, config.toml and skills/ are copied into the isolated CODEX_HOME, so custom skills are available without sharing the live ~/.codex tree. On exit, mat re-captures only changed credentials back into the profile (for example, after OAuth rotation), then removes the session directory. It never touches OS-global credentials or the mat exec lock, so sessions can run concurrently without interfering with each other.

mat session run uses the same materialize → env injection → re-capture → cleanup lifecycle without opening a shell. mat selects the built-in executable for <cli> (for example, codex) and passes [cli-args...] directly to it. The -- tail is argv for that selected CLI, not an arbitrary shell command. Today this command-scoped boundary is enabled only for built-ins with a known safe run path: Codex, Kimi, Crush, Gemini CLI, Claude on Linux, OpenCode safer-run, and Aider partial-run. Qwen is intentionally excluded.

Before a real run, use mat session run <cli> <profile> --check -- [cli-args...] (or --explain) to exercise the same support, profile, executable, Aider, and OpenCode hard-stop validators without spawning the CLI or creating a session directory. Exit code 0 means the real run would pass preflight, 1 means a validation blocker was found, and 2 means usage/parser error. Add --json to that --check/--explain command for an automation-friendly report with blockers, phases, selected executable, profile existence, and exact argv.

For dashboards and statuslines, mat status --json emits a stable schema-v1 summary of active profiles and sessions. Active profiles may include capture-time identity metadata such as masked account/email fingerprints or allowlisted tier/provider-mode signals; status never parses credential files or keyring entries on demand. mat session list --json emits schema-v1 lifecycle entries (active / orphan / unknown) with owner/child status and root env names only; it never includes session root paths. Mutating session lifecycle commands append best-effort, redacted JSONL events to ~/.multi-account-tool/audit.jsonl; persistent audit entries hash profile/session identifiers and redact secret-like strings.

Prompt/statusline snippets

Prompt renderers can consume mat status --json, but do not run it uncached on every redraw: the status report may inspect session liveness. The examples below cache for two seconds, fail empty if mat or JSON parsing fails, and are display-only. They call only mat status --json; they do not parse ~/.multi-account-tool, credential files, keyrings, mat freshness, or mat doctor.

Put the shared helper in a file such as ~/.config/mat/statusline.zsh:

: ${MAT_STATUS_CACHE_TTL:=2}
: ${MAT_STATUS_CACHE:="${XDG_CACHE_HOME:-$HOME/.cache}/mat/status.json"}

mat_status_cached() {
  local now mtime cache_dir tmp
  cache_dir="$(dirname "$MAT_STATUS_CACHE")" || return 0
  mkdir -p "$cache_dir" 2>/dev/null || return 0

  now=$(date +%s)
  if [[ -r "$MAT_STATUS_CACHE" ]]; then
    mtime=$(stat -f %m "$MAT_STATUS_CACHE" 2>/dev/null)
    if [[ -z "$mtime" || "$mtime" == *[!0-9]* ]]; then
      mtime=$(stat -c %Y "$MAT_STATUS_CACHE" 2>/dev/null || echo 0)
    fi
    if [[ -n "$mtime" && "$mtime" != *[!0-9]* ]] && (( now - mtime < MAT_STATUS_CACHE_TTL )); then
      cat "$MAT_STATUS_CACHE"
      return 0
    fi
  fi

  tmp="${MAT_STATUS_CACHE}.$$.$RANDOM"
  if command mat status --json > "$tmp" 2>/dev/null && mv "$tmp" "$MAT_STATUS_CACHE" 2>/dev/null; then
    cat "$MAT_STATUS_CACHE"
  else
    rm -f "$tmp"
  fi
}

mat_statusline() {
  mat_status_cached | node -e 'let s="";process.stdin.on("data",d=>s+=d);process.stdin.on("end",()=>{try{const r=JSON.parse(s||"{}");const profiles=(r.activeProfiles||[]).map(p=>`${p.cliId}:${p.profileName}`).join(" ");const sessions=r.sessions||{};const warn=(sessions.orphan||sessions.unknown)?`⚠${sessions.orphan||0}/${sessions.unknown||0}`:"";const out=[profiles,warn].filter(Boolean).join(" ");if(out)process.stdout.write(out);}catch{}});' 2>/dev/null
}

Use it from zsh RPROMPT:

source ~/.config/mat/statusline.zsh
setopt prompt_subst
RPROMPT='$(mat_statusline)'

Use it from tmux:

set -g status-right '#(zsh -lc "source ~/.config/mat/statusline.zsh && mat_statusline")'

Use it from Starship:

[custom.mat]
command = 'zsh -lc "source ~/.config/mat/statusline.zsh && mat_statusline"'
when = 'command -v zsh >/dev/null 2>&1 && command -v mat >/dev/null 2>&1 && command -v node >/dev/null 2>&1'
format = '[$output]($style) '
style = 'cyan'

The default formatter prints compact output such as codex:work gemini:personal ⚠1/0, where the warning counts are orphan/unknown sessions. Adjust the Node formatter if you want a different shape.

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
Gemini CLI GEMINI_CLI_HOME (.gemini envSubdir)
Claude Code (Linux only) CLAUDE_CONFIG_DIR
OpenCode (EXPERIMENTAL) XDG_DATA_HOME (opencode envSubdir; broad XDG side effects)

Not supported by mat session start (no safe credential-relocating env var; session start errors out): claude on macOS (Keychain service name is not env-overridable), aider (credential channels include provider env vars / CLI args / project-local config, not a session-relocatable home file; use the narrower mat session run aider partial support instead), goose (keychain/OS-keyring credentials cannot be env-redirected), grok (profile-swap-only ~/.grok/auth.json; config/env/project/MCP bypasses need a separate session-isolation design), Google Antigravity / agy (native keyring plus no stable CLI-specific credential redirect; HOME redirect is too broad), and any user plugin CLI (built-in only trust boundary).

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] [--check-only]

Compare live credentials with the active (or specified) profile snapshot and report drift before you swap. If <cli> is omitted, mat reports every built-in/plugin CLI that currently has an active profile. 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

# Statusline/dashboard mode: print the same report, but do not fail on unsafe states
mat freshness --check-only

Each source is classified into one of four states — fresh (byte-identical), rotated (token rotated), stale (identity changed — a different account; swap will revoke), inflight (multi-source CLI partially updated — retry shortly). rotated is safe only when an adapter can preserve identity with high/medium confidence. Fallback byte-diff results and parser failures can only say "bytes changed"; those low-confidence rotated results are treated as unsafe for exit-code purposes and return 1 unless --check-only is used.

--check-only is read-only monitoring mode: it still prints stale / low-confidence rotated / inflight results, but exits 0 so prompts, statuslines, and dashboards can display the warning without breaking the shell. Usage errors and source-read failures are not masked.

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 (unless --check-only)
2 Usage error
74 Internal check failed (e.g., source read error)

mat doctor — read-only safety diagnostics

mat doctor [--json]

Run a metadata-only safety audit across every known CLI. doctor reports active-profile state, sanitized capture-time identity metadata when present, profile directory presence, live source presence when it can be checked without reading secret values, session support flags, plugin warnings, and high-confidence ambient override channels such as provider API-key env vars or project-local config files.

mat doctor intentionally does not compare stored/live credential contents, parse profile credential files to backfill identity, or query OS keyring entries that would print secret values. Use mat freshness <cli> when you explicitly want the deeper OAuth rotation comparison.

# Human-readable report
mat doctor

# CI/statusline-friendly JSON report
mat doctor --json

See the OAuth Rotation Safety Matrix at the top of this README for per-CLI classification confidence.

mat support / mat explain — explain CLI support boundaries

mat support <cli> [--json]
mat explain <cli> [--json]

Show exactly what mat supports for one CLI and why. The report covers profile swap, freshness/drift checks, mat session start, mat session run, source types (without live paths or credential values), static capture-time profile identity signal support, ambient/project override risks, and the last verified upstream assumptions behind the support claim.

explain is an alias for support. Known blocked CLIs such as agy are explainable even when they are not valid profile-swap targets; user plugin CLIs are reported as profile-swap only with fallback freshness and no trusted session boundary.

mat support codex
mat support aider --json
mat explain agy

Data layout

~/.multi-account-tool/
├── config.json                   # active profile pointer + flags
├── app.log                       # best-effort TUI warnings / diagnostic trail
├── audit.jsonl                   # best-effort redacted session lifecycle audit log
├── cli-defs/                     # optional user plugins — see "Adding a new CLI"
│   └── <id>.json
├── locks/
│   ├── <cli>.lock/               # per-CLI `mat exec` lock dirs (auto-recovered on stale)
│   └── recapture/
│       └── <cli>/<profile>.lock/ # `mat session` profile recapture advisory locks
├── sessions/
│   └── <session-id>/             # ephemeral `mat session` dirs + session.json while running/orphaned
└── 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
    └── grok/                     # grok-auth.json (profile-swap-only ~/.grok/auth.json)

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" }
  ]
}

You can generate that starter JSON without writing files:

mkdir -p ~/.multi-account-tool/cli-defs
mat plugin scaffold my-cli > ~/.multi-account-tool/cli-defs/my-cli.json
mat plugin validate ~/.multi-account-tool/cli-defs/my-cli.json
mat plugin validate --json   # validate every installed ~/.multi-account-tool/cli-defs/*.json

mat plugin validate is a static JSON/schema/lint check. It does not read credential files, query Keychain/Secret Service/Windows Credential Manager secrets, or prove that an upstream CLI will prefer the intended credential source. A passing report means static validation passed, not that the plugin is security-certified. The JSON report is schemaVersion: 1; exit codes are 0 when there are no errors, 1 for validation/read/parse errors, and 2 for usage errors. Risky-but-compatible patterns (for example broad file paths or generic keychain services without account) are warnings.

mat loads every *.json in that directory at startup. Invalid plugins are warned and skipped, and mat keeps working. Built-in CLIs (claude, codex, gemini, aider, kimi, qwen, crush, opencode, goose, grok) cannot be overridden — id collision is rejected.

Field rules:

Plugins are profile-swap definitions only. They cannot define session, sessionRun, env policy, ambient credential scrub rules, or project override hard-stops, and user plugin CLIs are not trusted mat session start/run boundaries.

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