167 lines
7.6 KiB
Markdown
167 lines
7.6 KiB
Markdown
# 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.
|
|
|
|
```sh
|
|
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
|
|
|
|
```sh
|
|
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
|
|
|
|
```sh
|
|
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:
|
|
|
|
```json
|
|
{
|
|
"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:
|
|
|
|
```sh
|
|
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`.
|