Files
claude-account/README.md
T
nvrl 9ed50eab57 feat: add stats, borrow and return
Every proxy keeps its figures in its workspace, but reading them meant
opening JSON files one proxy at a time, and the /stats page starts from
zero with each restart. `stats` reads all workspaces, jails included,
and shows how much of the saving is tool definitions rather than
compression, how many calls ran past the size where /clear pays more,
and when each weekly limit runs out at the current pace.

Switching accounts moves the whole profile, which is wrong when one
account is out of limit and the other lacks its plugins and settings.
`borrow` lends one account's login into another's profile for a while.
The old helper did this and failed twice: an abort left the swap half
done, and a token refreshed in one copy broke the other. Here the
profile's own login is written to a journal before anything changes,
the lent credentials file is a link to the lender's, and `return` puts
everything back from the journal, by hand or from a systemd timer.
Profiles that jails run in are refused unless asked for, since the
login decides whose account customer data goes out under, and status
reports a borrow whose timer was lost.
2026-09-23 17:59:45 +02:00

11 KiB

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.

Lending a login is still useful when one account runs out of limit and its profile is the one with everything set up, so borrow brings it back, built against both ways the old helper failed -- see Borrowing a login.

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
stats [--json] what every proxy recorded since it was set up, side by side
borrow <lender> [--into <account>] [--for 4h] run a profile on another account's login for a while
return [account] give a borrowed login back
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.

What the proxies recorded

claude-account stats

Every account's proxy, and every jail's, keeps its figures in its workspace. stats reads them all and puts them side by side: calls, input, how much the proxy saved and how much of that is compression rather than tool definitions held back, the cache-read share, and how many calls ran above 150k tokens of context -- the size at which a /clear pays more than any proxy. Below the table it says what the totals mean, and lists each account's weekly limit with the pace of the last three hours and when that pace would use it up. --json prints the same figures for scripts.

The figures are the proxy's own, counted since its workspace was created; the counters on its /stats page start again with every restart.

Borrowing a login

claude-account borrow personal --into work --for 4h
claude                                    # a new session, on the personal login
claude-account return work                # or wait for the four hours

The profile stays -- its plugins, settings, projects and memories -- and only the login and the oauthAccount entry of .claude.json are lent. --into defaults to the active account; without --for the borrow lasts until return. Sessions that are already open keep the login they started with, so the borrow is felt from the next session on.

What makes it safe where the old helper was not:

  • A journal before anything moves. The profile's own credentials and oauthAccount are written to ~/.local/share/claude-account/borrows/ (mode 0600) first. return restores from it, whether it is run by hand, by the timer --for sets, or after a crash or an interrupted borrow.
  • One login, not two copies. The profile's .credentials.json becomes a link to the lender's, so a token refreshed in either profile is the one both use. If Claude Code replaces the link with a file, return gives whichever copy expires later back to the lender.
  • Jails are refused. A jail keeps customer data in a sandbox, and the login decides whose account that data goes out under. Borrowing into a profile a jail runs in needs --with-jails.
  • A lost timer is reported. --for sets a transient user timer, which a reboot drops; status and doctor then show the borrow as overdue with the command that returns it.

The profile's proxy polls the lender's limits while the borrow lasts, and counts the lender's calls in its figures. The enqs-info status line shows ⇄ <lender> next to the account name for as long as it does.

A borrow is for one person's own accounts. A login shared between people is against Anthropic's terms, however it is passed around -- a Team plan with a seat each is the way to pool limits. And a private account is covered by the consumer terms, not the company's agreement: work and customer data belong in the work login.

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.
  • headroom with the proxy extras: pip install "headroom-ai[proxy]", or a path in CLAUDE_ACCOUNT_HEADROOM_BIN.