Identity profiles for agents and shells.
direnv scopes environments to directories. hats scopes them to identities.
A hat is a named bundle of credentials: which Vercel account, which Google Workspace, which cloud project, which agent config a process sees. Run any command "wearing" a hat and it inherits that identity's environment, so every CLI it touches reads the right credential directory automatically.
hats wear project -- vercel deploy # deploys with your project's Vercel account
hats wear dayjob -- vercel deploy # same command, employer's account
hats wear project -- claude # an agent session, born scoped to the project(hats wear is the flagship verb; hats run is a familiar alias - use whichever.)
Agents roam directories, but they must never roam identities. If you run coding agents (Claude Code, Codex, your own orchestrator) across multiple clients, employers, or businesses, the question isn't "which folder am I in," it's "whose credentials is this process holding?" hats makes the answer structural: every process is born wearing exactly one hat.
You don't need multiple agent accounts for this to matter. hats scopes
every credential-bearing CLI it touches - Vercel, Google Workspace, Render,
gcloud, Neon, Doppler - not just your agent's config. The moment you have a
personal Vercel and a client's, or your own Google Workspace and a
contract's, a bare vercel deploy is ambiguous. One agent login, many
identities underneath: freelancers, moonlighters, and anyone juggling personal
plus contract work all have this.
Prompts are opt-in correctness: every command must remember the instruction, and one context compaction, subagent, or injection later it's gone. Environment injection is opt-out correctness: the default path is the right account, wrong-identity access requires a deliberate act, and subprocesses inherit it for free.
Agents inherit identity from their execution context, never from their prompt.
If you run Claude Code, you can already scope MCP servers per project
(.mcp.json) or per user. So why hats?
Because MCP scoping only covers the agent's MCP tools. The moment the agent
runs a shell - vercel deploy, gcloud ..., gws ..., render deploy - those
hit your default account; MCP scoping does nothing for them. hats scopes the
whole process environment, so every CLI is born on the right identity.
- Coverage.
.mcp.jsonscopes MCP servers. hats scopes Claude config plus every credential CLI (vercel, gcloud, gws, render, neon, doppler, gh, glab, ...), browser routing, and secrets. An agent with a shell reaches for those CLIs constantly - MCP scoping leaves them all on the wrong account. - Identity is not a folder. Project
.mcp.jsonscopes by directory (like direnv). But your client identity isn't a folder - you touch its work in a dozen folders, and personal in others. hats scopes by who you are, which spans directories. - Default-correct, not opt-in. MCP scoping (or per-command flags like
vercel --scope) is remember-every-time. hats is ambient: the process is born wearing the hat and every subprocess inherits it. Wrong-identity access takes a deliberate act, not a forgotten flag. - One source of truth. MCP-only still leaves each CLI's account configured
separately (vercel scope, gcloud config, aws profile...). hats defines the
identity once in
profiles.json; everything inherits. - It enables the boundary guard. Enforcing "this session can't touch that
identity" needs the whole identity's paths/aliases - hats derives them from
profiles.json(see Boundaries). MCP scoping has no equivalent; it can't build a guard. - Not Claude-specific. hats works for Codex, your own orchestrator, a plain shell, CI. MCP scoping is Claude-Code-only.
MCP scoping secures one tool's plugins; hats secures the whole identity - every CLI, browser, and secret - for any tool, by default.
brew install chrismcdermut/tap/hats
# or
go install github.com/chrismcdermut/hats@latest
# or (npm: downloads the release binary for your platform)
npm install -g manyhats- Homebrew tap: github.com/chrismcdermut/homebrew-tap
- npm: npmjs.com/package/manyhats
- Prebuilt binaries: GitHub Releases
Run hats init to drop a starter ~/.config/hats/profiles.json (and generate
the wrapper shims) in place, then edit it. Prefer to write it by hand? It lives
at ~/.config/hats/profiles.json - or set HATS_CONFIG to point hats at a
different directory (handy for a sandbox, CI, or keeping config in a dotfiles
repo).
Notice the pattern: same variables, different directory per identity. That
-<identity> suffix is the whole idea.
{
"profiles": {
"personal": {
"description": "Personal projects",
"environment": {
"CLAUDE_CONFIG_DIR": "~/.claude",
"GOOGLE_WORKSPACE_CLI_CONFIG_DIR": "~/.config/gws-personal",
"VERCEL_CONFIG_DIR": "~/.config/vercel-personal",
"RENDER_CLI_CONFIG_PATH": "~/.config/render-personal"
},
"aliases": ["ccp", "gwsp", "verp", "rendp"],
"reachable": ["project"]
},
"dayjob": {
"description": "Employer",
"environment": {
"CLAUDE_CONFIG_DIR": "~/.claude-dayjob",
"GOOGLE_WORKSPACE_CLI_CONFIG_DIR": "~/.config/gws-dayjob",
"VERCEL_CONFIG_DIR": "~/.config/vercel-dayjob",
"RENDER_CLI_CONFIG_PATH": "~/.config/render-dayjob",
"CLOUDSDK_CONFIG": "~/.config/gcloud-dayjob",
"NEON_CONFIG_DIR": "~/.config/neon-dayjob",
"DOPPLER_CONFIG_DIR": "~/.config/doppler-dayjob"
},
"path_prepend": ["~/.local/bin"],
"env_files": ["~/.env-dayjob"],
"doctor": {
"claude": "~/.claude-dayjob/.claude.json",
"vercel": "~/.config/vercel-dayjob/auth.json"
},
"logins": {
"gws": "gws auth login",
"vercel": "vercel login",
"render": "render login"
},
"aliases": ["ccd", "gwsd", "verd", "rendd"]
},
"project": {
"description": "Side project / OSS",
"environment": {
"CLAUDE_CONFIG_DIR": "~/.claude-project",
"GOOGLE_WORKSPACE_CLI_CONFIG_DIR": "~/.config/gws-project",
"VERCEL_CONFIG_DIR": "~/.config/vercel-project"
},
"env_files": ["~/.env-project"],
"aliases": ["ccc", "gwsc", "verc"]
}
}
}Only description and environment are required; everything else is optional.
dayjob above is a fully loaded profile - the others are trimmed to show the
minimum. Field by field: path_prepend front-loads dirs onto PATH (for the
wrapper shims below); env_files are gitignored secret files loaded only under
this hat; doctor refines the auto-derived credential checks (see
Doctor); logins declares each CLI's login command for
hats login; aliases lists this identity's launcher aliases; and reachable
lists other profiles this hat may deliberately reach.
The last two feed hats boundary: it doesn't store
a block list, it derives one. For a personal session it reads the other
profiles' config-dir names and aliases as foreign_paths / foreign_aliases
to block - except project, which personal marked reachable, so that one
stays allowed. Nothing about the boundary is hand-maintained; it all falls out
of the profiles above.
A real 3-identity setup (redacted) - what the author actually runs
Three identities - personal, an employer (dayjob), a project - each scoping
the same eight CLIs to its own directories, with per-identity secrets and browser
routing. This is the whole config, only the names/ids changed:
{
"profiles": {
"personal": {
"description": "Personal",
"environment": {
"GOOGLE_WORKSPACE_CLI_CONFIG_DIR": "~/.config/gws-personal",
"GOOGLE_WORKSPACE_PROJECT_ID": "gcp-personal",
"RENDER_CLI_CONFIG_PATH": "~/.config/render-personal",
"VERCEL_CONFIG_DIR": "~/.config/vercel-personal",
"CLOUDSDK_CONFIG": "~/.config/gcloud-personal",
"NEON_CONFIG_DIR": "~/.config/neon-personal",
"DOPPLER_CONFIG_DIR": "~/.config/doppler-personal",
"BROWSER": "~/.local/bin/browse-as-hat",
"CHROME_PROFILE_DIR": "Profile 1"
},
"path_prepend": ["~/.local/bin"],
"doctor": { "claude": "~/.claude.json" },
"logins": {
"gws": "gws auth login", "vercel": "vercel login", "render": "render login",
"gcloud": "gcloud auth login", "neon": "neonctl auth", "doppler": "doppler login"
},
"env_files": ["~/.env-personal"],
"aliases": ["ccp", "gwsp", "verp", "rendp", "neonp", "dopplerp"],
"reachable": ["project"]
},
"dayjob": {
"description": "Employer",
"environment": {
"CLAUDE_CONFIG_DIR": "~/.claude-dayjob",
"GOOGLE_WORKSPACE_CLI_CONFIG_DIR": "~/.config/gws-dayjob",
"GOOGLE_WORKSPACE_PROJECT_ID": "gcp-dayjob",
"RENDER_CLI_CONFIG_PATH": "~/.config/render-dayjob",
"VERCEL_CONFIG_DIR": "~/.config/vercel-dayjob",
"CLOUDSDK_CONFIG": "~/.config/gcloud-dayjob",
"NEON_CONFIG_DIR": "~/.config/neon-dayjob",
"DOPPLER_CONFIG_DIR": "~/.config/doppler-dayjob",
"BROWSER": "~/.local/bin/browse-as-hat",
"CHROME_PROFILE_DIR": "Profile 2"
},
"path_prepend": ["~/.local/bin"],
"logins": {
"gws": "gws auth login", "vercel": "vercel login", "render": "render login",
"gcloud": "gcloud auth login", "neon": "neonctl auth", "doppler": "doppler login"
},
"env_files": ["~/.env-dayjob"],
"aliases": ["ccd", "gwsd", "verd", "rendd", "neond", "dopplerd"]
},
"project": {
"description": "Side project / OSS",
"environment": {
"CLAUDE_CONFIG_DIR": "~/.claude-project",
"GOOGLE_WORKSPACE_CLI_CONFIG_DIR": "~/.config/gws-project",
"GOOGLE_WORKSPACE_PROJECT_ID": "gcp-project",
"RENDER_CLI_CONFIG_PATH": "~/.config/render-project",
"VERCEL_CONFIG_DIR": "~/.config/vercel-project",
"CLOUDSDK_CONFIG": "~/.config/gcloud-project",
"NEON_CONFIG_DIR": "~/.config/neon-project",
"DOPPLER_CONFIG_DIR": "~/.config/doppler-project",
"BROWSER": "~/.local/bin/browse-as-hat",
"CHROME_PROFILE_DIR": "Profile 3"
},
"path_prepend": ["~/.local/bin"],
"logins": {
"gws": "gws auth login", "vercel": "vercel login", "render": "render login",
"gcloud": "gcloud auth login", "neon": "neonctl auth", "doppler": "doppler login"
},
"env_files": ["~/.env-project"],
"aliases": ["ccc", "gwsc", "verc", "rendc", "neonc", "dopplerc"]
}
}
}A few non-obvious bits:
BROWSER+CHROME_PROFILE_DIR- a tinybrowse-as-hatscript opens OAuth flows in that identity's own Chrome profile, so a login started under one hat can't land in another hat's browser session.GOOGLE_WORKSPACE_PROJECT_ID- the GCP project backing that identity's Google Workspace CLI.env_fileshold each identity's API tokens (gitignored,chmod 600), loaded only under that hat - so a stray token is never global.personalmarksreachable: ["project"], so a personal session may deliberately reach the project hat;dayjobmarks nothing, so the employer identity is hard-walled from the others.personalneeds a one-linedoctor(it uses the default~/.claude, so there's noCLAUDE_CONFIG_DIRto derive from);dayjob/projectneed none.
Every identity scopes the same tools (Claude, Google Workspace, Vercel,
Render) - only the directory changes (gws-personal vs gws-dayjob vs
gws-project). So hats wear dayjob -- gws ... reads the employer's Google
Workspace; hats wear project -- gws ... reads the project's. Same command, same
tool, different identity, by construction.
Then:
hats init # scaffold config + generate wrapper shims
hats ls # list hats (* = the one you're wearing)
hats wear project -- vercel deploy # one command under an identity
hats shell project # a subshell wearing the hat
hats login project # log this hat's CLIs in (writes to its dirs)
hats which # what hat is this process wearing?
hats doctor # is every identity aligned and logged in?
hats env project # eval-able exports, for scripts
hats env project --json # machine-readable, for orchestratorsNo magic: hats wear <profile> -- <cmd> sets the profile's env vars, prepends
any path_prepend dirs to PATH, sets HATS_PROFILE, and then execs the
command (a true exec: hats replaces itself with your command, so signals, tty,
and exit codes flow naturally). Every subprocess inherits the hat.
- Most CLIs support this natively via a config-dir env var: gcloud
(
CLOUDSDK_CONFIG), doppler (DOPPLER_CONFIG_DIR), render (RENDER_CLI_CONFIG_PATH), Claude Code (CLAUDE_CONFIG_DIR), GitHub (GH_CONFIG_DIR), GitLab (GLAB_CONFIG_DIR), and more. Point each at a<tool>-<identity>dir and that CLI is scoped for free - so aworkhat and apersonalhat can each hold their own GitHub login, for example. - It doesn't have to be a config dir. A tool scoped by a config file works
too - point AWS at per-identity files (
AWS_SHARED_CREDENTIALS_FILE,AWS_CONFIG_FILE) - and so does a tool scoped by a plain token: drop a per-identityCLOUDFLARE_API_TOKEN+CLOUDFLARE_ACCOUNT_IDin the hat'senv_filesand wrangler is scoped with no dir at all. If a CLI reads any env var for its credentials, a hat can scope it. - CLIs without one (vercel, neon) need a small wrapper shim that translates an
env var into their
--configflag. Put shims in apath_prependdir and hats will front-load them ontoPATH.
Because each identity's credentials live in their own directory, isolation is structural: a process wearing the project hat cannot accidentally deploy with the dayjob account, because dayjob's tokens are simply not on its path.
A hat's environment only points at credential directories. You still have to put
credentials in them, and the golden rule is:
Always log in through the hat.
hats wear <profile> -- <cli> loginwrites the token to that profile's directory. A bare<cli> loginwrites to the CLI's default location and silently ends up under the wrong (or no) identity.
So to set up a new hat, log each CLI in wearing it:
hats wear project -- gws auth login # -> ~/.config/gws-project
hats wear project -- vercel login # -> ~/.config/vercel-project (needs shim, see below)
hats wear project -- render login # -> ~/.config/render-project
hats wear project -- gcloud auth login # -> ~/.config/gcloud-project
hats wear project -- neonctl auth # -> ~/.config/neon-project (needs shim)
hats wear project -- doppler login # -> ~/.config/doppler-project
hats doctor project # confirm each one landedEach is a normal browser OAuth flow; the only thing hats changes is where the resulting token is saved. Because the env var is set for that command, the CLI reads and writes the right directory. Do this once per identity per machine (tokens don't sync between machines, so you re-login on each, but the hat definition travels).
CLIs with a native config-dir env var (gcloud, doppler, render, gws, Claude
Code) work out of the box. But vercel and neon ignore env vars and always
use a fixed default location, so hats wear project -- vercel login would still
clobber your default vercel login. The fix is a tiny wrapper on PATH that
translates an env var into their --config flag:
#!/bin/sh
# ~/.local/bin/vercel (shim). Finds the real vercel on PATH (skipping itself),
# so there's nothing to rename. This is exactly what `hats init` generates.
selfdir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
real=; IFS=:
for d in $PATH; do [ "$d" = "$selfdir" ] && continue; [ -x "$d/vercel" ] && { real=$d/vercel; break; }; done
unset IFS
if [ -n "$VERCEL_CONFIG_DIR" ]; then exec "$real" --global-config "$VERCEL_CONFIG_DIR" "$@"; fi
exec "$real" "$@"Put the shim dir in the profile's path_prepend, and VERCEL_CONFIG_DIR in
its environment. Then vercel logins land per-hat like everything else. You
don't have to write these by hand - hats init generates the vercel/neon
shims for you (see Doctor).
Hats carry all the identity; aliases are just muscle memory. Point short
launchers at hats wear:
A convention that scales: cc + an identity initial, plus y for yolo
(skip-permissions) sessions:
# Claude Code: cc<initial>, add y for autonomous mode
alias cc='hats wear personal -- claude'
alias ccy='hats wear personal -- claude --dangerously-skip-permissions'
alias ccd='hats wear dayjob -- claude'
alias ccdy='hats wear dayjob -- claude --dangerously-skip-permissions'
alias ccc='hats wear project -- claude'
alias cccy='hats wear project -- claude --dangerously-skip-permissions'
# Codex: same pattern
alias cx='hats wear personal -- codex'
alias cxdy='hats wear dayjob -- codex --full-auto'
# or ad hoc, no alias needed
hats wear project -- claude -p "summarize this repo"The same trick works for any CLI, so you can reach a specific identity's
Vercel/Render/Google Workspace without wearing the whole hat. Name them
<cli><identity>:
# Google Workspace per identity (gws<initial>)
alias gwsp='hats wear personal -- gws'
alias gwsd='hats wear dayjob -- gws'
alias gwsc='hats wear project -- gws'
# Vercel / Render per identity
alias verp='hats wear personal -- vercel'
alias verc='hats wear project -- vercel'
alias rendd='hats wear dayjob -- render'
alias rendc='hats wear project -- render'Now gwsc drive files list runs Google Workspace as the project, and
rendd deploy deploys Render as your employer, from any directory. Because
these delegate to hats run, the identity still lives only in profiles.json:
every alias is a thin pointer with zero credentials or paths baked in.
Adding identity #4 is a profiles.json edit. No alias surgery, no duplicated env blocks, and the aliases contain zero identity information.
hats doctor audits every hat: does each credential path exist, and is it
non-empty (logged in)? It's the "is everything aligned" check you'd otherwise
do by hand after every laptop migration, reauth, or 2am login mishap. It exits
non-zero if any check fails, so you can gate CI or a shell startup on it.
dayjob - Employer
✓ claude ~/.claude-dayjob/.claude.json
✓ gws ~/.config/gws-dayjob
○ vercel ~/.config/vercel-dayjob/auth.json [empty]
1 check(s) not ok (○ empty = not yet logged in, ✗ missing = path absent)
You don't list doctor checks - hats reads them from environment. Three
rules, in order:
- Every config-dir env var becomes a check.
GOOGLE_WORKSPACE_CLI_CONFIG_DIR,RENDER_CLI_CONFIG_PATH,NEON_CONFIG_DIR, ... each turns into "does this dir exist and is it non-empty?" You never restate these paths. - For CLIs where a dir can exist while logged-out, hats checks the login-proof
file inside it instead -
VERCEL_CONFIG_DIR->auth.json,CLAUDE_CONFIG_DIR->.claude.json. This is built in, sovercel/claudederive as file checks automatically. Nothing to configure.
That's why a fully-scoped profile needs no doctor block at all - every check
comes from its env vars.
-
The
doctormap is only the leftovers rules 1-2 can't produce: a CLI with no config-dir env var (a hat that uses the default~/.claude, so there's nothing to derive from), or a non-standard proof path. Most profiles need zero lines here; apersonalhat on the default Claude dir needs exactly one:"doctor": { "claude": "~/.claude.json" }
Entries here override the derived check for that label, or add a new one.
So doctor is an exceptions list, not a copy of your paths. If you see more than
a line or two, something is probably restating what environment already implies.
hats init # scaffold ~/.config/hats/profiles.json (if absent)
# + generate the vercel/neon wrapper shims into ~/.local/bin
hats init --dir ~/bin # write shims somewhere else
hats init --force # overwrite existing shimshats init writes the vercel/neonctl shims described above so you don't hand-
write them. It never overwrites an existing file unless --force. Make sure the
target dir is on PATH ahead of the real CLIs (put it in a profile's
path_prepend).
hats env <profile> --json emits the resolved environment as JSON, so an
orchestrator can spawn each worker wearing the right hat:
const { env } = JSON.parse(execSync("hats env project --json"));
spawn(agentCmd, { env: { ...process.env, ...env } });Put a hat's tokens in a gitignored env_files secret file so they load only
when you wear that hat, instead of globally:
"kanda": {
"environment": { "CLAUDE_CONFIG_DIR": "~/.claude-kanda" },
"env_files": ["~/.env-kanda-secrets"]
}# ~/.env-kanda-secrets (chmod 600, never committed)
export JIRA_WORK_BASIC_AUTH="..."Now hats wear kanda -- ... has the token; hats wear personal does not. Missing
files are skipped, so profiles.json stays portable (secrets are per-machine).
Env injection makes the default identity correct, but a determined command can
still reach another hat explicitly (hats run other -- ..., or another
identity's alias). For sessions where that must be blocked, hats boundary
emits the identity signals belonging to other hats, derived from
profiles.json, so a guard never hand-maintains a block list:
hats boundary dayjob --json
# { "profile": "dayjob", "reachable": [],
# "foreign_profiles": ["project", "personal"],
# "foreign_paths": ["gws-project", "vercel-personal", ...],
# "foreign_aliases": ["gwsc", "vercelp", ...] }- foreign_paths are the config-dir basenames of other hats (shared values like a common browser are subtracted out automatically).
- foreign_aliases come from each profile's optional
aliasesfield (the short launchers; hyphenated<cli>-<identity>aliases are already caught as path fragments). - reachable lets a hat sanction specific crossings: list them and they move from foreign to allowed, so a combined session can touch, say, personal and project but nothing else.
By default every other hat is foreign. Add reachable to allow specific ones -
useful for a session that legitimately spans two identities but must stay out of
a third:
"personal": {
"environment": { "CLAUDE_CONFIG_DIR": "~/.claude", "...": "..." },
"reachable": ["project"]
}Now hats boundary personal --json drops project from foreign_profiles (and
its paths/aliases from the block lists), while any other hat stays foreign.
Point a guard at hats boundary from your harness. For Claude Code, add a
PreToolUse hook to the hat's settings.json:
{
"hooks": {
"PreToolUse": [
{ "matcher": "Bash",
"hooks": [{ "type": "command", "command": "$HOME/.local/bin/hats-guard" }] }
]
}
}The guard reads the command, asks hats boundary what's foreign for the active
hat, and blocks on a hit. A complete, minimal version:
#!/usr/bin/env python3
# hats-guard: block cross-identity commands in the active hat's session.
# exit 2 = block (stderr shown to the model), exit 0 = allow.
import json, os, re, subprocess, sys
cmd = (json.load(sys.stdin).get("tool_input") or {}).get("command", "")
prof = os.environ.get("HATS_PROFILE")
if not (cmd and prof):
sys.exit(0) # not wearing a hat, or nothing to check
b = json.loads(subprocess.run(["hats", "boundary", prof, "--json"],
capture_output=True, text=True).stdout)
hit = next((p for p in b["foreign_paths"] if p in cmd), None)
if not hit and b["foreign_aliases"]:
m = re.search(r"(?<![\w-])(" + "|".join(map(re.escape, b["foreign_aliases"])) + r")(?![\w-])", cmd)
hit = m.group(1) if m else None
if hit:
sys.stderr.write(f"blocked: '{hit}' belongs to another identity (this is a '{prof}' session)\n")
sys.exit(2)Because the block set is computed from profiles.json, adding or renaming a hat
updates every guard for free. This is mistake/casual-misuse prevention, not hard
isolation (a heuristic is defeatable by obfuscation) - it's the middle rung
below.
- Mistake prevention (
hats wear): correct-by-default env injection. - Misuse prevention (
hats boundary+ a guard hook): block explicit cross-identity reach, from a config-derived block list. - Compromise prevention: OS-level isolation (separate users, containers).
Most failures are rung-1 failures. Climb only as your threat model demands.
MIT