Skip to content

claude-account-selector: per-project Claude Code account switching#12

Merged
kriswill merged 24 commits into
mainfrom
claude-account-selector
Jun 5, 2026
Merged

claude-account-selector: per-project Claude Code account switching#12
kriswill merged 24 commits into
mainfrom
claude-account-selector

Conversation

@kriswill

@kriswill kriswill commented Jun 3, 2026

Copy link
Copy Markdown
Owner

What

New home-manager module claude-account-selector that adds a claude zsh function
selecting which Claude Code account/profile to use based on the launch directory — so a
personal Max account and a corporate Enterprise account coexist on one machine,
even simultaneously across terminals.

The wrapper shadows the real claude binary, picks a profile, sets CLAUDE_CONFIG_DIR
(isolated config/history/MCP/plugins) and — when a token is stashed in the Keychain —
CLAUDE_CODE_OAUTH_TOKEN (isolated login), then execs the real CLI. On macOS the OAuth
credential is a single shared Keychain item, so the per-profile token is what enables true
simultaneous use.

Profiles

Profile Config dir Keychain token
me ~/.claude-me claude-token-me
work ~/.claude-work claude-token-work

Resolution

  1. Explicit claude me / claude work (this run only)
  2. Longest-prefix match over built-in rules ∪ pins
  3. Fallback me

Built-in default: ~/src/perforce → work. Pins live in
$XDG_STATE_HOME/claude/profile-map.tsv (managed via claude pin|unpin, or hand-edited).

Commands

claude [me|work] …, claude pin <me|work> [path], claude unpin [path],
claude which [path], claude pins. Non-keyword args pass through to the real CLI
(so cyolo etc. still work). command claude … bypasses the wrapper.

Files

  • modules/home-manager/claude-account-selector/default.nix — module (kriswill.claude-account-selector.enable), contributes the wrapper to programs.zsh.initContent.
  • modules/home-manager/claude-account-selector/wrapper.zsh — the zsh function.
  • modules/home-manager/claude-account-selector/README.md — usage + one-time setup.
  • modules/home-manager/core.nix — adds claude-account-selector.enable = lib.mkDefault true; to the enable cascade.

Notes

  • Per-profile setup (config dirs + claude setup-token + security add-generic-password) is a one-time manual step documented in the README.
  • Validated via nix eval: the option resolves true for host k and the wrapper merges into the rendered zsh initContent. Resolution/pin/unpin/override logic exercised in a sandboxed shell.

kriswill added 16 commits June 3, 2026 14:32
New home-manager module providing a `claude` zsh wrapper that selects a
Claude Code profile by longest-prefix match of $PWD, setting CLAUDE_CONFIG_DIR
(isolated config/history/MCP/plugins) and, when present, a per-profile
Keychain OAuth token (CLAUDE_CODE_OAUTH_TOKEN) for simultaneous logins.

Defaults: anything under ~/src/perforce -> work, everything else -> me.
Commands: claude [me|work], claude pin|unpin|which|pins. Pins persist to
$XDG_STATE_HOME/claude/profile-map.tsv. Enabled via the kriswill.enable cascade.
A default-on toggle activated the wrapper on the next rebuild, redirecting every
interactive `claude` to `~/.claude-me` — which does not exist until the one-time
setup runs — silently abandoning the user's working ~/.claude login/MCP/history.
Drop the core.nix cascade entry so the module is opt-in; document enabling it in
the host module after setup.
…launch

The launch path unconditionally set CLAUDE_CONFIG_DIR=$HOME/.claude-$profile,
overriding any caller-supplied value — so the documented setup commands
(`CLAUDE_CONFIG_DIR=~/.claude-work claude setup-token`) silently acted on the
$PWD-resolved profile instead of the named dir. Now a non-empty CLAUDE_CONFIG_DIR
is passed through untouched (no profile logic, no token injection).
Once a profile had a stashed token, the wrapper injected CLAUDE_CODE_OAUTH_TOKEN
even for `claude setup-token` (and login), which can shadow the mint/auth flow.
Skip injection when the first arg is setup-token or login.
…ants

The default profile (`me`) was hardcoded in the `which` verb and the launch path,
and the valid set (`me`/`work`) inline in `pin`. Introduce _CCW_DEFAULT_PROFILE,
_CCW_VALID_PROFILES and an _ccw_is_profile helper so which/launch can't desync and
the valid set lives in one place.
…rate CRLF

A hand-edited or corrupted pin row (e.g. profile `prod`, or `work\r` from a
CRLF-saved file) was taken verbatim, routing to a nonexistent `~/.claude-prod`
or `~/.claude-work\r` (fresh empty account, silent). _ccw_resolve now strips a
trailing CR and skips any rule whose profile isn't in _CCW_VALID_PROFILES, so a
bad row falls back to the default instead of a bogus config dir.
A path containing a literal tab would write a row with extra columns, so the next
resolve would read a truncated prefix and a bogus profile. `pin` now refuses a
path containing a tab or newline (rc 2) instead of corrupting the TSV.
The 'delete row by key' awk idiom was duplicated in pin and unpin, and used
`awk -v p="$tgt"` which applies C-style escape processing to the value — so a
path containing a backslash could never be matched (unremovable pin, missed
dedup). Extract _ccw_map_remove using ENVIRON[] (no escape processing) and reuse
it; the unpin existence-check switches to ENVIRON too.
…ll sites

_ccw_abspath already defaults to $PWD when given no/empty argument, so the
`${1:-$PWD}` guards in pin/unpin/which and the `"$PWD"` in the launch path were
duplicating the helper's policy. Pass through directly.
Every bare `claude` ran _ccw_resolve, which forked `cat` to read the tiny TSV.
Read it natively with $(<file) into a here-string instead. (pins still uses cat
for display — not on the hot path.)
…oped subcommands

Document that a leading me/work (and pin/unpin/which/pins) is consumed by the
wrapper — so a prompt starting with those words must use `claude -p` or
`command claude` — and that stateful subcommands (mcp/config/update/doctor) act
on the resolved profile's config dir, not the legacy ~/.claude.
…file

The space-join substring test introduced in c5962b7 accepted the literal string
"me work" (" me work " contains " me work "), which the original
`== me || == work` rejected — letting `claude pin "me work"` write a rule that
resolved to a bogus `~/.claude-me work` config dir. Use zsh exact array
membership `(( ${_CCW_VALID_PROFILES[(Ie)$1]} ))` instead.
…le via nix

Expose defaultProfile, profiles, and rules (path-prefix -> profile) as module
options. The module emits them as well-known shell variable assignments
(_CCW_DEFAULT_PROFILE / _CCW_VALID_PROFILES / _CCW_RULES) prepended ahead of
wrapper.zsh, keeping wrapper.zsh a pure, standalone-runnable zsh file via built-in
fallbacks — no template interpolation. _CCW_RULES is a zsh associative array, so no
tabs are embedded in generated source. Defaults preserve existing behaviour
(profiles me/work, ~/src/perforce -> work). Verified with nix eval (option defaults +
extendModules-enabled snippet) and standalone + custom-input shell tests.
…d config

config.home.homeDirectory only resolves in the home-manager config scope; in the
darwin host module `config` is the flake-parts config (no `home`), so the rules
in k.nix failed to evaluate (attribute 'home' missing). Make home-manager.users.k
a `{ config, ... }:` function module. Also sync the README default to ~/src/work
and show the function-module form in the config example.
kriswill added 7 commits June 4, 2026 21:24
Extend profile selection to two launch contexts the per-$PWD shell wrapper
can't reach:

- desktopProfile: GUI apps launched from Dock/Spotlight/Finder inherit the
  launchd Aqua session env, not the shell, so the wrapper never runs and they
  fall back to ~/.claude. When set, a login LaunchAgent runs `launchctl setenv
  CLAUDE_CONFIG_DIR ~/.claude-<p>` in the Aqua domain. That value also leaks
  into GUI-launched terminals (where the wrapper treats any non-empty value as
  an explicit override), so a one-line zsh-init scrub drops a merely-inherited
  value and restores $PWD resolution; an explicit `CLAUDE_CONFIG_DIR=... claude`
  set after init still wins.

