Skip to content

claude-code

Modules: home/shell/claude-code.nix, system/services/claude-code.nix

Two subscriptions on one machine, one shared archive, and the rules every project inherits.

The problem: two accounts, one config directory

There are TWO subscriptions here (FAI/nonprofit and personal) and Claude Code keeps the login, the MCP servers and the settings in a single config directory. Running both in the same directory would mean logging in again on every switch. The way out is CLAUDE_CONFIG_DIR: one directory per account, and switching accounts means switching the variable.

Path What it is
~/.claude-fai FAI / nonprofit, and the DEFAULT
~/.claude-pessoal personal
~/.claude NOT an account: the shared ARCHIVE (projects/)

There are two accounts and not three, which is a correction from 11/08/2026. The first version of this module created an EMPTY ~/.claude-fai next to ~/.claude, which already WAS the FAI account (oauthAccount.emailAddress matched, a nonprofit premium seat). That would have been two logins for the same subscription, with the third "account" existing purely by accident of naming.

Now plain claude LANDS ON FAI, because CLAUDE_CONFIG_DIR is exported into the session. That holds for everything calling the binary without going through a wrapper: the VS Code extension, a script, cron. The other account is claude-pessoal, which overrides the variable.

Why the archive stays in ~/.claude

projects/ (transcripts plus memory, 200 MB across 13 projects) belongs to the MACHINE, not to a subscription. Keeping it on the canonical path means third-party tooling that looks for the standard (ccusage and friends) finds it by itself, and retiring an account one day does not orphan the archive.

And that is why ~/.claude cannot be the config folder, however tempting it looks now that it already is FAI. The .claude.json (project and MCP config, distinct from settings.json) lives at the ROOT of CLAUDE_CONFIG_DIR. Without the variable it is the home's ~/.claude.json; with it pointed at ~/.claude it would become ~/.claude/.claude.json, a SECOND file diverging from the first. Verified on 2.1.222: along with the symlink test, claude mcp add wrote exactly inside CLAUDE_CONFIG_DIR.

History and memory are SHARED on purpose: each account's projects/ is a symlink to the canonical archive, so any account resumes the same conversations and reads the same memories. On Arch this was a _claude_share_projects function in .zshrc, imperative and running on every shell open; here it is a declared symlink (rule 3). One price to know: ccusage cannot separate cost per account, because it reads the shared archive.

A wrapper, not an alias

An alias only exists in an INTERACTIVE zsh, so claude-fai did not work over non-interactive SSH, inside a script, in a VS Code task or in a Hyprland keybind. The wrapper is a binary on the PATH, and rule 7 asks for the logic in the build.

