Per-environment isolation for Claude Code — plugins, skills, MCPs, and secrets.
Reproducible, declarative, and team-shareable. Like venv for Python or rustup for Rust, but for your Claude Code config.
Install • Quick Start • Commands • How It Works • Comparison • Security
Claude Code stores everything — plugins, skills, MCP servers, sessions, credentials — in a single global ~/.claude directory. As you start using Claude Code seriously, that single directory becomes a liability:
- You want to try a risky plugin without polluting your main setup.
- Your work project has strict MCP allowlists; your side project needs an experimental sandbox.
- You're building an AI agent and need pinned plugin versions so production runs are reproducible.
- Your team wants a known-good baseline that everyone gets identically — not a wiki page that says "install these 7 things".
aenv solves all of these by treating each Claude Code config as a first-class, isolated, reproducible environment — declared in aenv.toml, locked by aenv.lock, committed to git.
aenv new my-project # isolated env
aenv use my-project # pin to cwd + activate in this shell
claude # auto-routes through shim → my-project's configSwitching projects switches plugins, skills, MCPs, hooks, and even MCP secrets. Your teammate clones the repo, runs aenv install, gets the same setup.
| 🧱 Per-env plugin visibility | Supervisor builds enabledPlugins for the launch overlay from the env's manifest — Claude Code only sees the plugins this env declared, even though all installs share ~/.claude/plugins/ cache. Fail-closed: if the overlay can't be generated, claude doesn't launch (no silent leak to user-level globals). |
| 🧱 Per-env MCPs / settings / sessions | --strict-mcp-config + --mcp-config <env> + --settings <env-overlay> + per-env XDG roots keep these fully isolated |
| 📦 Declarative + reproducible | aenv.toml (intent) + content-hashed aenv.lock (frozen state). Commit both — teammates get bit-identical envs |
| 🔐 Secrets in OS keyring | ${secret:KEY} references in manifest. Plaintext never hits disk in aenv.toml or settings.json |
| 🤝 Team-shareable | aenv export-profile / import-profile bundles. Hooks stripped on import (RCE protection) |
| ⚡ Disk-efficient | Content-addressed plugin/skill store with hardlink fanout — same plugin shared across envs by inode |
| 🔄 Transactional | Every mutating op auto-snapshots → aenv rollback (or --pending after a kill) |
| 🔑 Shared auth | Launch overlay leaves CLAUDE_CONFIG_DIR unset so claude reads your default ~/.claude — one login (file or macOS Keychain entry) covers every env, no re-login on switch |
| 🧪 In-session control | /aenv:use <name> + /aenv:reload slash commands queue an env switch / refresh from inside Claude Code |
| 🛡️ Hardened | 0700/0600 perms, atomic writes, Zip Slip-safe tar extraction, path-traversal validation |
🔌 claude mcp add-compatible |
Same flag grammar, plus --from claude-desktop/cursor/vscode-deeplink: bulk import |
Not on crates.io yet (pre-1.0 stabilization). Install from the GitHub source:
cargo install --git https://github.com/wnsdy95/aenv --lockedcargo install aenv (no --git) will fail with could not find aenv in registry until the first crates.io release. Once that lands, this section will show the shorter form.
This drops the binary at ~/.cargo/bin/aenv. Make sure ~/.cargo/bin is on your PATH (default for a standard Rust install).
Requires Rust 1.88+.
Don't manually copy the binary to
~/.local/binor/usr/local/bin. Theaenv upgradeflow re-runscargo installand writes back to~/.cargo/bin— a stale copy higher inPATHwould silently shadow every upgrade. If you previously copied the binary, delete that copy.
aenv upgradePulls the latest source, rebuilds the binary, refreshes the shim and /aenv:* slash commands. Does not auto-detect or self-heal — upgrades only happen when you explicitly opt in, so a new release never surprises you mid-task.
# 1. One-time setup (creates 'default' env from your existing ~/.claude)
aenv init
# 2. Wire your shell — pick the line for your shell, run once
echo 'eval "$(aenv shell-init zsh)"' >> ~/.zshrc # zsh (macOS default)
echo 'eval "$(aenv shell-init bash)"' >> ~/.bashrc # bash / Git Bash on Windows
echo 'aenv shell-init fish | source' >> ~/.config/fish/config.fish
# Then either reload (`source ~/.zshrc`) or open a new terminal.
# 3. (Optional) Migrate your existing Claude login so the new env reuses it
aenv migrate-auth
# 4. In any project — create an env and pin it to the directory
aenv new my-project
aenv use my-project # writes .aenv-version in cwd AND sets $AENV in this shell
# 5. Just run claude — the shim routes you into the env
claudeThat's it. Run aenv current from another shell to confirm the active env, or type /aenv:use <name> inside Claude Code to switch envs without leaving the session.
# Owner — declare what the project needs
aenv init --here my-project # writes aenv.toml + aenv.lock to cwd
aenv add mcp github -- npx -y @modelcontextprotocol/server-github
aenv add plugin engineering@1.4.0 --source npm:@anthropic/engineering-plugin@1.4.0
# Marketplace-style repos with many plugins can point at the plugin subdir.
aenv add plugin code-review \
--source git+https://github.com/anthropics/claude-plugins-official \
--subpath plugins/code-review
aenv secrets add gh-token # stored in OS keyring
git add aenv.toml aenv.lock
# Teammate — clone, install, add their own secrets
aenv install
aenv secrets add gh-token # their own token, in their keyring| Command | What happens | When to use |
|---|---|---|
aenv init [--no-default] |
Installs the shim, creates the default env from ~/.claude if present |
First time only. One command, original ~/.claude is preserved |
aenv init --here [<name>] |
Initializes a project-local env in cwd (writes aenv.toml + aenv.lock) |
When you want the manifest committable to git |
aenv new <name> [--from <src>] [--bare] [--ifl] |
Creates a fresh env, optionally cloned from another. --ifl opens the import TUI immediately |
Spinning up a new persona / project / experiment |
aenv use <name> [--global] |
Writes the cwd .aenv-version pin (or global default with --global) AND exports $AENV in the current shell so the env is active immediately — see [name] in your prompt |
Day-to-day project switching |
aenv quit |
Drops the shell-set override (unset AENV / AENV_OVERRIDE). cwd resolution / global default takes over. Does not delete on-disk pins |
Symmetric to aenv use; alias aenv deactivate |
aenv current [--explain] |
Prints the active env name. --explain shows the resolution chain |
Debugging "which env am I in?" |
aenv list [-l] |
Lists all envs. * = active, ! = broken (missing/invalid manifest) |
Inventory |
aenv remove <name> [--force] |
Deletes an env (refuses if active without --force) |
Cleanup |
aenv import-global <name> [--force] [--set-default] |
Copies your live ~/.claude into a named env |
Bootstrapping from an existing setup |
| Command | What happens |
|---|---|
aenv add mcp <name> [-- <cmd>...] |
Add an MCP server. Supports --transport stdio|http|sse, --json, --from claude-desktop|cursor|cursor-deeplink:<url>|vscode-deeplink:<url>|<path> (bulk import) |
aenv add plugin <name>[@<ver>] --source <src> [--subpath <path>] |
Add a plugin. --subpath selects a plugin inside a marketplace-style repo |
aenv add skill <name> --source <src> |
Add a skill |
aenv rm mcp|plugin|skill <name> |
Remove from manifest (also prunes from lockfile + on-disk on next install) |
aenv install [--no-lock] |
Resolve manifest → fetch → materialize → write lockfile |
aenv lock |
Re-generate aenv.lock from manifest without applying |
aenv sync |
Force the env to match aenv.lock exactly |
aenv ifl [--from <env> --plugin <name> ...] |
Import-from-list: interactive TUI to copy plugins/skills/MCPs from other envs (and a synthesized (global) source that mirrors your ~/.claude/) into the current one. Non-interactive form for CI/scripts. Pair with aenv new <name> --ifl to create + import in one step |
| Source | Example |
|---|---|
npm: |
npm:@anthropic/engineering-plugin@1.4.0 |
git+https:// |
git+https://github.com/me/skills@main |
| Git repo subdir | --source git+https://github.com/anthropics/claude-plugins-official --subpath plugins/code-review |
| Tarball over HTTPS | https://example.com/plugin.tar.gz |
| Local | file:///abs/path or any local path (dir or tarball) |
| Command | What happens |
|---|---|
aenv secrets add <key> [--value <v>] |
Stores a secret in the OS keyring (prompts on stdin if --value omitted) |
aenv secrets list |
Lists keys only — values are never displayed |
aenv secrets rm <key> |
Removes a secret |
aenv secrets rotate <key> |
Replaces the value of an existing secret |
| Command | What happens |
|---|---|
aenv export-profile [-o <file>] |
Bundles the env (excludes secrets, sessions, cache) as .aenv.tar.gz |
aenv import-profile <file> [--name <new>] [--force] [--trust-hooks] |
Imports a bundle. Hooks are stripped by default (RCE protection); --trust-hooks to keep |
| Command | What happens |
|---|---|
aenv rollback [--pending] |
Restores the most recent committed transaction. --pending recovers from a process killed mid-install |
aenv history [--limit N] |
Lists transactions newest-first |
aenv prune [--keep-count N] [--keep-days D] |
Deletes old snapshots |
aenv audit [--limit N] [--json] |
Append-only operation log |
| Command | What happens |
|---|---|
aenv exec [-E <env>] -- <cmd>... |
Runs a command (or child claude) inside an env's context, without switching the active env |
aenv reload [--to <env>] [--no-resume] [--session <id>] |
Asks the supervisor to relaunch claude (in-session env switching) |
aenv upgrade [--dry-run] |
Rebuilds the binary via cargo install --git, then refreshes the shim and ~/.claude/commands/aenv/ slash commands using the new binary so all three artifacts move in lockstep |
aenv doctor [<env>] [--json] |
Health checks: shim install, PATH order, cc_compatible semver, pending txns, managed-vs-user plugin counts, cross-platform sharing checks (autocrlf, .gitattributes, manifest portability) |
aenv status [-E <env>] [--json] |
Detailed env status (plugins, MCPs, paths) |
aenv which env <name>|claude|shim|home |
Resolve a path |
aenv migrate-auth [-E <env>] [--force] [--dry-run] |
One-shot migration of Claude's credentials out of legacy aenv state so overlay mode reuses them without re-login (macOS: probes Keychain via security CLI) |
aenv shell-init bash|zsh|fish |
Prints the shell init script. Refuses to emit if AENV_HOME contains shell metacharacters |
$PATH lookup of `claude`
│
▼
~/.aenv/shims/claude ─────► aenv binary ─────► shim::run dispatcher
│
▼
resolve active env from precedence:
1. $AENV_OVERRIDE
2. $AENV
3. .aenv-version walk-up from cwd
4. global default in ~/.aenv/config.toml
│
▼
launch real claude under supervisor loop with
CLAUDE_CONFIG_DIR unset (claude reads default ~/.claude/)
--settings <generated overlay with enabledPlugins reduced
to the env's manifest — user-level globals are
omitted, so claude treats them as disabled>
--plugin-dir <env-local plugin leaf dirs, one per --plugin-dir>
--mcp-config <env-only mcp.json>
--strict-mcp-config
AENV / AENV_ACTIVE breadcrumbs for plugins to introspect
Overlay mode, not symlink retargeting. Per-env scope comes from CLI flags injected at launch — ~/.claude/ itself is shared. This keeps macOS Keychain auth working across envs (one login covers all) since the keychain entry isn't path-derived.
The overlay generator is fail-closed: if --settings can't be written for any reason, claude is not launched. There's no fallback path that would silently expose user-level plugins to an env that didn't declare them — the tradeoff is a hard failure instead of a stealth leak.
Claude Code installs every plugin into ~/.claude/plugins/cache/ regardless of the active env — the install path itself is hardcoded and we can't redirect it from outside. Visibility is a different story: Claude Code reads enabledPlugins from the project-scope --settings file we inject, and that file deliberately omits any user-level plugin the active env didn't pin in aenv.toml. The result is per-env visibility without trying to fork the install path:
~/.claude/plugins/cache/ ← shared bytes (can't change, claude code hardcoded)
code-simplifier@.../
codex@.../
ralph-loop@.../
…
aenv launch overlay (settings.json) ← per-env enable list
enabledPlugins:
code-simplifier@claude-plugins-official: true ← in env's aenv.toml
codex@openai-codex: false ← not declared, hidden
ralph-loop@claude-plugins-official: false ← not declared, hidden
Bare names in the manifest (code-simplifier) get auto-mapped to their fully-qualified form (code-simplifier@claude-plugins-official) by scanning ~/.claude/plugins/installed_plugins.json for matches, so users don't have to write the marketplace suffix every time.
Ad-hoc installs via Claude Code's /plugin install UI still work — the bytes land in the shared cache. The next time a different env launches, our overlay will simply not enable them. To actually pin one of those installs to an env, run aenv ifl and pick from the (global) source.
Two slash commands ship with aenv init, both as prompts under ~/.claude/commands/aenv/:
/aenv:use <name>— instructs Claude to callaenv reload --to <name>via Bash. Queues the env switch in the supervisor's restart marker; tells the user to press Ctrl+D or/quitto apply. The supervisor catches the exit, swaps env, re-execs claude with--resume <session>so the conversation continues uninterrupted./aenv:reload— same flow but keeps the current env. Used afteraenv installadds plugins or edits MCPs and the running session needs a fresh overlay.
Both commands queue rather than restart immediately — slash commands can't make Claude Code exit itself, so the user closes the session voluntarily and the supervisor reopens it under the new env.
aenv.toml ← source of truth (committed to git)
[mcp.github]
env = { GITHUB_TOKEN = "${secret:gh-token}" }
│
▼ aenv install
.claude/settings.json ← rewritten reference
"mcpServers": {"github": {
"env": {"GITHUB_TOKEN": "${AENV_FULLSTACK_GH_TOKEN}"}
}}
│
▼ supervisor at launch
process env ← claude inherits, MCP launched with it
AENV_FULLSTACK_GH_TOKEN=<value-from-keyring>
│
▼ claude substitutes
MCP server ← gets resolved GITHUB_TOKEN=<value>
aenv.toml and settings.json both hold env-var references only. Plaintext exists only in the OS keyring and ephemerally in the supervisor's child process env.
aenv_schema_version = "1"
[env]
name = "fullstack"
description = "GitHub MCP + engineering plugin"
cc_compatible = ">=2.1, <3" # warns at `aenv use` if mismatched
[mcp.github]
command = "npx"
args = ["-y", "@modelcontextprotocol/server-github"]
# ${secret:KEY} → looked up in OS keyring, exported as env var at launch
# ${env:VAR} → inherited from caller's shell
# literal text → kept as-is
env = { GITHUB_TOKEN = "${secret:gh-token}" }
[[plugins.enabled]]
name = "engineering"
version = "1.4.0"
source = "npm:@anthropic/engineering-plugin@1.4.0"
[[skills.enabled]]
name = "code-review-strict"
source = "git+https://github.com/me/skills@main"
[hooks]
# Shell command run before each claude launch in this env.
# Stripped on `import-profile` unless --trust-hooks is passed (RCE protection).
pre_activate = "echo activating fullstack >&2"~/.aenv/ # 0700, owner-only
├── config.toml # global default, real-claude cache
├── .lock # mutating-op flock
├── envs/ # 0700
│ ├── <name>/ # global named env
│ └── <name>-<sha8>/ # project-mode slot (hashed)
│ ├── aenv.toml # global mode: source of truth
│ ├── aenv.lock # global mode: content hashes
│ ├── .aenv-project-source # project mode: → ./aenv.toml
│ ├── .claude/
│ │ ├── settings.json # 0600 — rendered mcpServers
│ │ ├── plugins/<name>/ # store hardlinks
│ │ └── skills/ # auto-generated wrapper plugins
│ ├── xdg/{config,data,state,cache}/
│ └── .secrets.list # 0600 — secret-key index
├── store/objects/<sha[..2]>/<sha>/ # content-addressed
├── state/<iso-timestamp>/ # transaction snapshots
│ └── manifest.json # status: Pending/Committed/RolledBack
├── audit.jsonl # mutating-op log (append-only)
└── shims/claude # → aenv binary
# Project-mode (committed to git)
<project>/
├── aenv.toml # source of truth
├── aenv.lock # source of truth
└── .gitattributes # `aenv.{toml,lock} -text` (auto-generated)
How aenv compares to other Claude Code config managers:
| Feature | aenv | clenv | ccp | clausona | cce |
|---|---|---|---|---|---|
| Language | Rust | Rust | TS | TS | Go |
Per-env ~/.claude isolation |
✅ | ✅ | ✅ | ❌ env-vars only | |
Per-directory pin (.aenv-version / .clenvrc) |
✅ | ✅ | ❌ | ❌ | ❌ |
| Declarative manifest + content-hashed lockfile | ✅ | ❌ | ❌ | ❌ | ❌ |
Reproducible install from manifest (aenv install) |
✅ | ❌ | ❌ | ❌ | ❌ |
| Plugin/skill source resolver (npm/git/https/local) | ✅ | ❌ | ❌ | ❌ | ❌ |
OS keyring secrets (${secret:K} references) |
✅ | ❌ | ❌ | ❌ | |
| Disk-efficient shared store | ✅ hardlinks | ❌ | ✅ symlinks | ✅ symlinks | ❌ |
| Version control / snapshot rollback | ✅ txn | ✅ git | ✅ git | ❌ | ❌ |
| Bundle export/import (with hook RCE strip) | ✅ | ✅ | ✅ | ❌ | ❌ |
| Supervisor restart loop (in-session reload) | ✅ | ❌ | ❌ | ❌ | ❌ |
claude mcp add-compatible grammar |
✅ | ❌ | ❌ | ❌ | ❌ |
| Bulk MCP import (Cursor / Claude Desktop / VS Code deeplink) | ✅ | ❌ | ❌ | ❌ | ❌ |
| Tar Zip-Slip / symlink attack defense | ✅ | ❓ | ❓ | ❓ | ❓ |
aenv is the most reproducibility- and security-focused option. If you only need to switch accounts, clausona or ccp are simpler. If you need to commit a manifest to git so your team gets identical setups, aenv is the only choice.
| Threat | Mitigation |
|---|---|
| Plugin visibility leak across envs | Supervisor regenerates enabledPlugins on every launch from the env's manifest. User-level installs not declared in aenv.toml are simply omitted from the overlay → Claude Code treats them as disabled |
| Silent fallback to user-level globals when overlay generation fails | Fail-closed: launch is aborted if the overlay can't be written. User sees a hard error rather than a session that quietly inherits user-level plugins/settings |
| Path traversal in plugin/skill/mcp names | Resource-name validation at CLI input AND manifest load ([a-zA-Z0-9._-]+, no leading dot) |
| Tar Zip Slip / symlink attack | unpack_safe: skip non-regular entries, reject ../absolute paths, target.starts_with(dst) final check |
| Hook RCE via shared bundle | import-profile strips hooks.pre_activate by default; --trust-hooks to keep |
Shell-init injection via AENV_HOME path |
Refuses paths with shell metacharacters ("'$\); emits return 1` on unsafe |
| Plaintext secrets on disk | aenv.toml holds only ${secret:K} refs; settings.json holds only ${AENV_X_Y} env-var refs; plaintext only in OS keyring + supervisor child env |
| Multi-user info leak | ~/.aenv/ 0700, env dirs 0700, settings.json + .secrets.list 0600 (best-effort: chmod-respecting FS only) |
| Non-atomic write corruption on SIGKILL | All critical writes go through paths::write_atomic (temp+fsync+rename) |
| Concurrent operation races | Global flock at ~/.aenv/.lock; non-blocking try first with "waiting…" message |
| Killed mid-install limbo | aenv rollback --pending recovers; aenv doctor surfaces pending txns |
| Keychain hash drift on env switch | Launch overlay leaves CLAUDE_CONFIG_DIR unset entirely → claude uses ~/.claude and the user's existing keychain entry. Per-env scope comes from CLI flags (--settings, --plugin-dir, --mcp-config, --strict-mcp-config) — no path-derived hash to drift |
aenv is two cooperating layers:
Shim, supervisor, env CRUD, content-addressed store, transactions, secrets. The pre-launch tooling and source of truth.
Two prompt-template markdown files (use.md, reload.md) embedded in the binary at compile time and emitted by aenv init into the user's commands directory. They instruct Claude (the assistant) to call aenv reload [--to <name>] via Bash, which writes the supervisor's restart marker. The actual env switch happens when the user closes the session and the supervisor relaunches.
Why two layers? Plugins and MCPs are loaded at Claude Code startup and can't be hot-swapped. The host binary owns isolation; the slash commands only queue the next launch (via supervisor restart) — they can't make Claude Code exit itself. Slash commands stay env-agnostic (they shell out to aenv reload which figures out the env), so a single global copy under ~/.claude/commands/aenv/ works for every env.
| Platform | Status |
|---|---|
| macOS | First-class. Keychain integration via security CLI |
| Linux | First-class. Native libsecret keyring |
| WSL | First-class. Treated as a regular Linux install |
| Git Bash (Windows) | Supported with safe fallbacks: shim is a file copy of aenv.exe (refreshed automatically by aenv upgrade); ~/.aenv/active is a directory junction; 0700/0600 chmod is a no-op (NTFS owner-only ACL by default in %USERPROFILE%); hooks go through sh -c (Git Bash provides) |
| Native cmd.exe / PowerShell | Not supported in this release — use Git Bash or WSL |
Overlay mode injects per-env scope through CLI flags at launch (--settings, --plugin-dir, --mcp-config, --strict-mcp-config), so each claude process carries its own env identity for the lifetime of that session. Two parallel claude sessions in different envs don't interfere — they're scoped by argv, not by a shared symlink.
aenv exec -E <env> -- claude ... is still useful for one-shot work in a different env without touching the cwd pin or $AENV.
- All tests green (
cargo test --releasefor integration,cargo test --bin aenvfor unit) cargo fmtandcargo clippy -D warningsclean- CI matrix: Linux + macOS + Windows × stable; Linux MSRV 1.88. Linux/macOS run the full integration suite; Windows runs build + unit tests only (the integration harness uses sh-based fake-
claudescripts with no Windows equivalent) - Cross-platform
aenv.lockround-trip verified by a dedicated CI job: macOS producer builds a project workspace + lockfile, Linux + Windows consumers download the artifact, runaenv install, and diff their lockfile against the producer's - Zero production
unwrap()s without invariant proofs - Zero hardcoded paths in
src/ - Zero
#[allow(dead_code)]
Deferred (per design): external security audit, cosign/SBOM, CLA, CVE SLA, notarization, telemetry, aenv stat token costs, aenv recommend.
Issues and pull requests welcome at github.com/wnsdy95/aenv.
git clone https://github.com/wnsdy95/aenv.git
cd aenv
cargo build
cargo test
cargo clippy -- -D warnings
cargo fmt --checkLicensed under either of
- Apache License, Version 2.0 (LICENSE-APACHE or http://www.apache.org/licenses/LICENSE-2.0)
- MIT License (LICENSE-MIT or http://opensource.org/licenses/MIT)
at your option.
Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in aenv by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions.
