🎩 You're Invited:Meet the Socket team at Black Hat in Las Vegas, August 3-6.RSVP
Sign In

manyhats

Package Overview
Dependencies
Maintainers
1
Versions
15
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

manyhats

Identity profiles for agents and shells - direnv scopes environments to directories; hats scopes them to identities.

latest
Source
npmnpm
Version
0.6.1
Version published
Maintainers
1
Created
Source

manyhats 🎩

Identity profiles for agents and shells.

npm Homebrew Release Stars License: MIT

direnv scopes environments to directories. hats scopes them to identities.

What is a hat?

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.

Why not just prompt the agent?

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.

Why not just scope MCP?

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.json scopes 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.json scopes 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.

Install

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

Quickstart

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 tiny browse-as-hat script 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_files hold each identity's API tokens (gitignored, chmod 600), loaded only under that hat - so a stray token is never global.
  • personal marks reachable: ["project"], so a personal session may deliberately reach the project hat; dayjob marks nothing, so the employer identity is hard-walled from the others.
  • personal needs a one-line doctor (it uses the default ~/.claude, so there's no CLAUDE_CONFIG_DIR to derive from); dayjob/project need 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 orchestrators

What wear actually does

No 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 a work hat and a personal hat 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-identity CLOUDFLARE_API_TOKEN + CLOUDFLARE_ACCOUNT_ID in the hat's env_files and 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 --config flag. Put shims in a path_prepend dir and hats will front-load them onto PATH.

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.

Logging in (populating a hat)

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> login writes the token to that profile's directory. A bare <cli> login writes 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 landed

Each 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).

The vercel / neon shim caveat

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).

Launcher aliases (Claude Code, Codex, ...)

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.

Doctor

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, so vercel/claude derive 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 doctor map 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; a personal hat 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.

Generating the shims: hats init

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 shims

hats 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).

For orchestrators

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 } });

Secrets (identity-scoped)

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).

Boundaries (hats boundary)

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 aliases field (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.

Sanctioning a crossing with reachable

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.

Enforcing it: a PreToolUse hook

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.

The boundary ladder

  • 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.

License

MIT

Keywords

identity

FAQs

Package last updated on 26 Jul 2026

Did you know?

Socket

Socket for GitHub automatically highlights issues in each pull request and monitors the health of all your open source dependencies. Discover the contents of your packages and block harmful activity before you install or update your dependencies.

Install

Related posts