For free, the wrapper PINS the claude version (the package's own store path) instead of depending on which claude the PATH resolves first, which matters here because this machine has an orphan native install in ~/.local/bin, the one claude doctor complains about.

Plain claude became a wrapper too (it used to be the raw binary), because plain claude IS the FAI account: without that, the Azure MCP would only appear for whoever remembered to type claude-fai, and never in the VS Code extension. The claude package no longer enters home.packages, since two bin/claude would collide at activation; it is referenced through lib.getExe inside the wrappers.

settings.json is versioned, and the atomic-write guard is what makes it safe

Same contract as VS Code and hyprland.lua: mkOutOfStoreSymlink to the REAL file in the repo, mutable, so the TUI's /config keeps working and every adjustment lands as a git diff instead of invisible drift (rule 16). A programs.* generating into the store does NOT work, because the store is read-only and CC writes to that file.

Measured on 11/08/2026 on 2.1.222: CC writes settings.json ATOMICALLY (tmp plus rename), and a rename over a symlink would REPLACE the link with a regular file, disconnecting the repo silently. But it resolves the realpath FIRST: the link survived intact and what changed inode was the TARGET (593793 to 593844, through claude auto-mode reset). If CC ever loses that guard, the symptom is ~/.claude-fai/settings.json no longer being a symlink.

No comments inside the JSON, on purpose: CC rewrites the whole file on save (it is not JSONC like VS Code's) and would erase them.

The permissions.allow list is declared HERE, by hand, because /auto-mode-setup refuses to save it (18/08/2026). It reports Could not write ~/.claude-fai/settings.json, check file permissions and disk space and neither is true: the file opens for writing as the user, the target resolves to the repo, and the disk had 363G free. The link survived the attempt, so the atomic-write guard measured above still holds; what the command dislikes about this path was not determined. It does not matter much, since the answer is the repo's answer for everything else: the rule goes in the versioned file and git diff shows it.

settings.local.json, in that same directory and NOT a symlink, is where the TUI writes an "always allow" answered at a prompt. It carried three rules from 23/07/2026 (lspci, lspci -nn, sudo -n true), which were merged into the versioned list on 18/08 and the file deleted. It comes BACK the next time a permission is approved in the UI, and that is fine: it is app state, it wins over this file by precedence, and what deserves to survive a reinstall gets moved HERE.

The list covers READ-ONLY commands only. git add/commit/push, nix build, nixos-rebuild and systemctl start/stop are OUT on purpose, since a prompt on the command that CHANGES something is the prompt worth keeping. That is also why the systemctl verbs are spelled out one by one: the tempting Bash(systemctl --user *) is a PREFIX rule, so it would quietly cover start and stop as well.

theme: dark-ansi is not neutral, it is as TokyoNight as it gets: it tells the TUI to use the terminal's 16 ANSI colors, which in this repo's kitty ALREADY are the my.theme palette (rule 9).

How the FAI settings were built: the personal one came from Arch whole; the FAI one is a MERGE of the Arch file with what ~/.claude had in use, namely the github, atlassian and frontend-design plugins. Taking only the Arch version would have SILENTLY turned off three plugins that were on, which is the kind of loss nobody connects to the migration two days later.

What died in the crossing (rule 16): the permissions.allow for mcp__pencil, and the two user MCP servers present in both accounts' .claude.json, pencil (an AUR package that does not exist here) and atlassian (through npx mcp-remote, today done by the official plugin). Migrating a permission for an MCP server that never comes up would be declaring the nonexistent.

The Azure MCP is FAI-only, and the delivery path was the hard part

The Azure MCP Server (pkgs/azure-mcp.nix) enters FAI ONLY, because the cloud is the work one. The personal account has nothing to do with it, and 68 extra tools cost context in every session. That is why mcp became a field of profiles: an account that declares nothing gets no flag.

Delivery is the wrapper's --mcp-config. Three alternatives were discarded, each for a concrete reason:

  • the repo root's .mcp.json (which serves the two Cloudflare MCP servers) is PROJECT scope, so Azure would only exist when running claude inside the dotfiles, which is exactly where we will never touch Azure;
  • user scope in .claude.json is app state, and CC rewrites the whole file, so declaring there is rule 14's recipe for drift;
  • /etc/claude-code/managed-mcp.json looks like the right place, being the sibling of the managed-settings.json we already use, and it is a TRAP: whoever deploys that file gains EXCLUSIVE control, and CC stops loading EVERYTHING else, including the MCP servers of the github and atlassian plugins that are in use. It would gain Azure and lose two.

The Vercel MCP is on BOTH accounts, and the login is per account

https://mcp.vercel.com is Vercel's official remote server, HTTP with OAuth, and its docs open the security section by asking that the endpoint be VERIFIED before connecting. That is not boilerplate: a client that connects gets the same access as my Vercel user, so a look-alike domain behind a one-click button from some marketplace owns the account. Declared here, the URL is typed once and reviewed in a diff, which is the one place a wrong character shows up.

It enters through shared and not twice inside profiles, because the same server written in two places is rule 11's duplicate waiting to drift. Both accounts carry it: unlike Azure, which is the work cloud, deploys happen on either side.

The OAuth is not declared, and it CANNOT be: the token is app state (rule 6) and it lives in each account's .credentials.json, inside CLAUDE_CONFIG_DIR. So it is one /mcp in claude-fai and another in claude-pessoal, each authorizing in the browser, and what Nix writes is the URL and nothing else (rule 12).

claude mcp add --transport http vercel https://mcp.vercel.com, the command in the docs, is the imperative form of this same line. It writes into .claude.json, which CC rewrites at runtime, so it would be a second owner on a file the app owns (rule 14) and invisible to git. The wrapper's --mcp-config puts it in the build instead, which is also why it reaches BOTH accounts without being done twice by hand.

The system side: what has to be IMPOSED

system/services/claude-code.nix holds the three things that cannot be merely suggested, all in /etc, which has the highest precedence and is read-only by nature:

The lifecycle hooks feed the Discord Rich Presence: six events POST to the local daemon (home/services/claude-discord-rpc.nix), which paints the card. Same format claude-presence setup would write, only declared. The path is FIXED in /etc, outside CLAUDE_CONFIG_DIR, so they hold for both accounts at once. SessionStart is sync and the rest are async, so they do not block CC. The hook is wrapped in a writeShellApplication because CC runs it with the USER's PATH, which may not have jq; the exec makes the wrapper disappear and leaves the real script.

The reason /etc and not the user's settings.json: CC WRITES to the user's settings at runtime (/config, permission approvals), so it can never become a read-only symlink into the store. The managed file is read-only by nature and does not fight those writes.

The global rules (/etc/claude-code/CLAUDE.md, 15/08/2026) are rule 3 applied to the agent contract. The three mandatory ones (incremental commits, everything in en-US, never a Co-Authored-By:) were being RETYPED BY HAND at the top of every prompt, which is the definition of manual: it works until the day you forget, and what comes out of that day is a repo in two languages with one blob commit signed by a coauthor.

Why /etc/claude-code/CLAUDE.md and not $CLAUDE_CONFIG_DIR/CLAUDE.md, which is the path everybody knows: the user one is PER ACCOUNT, so with two accounts it would be two copies drifting apart, and CC WRITES to it, because the # shortcut appends a memory to exactly that file. Nix owning it would break the shortcut and rule 14. The managed one CC only ever READS.

Measured on 2.1.222, in the bundle itself, because a memory file that is never read fails SILENTLY and looks exactly like a rule being ignored: the memory loader resolves "Managed" to join(fU(), "CLAUDE.md") and fU() returns /etc/claude-code on Linux. It is read UNCONDITIONALLY, unlike the User and Project layers, which are gated by settings, and it does not REPLACE the project's CLAUDE.md, it loads alongside it.

Discarded, and it is the near miss: managed-settings.json has a claudeMd STRING field that does the same job with no second file. It loses on the diff, since the markdown would become a JSON one-liner with escaped newlines, unreadable in a git diff and unreachable for markdownlint.

Keep that file short. It enters the context of EVERY conversation on this machine, so it is the most expensive documentation in the repo per line. It holds the rules and nothing else; the reasoning lives in ../rules.md.

The shared skills (/etc/claude-code/.claude/skills/, 27/08/2026) are the third: grill-me and grilling, from the pinned mattpocock-skills input. The next section is the reasoning.

The managed skills, and why not a plugin

grill-me interviews me about a plan until every branch of the decision tree is resolved, and it is the second most installed skill in the ecosystem (985k, behind only the 3.1M of the find-skills that every npx skills install carries along). It writes NOTHING: what comes out of a session is a decision, in my head and in the conversation. That property is the whole reason it can live at machine level.

Two skills and not one, because grill-me is a SHIM. Its SKILL.md is one line, Call the Skill tool with "grilling", and all the content is in grilling. Installed alone it is a command that does nothing, and the failure is SILENT: the agent guesses what grilling means and dumps every question at once, with no rounds and no recommended answers.

Why the ENTERPRISE layer, measured in the bundle on 2.1.234, because a skill that is never read fails exactly like a skill being ignored: the loader resolves the managed one to join(managedDir, ".claude", "skills"), with managedDir returning /etc/claude-code on Linux, and the personal one to userConfigDir("skills"), which follows CLAUDE_CONFIG_DIR. So the personal path here is ~/.claude-fai/skills AND ~/.claude-pessoal/skills, two copies of one artifact (rule 14), and a third the day an account is added. /etc is ONE declaration for both accounts and every repo, and it is read-only by nature, the same argument that put CLAUDE.md there.

What makes the Nix delivery work is that the entry may be a SYMLINK. CC follows a <skill-name> symlink in the enterprise, personal and project locations, reads SKILL.md from the target, and loads the skill once when two locations resolve to the same target. So the entry IS the store path, with no copy: the built tree is /etc/claude-code/.claude/skills/grill-me -> /nix/store/...-source/skills/productivity/grill-me.

The precedence TRAP: enterprise beats personal, project AND the bundled skill of the same name, though not that bundled skill's aliases, so a code-review declared here would answer /code-review while /review kept reaching the bundled one. Nothing in this module may be named after a bundled skill, or I shadow it on the whole machine and only find out through the behavior.

Three delivery paths were REJECTED, and the tempting one is the first:

  • enabledPlugins in the versioned settings.json. It is one line, and mattpocock-skills IS in claude-plugins-official, which is already registered here. But the plugin CONTENT is fetched at runtime into plugins/cache/, so the version would depend on the day of the fetch instead of the lock, which is rule 13 backwards, and git would see none of it (rule 16).
  • The per-account skills/: two copies of one artifact, the same reason as above.
  • The whole repo as a @skills-dir plugin. A folder carrying .claude-plugin/plugin.json inside a skills directory loads as a plugin with no marketplace and no install step, and this input has that manifest at its root, so ONE symlink would bring all 25 skills, pinned. It loses twice: the docs list that shape for the personal and project directories only, not for the enterprise one, and 25 skills is not what I want machine-wide.

The criterion for entering this layer is STATELESSNESS, not popularity. Among the neighbours in the same input, grill-with-docs writes a CONTEXT.md glossary at the repo root plus ADRs under docs/adr/, and improve-codebase-architecture writes into CONTEXT.md as well. Both would stand up a second documentation system next to docs/, which already splits by function and keeps the tried-and-rejected in notes/: two owners of one subject (rule 14) becoming drift (rule 16) within weeks. Whatever writes into a repo belongs to THAT repo's .claude/skills/, where its conventions are the ones in force.

And context is NOT the constraint here, which is worth writing down because it was my first argument and it was wrong: the listing of names and descriptions gets a budget of 1% of the model's window, so on a 1M model even the full set of 25 would fit with room to spare. What is expensive about a machine-wide skill is its blast radius, not its line in the prompt.

What is NOT declared here

  • .credentials.json (each account's OAuth token), a secret AND state: never versioned, never declared. A new account means one /login (rules 6 and 12).
  • .claude.json, history.jsonl, sessions/, plugins/, cache/: app state written at runtime, so restic and not git (rule 6).
  • ~/.claude/projects/, which is the TARGET of the symlinks. Nix owns the links; the content belongs to the app (rule 14).
  • The rest of ~/.claude (history.jsonl, settings.json, sessions/, shell-snapshots/), leftover from when it was an account. What was worth keeping was copied to ~/.claude-fai at the turn, and the rest gets pruned once the new account proves it walks. Noted in ../open-items.md.

The -- terminator rule is the opposite of the intuition

Measured on 2.1.222. --mcp-config is VARIADIC (it accepts N files), so it swallows everything after it until it finds a token starting with -.

  • With no terminator, claude-fai mcp list dies with MCP config file not found: …/mcp, because it read mcp and list as two more config files.
  • With the terminator ALWAYS, claude-fai --version opens a SESSION with --version as the prompt instead of printing the version.

Hence the case in the wrapper: what starts with - (or nothing, the interactive TUI) goes WITHOUT the terminator; a bare word (a subcommand or a prompt) goes WITH it.

And the price of the terminator is that mcp list does not SEE those servers, measured on 2.1.263 (12/09/2026): the listing shows the user and project scopes only, so vercel, azure, stitch and basic-memory are all absent from it while working fine in a session. The flag belongs to the default command and the -- hands the subcommand a clean argument list. The check is /mcp inside a session, or calling one of the server's tools; a missing line in mcp list proves nothing.

exec is there so the wrapper leaves the process tree and only the real claude remains, which is what makes signals and the TUI's TTY arrive directly.

The env var only takes effect after a RELOGIN

Measured on 11/08/2026, with the switch already applied and CLAUDE_CONFIG_DIR still empty in a freshly opened zsh. A new terminal is NOT enough.

The mechanism: the variable is written into hm-session-vars.sh, which ~/.zshenv loads, and the file guards itself with __HM_SESS_VARS_SOURCED=1 so it does not reload in a subshell. That mark is EXPORTED, so every child of the graphical session is born with it and skips the whole load. Proven with env -u __HM_SESS_VARS_SOURCED zsh -i -c: without the mark, the variable appears.

Same family as the NH_FLAKE trap in shell.md.

programs.zsh.sessionVariables was tried as a second layer and does NOT help: it lands in the SAME ~/.zshenv and brings its own guard (__HM_ZSH_SESS_VARS_SOURCED), which the session already exports even when the repo does not use the option, because home-manager always emits the block. Two layers with the same flaw; one honest layer remained.

Until the relogin, what delivers the right account is the WRAPPER, which exports the variable itself. That is the strongest argument for the wrapper existing: it does not depend on the session environment having been rebuilt.

Two operational details

The Azure MCP mode. --mode namespace (azmcp's default) exposes ONE tool per service, 68 in total. all would explode into hundreds and single would leave just one with an extra routing hop. To trim it, --namespace storage --namespace keyvault … limits it to the named services.

azure-mcp is also on the PATH as a command, and that is not convenience: the Azure login is a device code and it HAS to happen outside Claude Code, because inside a session the code comes out on the MCP server's stderr, where nobody reads. The closure cost is zero, since the MCP config already references that store path.

Restoring an account backup: the activation FAILS if ~/.claude-<account>/projects already exists as a real directory (existing file would be clobbered). Restore the CONTENT into ~/.claude/projects, never the account folder.

The Discord Rich Presence daemon

home/services/claude-discord-rpc.nix. The tool's trio: the HOOK (declared in system/services/claude-code.nix, which POSTs the events) feeds the DAEMON (here, which holds the Discord RPC connection plus a local HTTP server), which paints the CARD on Discord. It needs the Discord client running, since that is what exposes the IPC socket.

Everything is declarative: the package comes from the repo's overlay, the config is generated by Nix, and the daemon runs as a systemd --user service, WITHOUT the imperative claude-presence setup, which would mutate settings.json and bring the daemon up by hand.

The config file is generated but the DIRECTORY stays writable, because the daemon writes pid, log and aggregate files there. That is state, not config (rule 6).

The daemon comes up with the graphical session, since Discord lives in it, and reconnects on its own if Discord opens or closes later. It does not restart in a loop: with no Discord it just logs and carries on. The claude-presence CLI is on the PATH for diagnosis (status, doctor, preview).