Skip to content

wnsdy95/aenv

Repository files navigation

aenv — Per-environment isolation for Claude Code

aenv

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.

license rust tests clippy platforms

InstallQuick StartCommandsHow It WorksComparisonSecurity


Why aenv?

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 config

Switching projects switches plugins, skills, MCPs, hooks, and even MCP secrets. Your teammate clones the repo, runs aenv install, gets the same setup.


Highlights

🧱 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

Installation

Not on crates.io yet (pre-1.0 stabilization). Install from the GitHub source:

cargo install --git https://github.com/wnsdy95/aenv --locked

cargo 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/bin or /usr/local/bin. The aenv upgrade flow re-runs cargo install and writes back to ~/.cargo/bin — a stale copy higher in PATH would silently shadow every upgrade. If you previously copied the binary, delete that copy.

Upgrade

aenv upgrade

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


Quick Start

# 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
claude

That'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.

Reproducible team setup

# 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

Commands

Lifecycle

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

Declarative — manifest & lockfile

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

Sources

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)

Secrets (OS keyring)

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

Sharing

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

Transactions & history

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

Operations

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

How It Works

The shim model

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

How plugin isolation works

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.

In-session env switching

Two slash commands ship with aenv init, both as prompts under ~/.claude/commands/aenv/:

  • /aenv:use <name> — instructs Claude to call aenv reload --to <name> via Bash. Queues the env switch in the supervisor's restart marker; tells the user to press Ctrl+D or /quit to 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 after aenv install adds 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.

How secrets reach MCP servers (no plaintext on disk)

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.toml example

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"

Layout

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

Comparison

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 ⚠️ shared by default ❌ 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) ⚠️ on-disk
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.


Security Model

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

Architecture

aenv is two cooperating layers:

Layer 1 — host binary (Rust)

Shim, supervisor, env CRUD, content-addressed store, transactions, secrets. The pre-launch tooling and source of truth.

Layer 2 — slash commands at ~/.claude/commands/aenv/

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 Support

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

Concurrency

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.


Status

  • All tests green (cargo test --release for integration, cargo test --bin aenv for unit)
  • cargo fmt and cargo clippy -D warnings clean
  • 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-claude scripts with no Windows equivalent)
  • Cross-platform aenv.lock round-trip verified by a dedicated CI job: macOS producer builds a project workspace + lockfile, Linux + Windows consumers download the artifact, run aenv 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.


Contributing

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 --check

License

Licensed under either of

at your option.

Contribution

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.

About

Per-environment isolation for Claude Code — plugins, skills, MCPs, and secrets.

Topics

Resources

License

Apache-2.0, MIT licenses found

Licenses found

Apache-2.0
LICENSE-APACHE
MIT
LICENSE-MIT

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages