Two Claude Accounts, One Machine: Building cprof

· 14 min read

dcotelo/cprofThe code from this post: profiles, rules, and the statusline badge. macOS, bash, MIT.
brew install dcotelo/tap/cprof
claude plugin marketplace add dcotelo/cprof
claude plugin install cprof@dcotelo

cat >> ~/.zshrc <<'RC'
claude() { eval "$(cprof env)"; command claude "$@"; }
RC
exec zsh

The problem: two subscriptions, one login #

I have two Claude subscriptions: a personal one and one that belongs to my employer. The rule between them isn’t a preference. Company code goes through the company’s account, under the company’s agreement, with the company’s data handling. My personal subscription doesn’t get to see it.

That’s a straightforward policy. The problem is where it gets enforced.

Claude Code on macOS keeps your login in the keychain: one item, one account. So the boundary between “this is work” and “this is mine” lives nowhere in the system. It lives in my head, and it gets re-evaluated every time I cd into a different directory.

A policy enforced by human memory isn’t enforced. It’s just a policy you haven’t violated yet.


The part that makes it dangerous #

The obvious cost is friction: /logout, sign in again, wait for the browser flow, get back to work. Annoying, and annoying in a way that quietly reshapes how you work. The switch is expensive enough that you start batching tasks by account instead of by priority, which is exactly backwards.

But friction isn’t the real problem. The real problem is that the wrong-account state is invisible.

Nothing in a running session tells you which subscription is paying for it. You open a terminal, you’re in a work repo, you start a session, and there is no indication whether you’re on the right side of the line. You find out later, from a usage page, about work you did an hour ago.

So the tool I wanted wasn’t a faster switcher. It was something that made the account a property of where I am, not of what I last remembered to do.


The direction I’d been ignoring #

I started out thinking about one leak: company code ending up on a personal subscription. That’s the one with a policy attached, so it’s the one I worried about.

The other direction is the one worth reading the documentation about.

On a Team or Enterprise plan, your employer, not you, is the data controller for everything you send through that account. Anthropic’s Privacy Center is direct about it:

The customer is the “Controller” of the data submitted by its Users. […] The customer may access and export data (such as conversation history) submitted by its Users.

Source: Anthropic Privacy Center, “Does Anthropic Act as a Data Processor or Controller?”

That access is a real, documented mechanism, not a theoretical clause. The export path runs through a single role:

If you’re a member of a Team or Enterprise plan, only your organization’s Primary Owner can access data exports.

Source: Claude Help Center, “Export your Claude data”

And on Enterprise it’s an API, not a support ticket:

Rather than manual exports and periodic reviews, compliance teams get real-time programmatic access to Claude usage data and customer content, enabling them to build continuous monitoring and automated policy enforcement systems.

Source: Anthropic, “Claude Code and new admin controls for business plans”

None of this is a criticism. It’s the correct design for a plan a company buys to govern: an employer that’s accountable for what its staff do with an AI tool needs to be able to see it, and I’d argue the same thing from the other side of the table.

But it does settle what “wrong account” actually costs. If I’m signed into the work account and I debug my own side project, ask something personal, or paste from a private repo, that conversation now sits in an org-controlled history with a documented retrieval path, one that outlives both my forgetting about it and my leaving the company. That’s not a billing mistake I can shrug off. It’s my data landing under someone else’s control, by my own hand, because of which terminal I happened to have open.

So the boundary has to hold in both directions, which rules out “usually right” as a good-enough standard.


The lever: one environment variable #

Everything here rests on one behavior:

Claude Code keys its credentials to CLAUDE_CONFIG_DIR. Point it somewhere, and that directory gets a credential store of its own, separate from the one used when the variable is unset.

That’s it. That’s the whole mechanism. If credentials follow the config directory, then “which account” is a question of “which directory”, and pointing an environment variable somewhere before launching a process is a solved problem.

Where those credentials physically live is a detail that has already changed once. Claude Code before 2.1 wrote $CLAUDE_CONFIG_DIR/.credentials.json. Since 2.1 they go to the macOS keychain, under a service name derived from the directory: Claude Code-credentials-<sha256(CLAUDE_CONFIG_DIR)[0:8]>, against the plain Claude Code-credentials used when the variable is unset.

Which is a good argument for building against the guarantee rather than the storage. The invariant is “one config directory, one credential store.” So cprof asks claude auth status whether a profile is signed in, instead of looking for a file it would then have to keep guessing the location of.

