cvm is a fast, lightweight, cross-platform virtual environment manager for
Claude Code. It isolates
configuration, credentials, history, memories, and MCP server registrations
per-project or per-context by managing and overriding the
CLAUDE_CONFIG_DIR environment variable — the same mechanism Claude Code
already uses to locate its config directory.
On top of that, cvm lets teams share and reproduce Claude Code setups
through a plain YAML manifest (cvm.yaml) that can live in a Git repository
alongside your code, without ever leaking tokens, auth files, or session
history.
- Isolated environments — each environment is its own directory under
~/.cvm/envs/<name>, completely separate from your global~/.claudeconfig and from every other environment. - No repeated logins —
cvm createcopies your global Claude Code credentials into the new environment by default, so it starts out already logged in. Pass--anonymousto skip that and get a completely empty environment instead. - Zero config drift between machines — export an environment's settings,
MCP servers, and skills to
cvm.yaml; teammates import it and get an identical setup in one command. - Per-environment secrets in
.env— each environment can have its own.envfile for things like MCP server credentials, editable withcvm edit <env>. It's loaded into the process onuse/run/open, butcvm exportonly ever shares the names of those variables, never their values. Activation-owned keys such asPATH,CLAUDE_CONFIG_DIR, and theCVM_*namespace are ignored so an environment cannot corrupt activation state. - Safe by construction — export/import only ever touch
settings.json(permissions +mcpServers), theskills/directory, and the names of.envvariables. Auth tokens, credentials, and history files are never read, matched, or written by those code paths, and.envvalues never leave the machine they're on. - Ad hoc, no-commitment runs —
cvm run <env> -- clauderuns a single command inside an environment's context without switching your whole shell session. - Environment-local command shims — every environment gets
bin/claudeandbin/skills. Activating an environment or usingcvm runputs that directory first onPATH, so both commands automatically use the selected Claude config. The skills shim invokesnpx --yes skills. - Project auto-activation — put an environment name in a project's
.cvmfile and the shell hook activates it when you enter that directory tree. - Parallel Claude Code instances —
cvm open <env>launches Claude Code scoped to<env>, tagging that single process withCVM_ENV=<env>so you can run several isolated instances side by side (e.g. one per client or project) without them stepping on each other or on your shell. - Cross-shell — bash, zsh, fish, and PowerShell are all first-class.
- Lifecycle hooks — drop executable scripts in
~/.cvm/hooks/(e.g.post-create,pre-activate,post-deactivate) andcvmruns them automatically for every environment.pre-*hooks can abort the operation by exiting non-zero;post-*hooks only ever print a warning.
Linux / macOS:
curl -fsSL https://getcvm.com/install.sh | bashWindows (PowerShell):
powershell -c "irm https://getcvm.com/install.ps1 | iex"This downloads the right prebuilt binary for your OS/architecture, installs
it to ~/.cvm/bin (%USERPROFILE%\.cvm\bin on Windows), and appends the
PATH + shell-hook lines to your shell rc file / PowerShell profile (see
Shell Integration Setup if you'd rather do that
by hand).
Download the archive for your platform from the
Releases page, extract it, and
place the cvm binary somewhere on your PATH (e.g. ~/.cvm/bin).
git clone https://github.com/acwoss/cvm.git
cd cvm
cargo install --path . --lockedcvm updateChecks GitHub Releases for a newer version and, if one exists, downloads
the right asset for your platform and replaces the running binary in
place — no need to re-run the install script. Requires curl and tar on
PATH (the same requirement install.sh already has). If you installed
via cargo install, re-run that command instead.
cvm create and cvm use also check for a newer version on their own
(cached for 24h, silent on any failure) and print a one-line suggestion to
run cvm update when one is available — never during activation output, so
it can't interfere with shell hooks.
cvm also ships a desktop app, cvm-ui, for people who'd rather click
through their environments than run every command from a terminal. It's a
thin visual layer over the same ~/.cvm/envs/ directories the CLI manages -
nothing about the underlying environment format changes.
cvm launchThe first run downloads the matching cvm-ui release asset into
~/.cvm/bin/ and launches it; later runs just launch the already-installed
binary. See the screenshot above for the environments list - the app also
has per-environment tabs for MCP servers, env vars, skills & agents, and
config, a global Hooks editor, and a Settings screen with its own
check-for-updates/update-now action.
cvm is a compiled binary, so it cannot change the environment variables of
the shell that launched it — a child process can never modify its parent's
environment. cvm init <shell> prints a small shell function that wraps the
cvm binary; that wrapper is what actually exports/unsets
CLAUDE_CONFIG_DIR in your current shell when you run cvm use/cvm activate/cvm deactivate. Every other subcommand is forwarded to the real
binary unchanged.
Add the appropriate line to your shell's startup file and restart your shell (or source the file):
| Shell | File | Line to add |
|---|---|---|
| Bash | ~/.bashrc |
eval "$(cvm init bash)" |
| Zsh | ~/.zshrc |
eval "$(cvm init zsh)" |
| Fish | ~/.config/fish/config.fish |
cvm init fish | source |
| PowerShell | $PROFILE |
cvm init powershell | Out-String | Invoke-Expression |
Once installed, calling cvm in your shell always goes through the wrapper
function first — use, activate, and deactivate are intercepted, and
everything else is passed straight through to the compiled binary.
The shell hook prefixes the prompt with the active environment name, such as
(work), saves the original PATH in CVM_OLD_PATH, and prepends the active
environment's bin/ directory. cvm deactivate restores both the previous
prompt and PATH. After upgrading cvm, re-run cvm init for your shell to
install the latest hook.
If you run the raw cvm use/cvm deactivate binary without this hook
installed, cvm prints a warning explaining that shell integration isn't
active instead of silently doing nothing.
Create a .cvm file in a project directory containing the environment name:
# Claude environment for this project
project-backend-api
The first non-empty line that is not a # comment is the environment name.
On directory changes, the shell hook searches the current directory and its
parents for .cvm. It activates a different named environment when found and
automatically deactivates it after you leave that directory tree. A manual
cvm use <env> pins the session instead, so later directory changes do not
deactivate that deliberate selection.
After upgrading, re-run cvm init in each shell configuration to install the
auto-activation hook.
If you prefer direnv, an optional .envrc equivalent
can consume the activation resolver's KEY=VALUE output:
while IFS='=' read -r key value; do
export "$key=$value"
done < <(cvm __resolve-activate project-backend-api)
PATH="$CLAUDE_CONFIG_DIR/bin:$PATH"
export PATHcvm export and cvm import let a team commit a reproducible Claude Code
setup to a Git repository, right next to the project it configures.
cvm use project-backend-api
cvm export -o cvm.yamlThis inspects the active environment's settings.json, skills/ directory,
and the variable names (never values) in .env, and writes a manifest
like:
name: project-backend-api
version: "1.0.0"
description: "Standardized Claude environment for backend team"
# Non-sensitive settings & permission rules
settings:
allowed_tools:
- "read-file"
- "run-tests"
# MCP Servers to be registered in the environment
mcp_servers:
postgres:
command: "npx"
args:
- "-y"
- "@modelcontextprotocol/server-postgres"
- "postgresql://localhost:5432/dev_db"
github:
command: "npx"
args:
- "-y"
- "@modelcontextprotocol/server-github"
# Custom skills or extensions registered in this env
skills:
- "git-conventional-commits"
- "prisma-migration-helper"
# Names of variables expected in this environment's .env file (e.g. MCP
# server credentials). Only names are shared here - never values.
env_vars:
- "POSTGRES_PASSWORD"
- "GITHUB_TOKEN"Commit cvm.yaml to the repo. Teammates run cvm import to reproduce the
same environment locally.
cvm import cvm.yaml -n project-backend-api
cvm use project-backend-apicvm import creates the environment if it doesn't exist yet (or updates it
in place if it does), merges the manifest's permissions and mcpServers
into settings.json, creates a placeholder directory + SKILL.md stub for
every skill listed (fill those in, or reinstall the skill from its original
source/marketplace), and ensures .env has a blank entry for every variable
name in env_vars (existing values, if any, are left untouched). Teammates
fill in the real values themselves:
cvm import cvm.yaml -n project-backend-api
$EDITOR ~/.cvm/envs/project-backend-api/.env # fill in POSTGRES_PASSWORD, GITHUB_TOKEN, ...
cvm use project-backend-apiExport never includes:
auth.json,.credentials.json, or any OAuth/API tokens- Session history (
history.json/history.jsonl) - Memories or other local-only state
.envvalues — only the variable names are shared, so a teammate knows what to set without ever seeing what you set it to
This isn't a filtering step that could miss a new sensitive filename in the
future — the export/import code paths are architecturally limited to three
things inside an environment directory: settings.json, the skills/
subdirectory, and .env (from which only keys, never values, are ever
copied into the manifest). Nothing else is ever opened.
Create and activate an environment:
cvm create work
cvm use work
claude # env-local shim runs against ~/.cvm/envs/work instead of ~/.claude
skills # env-local shim runs npx --yes skills with the same environmentTo create an environment and open Claude Code in it right away:
cvm create work --openTo seed a new environment with your global skills and settings:
cvm create work --inherit--inherit links each skill from ~/.claude/skills into the environment
(falling back to a recursive copy when links are unavailable) and copies
~/.claude/settings.json when present. The new environment remains independent
after creation except for skills that were successfully linked.
cvm create copies your global Claude Code credentials
(~/.claude/.credentials.json) into the new environment if that file
exists, so claude above doesn't prompt you to log in again. Everything
else about the environment (settings, MCP servers, .env, history) still
starts out empty and isolated. If you want a completely empty environment
instead — no shared credentials at all — pass --anonymous:
cvm create sandbox --anonymous
cvm use sandbox
claude # prompts for a fresh loginNote: this only works on platforms where Claude Code stores its credentials as a file in the config directory (Linux, Windows). On macOS, Claude Code may store them in the system Keychain instead, in which case there is nothing for
cvm createto copy and you'll still need to log in once per environment.
Give it credentials an MCP server needs, without putting them in
settings.json (and therefore never in cvm.yaml either):
cvm edit work # opens ~/.cvm/envs/work/.env in $VISUAL/$EDITOR, creating it if needed
# POSTGRES_PASSWORD=super-secret-value
# GITHUB_TOKEN=ghp_xxx
cvm run work -- claude mcp list # POSTGRES_PASSWORD/GITHUB_TOKEN are set for this process onlycvm edit also works without a name if you have an environment active
(cvm use work first), and respects $VISUAL before falling back to
$EDITOR — the same convention git commit/crontab -e use.
Check what's active:
cvm current
# workList all environments:
cvm list
# personal
# * work (active)Go back to your default global Claude Code setup:
cvm deactivateRun a single command in an environment without switching your session:
cvm run client-acme -- claude
cvm run client-acme -- claude mcp listOpen Claude Code directly inside an environment — a shorthand for cvm run <env> -- claude that also tags the process with CVM_ENV=<env>:
cvm open client-acmeBecause each invocation only sets CVM_ENV/CLAUDE_CONFIG_DIR on that one
child process, you can open several isolated instances in parallel (e.g. in
separate terminal tabs or tmux panes) without any of them interfering with
each other or with your shell's own active environment:
cvm open client-acme &
cvm open client-globex &
cvm open personal &Remove an environment you no longer need:
cvm remove work
# Delete environment 'work'? This cannot be undone. [y/N]Share and reproduce a setup across the team:
cvm export -o cvm.yaml
git add cvm.yaml && git commit -m "Add shared Claude environment"
# teammate:
git pull
cvm import cvm.yaml -n project-backend-api
cvm use project-backend-api| Command | Aliases | Description |
|---|---|---|
cvm init <shell> |
— | Prints shell integration hooks for bash, zsh, fish, or powershell. |
cvm update |
— | Checks GitHub Releases for a newer version and replaces the running binary in place. |
cvm create <env> [--anonymous] [--inherit] [--open] |
— | Creates a new isolated environment at ~/.cvm/envs/<env>, reusing global credentials unless --anonymous is passed. --inherit links global skills and copies global settings; --open launches Claude Code immediately. |
cvm list |
ls |
Lists all environments, highlighting the active one. |
cvm use <env> |
activate |
Activates <env> in the current shell session (needs shell integration). |
cvm deactivate |
— | Restores the default global Claude Code setup (needs shell integration). |
cvm current |
— | Prints the name of the environment active in this shell. |
cvm remove <env> |
rm |
Deletes an environment directory, with confirmation. -y/--yes to skip. |
cvm edit [env] |
— | Opens <env>'s .env in $VISUAL/$EDITOR (defaults to active), creating it first if missing. |
cvm run <env> -- <cmd> |
— | Runs <cmd> scoped to <env> without activating it globally. |
cvm open <env> |
— | Runs claude scoped to <env>, tagging that process with CVM_ENV=<env>. Alias for cvm run <env> -- claude; safe to run several in parallel. |
cvm export [env] [-o <file>] |
— | Exports an environment (defaults to active) to a YAML manifest. .env values are never included, only variable names. |
cvm import <file> [-n <env>] |
— | Creates/updates an environment from a YAML manifest. Recreates .env with blank placeholders for any variable names listed. |
Claude Code reads CLAUDE_CONFIG_DIR to decide where its config lives
(defaulting to ~/.claude). cvm never patches Claude Code itself. It sets
these environment variables:
CLAUDE_CONFIG_DIR— points at~/.cvm/envs/<name>while<name>is active.CVM_ENV— the name of the environment a process is running under, used bycvm current,cvm list, and available to any script (like a statusline) that wants to know which environment is active.CVM_OLD_PATH— shell-hook backup used only while an environment is activated, socvm deactivatecan restore the originalPATH.
Because a process can't mutate its parent shell's environment, use,
activate, and deactivate are implemented as a shell function (installed
by cvm init) that asks the compiled binary — via the hidden
cvm __resolve-activate <env> / cvm __resolve-deactivate commands — which
variables to export or unset, then applies them itself. That resolution also
loads the environment's .env file, so its variables get exported (and
later unset) right alongside CLAUDE_CONFIG_DIR/CVM_ENV. cvm run and
cvm open sidestep the shell function entirely: they spawn the target
command directly as a child process with .env's variables plus
CLAUDE_CONFIG_DIR/CVM_ENV already set and the environment's bin/
prepended to its PATH, so they work with or without shell integration
installed. Multiple cvm open processes can run in parallel without
interfering with each other or with whatever environment (if any) is active
in the parent shell.
~/.cvm/
├── bin/ # cvm binary, if installed via install.sh
└── envs/
├── work/ # = $CLAUDE_CONFIG_DIR when "work" is active
│ ├── .env # starter file for MCP credentials & other local secrets
│ ├── skills/ # custom skills (created on `cvm create`)
│ ├── bin/ # claude/skills shims, first on PATH while active
│ ├── settings.json
│ └── ... # anything else Claude Code itself creates here
└── personal/
└── ...
Each environment directory is the CLAUDE_CONFIG_DIR Claude Code will use
— cvm doesn't copy or mirror files into a separate location.
The generated claude shim removes its own bin/ directory from PATH
before resolving the real Claude executable, which prevents recursion. The
skills shim does the same environment setup and delegates to npx --yes skills. Existing environments receive missing or stale shims lazily the next
time they are activated or used with cvm run/cvm open.
cvm.yaml is a deliberately small, human-reviewable surface:
settings.allowed_toolsmaps topermissions.allowinsettings.json.mcp_serversmaps directly to themcpServersblock insettings.json.skillsis a list of skill names; import creates a stub directory per skill underskills/<name>/SKILL.mdfor you to fill in or reinstall.env_varsis a list of variable names found in.env; import ensures each one exists in.env(blank if new, untouched if you'd already set it), and you fill in the real values by hand.
Any other keys already present in an environment's settings.json (or
values already set in .env) are left untouched on import — cvm only
ever merges the fields it manages.
Drop an executable script per event into ~/.cvm/hooks/ and cvm runs it
automatically, for every environment — no configuration file needed:
| Event | Runs | A failing hook (non-zero exit)... |
|---|---|---|
post-create |
after cvm create finishes setting up the environment |
only prints a warning |
pre-activate |
before cvm resolves the variables to export for cvm use/activate |
aborts the activation |
post-activate |
after cvm has resolved the variables to export (the shell applies them immediately afterwards) |
only prints a warning |
pre-deactivate |
before cvm resolves the variables to unset for cvm deactivate |
aborts the deactivation |
post-deactivate |
after cvm has resolved the variables to unset (the shell unsets them immediately afterwards) |
only prints a warning |
pre-remove |
before cvm remove deletes the environment directory |
aborts the removal |
post-remove |
after cvm remove deletes the environment directory |
only prints a warning |
pre-activate/post-activate and pre-deactivate/post-deactivate all run
inside cvm's hidden __resolve-activate/__resolve-deactivate commands,
which is before the shell wrapper installed by cvm init has actually
exported or unset anything in your shell (see Shell Integration
Setup — the wrapper only applies the returned
variables after cvm returns). This means a hook can't rely on inherited
variables like CLAUDE_CONFIG_DIR to reflect the new/old state — a
post-activate hook reading it from its own inherited environment still
sees the previous value (or none), and a pre-deactivate hook still sees
the currently active one either way. Rely on the explicitly injected
CVM_HOOK_EVENT/CVM_ENV/CVM_ENV_PATH instead (see below).
Switching directly from one active environment to another (cvm use B while
A is active) runs B's pre-activate/post-activate before A's
pre-deactivate/post-deactivate — the shell wrapper resolves and applies
the new environment first, only deactivating the old one afterwards. If
A's pre-deactivate hook then fails, the command aborts after B's
post-activate side effects have already run, with neither environment's
variables actually swapped in the shell. Hook authors relying on stateful
pairs (e.g. a lock file written by pre-activate and removed by
post-deactivate) should account for this ordering.
Each hook receives which event fired, plus the environment's name and directory, as environment variables:
CVM_HOOK_EVENT— the event name (e.g.post-create)CVM_ENV— the environment's nameCVM_ENV_PATH— the environment's absolute directory (may no longer exist by the timepost-removeruns)
On Unix, a hook file needs to be executable (chmod +x) and can use any
shebang; a present-but-non-executable hook is skipped with a warning rather
than blocking anything. On Windows, hooks use a .cmd extension (e.g.
post-create.cmd), matching the convention already used for the bin/
shims.
A hook's stdout is always discarded, for every event — a post-create or
pre-remove hook that echos progress won't print anything the user can
see. Hooks that want to print something for the user should write to
stderr instead.
Hooks are global and local to your machine — they are never included in
cvm export/cvm import (importing an environment that doesn't exist yet
does not fire post-create either), so importing a teammate's cvm.yaml
never runs code you didn't write yourself.
When you run several cvm open/cvm run instances in parallel (or just
switch between activated environments a lot), it's easy to lose track of
which terminal is pointed at which CLAUDE_CONFIG_DIR. Claude Code's
statusline can surface that for you, since CVM_ENV is already set in the
environment of any process started via cvm use/activate, cvm run, or
cvm open.
Paste the prompt below into Claude Code (in the project or globally, your choice) to have it wire this up for you:
Please update my Claude Code statusline so it shows the active cvm environment when there is one. Find my current
statusLineconfiguration (thestatusLineblock insettings.json— check the project's.claude/settings.json, then~/.claude/settings.json) and adjust its command/script in place, without removing anything it already displays.Requirements:
- If the
CVM_ENVenvironment variable is set and non-empty when the statusline script runs, prepend a short badge with its value (e.g.[env-name]) before the rest of the existing statusline output.- If
CVM_ENVis not set (or empty), the statusline must render exactly as it does today — no empty brackets, no extra spaces.- Keep whatever language/tooling the existing statusline script already uses (shell, Node, Python, etc.) instead of rewriting it in something else.
- If there is no statusline configured yet, create a minimal one that shows the model name plus the
CVM_ENVbadge when present.Show me the diff before writing it, and explain where the file lives.
This keeps the change scoped to your own statusline script and lets Claude Code adapt it to however your statusline is already implemented, rather than prescribing one exact shell snippet here.
cargo build
cargo test
cargo fmt --all -- --check
cargo clippy --all-targets --all-features -- -D warningsSee CONTRIBUTING.md for how to set up a pull request.
