aenv: Version-Controlled Environments for AI Coding Agent Configuration
Hook
Your team's CLAUDE.md files are diverging across 12 repositories, nobody remembers which .cursorrules actually work, and onboarding requires a 47-step install guide. Welcome to the AI coding assistant coordination crisis.
Context
AI coding assistants like Claude Code and Cursor have introduced a new category of dotfiles: harness configurations that shape how agents behave. CLAUDE.md tells Claude Code your project conventions. .cursorrules guides Cursor's completions. MCP server configs wire up tool integrations. These files are critical infrastructure—they determine whether your AI pair programmer suggests Flask idioms in a Django codebase or hallucinates deprecated APIs.
But unlike traditional dotfiles, AI harness configs need to be project-scoped, composable, and synchronized across teams. You want a base CLAUDE.md for company-wide Python standards, extended per-project with framework-specific rules. You want to snapshot a working Cursor configuration and share it via git. You need to activate colleague setups without clobbering your own. Existing dotfile managers like chezmoi and yadm treat these as opaque files—they'll symlink CLAUDE.md into place but can't compose rule hierarchies or manage skill dependencies. Manual git submodules work until someone force-pushes over your config and you have no backup. aenv brings the virtualenv activation model to AI agent configuration: isolated namespaces you activate per-project, with automatic backups and deterministic deactivation.
Technical Insight
aenv's architecture centers on three primitives: namespaces, adapters, and skills. Namespaces are directories in ~/.aenv/envs/<name>/ containing config files and an aenv.toml manifest. Adapters define tool-specific file patterns—the claude-code adapter declares CLAUDE.md, the cursor adapter owns .cursorrules. Skills are reusable components (prompt libraries, MCP server configs) imported via git or authored inline.
The activation flow uses symlinks with copy-on-write semantics. When you run aenv use my-namespace in a project, aenv walks the namespace directory, matches files against adapter patterns, and symlinks them into your project root. Pre-existing files get moved to .aenv-state/backup/<nanosecond-timestamp>/ before linking—this isn't a casual mv .cursorrules .cursorrules.bak. The nanosecond precision means multiple activation cycles create separate backup directories. If state.json corrupts, you can manually restore from any timestamp, not just the most recent.
Here's a minimal namespace manifest showing composition:
# ~/.aenv/envs/python-django/aenv.toml
extends = "python-base"
[adapters.claude-code]
files = { "CLAUDE.md" = "CLAUDE.md" }
[adapters.cursor]
files = { ".cursorrules" = ".cursorrules" }
[skills.django-patterns]
type = "imported"
repo = "https://github.com/company/ai-skills"
path = "skills/django"
pin = "a1b2c3d4"
The extends directive composes at parse time—python-django inherits python-base's adapter declarations and skill imports, then overrides specific sections. This avoids deep-merging TOML arrays, which devolves into order-dependent chaos. Child manifests replace parent sections wholesale.
Skills use sparse git clones to minimize disk overhead. When you import a skill from a monorepo, aenv runs git clone --depth 1 --filter=blob:none --sparse-checkout <repo> and checks out only the specified path. The .aenv/cache/ directory holds these clones, and aenv cache prune reclaims space when you bump pins. For authored skills, you just create a directory in the namespace:
mkdir -p ~/.aenv/envs/my-project/skills/custom-mcp
cat > ~/.aenv/envs/my-project/skills/custom-mcp/config.json <<EOF
{
"mcpServers": {
"filesystem": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/workspace"]
}
}
}
EOF
Global namespaces extend this model to $HOME for user-scoped configs. Declaring user_files in the manifest symlinks files into your home directory—~/.claude/CLAUDE.md, ~/.cursor/extensions/. The killer feature here is lifecycle hooks:
[global]
user_files = [
{ src = "claude/CLAUDE.md", dest = "~/.claude/CLAUDE.md" },
{ src = "mcp/config.json", dest = "~/.claude/mcp.json" }
]
[lifecycle]
on_activate = "scripts/install-mcp-servers.sh"
on_activate_sha256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
The on_activate hook runs shell scripts during global activation, but only after verifying the sha256 pin matches. This lets you package MCP server installers or shell modifications (adding PATH entries, injecting aliases) without blind trust. First activation prompts for approval; subsequent runs check the hash. It's a pragmatic supply-chain mitigation—not sandboxed execution, but explicit pinning.
The snapshot command reverses the workflow. Point it at a project with ad-hoc configs:
cd ~/projects/legacy-app
aenv snapshot captured-setup
aenv walks the directory against all adapter globs, captures matching files (CLAUDE.md, .cursorrules, .mcp.json), and generates a manifest in ~/.aenv/envs/captured-setup/. You've turned a one-off configuration into a reusable namespace without handwriting TOML.
Adapters themselves are versioned manifests in ~/.aenv/adapters/, not hardcoded. Want to support a new tool? Drop an adapter manifest declaring its file patterns:
# ~/.aenv/adapters/aider.toml
name = "aider"
version = "0.1.0"
[files]
".aider.conf.yml" = { glob = ".aider.conf.yml", dest = ".aider.conf.yml" }
Reference it in your namespace with [adapters.aider] and aenv's expansion logic handles the rest. No forking required.
Gotcha
Windows support is broken until the symlink fallback lands (roadmap Phase 7). The tool errors on activation because Unix mv + ln -s semantics don't map cleanly to Windows. Developer Mode symlinks and directory junctions exist but aren't implemented. If you're on Windows, this is a non-starter right now.
Conflict resolution doesn't exist. If two adapters both declare CLAUDE.md, last-wins behavior is implicit—manifest validation won't catch it, so you hit runtime failures during activation. You're responsible for ensuring adapter patterns don't overlap. The skill import --pin mechanism trusts git refs without cryptographic verification. Pinning to a branch means future activations pull whatever's at HEAD. No subresource integrity checks, no vendoring for airgapped environments. If your threat model includes compromised upstream repos, you need additional controls.
State corruption from manual .aenv-state/ edits or interrupted activations (Ctrl+C during backup) leaves the tool unable to deactivate cleanly. aenv restore is a blunt hammer that copies everything back—no surgical rollback for partial failures. The symlink model also means edits to CLAUDE.md during activation modify the namespace directly, not your project. This collapses config drift but requires aenv fork to capture intentional divergence. If your workflow involves heavy runtime mutations to active configs, you'll fight the tool.
Verdict
Use if: You're standardizing AI coding assistant behavior across multiple projects or team members, need composable config inheritance (base + project-specific overrides), want activation safety with automatic backups, or are tired of 47-step MCP server install guides. The global namespace with lifecycle hooks is transformative for onboarding—aenv global use <github-url> beats wiki documentation every time. The snapshot command makes capturing working configs trivial, and sparse git clones show serious attention to disk efficiency. Skip if: You're on Windows until symlink fallback ships, your workflow involves heavily mutating active configs (the symlink model creates friction), you need merge semantics rather than wholesale section replacement, or your security posture requires cryptographic verification of dependencies (the pin mechanism trusts git refs). This is a power tool for practitioners who understand filesystem semantics—acceptable for individual developers and small teams, questionable for orgs needing guardrails against foot-guns.