cprof is the small amount of bash between that fact and not having to think about it:

$ cd ~/dev/company/api && cprof which
work  native (keychain)  rule ~/dev/company

$ cd ~/dev/side-project && cprof which
personal  ~/.claude-profiles/personal  default

The rest of this post is the design decisions around that, because the mechanism was the easy part.


Decision 1: a profile is a config directory, not a stashed credential #

The tempting design is a credential vault: copy the keychain item out, keep a few of them in a file, swap the active one on demand.

I didn’t want to go anywhere near that. It means my working credentials get read, copied, and written by my own scripts, and every bug in that code is a bug that can lose me an account.

A profile is instead just a config directory that Claude Code owns end to end. cprof never writes a credential. It points CLAUDE_CONFIG_DIR at a directory and lets Claude Code write its own. Signing a profile in is a normal interactive login that happens to land somewhere else.

Which leaves the setup I already had. The account already in my keychain doesn’t get migrated, exported, or re-signed-in. It gets registered as a native profile, meaning “export nothing for this one.” The launcher leaves CLAUDE_CONFIG_DIR unset, the keychain and ~/.claude.json are used exactly as before, and the working account I depend on is never part of any migration:

cprof add work --native      # adopts the login already in the keychain
cprof add personal           # a new directory, signed in separately

One hard rule falls out of this: no profile may point at ~/.claude itself. That would relocate .claude.json and break authentication for the native case. The tool refuses it.


Decision 2: the machine decides, not me #

Adding an explicit switch command would have missed the point. cprof use work is still a thing I have to remember, at exactly the moment I’m least likely to.

So resolution is derived from context. There are four ways to express it, and a fallback for when none of them match. First match wins:

#SourceSet with
1environment override, one sessionCLAUDE_PROFILE=work claude
2repository pin, keyed on the git top levelcprof pin work
3directory rule, longest matching prefixcprof rule add ~/dev/company work
4default profilecprof default personal
5nothing matched, stock behaviorn/a

The ladder maps to how the boundary actually looks. A directory rule covers the common case: everything under ~/dev/company is work, everything else is personal. A repo pin handles the exception that lives outside the tree. The environment override handles the one-off without leaving anything behind.

Two details matter more than they look:

Rules are longest-prefix, and respect path boundaries. A rule for ~/dev/work never matches ~/dev/workshop. There’s no glob support, deliberately: a routing rule that decides which account handles company code is not a place I want pattern-matching subtleties.

which reports the reason, not just the answer. work native (keychain) rule ~/dev/company tells me why this directory resolved that way. When the answer is surprising, the reason is the thing I actually need.


Decision 3: which and status are different questions #

Credentials are read at process start. That’s not a limitation I could design around. It’s a fact, and it has a consequence worth being loud about:

A change takes effect on the next claude launch, never in a running session.

Which is why there are two commands that look redundant and aren’t:

  • cprof which: what should this directory use?
  • cprof status: what am I actually signed in as right now?

They agree almost always. They disagree right after you add a rule or pin a repo without relaunching, which is precisely the moment the difference is dangerous, because you’re now working in a directory that expects one account while your session runs on another.

So a SessionStart hook warns when you’ve wandered into a directory that expects something else, and there’s a statusline badge (⚑ work) for when you’d rather see it than ask. Each profile gets its own badge color, hashed from the name, so two profiles never look alike even before you configure anything. The invisible state I started this post complaining about is the thing the tool exists to make visible.


Decision 4: separate identity, share everything else #

This is the decision that took the longest to get right, because CLAUDE_CONFIG_DIR does more than I first assumed.

It doesn’t relocate your credentials. It relocates the entire configuration directory: plugins, skills, agents, commands, hooks, settings.json, CLAUDE.md, all of it. Point a profile at a fresh directory and it starts with none of them. Switching accounts silently means switching away every customization you’ve built up, which is a great way to make a tool nobody keeps using.

The fix is to split the directory by what it’s for:

Shared across profilesKept per-profile
settings.json, keybindings.jsoncredentials (keychain item, or .credentials.json before Claude Code 2.1)
CLAUDE.md.claude.json
plugins, skills, agents, commands, hooksprojects, sessions, history.jsonl, todos, caches

The left column is how I work, the same regardless of who’s paying. Those get symlinked in from ~/.claude, so installing a plugin or editing settings once applies everywhere with nothing to re-sync. add links them when it creates the profile, so this is the default rather than a step you have to know about.

The right column is what keeps the accounts apart, and nothing in it is ever linked. Credentials, obviously. But also conversation history, project state, and session data. Under a compliance framing, work transcripts leaking into the personal profile’s history would defeat the whole exercise as thoroughly as using the wrong credentials would.

cprof share <name> does the same job for a profile that predates the behavior, or one adopted from a directory you’d already furnished:

$ cprof share personal
ASSET          RESULT
settings.json  linked (previous kept as settings.json.moved-20260729-103012)
CLAUDE.md      linked
plugins        linked
skills         linked
hooks          linked

Anything the profile already had is moved aside rather than deleted, and unshare removes only the links this created: a real file, or a link pointing somewhere else, is left alone. add --isolated opts out entirely for a profile that should share nothing.


Decision 5: fail toward stock behavior #

This sits between me and the tool I work in all day, in a shell function, on every launch. The question that shaped the last chunk of the design is: what happens when it breaks?

The wrapper is deliberately trivial:

claude() { eval "$(cprof env)"; command claude "$@"; }

cprof env prints export or unset statements and never exits non-zero. A missing jq, a malformed config, a profile directory that isn’t there: all of it degrades to one line on stderr and stock Claude Code behavior. If the command fails outright, eval of nothing is a no-op and claude starts normally. The failure mode is “you get the default account,” never “your shell is broken” or “your editor won’t start.”

That’s also the honest caveat: silent degradation means a broken cprof looks like a working claude. Worth knowing, and command claude bypasses the whole thing on purpose whenever you want stock behavior.

The other guard is around login, where the actual risk lives. Signing a profile in shouldn’t be able to overwrite the keychain item my working account depends on. So cprof login snapshots the shared keychain item to ~/.cprof/keychain.bak first (mode 600), runs the login, then verifies two things: that claude auth status reports the profile signed in, and that the shared item went untouched. If a login wrote to the shared item instead of the profile’s own, the snapshot is restored and the command fails loudly.

I don’t expect that path to trigger. It exists because “my working account is gone” is not a failure I’m willing to discover empirically.


Where it landed #

macOS, bash 3.2 (the system shell, which rules out a lot of conveniences), jq as the only dependency. The CLI installs from Homebrew; a Claude Code plugin adds the ambient parts: the session-start warning, a /profile command, and the statusline badge. MIT licensed, on GitHub at dcotelo/cprof.

Setup is about two minutes. Install the CLI, and optionally the plugin:

brew install dcotelo/tap/cprof               # the CLI; jq comes with it
claude plugin marketplace add dcotelo/cprof  # optional, the ambient parts
claude plugin install cprof@dcotelo

Homebrew puts the CLI on PATH, so the only shell config needed is the one-line claude wrapper from Decision 5. The Quickstart has the whole sequence in copy-paste form.

Then describe your boundary, once:

cprof add work --native      # adopts the login already in your keychain
cprof add personal           # a second profile, signed in separately
cprof login personal         # interactive, opens a browser

cprof default personal       # the fallback everywhere else
cprof rule add ~/dev/company work

And check it:

$ cprof list
PROFILE   PLAN  ACCOUNT            FLAGS
work      team  you@company.com    native
personal  max   you@personal.dev   (default) (active)

$ cd ~/dev/company/api && cprof which
work  native (keychain)  rule ~/dev/company

Nothing was moved and nothing was re-authenticated along the way. --native adopted the login where it already lived, and login wrote only inside the new profile’s own directory.

If you’ve been doing this by hand already, with an alias along the lines of CLAUDE_CONFIG_DIR=~/.claude-client claude, then that directory is a profile in all but name and add adopts it where it stands:

cprof add client --dir ~/.claude-client --isolated --note 'client account'

--isolated is the flag that matters there, since a directory you’ve already furnished usually wants to keep its own settings rather than have them moved aside for links to ~/.claude. The existing login carries over, with one caveat: add stores the physical path, so a symlink between that and the string your alias exported leaves the credentials filed under a name nothing wrote. cprof list reports the account each profile actually resolves to, so it tells you which happened, and the fix is one cprof login client.

What I actually got out of it is smaller than the README makes it look. I stopped thinking about accounts. The compliance boundary I used to enforce by remembering is now a rule in a config file, evaluated by the machine, every launch, whether or not I’m paying attention.

That’s the whole ambition. The best outcome for a tool like this is that you forget it’s there.