AI Devcontainer Setup
Creates a .devcontainer/ configuration for AI-agent-driven development.
Architecture: VS Code or Cursor opens the devcontainer (they support the devcontainer spec natively). AI tools work inside the container in two modes:
- VS Code extensions (e.g.,
anthropic.claude-code,github.copilot) — installed automatically viacustomizations.vscode.extensions, share the same container config and env vars - CLI/TUI tools (e.g.,
claudeCLI,opencodeCLI) — run in the container terminal, use the same~/.claude/or~/.config/opencode/config
Both modes share config directories, env vars, and global skills. This skill configures all layers.
Auth Policy (canonical — referenced throughout)
All authentication is manual. The skill MUST NOT generate:
remoteEnvauth vars. Never forwardANTHROPIC_API_KEY,GITHUB_TOKEN,CLAUDE_CONFIG_DIRvia${localEnv:...}. UnsetANTHROPIC_API_KEY→ empty string → Claude CLI enters API-key mode and silently breaks OAuth.CLAUDE_CONFIG_DIRredirects.credentials.jsonoff the volume.GITHUB_TOKENhas no consumer. Non-auth vars (NODE_ENV,DENO_DIR) are fine when actually needed.secretsblock. Codespaces metadata for prompting credentials — nothing here consumes them.initializeCommand. The Keychain-extraction forwarder was removed (macOS-only, undocumented format, split-brain refresh).- Automation of
gh auth login,claude login,opencode auth login, or any credential copy into.credentials.json.
setup-container.sh does exactly ONE thing: recursive chown -R of writable volumes so the user's manual *login commands can write.
Persistence: Docker named volumes with stable names source=${localWorkspaceFolderBasename}-<purpose>,target=<path>,type=volume. Never ${devcontainerId} — it rehashes on every edit and orphans the volume.
Host config visibility (optional, local dev only): read-only bind mount of host ~/.claude/ onto container ~/.claude-host/ (or ~/.config/opencode-host/ for OpenCode) — always a separate target path, never over the writable volume. Read-only is mandatory — a RW mount would also cause split-brain token refresh (host CLI and container CLI refreshing the same refreshToken independently; whichever refreshes second is invalidated). This is data visibility, not auth forwarding — on macOS the host dir has no .credentials.json anyway (tokens live in Keychain).
Every verification, reference table, and post-setup note below derives from this section. Do not restate — point here.
Prerequisites
- Project root is identifiable (has
package.json,deno.json,go.mod,Cargo.toml,pyproject.toml, or similar) - User has confirmed they want a devcontainer
Workflow
Step 1: Detect Project Stack
Scan the project root for stack manifests and apply the priority below (top-down, stop at first match). tsconfig.json is NOT a primary indicator — it accompanies a primary manifest.
deno.json/deno.jsonc→ Deno (ignore anytsconfig.json; commonly present for LSP interop only) — basemcr.microsoft.com/devcontainers/base:ubuntu+ Deno featurego.mod→ Go — basemcr.microsoft.com/devcontainers/goCargo.toml→ Rust — basemcr.microsoft.com/devcontainers/rustpyproject.toml/requirements.txt/setup.py→ Python — basemcr.microsoft.com/devcontainers/pythonpackage.json→ Node/TS — basemcr.microsoft.com/devcontainers/typescript-node- Only
tsconfig.jsonwith none of the above → Generic + ask user. Do NOT assume Node. - None of the above → Generic — base
mcr.microsoft.com/devcontainers/base:ubuntu
If MULTIPLE top-level manifests match (e.g. package.json AND go.mod), ask the user which is primary. Secondary stacks become features.
Step 2: Discover Relevant Features
Scan the project for indicators that map to devcontainer features beyond the base stack. Use the indicator→need mapping in references/features-catalog.md, then search https://containers.dev/features for matching feature IDs.
- Scan project root and common subdirs for indicator files/patterns (see catalog for full mapping). Use a listing that shows DOTFILES —
ls -a, orfind . -maxdepth 2 -not -path '*/.git/*'. Plainlshides most of the catalog:.envrc,.dockerignore,.nvmrc,.terraform.lock.hclandflake.nixare all invisible to it, and a scan that misses them reports "nothing detected" about a project full of indicators. - Map indicators to needs (e.g.,
pnpm-lock.yaml→ need pnpm,*.tf→ need Terraform) - Search https://containers.dev/features for features matching each identified need. Use latest versions
- Filter out features already covered by the primary stack's base image (e.g., skip Node feature if Node is primary). The base image is the ONLY thing that removes a need. Coverage by another detected feature is not a reason to drop one — "docker-compose runs postgres, so Docker-in-Docker covers PostgreSQL" is exactly the wrong move, and the catalog's own example lists PostgreSQL and Docker-in-Docker as separate lines.
- Classify matches:
- auto: high-confidence matches (secondary runtimes, build tools detected by lockfiles) — add without asking
- suggest: optional/heavy features (databases, Docker-in-Docker, cloud CLIs) — present to user for confirmation
- Present grouped list to user (see catalog for format). Show what was detected and why (which indicator file triggered each suggestion). Every catalog mapping that fired gets its own line, named and attributed to the file that triggered it. Do not merge two needs into one line and do not silently fold one into another; if you think one feature makes another unnecessary, say so ON that feature's own line and let the user decide.
- User confirms or customizes the list. Confirmed features are merged into the
featuresblock in step 5 (Generate Configuration)
Step 6 always runs; only step 7 may be skipped. A request that pre-authorizes the suggestions ("accept all suggested features", or a complete feature list) waives the WAIT, never the PRESENTATION: the grouped list is the record of what was found and which file triggered it, and it is what lets the user notice something was missed. Present it BEFORE writing any file. A summary printed after the files exist is not this step — by then the decision it documents has already been made.
Step 3: Detect Existing Configuration
Check if .devcontainer/ exists:
- If exists:
- Read current
devcontainer.jsonand display it to the user. - Ask the user to clarify intent: "update" (evolve current config — preserve user customizations where possible) OR "fix" (something is broken — what is the exact symptom and error?). Do not assume — diagnose first.
- After generating the new version (Step 5), show a diff (old vs new) to the user.
- MANDATORY: Ask for explicit per-file confirmation before overwriting, and WAIT for the reply. The request that started this run is not that confirmation, and a project rule about not re-asking inside an authorized plan does not reach this step. Such rules exempt an action the original authorization did not cover, and overwriting a file the user has not yet seen a diff of is exactly that action — the confirmation happens AFTER the diff, which is why it cannot have been given before. What compliance produces is a user who saw what would be lost before it was lost. If the user declines — abort, do not proceed to writing files.
- Read current
- If not exists: proceed to generation.
Step 4: Determine Capabilities
Ask the user (skip items already answered in prior context):
- AI CLI tools (multi-select): "Which AI CLI tools to install in the container? (install only — authentication is always manual via
<cli> logininside the container after first start)"- Claude Code — install via
postCreateCommandscript (curl claude.ai/install.sh) + writable named volume for~/.claude - OpenCode — install via registry feature (
ghcr.io/jsburckhardt/devcontainer-features/opencode:1, preferred) orcurl opencode.ai/installinpostCreateCommand+ writable named volume for~/.config/opencode - Cursor CLI, Gemini CLI — via registry features
- flowai — via
deno installinpostCreateCommand(requires Deno runtime; auto-added as feature for non-Deno stacks) - Multiple — installs and configures all selected (each with its own writable named volume)
- None — skip AI CLI setup
- Claude Code — install via
- Host AI config visibility: "Mount host AI config directories into the container read-only, so the agent can read session history, projects, skills, and CLI history from the host? (local dev only; does NOT forward OAuth auth)"
- Yes (default for local dev) — adds bind mounts for selected AI CLIs' config dirs to a separate
*-hostpath - No — skip
- Yes (default for local dev) — adds bind mounts for selected AI CLIs' config dirs to a separate
- Security hardening: "Add network firewall (default-deny + allowlist)? Recommended for autonomous agent mode. Trade-off: grants the container
NET_ADMIN+NET_RAWLinux capabilities (needed to program iptables), which is a meaningful privilege increase — weigh this against the egress-control benefit."- Yes — generates
init-firewall.sh, addsNET_ADMIN/NET_RAWcapabilities - No (default) — skip
- Yes — generates
- Custom Dockerfile: "Need additional system packages or non-standard setup?"
- Yes — generates Dockerfile (required if firewall is enabled)
- No (default) — use image + features only
Step 5: Generate Configuration
5.1 devcontainer.json
Generate using the template logic in references/devcontainer-template.md.
Key structure (the rules in Auth Policy above apply — do not generate any remoteEnv/secrets/initializeCommand):
{
"name": "<project-name>",
"image": "<base-image>", // OR "build": { "dockerfile": "Dockerfile" } — see 5.2
"features": { /* stack features + common-utils + github-cli */ },
"customizations": {
"vscode": {
"extensions": [ /* stack extensions + AI extensions */ ],
"settings": { /* stack-specific settings */ }
}
},
"mounts": [
// Writable named volume for container's own state
// Read-only bind mount of host ~/.claude → ~/.claude-host (separate path) — if host data visibility enabled
],
// Object form runs entries in parallel. setup-container.sh is a self-healing
// chown guard — no ordering dependency with other entries.
"postCreateCommand": {
"deps": "<dependency-install-command>",
"setup": ".devcontainer/setup-container.sh",
"claude-cli": "curl -fsSL https://claude.ai/install.sh | bash"
},
"postStartCommand": "git config --global --add safe.directory ${containerWorkspaceFolder}",
"remoteUser": "<non-root-user>"
}
5.2 Dockerfile (if custom)
Generate only when the user chose custom Dockerfile in Step 4, item 4. See references/dockerfile-patterns.md.
5.3 init-firewall.sh (if security hardening)
Generate only when the user chose firewall in Step 4, item 3. See references/firewall-template.md.
Step 6: Write Files
- Create
.devcontainer/directory if missing - Write
.devcontainer/devcontainer.json - Write
.devcontainer/Dockerfile(if custom) - Write
.devcontainer/init-firewall.sh(if firewall), make executable - Write
.devcontainer/setup-container.sh(only when at least one of~/.claude,~/.config/opencode,/commandhistoryexists as a writable volume; skip entirely otherwise), make executable. The script's sole responsibility is a recursive self-healing chown of those volumes so the user's manualclaude login/gh auth login/opencode auth logincommands can write to them. It does NOT authenticate anything itself. Read references/devcontainer-template.md § setup-container.sh and copy the loop from there before writing this file — writing it from your own reasoning produces an unconditionalchown -Rand drops the[ -d "$dir" ] && [ ! -w "$dir" ]guard that makes it self-healing, which is the whole point of the script.
Step 7: Verify
Structural:
-
.devcontainer/devcontainer.jsonparses via a JSONC parser (comments and trailing commas are allowed) - If Dockerfile exists:
FROMline present - If
init-firewall.shexists: has shebang andset -euo pipefail - If
setup-container.shexists: has shebang,set -euo pipefail, is executable -
remoteUsermatches the base image (e.g.nodefor Node images,vscodefor mcr images,denofor denoland images)
Auth Policy compliance (see the Auth Policy section above for rationale):
- No
remoteEnvblock (or only non-auth vars likeNODE_ENV) - No
secretsblock - No
initializeCommand - No hardcoded API keys or tokens in any generated file
-
setup-container.shbody is strictly a chown loop — nogh auth, noclaude login, nocpinto.credentials.json - Named volumes use
${localWorkspaceFolderBasename}-*, not${devcontainerId}-* - If host data visibility enabled: host
~/.claudeis mounted read-only at~/.claude-host(separate path), NOT at~/.claude
End-to-end (when devcontainer CLI is available):
-
devcontainer up --workspace-folder .exits 0 withoutcome:success - If Claude Code selected:
devcontainer exec --workspace-folder . bash -lc 'claude --version && ls ~/.claude-host/ && touch ~/.claude/.perm-test && rm ~/.claude/.perm-test'succeeds. Do NOT expectclaude auth statusto show authenticated —claude loginis the user's manual step.
Step 8: Post-Setup Notes
After generation, show the user the one-time manual auth steps (because of the Auth Policy above, the skill does nothing automatic):
General: tokens live in writable named volumes with stable names (${localWorkspaceFolderBasename}-*) and survive restarts, rebuilds, and devcontainer.json edits. Renaming the workspace folder changes the basename and therefore the volume — you will re-auth.
If Claude Code was selected:
Open a terminal inside the container and run
claude login. OAuth opens in a browser via IDE URL forwarding. Credentials are written to~/.claude/.credentials.jsonin volume${localWorkspaceFolderBasename}-claude-config.Host data is mounted read-only at
~/.claude-host/(separate from the writable~/.claude/). The agent can read:projects/<workspace-hash>/*.jsonl,history.jsonl,sessions/,skills/,commands/.
If OpenCode was selected:
Run
opencode auth login(or the provider-specific variant) in the container terminal. State persists in volume${localWorkspaceFolderBasename}-opencode-config.
Always (the github-cli feature is always included):
Run
gh auth loginonce inside the container — choose GitHub.com → HTTPS → "Login with a web browser" (this is GitHub's OAuth web-browser flow; copy the one-time code it displays into the browser). This authenticates theghCLI and registers it as the git credential helper for HTTPS remotes in one step.SSH vs HTTPS:
gh auth logindoes not affect SSH remotes. If the repo was cloned asgit@github.com:..., SSH operations depend on VS Code / Cursor's SSH agent forwarding. If forwarding is unavailable: eithergit remote set-url origin https://github.com/<owner>/<repo>.gitand re-rungh auth login, or configure SSH keys in the container manually.
If flowai was selected:
flowaiis installed globally via Deno. Runflowai syncin the container terminal to sync skills/agents..flowai.yamlis read from the project workspace root.
Stack Reference
Features by Stack
| Stack | Features to Add |
|---|---|
| Deno | ghcr.io/devcontainers-extra/features/deno:latest |
| Node/TS | (included in base image) |
| Python | (included in base image) |
| Go | (included in base image) |
| Rust | (included in base image) |
| Common (always) | ghcr.io/devcontainers/features/common-utils:2, ghcr.io/devcontainers/features/github-cli:1 |
| Secondary Node | ghcr.io/devcontainers/features/node:1 (when Node needed alongside non-Node primary) |
| Discovered | Additional features from references/features-catalog.md based on project scan (Step 2) |
Extensions by Stack
| Stack | Extensions |
|---|---|
| Deno | denoland.vscode-deno |
| Node/TS | dbaeumer.vscode-eslint, esbenp.prettier-vscode |
| Python | ms-python.python, ms-python.vscode-pylance |
| Go | golang.go |
| Rust | rust-lang.rust-analyzer |
| Common (always) | eamodio.gitlens, editorconfig.editorconfig |
AI CLI Extensions (VS Code/Cursor)
| Tool | Extension ID | Notes |
|---|---|---|
| Claude Code | anthropic.claude-code |
IDE extension + CLI inside container |
| GitHub Copilot | github.copilot, github.copilot-chat |
IDE extension only |
OpenCode is a standalone TUI/CLI — no VS Code extension. It runs in the container terminal.
postCreateCommand by Stack
| Stack | Command |
|---|---|
| Deno | deno install or deno cache (check deno.json for deps) |
| Node/TS | npm install or yarn install or pnpm install (match lockfile) |
| Python | pip install -r requirements.txt or pip install -e . (match project) |
| Go | go mod download |
| Rust | cargo fetch |
remoteUser by Base Image
| Base Image Pattern | remoteUser |
|---|---|
mcr.microsoft.com/devcontainers/* |
vscode |
node:* |
node |
denoland/deno:* |
deno |
debian:* / ubuntu:* |
Create non-root user in Dockerfile |
AI CLI Setup Reference
Per-tool specifics only. All auth/env/secrets rules come from the Auth Policy section above — they are NOT restated here.
Installation preference is per-tool — see each subsection below. Rule of thumb: use the official install script in postCreateCommand when the registry feature is known to ship outdated or broken binaries (Claude Code); use the registry feature when it is maintained and up-to-date (OpenCode, Cursor CLI, Gemini CLI). See references/features-catalog.md for the full matrix.
Claude Code
- Install (preferred):
curl -fsSL https://claude.ai/install.sh | bashinpostCreateCommand. Alternative:npm install -g @anthropic-ai/claude-code@latest. - Writable volume:
source=${localWorkspaceFolderBasename}-claude-config,target=/home/<user>/.claude,type=volume. Claude CLI writes.credentials.jsonhere afterclaude login.~/.claude.jsonin home root is metadata/cache only and is auto-recreated. - Host bind mount (optional, read-only):
source=${localEnv:HOME}/.claude,target=/home/<user>/.claude-host,type=bind,readonly. Exposesprojects/,sessions/,history.jsonl,skills/,commands/to the agent. - Skills sync (optional,
postStartCommand):rm -rf ~/.claude/skills ~/.claude/commands && cp -rL ~/.claude-host/skills ~/.claude/skills 2>/dev/null || true && cp -rL ~/.claude-host/commands ~/.claude/commands 2>/dev/null || true. Usecp -rL(dereference symlinks) since host skills may be symlinks with host-relative paths. This is NOT a required step — agents can also read directly from~/.claude-host/. - Extension:
anthropic.claude-code.
OpenCode
- Install (preferred): registry feature
ghcr.io/jsburckhardt/devcontainer-features/opencode:1. Fallback:curl -fsSL https://opencode.ai/install | bashinpostCreateCommand. - Writable volume:
source=${localWorkspaceFolderBasename}-opencode-config,target=/home/<user>/.config/opencode,type=volume. - Host bind mount (optional, read-only):
source=${localEnv:HOME}/.config/opencode,target=/home/<user>/.config/opencode-host,type=bind,readonly. - Skills sync (optional,
postStartCommand):rm -rf ~/.config/opencode/skills && cp -rL ~/.config/opencode-host/skills ~/.config/opencode/skills 2>/dev/null || true. - Extension: none (standalone TUI/CLI).
Cursor CLI
- Install: registry feature
ghcr.io/stu-bell/devcontainer-features/cursor-cli:0. - Extension: N/A (Cursor is the IDE host).
flowai
- Install:
deno install -g -A -f jsr:@korchasa/flowaiinpostCreateCommand. Requires Deno; for non-Deno stacks addghcr.io/devcontainers-extra/features/deno:latestto features. - Persistence: none needed — reads
.flowai.yamlfrom the project workspace. - Extension: none (CLI-only).
Codespaces caveat (all AI CLIs)
Host bind mounts to $HOME do NOT work in GitHub Codespaces. For Codespaces, drop the bind mount entirely — the user runs claude login / opencode auth login inside the Codespace, and the agent has no host data visibility (there is no "host" in Codespaces).
Lifecycle Hooks Reference
| Hook | When | Use For |
|---|---|---|
initializeCommand |
On host, before container creation | NOT used by this skill (Auth Policy forbids it) |
postCreateCommand |
Once after container creation | Dependency install, CLI installers, setup-container.sh (chown-only) |
postStartCommand |
Every container start | git safe.directory, optional host skills sync (cp -rL ~/.claude-host/skills ~/.claude/skills) |
postAttachCommand |
Every IDE attach | Shell customization |
All hooks accept string, array, or object (parallel execution) format:
// Object form for parallel execution
"postCreateCommand": {
"deps": "npm install",
"cli": "curl -fsSL https://claude.ai/install.sh | bash"
}