- ccglass(): the LLM-traffic inspector spawns the real claude binary directly
  via Node (no shell), dodging the claude() function, so the child would land
  in the default ~/.claude scope. Resolve the profile (same rules as claude)
  and export the same env; ccglass passes it through to the child, so claude
  lands in the right account and its traffic flows through the proxy.

Factor the shared env injection into _ccw_exec_with_profile so claude() and
ccglass() stay in lock-step. Pin this host's desktop app to ~/.claude-me.
Package ccglass as a flake-parts package + overlay and consume it from the
claude-account-selector module, replacing the out-of-band `bun -g i ccglass`
that assumed ~/.bun/bin on PATH.

buildNpmPackage provides reproducible node_modules from the upstream
package-lock.json; `bun build --compile` produces a standalone binary. A small
maintained fork (fork.patch) makes the source survive compilation: hardcode the
version (read from ../package.json at load), embed the web/ dashboard assets
(served via __dirname), and route the MCP subprocess through a `__mcp__`
self-exec sentinel.
…ivation

A bun + TypeScript driver (.claude/skills/patch-ccglass/driver.ts) that automates
the ccglass upgrade/patch workflow: clone the latest upstream tag, scan the source
for bun-compile hazards (script-relative version/web/MCP reads), check whether
fork.patch still applies, regenerate it, then `nix build` the flake output and
verify the binary (--version), MCP stdio server, and embedded dashboard assets.
Troubleshoots hash mismatches by printing the corrected hashes.

Un-ignore .claude/skills/ so shared skills are tracked while local Claude state
(settings.local.json, worktrees) stays ignored.
ccglass builds cleanly on Linux (pure-JS deps, `bun build --compile` → ELF), so
widen meta.platforms to darwin + linux and add the two Linux package outputs.

The repo's flake-parts `systems` list stays aarch64-darwin only; the extra outputs
are added via `flake.packages.<system>.ccglass` (using nixpkgs.legacyPackages) so
only ccglass gains Linux outputs — kitten/iv/devShells aren't forced onto Linux.
`nix flake check` cleanly omits the incompatible systems on darwin.

The patch-ccglass driver now auto-detects the current system for `nix build`
instead of hardcoding aarch64-darwin.
…e root

Move the ccglass package out of pkgs/ into a self-contained flake at
flakes/ccglass (flake.nix via flake-parts, exposing packages.<system>.{ccglass,
default} for aarch64-darwin + aarch64-linux + x86_64-linux). The root flake now
consumes it through a relative-path input:

    inputs.ccglass.url = "./flakes/ccglass";   # + nixpkgs/flake-parts follows

modules/packages.nix re-exports it, and modules/overlays.nix defines the ccglass
overlay inline (closing over inputs) so pkgs.ccglass on the darwin host comes from
the sub-flake. overlays/ccglass.nix and pkgs/ccglass/ are removed.

A reusable extraction pattern: one git tree serves both, and moving ccglass to its
own repo later is just swapping the input URL to github:. Documented in AGENTS.md
("Adding a Custom Package as a Sub-flake"). The patch-ccglass skill is repointed at
flakes/ccglass and builds the sub-flake directly.

