CtrlK
BlogDocsLog inGet started
Tessl Logo
SkillPublic

setup-ai-ide-devcontainer

Generate a .devcontainer (devcontainer.json plus Dockerfile) for AI-IDE development with AI CLI integration, skill mounting and security hardening. Use when the user wants to set up or containerize a local dev environment. Not for cloud deploys.

77

Quality

97%

Does it follow best practices?

Run evals on this skill

Adds up to 20 points to the overall score

View guide
SecuritybySnyk

Low

Low-risk findings worth noting

SKILL.md
Quality
Evals
Security

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 via customizations.vscode.extensions, share the same container config and env vars
  • CLI/TUI tools (e.g., claude CLI, opencode CLI) — 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:

  • remoteEnv auth vars. Never forward ANTHROPIC_API_KEY, GITHUB_TOKEN, CLAUDE_CONFIG_DIR via ${localEnv:...}. Unset ANTHROPIC_API_KEY → empty string → Claude CLI enters API-key mode and silently breaks OAuth. CLAUDE_CONFIG_DIR redirects .credentials.json off the volume. GITHUB_TOKEN has no consumer. Non-auth vars (NODE_ENV, DENO_DIR) are fine when actually needed.
  • secrets block. 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.

  1. deno.json / deno.jsonc → Deno (ignore any tsconfig.json; commonly present for LSP interop only) — base mcr.microsoft.com/devcontainers/base:ubuntu + Deno feature
  2. go.mod → Go — base mcr.microsoft.com/devcontainers/go
  3. Cargo.toml → Rust — base mcr.microsoft.com/devcontainers/rust
  4. pyproject.toml / requirements.txt / setup.py → Python — base mcr.microsoft.com/devcontainers/python
  5. package.json → Node/TS — base mcr.microsoft.com/devcontainers/typescript-node
  6. Only tsconfig.json with none of the above → Generic + ask user. Do NOT assume Node.
  7. 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.

  1. Scan project root and common subdirs for indicator files/patterns (see catalog for full mapping). Use a listing that shows DOTFILES — ls -a, or find . -maxdepth 2 -not -path '*/.git/*'. Plain ls hides most of the catalog: .envrc, .dockerignore, .nvmrc, .terraform.lock.hcl and flake.nix are all invisible to it, and a scan that misses them reports "nothing detected" about a project full of indicators.
  2. Map indicators to needs (e.g., pnpm-lock.yaml → need pnpm, *.tf → need Terraform)
  3. Search https://containers.dev/features for features matching each identified need. Use latest versions
  4. 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.
  5. 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
  6. 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.
  7. User confirms or customizes the list. Confirmed features are merged into the features block 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, in the same message that asks the Step 4 questions the request left unanswered — that message ends your turn (see Step 4). Only when the request answered every Step 4 item too does the list go out as a message of its own before the first file write, with no stop. 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:
    1. Read current devcontainer.json and display it to the user.
    2. 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.
    3. After generating the new version (Step 5), show a diff (old vs new) to the user.
    4. 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.
  • If not exists: proceed to generation.

Step 4: Determine Capabilities

