Problem: Hardcoded Go constants for curated MCPs/skills — users couldn't customize. Create flow was linear, no way to reorder or remove selections.
Solution:
- Replaced hardcoded Go constants with YAML-backed curated config at
curated/mcps.yaml+curated/skills.yaml, bundled viaembed.FS - User override support:
~/.config/aienv/curated/*.yamlmerged by name, tagged(user override)in menus - Loop-based create flow — users freely mix curated selection, online search, and custom entries;
rto remove;dto finish - Deduplication: checks for existing name before appending
- Registry API parses
packages[].registryType+packages[].identifierfor proper command generation (npm→npx -y, pypi→uvx, go→go run) - 12 of 20 curated MCPs include
env[]metadata (key, description, required); env var requirements printed after selection - Activation-time env var validation:
checkMCPEnvVars()warns if anyenv:KEYreference has no matching shell variable - Registry search results cross-referenced against curated entries to enrich with env var metadata
Goal: Run agents and MCP servers inside a Docker container, isolating them from the host.
Approach: Per-agent Dockerfiles (inline in Go source, Ubuntu 24.04 + Node.js 18 + Python 3.12 + Go 1.22), auto-built on first --docker use, docker run --rm -it with auto-cleanup on exit.
Volume mounts:
$(pwd):/workspace(rw) — project source~/.ai-envs/<name>/opencode.json:/ai-env/opencode.json:ro— agent config~/.config/opencode/skills:/home/user/.config/opencode/skills:ro— installed skills~/.config/opencode/:/home/user/.config/opencode:ro— global config (providers, auth, themes)~/.gitconfig:/home/user/.gitconfig:ro— git authorship
Bugs fixed during testing:
- UID 1000 conflict with base image's
ubuntuuser →userdel -r ubuntubeforeuseradd - agent not in container → binary mount from host
- TUI not rendering → pass
TERM/COLORTERMenv vars - eval subshell kills TTY → shell function bypasses
evalwhen--dockeris detected
--promptflag onaienv activateto inject arbitrary starter text- Optional
promptfield in env schema for persisted defaults - Prompt text written to
starter-prompt.mdand prepended to instructions array - Runtime flag overrides env default
- Also added: self-referential
aienvenv with GitHub MCP + tdd/grill-me/caveman skills + AGENTS.md + Obsidian design notes as rules
Goal: Support agents beyond OpenCode; clean pluggable architecture for per-agent config generation.
Solution:
- Agent interface:
internal/agents/agent.go—Agentinterface + globalRegister()/Get()registry - OpenCode agent generates
opencode.jsonwithmcp.<name>.commandas array - Claude Code agent generates
mcp-config.jsonwithmcpServers.<name>.command(string) +args(array),envwith${VAR}syntax. Remote MCPs skipped. GeneratesCLAUDE.mdwith prompt. - Agent selection in create flow after name prompt, defaults to
"opencode" - Separate Dockerfiles per agent (
opencode.Dockerfile,claude.Dockerfile) embedded via//go:embed *.Dockerfile npx skills addpasses--agent <agent>for agent-scoped installs
Problem: Generated config was a flat struct with only mcp, model, instructions, permission.skill — all other user settings were dropped. Docker containers had no access to global config.
Solution — minimal override generation:
- Replaced rigid struct with
map[string]anygeneration containing ONLY env-specific overrides - OpenCode's native config merging handles inheritance of all other keys
- Global MCPs disabled at env level via
"enabled": falsefor servers not in the env - Permission deep-merge:
permission.skillsub-key merged into globalpermissionobject - Same code path for Docker and non-Docker
Problem: ~/.local/share/opencode/ was mounted as writable bind mount — agent writes leaked to host.
Design evolution: OverlayFS was the first approach but failed on Docker Desktop (kernel overlay not visible inside VM).
Final approach: Session-unique Docker named volume, initialized from host data, mounted writable. Writes go to /var/lib/docker/volumes/, never touch host.
Implementation:
docker run --rm --user rootto copy host data into volume, chown to uid 1000sessionID = aienv-<envName>-<random>per launchdeferwithdocker volume rm -fon exit;signal.Notifyfor SIGINT/SIGTERM~/.ssh/removed — auth viaghCLI + token env varsghCLI installed in both Dockerfiles
Problem: Claude Code inside Docker had ~/.claude/ mounted :ro (no write access to sessions, history). ~/.claude.json not mounted — Claude started unauthenticated.
Solution:
- Extracted
mountIsolatedVolume(hostDir, volName, imageTag)helper - Refactored opencode
~/.local/share/opencode/to use helper - Changed claude
~/.claude/from:roto volume-init — writable, isolated from host - Added
~/.claude.jsonoverlay::romount +sh -c "cp ...; exec claude ..."wrapper
Problem: CLAUDE_CONFIG_DIR=<envDir>/claude-config/ replaced ~/.claude/ entirely, dropping user's global config.
Solution:
- Removed
CLAUDE_CONFIG_DIRexport/unset — Claude uses global~/.claude/ - Env-specific overrides come via CLI flags (
--mcp-config,--append-system-prompt-file,--model) - Removed skill symlink logic — skills already installed globally by
npx skills add --agent claude
Problem: Activation always stayed in the user's current directory. No way to configure a default workspace for an environment.
Solution:
- Added
workdirfield toEnvstruct (yaml:"workdir,omitempty") - Create flow prompts: "Default working directory (absolute path, or '.' for current): "
- Empty → prints note about activation-time CWD behavior
~or~/...→ expanded viaExpandTilde()helper.or relative → converted to absolute viafilepath.Abs()- Validates directory exists with
os.Stat
- Activation (
cmd/activate_cmd.go):- Resolves workdir, expands
~, validates directory exists (warns + fallback to CWD if missing) - Passes resolved workdir to
GenerateFiles()for correct rule path resolution - Non-Docker: prepends
cd <workdir>\nto the activation command output → shellevalexecutes thecdin the calling shell - Docker: passes workdir to
docker.Run()→ mounts it as/workspace(container'sWORKDIR /workspaceinherited from Dockerfile)
- Resolves workdir, expands
showand create summary display workdir when seteditworks naturally — field is just another YAML keyExpandTilde(path)helper ininternal/env/env.goshared across create and activate