Verified: sub-flake builds standalone (.#ccglass / .#default / all 3 systems), root
builds the same drv via the input, nix flake check green, skill prepare+verify pass.
Captures the in-repo derivation-extraction workflow (the ccglass pattern) as a
reusable skill: scaffold a package into flakes/<name>/, wire the root via a
relative-path input + follows, verify (standalone build + parity + flake check +
stale-ref scan), adversarial quality review, and documentation. Bundled bun/TypeScript
scripts (inventory.ts, scaffold.ts, verify.ts) do the rote steps.

Validated: scripts run green against the repo; a worktree dry-run extracted `iv` into a
valid 3-system flake; a with-skill vs no-skill benchmark (iv + kitten in isolated
worktrees) confirmed the skill standardizes output (README, doc updates, all-systems
re-export); description triggering scored 100% train/test on a 20-query should/shouldn't set.
@kriswill kriswill force-pushed the claude-account-selector branch from 51d2ed7 to 371fa1b Compare June 5, 2026 04:26
refactor(ccglass): extract into a standalone sub-flake consumed by the root
@kriswill kriswill merged commit aa761c9 into main Jun 5, 2026
kriswill added a commit that referenced this pull request Jun 29, 2026
nix-darwin has no programs.* for most of these tools, so port them the way
the rest of the repo already does (cf. tmux/zsh/yazi): static config lives in
the stow tree (home/) and the /nix/store-derived bits are generated and linked
during activation. The shell integrations (fzf/zoxide/direnv/hstr) already live
in the stow zshrc, so only the FZF_* env vars needed re-adding there.

Moved out of modules/home-manager/core.nix (now deleted) into system-level
modules/darwin/<feature>.nix + home/<pkg> stow packages:

  git/gh      modules/darwin/git.nix         + home/git, home/gh (bare-name
                                                helpers, no stale store paths)
  ssh         modules/darwin/ssh.nix         + home/ssh
  zk          modules/darwin/zk.nix
  diffnav     modules/darwin/diffnav.nix     installs a delta wrapped with the
                                              kanagawa theme (matches HM's
                                              finalPackage; diffnav bundles its
                                              own delta and is unaffected)
  kitty       modules/darwin/kitty.nix       + home/kitty
  neovide     modules/darwin/neovide.nix     toggle only; pkg is per-host
  direnv      modules/darwin/direnv.nix      + home/direnv; nix-direnv stdlib
                                              linked into ~/.config/direnv/lib
  direnv-nom  modules/darwin/direnv-nom.nix  nom wrapper generated + linked
  htop        modules/darwin/htop.nix        immutable htoprc generated + linked
  qmd-sqlite  modules/darwin/qmd-sqlite.nix  extension-enabled sqlite (hiPrio so
                                              it wins the sqlite3 collision with
                                              neovim's plain sqlite) + qmd link
  bat jq nix-index lazygit rmpc fzf go nodejs_24 yamlfmt -> user-packages.nix
  FZF_* env vars -> home/zsh/.config/zsh/.zshrc

The home-manager master `kriswill.enable` toggle and core.nix were removed;
home-manager stays wired only for the GUI/per-host modules (brave, firefox,
vscode, podman-desktop, claude-account-selector). All three host configs build
(nix flake check) and the HM->stow config handoff is clean (the old HM configs
were store symlinks HM removes on switch, then stow/activation redeploys).

flake.lock: `nix flake update` bumps nixpkgs, home-manager and yazi-plugins.
This is a closure no-op for host k -- the new lock evaluates to the identical
darwin-system derivation (verified: same .drv d15di5x4..., same output
axiznxg2...). It is included so future builds and the other hosts track the
newer inputs.

nvd diff -- previous generation (system-184, pre-migration) -> this commit's
closure (== the running gen-185 system). The Selection-state / Added / Removed
sections are this port (note the hm_* generated configs removed and the
delta-wrapped/delta-config -> delta-kanagawa swap); the Version changes are the
already-committed af761b8 nixpkgs bump that the pre-port generation had not yet
realized.

<<< /nix/store/7kx6ii60i6fwp4gsn4l987r9klgwd909-darwin-system-26.11.a1fa429
>>> /nix/store/axiznxg2fgby564shlpxjkjp208wa6p8-darwin-system-26.11.a1fa429
Version changes:
[U*]  #1  buf                           1.70.0 -> 1.71.0
[U.]  #2  expat                         2.8.0 -> 2.8.1
[U*]  #3  fastfetch                     2.64.2, 2.64.2-man -> 2.65.1, 2.65.1-man
[U.]  #4  ffmpeg-headless               8.1-bin, 8.1-data, 8.1-lib -> 8.1.1-bin, 8.1.1-data, 8.1.1-lib
[U.]  #5  fftw-double                   3.3.10 -> 3.3.11
[U*]  #6  fish                          4.7.1, 4.7.1-doc -> 4.8.0, 4.8.0-doc
[U.]  #7  freetype                      2.14.2 -> 2.14.3
[U+]  #8  gh                            2.94.0 -> 2.95.0
[U*]  #9  ghostscript                   10.07.0, 10.07.0-fonts, 10.07.0-man -> 10.07.1, 10.07.1-fonts, 10.07.1-man
[U*]  #10  go                            1.26.3 -> 1.26.4
[U.]  #11  icu4c                         76.1, 76.1-dev -> 78.3, 78.3-dev
[U.]  #12  ijs                           10.07.0 -> 10.07.1
[U*]  #13  imagemagick                   7.1.2-23 -> 7.1.2-24
[U.]  #14  just                          1.51.0, 1.51.0-man -> 1.54.0, 1.54.0-man
[U.]  #15  krb5                          1.22.1-lib -> 1.22.2-lib
[U.]  #16  libde265                      1.0.18 -> 1.1.1
[U.]  #17  libgcrypt                     1.11.2-lib -> 1.12.2-lib
[U.]  #18  libheif                       1.21.2-lib -> 1.23.0-lib
[U.]  #19  libpng-apng                   1.6.56 -> 1.6.58
[U*]  #20  libxml2                       2.15.2, 2.15.2-bin -> 2.15.3, 2.15.3-bin
[U*]  #21  lua-language-server           3.18.1 -> 3.18.2
[D.]  #22  nix                           2.34.7+1 x2 -> 2.34.7 x2
[D.]  #23  nix-cmd                       2.34.7+1 -> 2.34.7
[D.]  #24  nix-expr                      2.34.7+1 -> 2.34.7
[D.]  #25  nix-fetchers                  2.34.7+1 -> 2.34.7
[D.]  #26  nix-flake                     2.34.7+1 -> 2.34.7
[D.]  #27  nix-main                      2.34.7+1 -> 2.34.7
[D.]  #28  nix-store                     2.34.7+1 -> 2.34.7
[D.]  #29  nix-util                      2.34.7+1 -> 2.34.7
[U*]  #30  nodejs                        24.15.0 -> 24.16.0
[C.]  #31  nodejs-slim                   22.22.3, 24.15.0, 24.15.0-corepack, 24.15.0-npm -> 22.23.1, 24.16.0, 24.16.0-corepack, 24.16.0-npm
[U.]  #32  openapv                       0.2.1.2 -> 0.2.1.3
[U.]  #33  openexr                       3.4.10 -> 3.4.11
[U.]  #34  podman                        5.8.2, 5.8.2-man -> 5.8.3, 5.8.3-man
[U.]  #35  publicsuffix-list             0-unstable-2026-03-26 -> 0-unstable-2026-05-13
[U.]  #36  rsync                         3.4.1 -> 3.4.4
[U*]  #37  rumdl                         0.2.16 -> 0.2.21
[U.]  #38  simdjson                      4.6.0 -> 4.6.4
[C*]  #39  sqlite                        3.51.2 x2, 3.51.2-bin x2, 3.51.2-dev, 3.51.2-man x2 -> 3.51.2 x2, 3.51.2-bin x2, 3.51.2-dev, 3.51.2-man
[U.]  #40  unbound                       1.25.0-lib -> 1.25.1-lib
[U.]  #41  uv                            0.11.19 -> 0.11.22
[D*]  #42  vscode-langservers-extracted  4.10.0 -> 1.121.03429
Selection state changes:
[C+]  #1  direnv   2.37.1
[C+]  #2  git-lfs  3.7.1
[C+]  #3  htop     3.5.1, 3.5.1-man
[C+]  #4  kitty    0.47.4, 0.47.4-terminfo
Added packages:
[A+]  #1  delta-kanagawa                                   <none>
[A.]  #2  delta-kanagawa.gitconfig                         <none>
[A.]  #3  fastfetch-unwrapped                              2.65.1, 2.65.1-man
[A.]  #4  home-manager-agent-domains                       <none>
[A.]  #5  org.nix-community.home.claude-config-dir.domain  <none>
[A.]  #6  zz-nom-wrapper.sh                                <none>
Removed packages:
[R.]  #1  delta-config                 <none>
[R.]  #2  delta-wrapped                <none>
[R.]  #3  direnv-config                <none>
[R.]  #4  empty-directory              <none>
[R.]  #5  gh-config.yml                <none>
[R.]  #6  hm_.sshconfig                <none>
[R.]  #7  hm_direnvlibzznomwrapper.sh  <none>
[R.]  #8  hm_gitallowed_signers        <none>
[R.]  #9  hm_gitconfig                 <none>
[R.]  #10  hm_gitignore                 <none>
[R.]  #11  hm_kanagawa.conf             <none>
[R.]  #12  hm_kittydiff.conf            <none>
[R.]  #13  hm_kittykitty.conf           <none>
[R.]  #14  yyjson                       0.12.0
Closure size: 624 -> 616 (611 paths added, 619 paths removed, delta -8, disk usage -53.1MiB).
kriswill added a commit that referenced this pull request Jun 29, 2026
Completes the migration. The home-manager bridge, flake input, and the last
runtime hook are gone; the repo is now pure nix-darwin + the GNU Stow tree under
home/.

- flake.nix: drop the home-manager input (flake.lock: removed the home-manager
  and home-manager/nixpkgs nodes).
- modules/home.nix deleted -- the darwin <- home-manager bridge.
- modules/darwin/core.nix: drop pkgs.home-manager from environment.systemPackages.
- home/zsh/.config/zsh/.zshrc: drop the hm-session-vars.sh source line.
- AGENTS.md / CLAUDE.md updated to describe a darwin-only repo (no home.nix, no
  modules/home-manager/, no mkOutOfStoreSymlink mechanism).

Terminfo is unaffected -- the one real risk. nix-darwin's own set-environment
already exports the full TERMINFO_DIRS (the Ghostty.app bundle, the per-user and
system nix profiles, and /usr/share/terminfo), so xterm-kitty (in the system
profile's share/terminfo) and xterm-ghostty (Ghostty's bundle) still resolve
without the home-manager session-vars line. Verified with infocmp.

nix flake check passes for k, mini, SOC.

nvd diff -- previous generation (system-186, home-manager present) -> this commit
(system-187, now active). Removes the entire home-manager apparatus plus the deps
nothing else in the closure used (man-db, groff, diffutils, inetutils,
libpipeline).

<<< /nix/store/gf8hnm6w88ng2kajiy4x5n29k55nq0wz-darwin-system-26.11.a1fa429
>>> /run/current-system
Removed packages:
[R.]  #1  activation-k                             <none>
[R.]  #2  check-link-targets.sh                    <none>
[R.]  #3  cleanup                                  <none>
[R.]  #4  diffutils                                3.12
[R.]  #5  groff                                    1.24.1
[R.]  #6  hm-modules-messages                      <none>
[R.]  #7  hm-session-vars.sh                       <none>
[R.]  #8  hm_LibraryFonts.homemanagerfontsversion  <none>
[R.]  #9  hm_Usersk.cache.keep                     <none>
[R.]  #10  hm_Usersk.localstate.keep                <none>
[R-]  #11  home-manager                             0-unstable-2026-04-24
[R.]  #12  home-manager-agent-domains               <none>
[R.]  #13  home-manager-agents                      <none>
[R.]  #14  home-manager-applications                <none>
[R.]  #15  home-manager-files                       <none>
[R.]  #16  home-manager-fonts                       <none>
[R.]  #17  home-manager-generation                  <none>
[R.]  #18  home-manager-path                        <none>
[R.]  #19  home-manager-source                      <none>
[R.]  #20  home-manager.sh                          <none>
[R.]  #21  inetutils                                2.7
[R.]  #22  libpipeline                              1.5.8
[R.]  #23  link                                     <none>
[R.]  #24  man-db                                   2.13.1
[R.]  #25  nixos-option                             <none>
[R.]  #26  nixos-option.nix                         <none>
Closure size: 613 -> 587 (6 paths added, 32 paths removed, delta -26, disk usage -20.7MiB).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant