claude-account
One claude command, several accounts.
Each Claude account on this machine is a whole Claude Code profile: its own
login, settings, plugins, projects and memories. claude-account switch picks
which of them the claude command runs as, and every account gets a Headroom
proxy of its own, reachable under a name that does not change.
claude-account setup # wrapper, a proxy per account, the router, completions
claude-account add kunde # another account: profile, port, workspace, proxy
claude-account switch work # `claude` runs as the work account from now on
claude-account status # everything, and everything that is wrong
What it replaces
The previous helper lent the private account's OAuth token to the work profile
and had to be given back afterwards. Only the credentials moved, plugins and
settings stayed behind, and a session that exited at the wrong moment left the
swap half-done. Here nothing moves: an account is a directory, and switching
means pointing CLAUDE_CONFIG_DIR at another one. There is nothing to restore,
and the plugins of an account come with it.
How the switch reaches claude
~/.local/bin/claude is a small wrapper that asks this tool which profile is
active, exports CLAUDE_CONFIG_DIR and hands over to the real claude. A
wrapper and not a shell alias, because an alias only exists in an interactive
shell: hooks, editors and subprocesses call claude without one and must land
in the same account as the terminal.
A CLAUDE_CONFIG_DIR that is already set wins over the wrapper, so a session
started deliberately in another profile -- a jail does this -- stays where it
was put. Sessions that are already open are never disturbed by a switch; each
keeps the profile it started with.
One thing beats the wrapper and should not: an alias claude=... or a shell
function of that name, which is found before anything in the PATH and, where
it sets CLAUDE_CONFIG_DIR itself, makes every switch look ignored. status
reads the startup files of bash, zsh and fish and names the file and the line,
because nothing else in the report would show why the account never changes.
A proxy per account
A Headroom proxy keeps what it sees: a cache, a retrieval store and logs, all under one workspace directory. One proxy for two accounts would put the work of both in one place, which is the thing keeping two accounts is meant to avoid. So every account gets its own port, its own workspace and its own user service, written from one systemd template:
| Address | What |
|---|---|
<account>.headroom.localhost |
the proxy of that account, active or not |
<jail>.headroom.localhost |
the proxy a jail started for one session |
headroom.localhost |
the proxy of the account that is active right now |
headroom.localhost/names |
everything that resolves, and ?port= for one name |
Every account has its own name, so two accounts in use at the same time -- one in each terminal -- never share an address. The bare domain follows the switch, for a bookmark that should show whichever account is being worked in, but nothing prints it: an address printed in one session would open the other account's proxy after a switch.
A jail called like an account does not get the name; the account keeps it, and
status points the jail out with the command that renames it. Programs that
want the name of a port ask headroom.localhost/names?port=8791, which answers
with the address as plain text, or 404 where the port has no name. That is how
the enqs-jail and enqs-info plugins find it, so the rules of who gets which
name live here alone.
Jails keep their own names. They are published as run files by the enqs-jail
plugin, and the router here reads the same directory in the same format, so a
jail is reachable whichever of the two routers is serving the domain -- but
only one of them can hold the port. claude-account adopt stops the plugin's
router and the hand-written proxy services, and takes their ports and
workspaces over unchanged.
Commands
| Command | What |
|---|---|
status [-v] |
accounts, proxies, names, sessions, and a list of what to fix |
doctor |
the same, and a non-zero exit when something is wrong |
list, current, dir, env, url |
the small answers, for scripts |
switch <account> |
make an account the one claude runs as |
add <name> |
register an account: profile, port, workspace, proxy, all at once |
remove <account> |
forget one; its profile directory stays |
completions [shell] |
print a completion script, or --install it |
setup |
wrapper, proxies, router and completions in one go |
adopt |
take over services that were written by hand |
shim install | uninstall |
the claude wrapper |
headroom install | start | stop | restart | port | logs |
one account's proxy |
router serve | install | uninstall | port | list |
the name router |
Adding an account
claude-account add kunde # ~/.claude-kunde, the next free port
claude-account add kunde --dir ~/elsewhere --port 8795
claude-account add kunde --switch # and run as it straight away
add does everything an account needs rather than half of it: it creates the
profile directory, reserves a port, gives the account a workspace of its own,
writes and starts its proxy service, and points the profile's settings.json
at that proxy. A login is the one thing left, because only a browser can do it:
claude-account switch kunde && claude /login.
remove is the counterpart and undoes the registration alone. The profile
directory and the proxy workspace stay where they are -- they hold a login,
projects and memories -- so a profile that still has credentials keeps showing
up in list, found rather than configured.
Completions
claude-account completions --install # every shell on this machine
claude-account completions fish # or print one
setup installs them as well. The subcommands come from the parser, so they
cannot drift; the account names are not written into the file but asked for
when Tab is pressed (claude-account list --names), so an account added a
minute ago completes without regenerating anything. Files go to
~/.config/fish/completions, ~/.local/share/bash-completion/completions and
~/.local/share/zsh/site-functions -- the last one has to be in the fpath
before compinit.
Configuration
~/.config/claude-account/config.json. Everything in it has a default derived
from the machine, so the file is an override and not a requirement:
{
"active": "work",
"router_port": 80,
"accounts": {
"personal": { "dir": "~/.claude", "port": 8787, "workspace": "~/.headroom" },
"work": { "dir": "~/.claude-work", "port": 8788, "workspace": "~/.headroom-work" }
}
}
An account is discovered from ~/.claude (called personal) and every
~/.claude-<name> that holds a login. Ports come from the profile's own
settings.json where it names one, and are handed out from 8790 upwards where
it does not.
The router wants port 80, because that is what makes the address
headroom.localhost rather than headroom.localhost:8799. A user service may
only take it once the machine allows unprivileged low ports:
sudo sysctl -w net.ipv4.ip_unprivileged_port_start=80 # and in /etc/sysctl.d
claude-account router port 8799 # or move the router
Requirements
- Rust 1.82 to build.
- systemd as a user session, which keeps the proxies and the router running.
headroomwith the proxy extras:pip install "headroom-ai[proxy]", or a path inCLAUDE_ACCOUNT_HEADROOM_BIN.