Ask the user every item below that the request and prior context did not answer, then STOP for the reply. Skip only the items the user has already answered.

  • A "(default)" label is not an answer. It marks the option to recommend inside the question, not one to pick on the user's behalf. Writing "you didn't say, so I chose the defaults" in the closing summary means these questions were skipped.
  • "Accept all suggested features" answers Step 2's confirmation only. It says nothing about AI CLIs, host mounts, the firewall or a Dockerfile.
  • A project rule about forward motion inside an authorized plan does not reach these questions. The request authorized a devcontainer, not its privileges: the firewall adds Linux capabilities, and the host mount exposes the user's AI config to the container. Those are the user's choices to make.
  • Ask the unanswered items in ONE message together with the Step 2 grouped list (and the Step 3 questions, when a config exists), then end your turn. Generate and write nothing before the reply.
  1. AI CLI tools (multi-select): "Which AI CLI tools to install in the container? (install only — authentication is always manual via <cli> login inside the container after first start)"
    • Claude Code — install via postCreateCommand script (curl claude.ai/install.sh) + writable named volume for ~/.claude
    • OpenCode — install via registry feature (ghcr.io/jsburckhardt/devcontainer-features/opencode:1, preferred) or curl opencode.ai/install in postCreateCommand + writable named volume for ~/.config/opencode
    • Cursor CLI, Gemini CLI — via registry features
    • flowai — a plugin of the AI CLI, not a separate binary. On Claude Code and Codex, chain plugin marketplace add korchasa/flowai-plugins and plugin install flowai@flowai-plugins onto that CLI's own install entry, so the plugin commands run after the CLI exists. Cursor and OpenCode have no plugin marketplace: clone korchasa/flowai, run deno task build-plugins, and copy the rendered skills into .claude/skills/ — that path needs a Deno runtime, the marketplace path does not.
    • Multiple — installs and configures all selected (each with its own writable named volume)
    • None — skip AI CLI setup
  2. 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 *-host path
    • No — skip
  3. Security hardening: "Add network firewall (default-deny + allowlist)? Recommended for autonomous agent mode. Trade-off: grants the container NET_ADMIN+NET_RAW Linux capabilities (needed to program iptables), which is a meaningful privilege increase — weigh this against the egress-control benefit."
    • Yes — generates init-firewall.sh, adds NET_ADMIN/NET_RAW capabilities
    • No (default) — skip
  4. 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

  1. Create .devcontainer/ directory if missing
  2. Write .devcontainer/devcontainer.json
  3. Write .devcontainer/Dockerfile (if custom)
  4. Write .devcontainer/init-firewall.sh (if firewall), make executable
  5. Write .devcontainer/setup-container.sh (only when at least one of ~/.claude, ~/.config/opencode, /commandhistory exists 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 manual claude login / gh auth login / opencode auth login commands 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 unconditional chown -R and 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.json parses via a JSONC parser (comments and trailing commas are allowed)
  • If Dockerfile exists: FROM line present
  • If init-firewall.sh exists: has shebang and set -euo pipefail
  • If setup-container.sh exists: has shebang, set -euo pipefail, is executable
  • remoteUser matches the base image (e.g. node for Node images, vscode for mcr images, deno for denoland images)

Auth Policy compliance (see the Auth Policy section above for rationale):

  • No remoteEnv block (or only non-auth vars like NODE_ENV)
  • No secrets block
  • No initializeCommand
  • No hardcoded API keys or tokens in any generated file
  • setup-container.sh body is strictly a chown loop — no gh auth, no claude login, no cp into .credentials.json
  • Named volumes use ${localWorkspaceFolderBasename}-*, not ${devcontainerId}-*
  • If host data visibility enabled: host ~/.claude is 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 with outcome: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 expect claude auth status to show authenticated — claude login is 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.json in 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 login once 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 the gh CLI and registers it as the git credential helper for HTTPS remotes in one step.

SSH vs HTTPS: gh auth login does not affect SSH remotes. If the repo was cloned as git@github.com:..., SSH operations depend on VS Code / Cursor's SSH agent forwarding. If forwarding is unavailable: either git remote set-url origin https://github.com/<owner>/<repo>.git and re-run gh auth login, or configure SSH keys in the container manually.

If flowai was selected:

flowai is a plugin of the AI CLI you chose, so there is nothing to sync. Confirm it with claude plugin list (or codex plugin list) in the container terminal; its commands appear as /flowai:*. Update it later with claude plugin update flowai@flowai-plugins (or codex plugin marketplace upgrade flowai-plugins followed by codex plugin add flowai@flowai-plugins). On Cursor and OpenCode the skills were copied into .claude/skills/ when the container was built — re-run the build-and-copy step to update them.


Stack Reference

Features by Stack

StackFeatures to Add
Denoghcr.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 Nodeghcr.io/devcontainers/features/node:1 (when Node needed alongside non-Node primary)
DiscoveredAdditional features from references/features-catalog.md based on project scan (Step 2)

Extensions by Stack

StackExtensions
Denodenoland.vscode-deno
Node/TSdbaeumer.vscode-eslint, esbenp.prettier-vscode
Pythonms-python.python, ms-python.vscode-pylance
Gogolang.go
Rustrust-lang.rust-analyzer
Common (always)eamodio.gitlens, editorconfig.editorconfig

AI CLI Extensions (VS Code/Cursor)

ToolExtension IDNotes
Claude Codeanthropic.claude-codeIDE extension + CLI inside container
GitHub Copilotgithub.copilot, github.copilot-chatIDE extension only

OpenCode is a standalone TUI/CLI — no VS Code extension. It runs in the container terminal.

postCreateCommand by Stack

StackCommand
Denodeno install or deno cache (check deno.json for deps)
Node/TSnpm install or yarn install or pnpm install (match lockfile)
Pythonpip install -r requirements.txt or pip install -e . (match project)
Gogo mod download
Rustcargo fetch

remoteUser by Base Image

Base Image PatternremoteUser
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 | bash in postCreateCommand. 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.json here after claude login. ~/.claude.json in 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. Exposes projects/, 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. Use cp -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 | bash in postCreateCommand.
  • 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 (Claude Code): append && claude plugin marketplace add korchasa/flowai-plugins && claude plugin install flowai@flowai-plugins to the claude-cli entry of postCreateCommand. The two plugin commands need the CLI on PATH, so they MUST be chained onto that entry and never run as a sibling entry — object-form entries run in parallel.
  • Install (Codex): the same shape with codex plugin marketplace add korchasa/flowai-plugins && codex plugin add flowai@flowai-plugins.
  • Install (Cursor, OpenCode): no plugin marketplace exists for either. Clone korchasa/flowai, run deno task build-plugins, and copy dist/claude-plugins/plugins/flowai/skills/* into .claude/skills/. This is the one path that needs a Deno runtime in the container.
  • Persistence: none needed — the plugin lives in the AI CLI's own plugin cache, which already sits on that CLI's config volume.
  • Extension: none.
  • Never install jsr:@korchasa/flowai. That CLI is archived and receives no framework updates.

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

HookWhenUse For
initializeCommandOn host, before container creationNOT used by this skill (Auth Policy forbids it)
postCreateCommandOnce after container creationDependency install, CLI installers, setup-container.sh (chown-only)
postStartCommandEvery container startgit safe.directory, optional host skills sync (cp -rL ~/.claude-host/skills ~/.claude/skills)
postAttachCommandEvery IDE attachShell 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"
}
Repository

korchasa/flowai-plugins

Path

plugins/flowai/skills/setup-ai-ide-devcontainer/SKILL.md

Last updated
First committed

Is this your skill?

If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.