Claude Skill

devcontainer-setup

Creates devcontainers with Claude Code, language-specific tooling (Python/Node/Rust/Go), and persistent volumes. Use when adding devcontainer support to a project, setting up isolated development environments, or configuring sandboxed Claude Code workspaces.

LLM Mart · 0 points · 10 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download trailofbits-skills-plugins_devcontainer-setup_skills_devcontainer-setup-123037e.zip · 20 KB
trailofbits/skills 7234 616 forks CC-BY-SA-4.0 Updated 9h ago
Part of trailofbits/skills — 100 skills

Install

skills CLI npx skills add https://github.com/trailofbits/skills/tree/main/plugins/devcontainer-setup/skills/devcontainer-setup
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install trailofbits-skills@llmmart
Git git clone https://github.com/trailofbits/skills.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole trailofbits/skills collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

Devcontainer Setup Skill

Creates a pre-configured devcontainer with Claude Code and language-specific tooling.

When to Use

  • User asks to "set up a devcontainer" or "add devcontainer support"
  • User wants a sandboxed Claude Code development environment
  • User needs isolated development environments with persistent configuration

When NOT to Use

  • User already has a devcontainer configuration and just needs modifications
  • User is asking about general Docker or container questions
  • User wants to deploy production containers (this is for development only)

Workflow

flowchart TB
    start([User requests devcontainer])
    recon[1. Project Reconnaissance]
    detect[2. Detect Languages]
    generate[3. Generate Configuration]
    write[4. Write files to .devcontainer/]
    done([Done])

    start --> recon
    recon --> detect
    detect --> generate
    generate --> write
    write --> done

Phase 1: Project Reconnaissance

Infer Project Name

Check in order (use first match):

  1. package.json → name field
  2. pyproject.toml → project.name
  3. Cargo.toml → package.name
  4. go.mod → module path (last segment after /)
  5. Directory name as fallback

Convert to slug: lowercase, replace spaces/underscores with hyphens.

Detect Language Stack

Language Detection Files
Python pyproject.toml, *.py
Node/TypeScript package.json, tsconfig.json
Rust Cargo.toml
Go go.mod, go.sum

Multi-Language Projects

If multiple languages are detected, configure all of them in the following priority order:

  1. Python - Primary language, uses Dockerfile for uv + Python installation
  2. Node/TypeScript - Uses devcontainer feature
  3. Rust - Uses devcontainer feature
  4. Go - Uses devcontainer feature

For multi-language postCreateCommand, chain all setup commands:

uv run /opt/post_install.py && uv sync && npm ci

Extensions and settings from all detected languages should be merged into the configuration.

Phase 2: Generate Configuration

Start with base templates from resources/ directory. Substitute:

  • {{PROJECT_NAME}} → Human-readable name (e.g., "My Project")
  • {{PROJECT_SLUG}} → Slug for volumes (e.g., "my-project")

Then apply language-specific modifications below.

Base Template Features

The base template includes:

  • Claude Code with marketplace plugins (anthropics/skills, trailofbits/skills, trailofbits/skills-curated)
  • Sandboxing via bubblewrap and socat
  • Python 3.13 via uv (fast binary download)
  • Node 22 via fnm (Fast Node Manager)
  • ast-grep for AST-based code search
  • Network isolation tools (iptables, ipset) with NET_ADMIN capability
  • Security mounts: .devcontainer/ mounted read-only to prevent container escape
  • Token forwarding: CLAUDE_CODE_OAUTH_TOKEN and ANTHROPIC_API_KEY via remoteEnv
  • Modern CLI tools: ripgrep, fd, fzf, tmux, git-delta

Language-Specific Sections

Python Projects

Detection: pyproject.toml, requirements.txt, setup.py, or *.py files

Dockerfile additions:

The base Dockerfile already includes Python 3.13 via uv. If a different version is required (detected from pyproject.toml), modify the Python installation:

# Install Python via uv (fast binary download, not source compilation)
RUN uv python install <version> --default

devcontainer.json extensions:

Add to customizations.vscode.extensions:

"ms-python.python",
"ms-python.vscode-pylance",
"charliermarsh.ruff"

Add to customizations.vscode.settings:

"python.defaultInterpreterPath": ".venv/bin/python",
"[python]": {
  "editor.defaultFormatter": "charliermarsh.ruff",
  "editor.codeActionsOnSave": {
    "source.organizeImports": "explicit"
  }
}

postCreateCommand: If pyproject.toml exists, chain commands:

rm -rf .venv && uv sync && uv run /opt/post_install.py

Node/TypeScript Projects

Detection: package.json or tsconfig.json

No Dockerfile additions needed: The base template includes Node 22 via fnm (Fast Node Manager).

devcontainer.json extensions:

Add to customizations.vscode.extensions:

"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode"

Add to customizations.vscode.settings:

"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.codeActionsOnSave": {
  "source.fixAll.eslint": "explicit"
}

postCreateCommand: Detect package manager from lockfile and chain with base command:

  • pnpm-lock.yaml → uv run /opt/post_install.py && pnpm install --frozen-lockfile
  • yarn.lock → uv run /opt/post_install.py && yarn install --frozen-lockfile
  • package-lock.json → uv run /opt/post_install.py && npm ci
  • No lockfile → uv run /opt/post_install.py && npm install

Rust Projects

Detection: Cargo.toml

Features to add:

"ghcr.io/devcontainers/features/rust:1": {}

devcontainer.json extensions:

Add to customizations.vscode.extensions:

"rust-lang.rust-analyzer",
"tamasfe.even-better-toml"

Add to customizations.vscode.settings:

"[rust]": {
  "editor.defaultFormatter": "rust-lang.rust-analyzer"
}

postCreateCommand: If Cargo.lock exists, use locked builds:

uv run /opt/post_install.py && cargo build --locked

If no lockfile, use standard build:

uv run /opt/post_install.py && cargo build

Go Projects

Detection: go.mod

Features to add:

"ghcr.io/devcontainers/features/go:1": {
  "version": "latest"
}

devcontainer.json extensions:

Add to customizations.vscode.extensions:

"golang.go"

Add to customizations.vscode.settings:

"[go]": {
  "editor.defaultFormatter": "golang.go"
},
"go.useLanguageServer": true

postCreateCommand:

uv run /opt/post_install.py && go mod download

Reference Material

For additional guidance, see:

  • references/dockerfile-best-practices.md - Layer optimization, multi-stage builds, architecture support
  • references/features-vs-dockerfile.md - When to use devcontainer features vs custom Dockerfile

Adding Persistent Volumes

Pattern for new mounts in devcontainer.json:

"mounts": [
  "source={{PROJECT_SLUG}}-<purpose>-${devcontainerId},target=<container-path>,type=volume"
]

Common additions:

  • source={{PROJECT_SLUG}}-cargo-${devcontainerId},target=/home/vscode/.cargo,type=volume (Rust)
  • source={{PROJECT_SLUG}}-go-${devcontainerId},target=/home/vscode/go,type=volume (Go)

Output Files

Generate these files in the project's .devcontainer/ directory:

  1. Dockerfile - Container build instructions
  2. devcontainer.json - VS Code/devcontainer configuration
  3. post_install.py - Post-creation setup script
  4. .zshrc - Shell configuration
  5. install.sh - CLI helper for managing the devcontainer (devc command)

Validation Checklist

Before presenting files to the user, verify:

  1. All {{PROJECT_NAME}} placeholders are replaced with the human-readable name
  2. All {{PROJECT_SLUG}} placeholders are replaced with the slugified name
  3. JSON syntax is valid in devcontainer.json (no trailing commas, proper nesting)
  4. Language-specific extensions are added for all detected languages
  5. postCreateCommand includes all required setup commands (chained with &&)

User Instructions

After generating, inform the user:

  1. How to start: "Open in VS Code and select 'Reopen in Container'"
  2. Alternative: devcontainer up --workspace-folder .
  3. CLI helper: Run .devcontainer/install.sh self-install to add the devc command to PATH
Files (skills)
  • agents
    • openai.yaml 239 B
      interface:
        display_name: "Devcontainer Setup"
        short_description: "Set up isolated development containers and tooling"
        icon_small: "assets/trail-of-bits-mark.svg"
        icon_large: "assets/trail-of-bits-mark.svg"
        brand_color: "#D83A34"
      
  • assets
    • trail-of-bits-mark.svg 3 KB · in bundle
  • references
    • dockerfile-best-practices.md 3.3 KB
      # Dockerfile Best Practices
      
      ## Quick Reference
      
      | Practice | Why |
      |----------|-----|
      | Order by change frequency | Rarely-changing layers first (base, system packages), frequently-changing last |
      | Combine related RUN commands | Reduces layers and ensures cache coherence |
      | Clean up in same layer | Don't leave apt cache in a layer |
      | Use multi-stage builds | Separate build dependencies from runtime, reduce final image size |
      | Pin versions with digests | Supply chain security: `FROM alpine:3.21@sha256:abc123...` |
      | Switch to non-root user last | Do root operations first, then `USER vscode` |
      | Use COPY over ADD | ADD has extra features you usually don't need |
      | Use .dockerignore | Exclude build-irrelevant files to reduce context size |
      
      ## Base Image Selection
      
      Choose minimal, trusted base images:
      - **Docker Official Images** - curated, documented, regularly updated
      - **Alpine Linux** - under 6 MB, tightly controlled
      - **Verified Publisher** or **Docker-Sponsored Open Source** images
      
      Pin images to specific digests for reproducible builds:
      ```dockerfile
      FROM alpine:3.21@sha256:a8560b36e8b8210634f77d9f7f9efd7ffa463e380b75e2e74aff4511df3ef88c
      ```
      
      Avoid `latest` tag - it can change unexpectedly and cause breaking builds.
      
      ## apt-get Best Practices
      
      Always combine `update` with `install` in the same RUN statement:
      
      ```dockerfile
      RUN apt-get update && apt-get install -y --no-install-recommends \
          curl \
          git \
          vim \
          && rm -rf /var/lib/apt/lists/*
      ```
      
      **Why combine?** Keeping them separate causes Docker to cache the `update` layer, potentially installing outdated packages on subsequent builds.
      
      **Best practices:**
      - Use `--no-install-recommends` to minimize installed packages
      - Sort packages alphabetically within each section for easier maintenance and PR reviews
      - Clean up with `rm -rf /var/lib/apt/lists/*` in the same layer
      
      ## Pipe Safety
      
      When using pipes, prepend `set -o pipefail &&` to fail if any command fails:
      
      ```dockerfile
      RUN set -o pipefail && curl -fsSL https://example.com/install.sh | bash
      ```
      
      Without this, a failed `curl` would be masked by a successful `bash`.
      
      ## Environment Variables
      
      Use `ENV` for paths, versions, and configuration:
      
      ```dockerfile
      ENV PYTHON_VERSION=3.13
      ENV PATH=/home/vscode/.local/bin:$PATH
      ```
      
      Note: `ENV` instructions add metadata, not filesystem layers like `RUN`. Multiple separate `ENV` lines are fine and often more readable than combining them.
      
      ## WORKDIR
      
      Always use absolute paths. Avoid `RUN cd ... && command` patterns:
      
      ```dockerfile
      # Good
      WORKDIR /app
      RUN make install
      
      # Bad
      RUN cd /app && make install
      ```
      
      ## Architecture Support
      
      The templates support both AMD64 and ARM64 (Apple Silicon) automatically. Use `TARGETARCH` build arg for architecture-specific downloads:
      
      ```dockerfile
      ARG TARGETARCH
      RUN curl -fsSL "https://example.com/tool-${TARGETARCH}.tar.gz" | tar xz
      ```
      
      ## Devcontainer-Specific Tips
      
      **Resource allocation:** Docker Desktop has limited defaults. Increase CPU/Memory in Docker settings for resource-intensive builds.
      **Windows/WSL2:** Use Docker Desktop's WSL 2 backend for better file sharing performance.
      
      ## Sources
      
      - [Docker Build Best Practices](https://docs.docker.com/build/building/best-practices/)
      - [VS Code Dev Containers Tips](https://code.visualstudio.com/docs/devcontainers/tips-and-tricks)
      
    • features-vs-dockerfile.md 675 B
      # Features vs Dockerfile
      
      ## Use devcontainer features when:
      
      - Installing standard development tools (GitHub CLI, languages, etc.)
      - The feature does what you need out of the box
      - You want automatic updates with feature version bumps
      
      ## Use Dockerfile when:
      
      - Installing specific versions of tools
      - Custom configuration is needed
      - Combining multiple tools in optimized layers
      - The feature doesn't exist or is poorly maintained
      
      ## Example: Python
      
      For Python, we use Dockerfile + uv instead of the Python feature because:
      
      1. uv installs Python binaries instantly (vs compiling from source)
      2. We get uv for dependency management
      3. More control over the installation
      
  • resources
    • .zshrc 1.8 KB · in bundle
    • devcontainer.json 2.5 KB
      {
        "$schema": "https://raw.githubusercontent.com/devcontainers/spec/main/schemas/devContainer.schema.json",
        "name": "{{PROJECT_NAME}}",
        "build": {
          "dockerfile": "Dockerfile",
          "args": {
            "TZ": "${localEnv:TZ:UTC}",
            "GIT_DELTA_VERSION": "0.18.2",
            "ZSH_IN_DOCKER_VERSION": "1.2.1"
          }
        },
        "features": {
          "ghcr.io/devcontainers/features/github-cli:1": {}
        },
        "runArgs": [
          "--cap-add=NET_ADMIN",
          "--cap-add=NET_RAW"
        ],
        "init": true,
        "updateRemoteUserUID": true,
        "customizations": {
          "vscode": {
            "extensions": [
              "anthropic.claude-code"
            ],
            "settings": {
              "terminal.integrated.defaultProfile.linux": "zsh",
              "terminal.integrated.profiles.linux": {
                "bash": {
                  "path": "bash",
                  "icon": "terminal-bash"
                },
                "zsh": {
                  "path": "zsh"
                }
              },
              "files.trimTrailingWhitespace": true,
              "files.insertFinalNewline": true,
              "files.trimFinalNewlines": true
            }
          }
        },
        "remoteUser": "vscode",
        "mounts": [
          "source={{PROJECT_SLUG}}-bashhistory-${devcontainerId},target=/commandhistory,type=volume",
          "source={{PROJECT_SLUG}}-config-${devcontainerId},target=/home/vscode/.claude,type=volume",
          "source={{PROJECT_SLUG}}-gh-${devcontainerId},target=/home/vscode/.config/gh,type=volume",
          "source=${localEnv:HOME}/.gitconfig,target=/home/vscode/.gitconfig,type=bind,readonly",
          "source=${localWorkspaceFolder}/.devcontainer,target=/workspace/.devcontainer,type=bind,readonly"
        ],
        "containerEnv": {
          "NODE_OPTIONS": "--max-old-space-size=4096",
          "CLAUDE_CONFIG_DIR": "/home/vscode/.claude",
          "POWERLEVEL9K_DISABLE_GITSTATUS": "true",
          "GIT_CONFIG_GLOBAL": "/home/vscode/.gitconfig.local",
          "UV_LINK_MODE": "copy",
          "NPM_CONFIG_IGNORE_SCRIPTS": "true",
          "NPM_CONFIG_AUDIT": "true",
          "NPM_CONFIG_FUND": "false",
          "NPM_CONFIG_SAVE_EXACT": "true",
          "NPM_CONFIG_UPDATE_NOTIFIER": "false",
          "NPM_CONFIG_MINIMUM_RELEASE_AGE": "1440",
          "PYTHONDONTWRITEBYTECODE": "1",
          "PIP_DISABLE_PIP_VERSION_CHECK": "1"
        },
        "remoteEnv": {
          "CLAUDE_CODE_OAUTH_TOKEN": "${localEnv:CLAUDE_CODE_OAUTH_TOKEN:}",
          "ANTHROPIC_API_KEY": "${localEnv:ANTHROPIC_API_KEY:}"
        },
        "initializeCommand": "test -f \"$HOME/.gitconfig\" || touch \"$HOME/.gitconfig\"",
        "workspaceMount": "source=${localWorkspaceFolder},target=/workspace,type=bind,consistency=delegated",
        "workspaceFolder": "/workspace",
        "postCreateCommand": "uv run --no-project /opt/post_install.py"
      }
      
    • Dockerfile 3.6 KB · in bundle
    • install.sh 22.6 KB
      #!/bin/bash
      set -euo pipefail
      
      # Claude Code Devcontainer CLI Helper
      # Provides the `devc` command for managing devcontainers
      
      # Resolve symlinks to get actual script location
      SOURCE="${BASH_SOURCE[0]}"
      while [[ -L "$SOURCE" ]]; do
        DIR="$(cd "$(dirname "$SOURCE")" && pwd)"
        SOURCE="$(readlink "$SOURCE")"
        [[ $SOURCE != /* ]] && SOURCE="$DIR/$SOURCE"
      done
      SCRIPT_DIR="$(cd "$(dirname "$SOURCE")" && pwd)"
      SCRIPT_NAME="$(basename "$0")"
      
      # Colors for output
      RED='\033[0;31m'
      GREEN='\033[0;32m'
      YELLOW='\033[1;33m'
      BLUE='\033[0;34m'
      NC='\033[0m' # No Color
      
      print_usage() {
        cat <<EOF
      Usage: devc <command> [options]
      
      Commands:
          .                   Install devcontainer template to current directory and start
          up                  Start the devcontainer in current directory
          rebuild             Rebuild the devcontainer (preserves auth volumes)
          down                Stop the devcontainer
          shell               Open a shell in the running container
          self-install        Install 'devc' command to ~/.local/bin
          update              Update devc to the latest version
          template [dir]      Copy devcontainer template to directory (default: current)
          exec <cmd>          Execute a command in the running container
          upgrade             Upgrade Claude Code to latest version
          mount <host> <cont> Add a mount to the devcontainer (recreates container)
          sync [project] [--trusted]  Sync sessions from devcontainers to host
          cp <cont> <host>    Copy files/directories from container to host
          destroy [-f]        Remove container, volumes, and image for current project
          help                Show this help message
      
      Examples:
          devc .                      # Install template and start container
          devc up                     # Start container in current directory
          devc rebuild                # Clean rebuild
          devc shell                  # Open interactive shell
          devc self-install           # Install devc to PATH
          devc update                 # Update to latest version
          devc exec ls -la            # Run command in container
          devc upgrade                # Upgrade Claude Code to latest
          devc mount ~/data /data     # Add mount to container
          devc sync                   # Sync sessions from all devcontainers
          devc sync crypto            # Sync only matching devcontainer
          devc cp /some/file ./out    # Copy a path from container to host
          devc destroy                # Remove all project Docker resources
          devc destroy -f             # Skip confirmation prompt
      EOF
      }
      
      log_info() {
        echo -e "${BLUE}[devc]${NC} $1"
      }
      
      log_success() {
        echo -e "${GREEN}[devc]${NC} $1"
      }
      
      log_warn() {
        echo -e "${YELLOW}[devc]${NC} $1"
      }
      
      log_error() {
        echo -e "${RED}[devc]${NC} $1" >&2
      }
      
      check_devcontainer_cli() {
        if ! command -v devcontainer &>/dev/null; then
          log_error "devcontainer CLI not found."
          log_info "Install it with: npm install -g @devcontainers/cli"
          exit 1
        fi
      }
      
      check_no_sys_admin() {
        local workspace="${1:-.}"
        local dc_json="$workspace/.devcontainer/devcontainer.json"
        [[ -f "$dc_json" ]] || return 0
        if jq -e \
          '.runArgs[]? | select(test("SYS_ADMIN"))' \
          "$dc_json" >/dev/null 2>&1; then
          log_error "SYS_ADMIN capability detected in runArgs."
          log_error "This defeats the read-only .devcontainer mount."
          exit 1
        fi
      }
      
      get_workspace_folder() {
        echo "${1:-$(pwd)}"
      }
      
      # Extract custom mounts from devcontainer.json to a temp file
      # Returns the temp file path, or empty string if no custom mounts
      #
      # Security: .devcontainer/ is mounted read-only inside the container to prevent
      # a compromised process from injecting malicious mounts or commands into
      # devcontainer.json that execute on the host during rebuild. This protection
      # requires that SYS_ADMIN is never added to runArgs (it would allow remounting
      # read-write).
      extract_mounts_to_file() {
        local devcontainer_json="$1"
        local temp_file
      
        [[ -f "$devcontainer_json" ]] || return 0
      
        temp_file=$(mktemp)
      
        # Filter out default mounts by target path (immune to project name changes)
        local custom_mounts
        custom_mounts=$(jq -c '
          .mounts // [] | map(
            select(
              (contains("target=/commandhistory,") | not) and
              (contains("target=/home/vscode/.claude,") | not) and
              (contains("target=/home/vscode/.config/gh,") | not) and
              (contains("target=/home/vscode/.gitconfig,") | not) and
              (contains("target=/workspace/.devcontainer,") | not)
            )
          ) | if length > 0 then . else empty end
        ' "$devcontainer_json" 2>/dev/null) || true
      
        if [[ -n "$custom_mounts" ]]; then
          echo "$custom_mounts" >"$temp_file"
          echo "$temp_file"
        else
          rm -f "$temp_file"
        fi
      }
      
      # Merge preserved mounts back into devcontainer.json
      merge_mounts_from_file() {
        local devcontainer_json="$1"
        local mounts_file="$2"
      
        [[ -f "$mounts_file" ]] || return 0
        [[ -s "$mounts_file" ]] || return 0
      
        local custom_mounts
        custom_mounts=$(cat "$mounts_file")
      
        local updated
        updated=$(jq --argjson custom "$custom_mounts" '
          .mounts = ((.mounts // []) + $custom | unique)
        ' "$devcontainer_json")
      
        echo "$updated" >"$devcontainer_json"
      }
      
      # Add or update a mount in devcontainer.json
      update_devcontainer_mounts() {
        local devcontainer_json="$1"
        local host_path="$2"
        local container_path="$3"
        local readonly="${4:-false}"
      
        local mount_str="source=${host_path},target=${container_path},type=bind"
        [[ "$readonly" == "true" ]] && mount_str="${mount_str},readonly"
      
        local updated
        updated=$(jq --arg target "$container_path" --arg mount "$mount_str" '
          .mounts = (
            ((.mounts // []) | map(select(contains("target=" + $target + ",") or endswith("target=" + $target) | not)))
            + [$mount]
          )
        ' "$devcontainer_json")
      
        echo "$updated" >"$devcontainer_json"
      }
      
      cmd_template() {
        local target_dir="${1:-.}"
        target_dir="$(cd "$target_dir" 2>/dev/null && pwd)" || {
          log_error "Directory does not exist: $1"
          exit 1
        }
      
        local devcontainer_dir="$target_dir/.devcontainer"
        local devcontainer_json="$devcontainer_dir/devcontainer.json"
        local preserved_mounts=""
      
        if [[ -d "$devcontainer_dir" ]]; then
          log_warn "Devcontainer already exists at $devcontainer_dir"
          read -p "Overwrite? [y/N] " -n 1 -r
          echo
          if [[ ! $REPLY =~ ^[Yy]$ ]]; then
            log_info "Aborted."
            exit 0
          fi
      
          # Preserve custom mounts before overwriting
          preserved_mounts=$(extract_mounts_to_file "$devcontainer_json")
          if [[ -n "$preserved_mounts" ]]; then
            log_info "Preserving custom mounts..."
          fi
        fi
      
        mkdir -p "$devcontainer_dir"
      
        # Copy template files
        cp "$SCRIPT_DIR/Dockerfile" "$devcontainer_dir/"
        cp "$SCRIPT_DIR/devcontainer.json" "$devcontainer_dir/"
        cp "$SCRIPT_DIR/post_install.py" "$devcontainer_dir/"
        cp "$SCRIPT_DIR/.zshrc" "$devcontainer_dir/"
      
        # Restore preserved mounts
        if [[ -n "$preserved_mounts" ]]; then
          merge_mounts_from_file "$devcontainer_json" "$preserved_mounts"
          rm -f "$preserved_mounts"
          log_info "Custom mounts restored"
        fi
      
        log_success "Template installed to $devcontainer_dir"
      }
      
      cmd_up() {
        local workspace_folder
        workspace_folder="$(get_workspace_folder "${1:-}")"
      
        check_devcontainer_cli
        check_no_sys_admin "$workspace_folder"
        log_info "Starting devcontainer in $workspace_folder..."
      
        devcontainer up --workspace-folder "$workspace_folder"
        log_success "Devcontainer started"
      }
      
      cmd_rebuild() {
        local workspace_folder
        workspace_folder="$(get_workspace_folder "${1:-}")"
      
        check_devcontainer_cli
        check_no_sys_admin "$workspace_folder"
        log_info "Rebuilding devcontainer in $workspace_folder..."
      
        devcontainer up \
          --workspace-folder "$workspace_folder" \
          --remove-existing-container
        log_success "Devcontainer rebuilt"
      }
      
      cmd_down() {
        local workspace_folder
        workspace_folder="$(get_workspace_folder "${1:-}")"
      
        check_devcontainer_cli
        log_info "Stopping devcontainer..."
      
        # Get container ID and stop it
        local container_id
        local label="devcontainer.local_folder=$workspace_folder"
        container_id=$(docker ps -q --filter "label=$label" 2>/dev/null || true)
      
        if [[ -n "$container_id" ]]; then
          docker stop "$container_id"
          log_success "Devcontainer stopped"
        else
          log_warn "No running devcontainer found for $workspace_folder"
        fi
      }
      
      cmd_shell() {
        local workspace_folder
        workspace_folder="$(get_workspace_folder)"
      
        check_devcontainer_cli
        log_info "Opening shell in devcontainer..."
      
        devcontainer exec --workspace-folder "$workspace_folder" zsh
      }
      
      cmd_exec() {
        local workspace_folder
        workspace_folder="$(get_workspace_folder)"
      
        check_devcontainer_cli
        devcontainer exec --workspace-folder "$workspace_folder" "$@"
      }
      
      cmd_upgrade() {
        local workspace_folder
        workspace_folder="$(get_workspace_folder)"
      
        check_devcontainer_cli
        log_info "Upgrading Claude Code..."
      
        devcontainer exec --workspace-folder "$workspace_folder" claude update
      
        log_success "Claude Code upgraded"
      }
      
      cmd_mount() {
        local host_path="${1:-}"
        local container_path="${2:-}"
        local readonly="false"
      
        if [[ -z "$host_path" ]] || [[ -z "$container_path" ]]; then
          log_error "Usage: devc mount <host_path> <container_path> [--readonly]"
          exit 1
        fi
      
        [[ "${3:-}" == "--readonly" ]] && readonly="true"
      
        # Expand and validate host path
        host_path="$(cd "$host_path" 2>/dev/null && pwd)" || {
          log_error "Host path does not exist: $1"
          exit 1
        }
      
        local workspace_folder
        workspace_folder="$(get_workspace_folder)"
        local devcontainer_json="$workspace_folder/.devcontainer/devcontainer.json"
      
        if [[ ! -f "$devcontainer_json" ]]; then
          log_error "No devcontainer.json found. Run 'devc template' first."
          exit 1
        fi
      
        check_devcontainer_cli
      
        log_info "Adding mount: $host_path → $container_path"
        update_devcontainer_mounts "$devcontainer_json" "$host_path" "$container_path" "$readonly"
      
        log_info "Recreating container with new mount..."
        devcontainer up \
          --workspace-folder "$workspace_folder" \
          --remove-existing-container
      
        log_success "Mount added: $host_path → $container_path"
      }
      
      cmd_sync() {
        local filter=""
        local trusted=false
      
        while [[ $# -gt 0 ]]; do
          case "$1" in
            --trusted)
              trusted=true
              shift
              ;;
            *)
              filter="$1"
              shift
              ;;
          esac
        done
      
        local host_projects="${HOME}/.claude/projects"
      
        if [[ "$trusted" == false ]]; then
          log_warn "This copies files from devcontainers to your host filesystem."
          log_warn "Only proceed if you trust the container contents."
          log_info "Use --trusted to skip this prompt."
          read -p "Continue? [y/N] " -n 1 -r
          echo
          if [[ ! $REPLY =~ ^[Yy]$ ]]; then
            log_info "Aborted."
            exit 0
          fi
        fi
      
        # Discover all devcontainers (running + stopped) by label.
        local container_ids
        container_ids=$(docker ps -a -q \
          --filter "label=devcontainer.local_folder" 2>/dev/null || true)
      
        if [[ -z "$container_ids" ]]; then
          log_error "No devcontainers found (running or stopped)."
          exit 1
        fi
      
        # List discovered devcontainers.
        log_info "Discovered devcontainers:"
        local matched_any=false
        while IFS= read -r cid; do
          local name folder status
          name=$(sync_get_project_name "$cid")
          folder=$(docker inspect --format \
            '{{index .Config.Labels "devcontainer.local_folder"}}' "$cid")
          status=$(docker inspect --format '{{.State.Status}}' "$cid")
      
          if [[ -n "$filter" ]]; then
            if ! echo "$name" | grep -qi "$filter"; then
              continue
            fi
          fi
      
          matched_any=true
          echo "  - ${name} (${status}) ${folder}"
        done <<<"$container_ids"
      
        if [[ "$matched_any" == false ]]; then
          log_error "No devcontainers matching '${filter}'."
          echo ""
          echo "Available:"
          while IFS= read -r cid; do
            local name status
            name=$(sync_get_project_name "$cid")
            status=$(docker inspect --format '{{.State.Status}}' "$cid")
            echo "  - ${name} (${status})"
          done <<<"$container_ids"
          exit 1
        fi
      
        echo ""
      
        # Sync matching containers.
        while IFS= read -r cid; do
          local name
          name=$(sync_get_project_name "$cid")
      
          if [[ -n "$filter" ]]; then
            if ! echo "$name" | grep -qi "$filter"; then
              continue
            fi
          fi
      
          sync_one_container "$cid" "$host_projects"
          echo ""
        done <<<"$container_ids"
      
        log_success "Run '/insights' in Claude Code to include these sessions."
      }
      
      # Extract project name from devcontainer.local_folder label.
      sync_get_project_name() {
        local folder
        folder=$(docker inspect --format \
          '{{index .Config.Labels "devcontainer.local_folder"}}' "$1")
        basename "$folder"
      }
      
      # Resolve the Claude projects dir inside a container without
      # docker exec (works on stopped containers too).
      # Reads CLAUDE_CONFIG_DIR from container env, falls back to
      # /home/<user>/.claude.
      sync_get_claude_projects_dir() {
        local cid="$1"
        local claude_dir
      
        claude_dir=$(docker inspect --format '{{json .Config.Env}}' "$cid" |
          tr ',' '\n' | tr -d '[]"' |
          grep '^CLAUDE_CONFIG_DIR=' |
          cut -d= -f2- || true)
      
        if [[ -n "$claude_dir" ]]; then
          echo "${claude_dir}/projects"
          return
        fi
      
        local user
        user=$(docker inspect --format '{{.Config.User}}' "$cid")
        if [[ -z "$user" || "$user" == "root" ]]; then
          echo "/root/.claude/projects"
        else
          echo "/home/${user}/.claude/projects"
        fi
      }
      
      sync_one_container() {
        local cid="$1"
        local host_projects="$2"
        local project_name status claude_dir folder
      
        project_name=$(sync_get_project_name "$cid")
        folder=$(docker inspect --format \
          '{{index .Config.Labels "devcontainer.local_folder"}}' "$cid")
        status=$(docker inspect --format '{{.State.Status}}' "$cid")
        claude_dir=$(sync_get_claude_projects_dir "$cid")
      
        log_info "=== ${project_name} (${status}) ==="
        echo "  Host path:  ${folder}"
        echo "  Container:  ${cid:0:12}"
      
        # docker cp works on both running and stopped containers.
        local tmpdir
        tmpdir=$(mktemp -d)
      
        if ! docker cp "${cid}:${claude_dir}/." "$tmpdir/" 2>/dev/null; then
          echo "  No sessions found, skipping."
          rm -rf "$tmpdir"
          return 0
        fi
      
        local session_count
        session_count=$(find "$tmpdir" -name '*.jsonl' | wc -l | tr -d ' ')
      
        if [[ "$session_count" -eq 0 ]]; then
          echo "  No sessions found, skipping."
          rm -rf "$tmpdir"
          return 0
        fi
      
        echo "  Sessions:   ${session_count}"
      
        local total_copied=0
      
        # Sync each project key subdirectory.
        for key_path in "$tmpdir"/*/; do
          [[ ! -d "$key_path" ]] && continue
          local key dest_key
          key=$(basename "$key_path")
      
          if [[ "$key" == "-workspace" ]]; then
            dest_key="-devcontainer-${project_name}"
          else
            dest_key="${key}"
          fi
      
          local dest_dir="${host_projects}/${dest_key}"
          mkdir -p "$dest_dir"
      
          local copied=0
          while IFS= read -r -d '' file; do
            local rel="${file#"$key_path"}"
            local dest_file="${dest_dir}/${rel}"
            mkdir -p "$(dirname "$dest_file")"
      
            if [[ ! -e "$dest_file" ]] ||
              [[ "$file" -nt "$dest_file" ]]; then
              cp -p "$file" "$dest_file"
              copied=$((copied + 1))
            fi
          done < <(find "$key_path" -type f -print0)
      
          if [[ "$copied" -gt 0 ]]; then
            echo "  Synced ${copied} file(s) -> ${dest_key}"
          fi
          total_copied=$((total_copied + copied))
        done
      
        # Handle .jsonl files directly in projects/ (no subdirectory).
        local orphan_copied=0
        local dest_dir="${host_projects}/-devcontainer-${project_name}"
        mkdir -p "$dest_dir"
      
        while IFS= read -r -d '' file; do
          local name
          name=$(basename "$file")
          local dest_file="${dest_dir}/${name}"
      
          if [[ ! -e "$dest_file" ]] ||
            [[ "$file" -nt "$dest_file" ]]; then
            cp -p "$file" "$dest_file"
            orphan_copied=$((orphan_copied + 1))
          fi
        done < <(find "$tmpdir" -maxdepth 1 -name '*.jsonl' -print0)
      
        if [[ "$orphan_copied" -gt 0 ]]; then
          echo "  Synced ${orphan_copied} file(s) -> -devcontainer-${project_name}"
          total_copied=$((total_copied + orphan_copied))
        fi
      
        rm -rf "$tmpdir"
      
        echo "  Total: ${total_copied} file(s) synced."
      }
      
      cmd_cp() {
        local container_path="${1:-}"
        local host_path="${2:-}"
      
        if [[ -z "$container_path" ]] || [[ -z "$host_path" ]]; then
          log_error "Usage: devc cp <container_path> <host_path>"
          exit 1
        fi
      
        local workspace_folder
        workspace_folder="$(get_workspace_folder)"
      
        # Find the running container
        local label="devcontainer.local_folder=$workspace_folder"
        local container_id
        container_id=$(docker ps -q --filter "label=$label" 2>/dev/null || true)
      
        if [[ -z "$container_id" ]]; then
          log_error "No running devcontainer found for $workspace_folder"
          exit 1
        fi
      
        log_info "Copying $container_path → $host_path"
        docker cp "$container_id:$container_path" "$host_path"
        log_success "Copied $container_path → $host_path"
      }
      
      # Discovers all Docker resources associated with the current workspace.
      # Sets global variables: CONTAINER_ID, CONTAINER_STATUS, VOLUMES (array), IMAGE, IMAGE_UID
      discover_resources() {
        local workspace_folder="$1"
        local label="devcontainer.local_folder=$workspace_folder"
      
        CONTAINER_ID=""
        CONTAINER_STATUS=""
        VOLUMES=()
        IMAGE=""
        IMAGE_UID=""
      
        # Find container (any state: running, stopped, created, etc.)
        CONTAINER_ID=$(docker ps -aq --filter "label=$label" 2>/dev/null | head -1)
      
        if [[ -z "$CONTAINER_ID" ]]; then
          return 0
        fi
      
        # Get container status
        CONTAINER_STATUS=$(docker inspect "$CONTAINER_ID" --format '{{.State.Status}}' 2>/dev/null || true)
      
        # Get volumes (docker volumes only, not bind mounts)
        while IFS= read -r vol; do
          [[ -n "$vol" ]] && VOLUMES+=("$vol")
        done < <(docker inspect "$CONTAINER_ID" --format '{{json .Mounts}}' 2>/dev/null |
          jq -r '.[] | select(.Type == "volume") | .Name' 2>/dev/null)
      
        # Get image and its -uid variant
        IMAGE=$(docker inspect "$CONTAINER_ID" --format '{{.Config.Image}}' 2>/dev/null || true)
        if [[ -n "$IMAGE" ]]; then
          if [[ "$IMAGE" == *-uid ]]; then
            IMAGE_UID="$IMAGE"
            IMAGE="${IMAGE%-uid}"
          else
            IMAGE_UID="${IMAGE}-uid"
          fi
        fi
      }
      
      print_destroy_summary() {
        echo ""
        log_warn "The following resources will be permanently removed:"
        echo ""
      
        if [[ -n "$CONTAINER_ID" ]]; then
          local container_name
          container_name=$(docker inspect "$CONTAINER_ID" --format '{{.Name}}' 2>/dev/null | sed 's|^/||')
          echo "  Container:  ${container_name:-$CONTAINER_ID}"
          if [[ "$CONTAINER_STATUS" == "running" ]]; then
            echo "              (currently running -- will be force-stopped)"
          fi
        fi
      
        if [[ ${#VOLUMES[@]} -gt 0 ]]; then
          echo "  Volumes:"
          for vol in "${VOLUMES[@]}"; do
            echo "              $vol"
          done
        fi
      
        if [[ -n "$IMAGE" ]]; then
          echo "  Image:      $IMAGE"
          if docker image inspect "$IMAGE_UID" &>/dev/null; then
            echo "              $IMAGE_UID"
          fi
        fi
      
        echo ""
      }
      
      cmd_destroy() {
        local force=false
      
        # Parse flags
        while [[ $# -gt 0 ]]; do
          case "$1" in
            -f | --force)
              force=true
              shift
              ;;
            *)
              break
              ;;
          esac
        done
      
        local workspace_folder
        workspace_folder="$(get_workspace_folder "${1:-}")"
      
        discover_resources "$workspace_folder"
      
        # No resources found (idempotent behavior)
        if [[ -z "$CONTAINER_ID" ]]; then
          log_info "No devcontainer found for $workspace_folder"
          return 0
        fi
      
        print_destroy_summary
      
        # Running container warning
        if [[ "$CONTAINER_STATUS" == "running" && "$force" != true ]]; then
          log_warn "Container is currently running!"
          read -p "Force-stop the running container? [y/N] " -n 1 -r
          echo
          if [[ ! $REPLY =~ ^[Yy]$ ]]; then
            log_info "Aborted."
            return 0
          fi
        fi
      
        # Main confirmation prompt
        if [[ "$force" != true ]]; then
          read -p "Destroy these resources? [y/N] " -n 1 -r
          echo
          if [[ ! $REPLY =~ ^[Yy]$ ]]; then
            log_info "Aborted."
            return 0
          fi
        fi
      
        # Deletion, in order: stop, remove container, volumes, images
        if [[ -n "$CONTAINER_ID" && "$CONTAINER_STATUS" == "running" ]]; then
          log_info "Stopping container..."
          docker stop "$CONTAINER_ID" >/dev/null 2>&1 || true
        fi
      
        if [[ -n "$CONTAINER_ID" ]]; then
          log_info "Removing container..."
          docker rm -f "$CONTAINER_ID" >/dev/null 2>&1 || true
        fi
      
        for vol in "${VOLUMES[@]}"; do
          log_info "Removing volume: $vol"
          docker volume rm -f "$vol" >/dev/null 2>&1 || true
        done
      
        if [[ -n "$IMAGE" ]]; then
          log_info "Removing image: $IMAGE"
          docker rmi -f "$IMAGE" >/dev/null 2>&1 || true
          if docker image inspect "$IMAGE_UID" &>/dev/null 2>&1; then
            log_info "Removing image: $IMAGE_UID"
            docker rmi -f "$IMAGE_UID" >/dev/null 2>&1 || true
          fi
        fi
      
        log_success "All resources destroyed for $workspace_folder"
      }
      
      cmd_self_install() {
        local install_dir="$HOME/.local/bin"
        local install_path="$install_dir/devc"
      
        mkdir -p "$install_dir"
      
        # Create a symlink to the original script
        ln -sf "$SCRIPT_DIR/$SCRIPT_NAME" "$install_path"
      
        log_success "Installed 'devc' to $install_path"
      
        # Check if in PATH
        if [[ ":$PATH:" != *":$install_dir:"* ]]; then
          log_warn "$install_dir is not in your PATH"
          log_info "Add this to your shell profile:"
          echo "    export PATH=\"\$HOME/.local/bin:\$PATH\""
        fi
      }
      
      cmd_update() {
        log_info "Updating devc..."
      
        if ! git -C "$SCRIPT_DIR" rev-parse --is-inside-work-tree &>/dev/null; then
          log_error "Not a git repository: $SCRIPT_DIR"
          log_info "Re-clone with: rm -rf ~/.claude-devcontainer && git clone https://github.com/trailofbits/claude-code-devcontainer ~/.claude-devcontainer"
          exit 1
        fi
      
        local before_sha after_sha
        before_sha=$(git -C "$SCRIPT_DIR" rev-parse HEAD)
      
        if ! git -C "$SCRIPT_DIR" pull --ff-only; then
          log_error "Update failed. Try: cd $SCRIPT_DIR && git pull"
          exit 1
        fi
      
        after_sha=$(git -C "$SCRIPT_DIR" rev-parse HEAD)
      
        if [[ "$before_sha" == "$after_sha" ]]; then
          log_success "Already up to date"
        else
          log_success "Updated from ${before_sha:0:7} to ${after_sha:0:7}"
        fi
      }
      
      cmd_dot() {
        # Install template and start container in one command
        cmd_template "."
        cmd_up "."
      }
      
      # Main command dispatcher
      main() {
        if [[ $# -eq 0 ]]; then
          print_usage
          exit 1
        fi
      
        local command="$1"
        shift
      
        case "$command" in
          .)
            cmd_dot
            ;;
          up)
            cmd_up "$@"
            ;;
          rebuild)
            cmd_rebuild "$@"
            ;;
          down)
            cmd_down "$@"
            ;;
          destroy)
            cmd_destroy "$@"
            ;;
          shell)
            cmd_shell
            ;;
          exec)
            [[ "${1:-}" == "--" ]] && shift
            cmd_exec "$@"
            ;;
          upgrade)
            cmd_upgrade
            ;;
          mount)
            cmd_mount "$@"
            ;;
          sync)
            cmd_sync "$@"
            ;;
          cp)
            cmd_cp "$@"
            ;;
          self-install)
            cmd_self_install
            ;;
          update)
            cmd_update
            ;;
          template)
            cmd_template "$@"
            ;;
          help | --help | -h)
            print_usage
            ;;
          *)
            log_error "Unknown command: $command"
            print_usage
            exit 1
            ;;
        esac
      }
      
      main "$@"
      
    • post_install.py 8.4 KB
      #!/usr/bin/env python3
      """Post-install configuration for Claude Code devcontainer.
      
      Runs on container creation to set up:
      - Onboarding bypass (when CLAUDE_CODE_OAUTH_TOKEN is set)
      - Claude settings (bypassPermissions mode)
      - Tmux configuration (200k history, mouse support)
      - Directory ownership fixes for mounted volumes
      """
      
      import contextlib
      import json
      import os
      import subprocess
      import sys
      from pathlib import Path
      
      
      def setup_onboarding_bypass():
          """Bypass the interactive onboarding wizard when CLAUDE_CODE_OAUTH_TOKEN is set.
      
          Runs `claude -p` to seed ~/.claude.json with auth state. The subprocess
          writes the config file during startup before the API call completes, so
          a timeout is expected and acceptable. After the subprocess finishes (or
          times out), we check whether ~/.claude.json was populated and only then
          set hasCompletedOnboarding.
      
          Workaround for https://github.com/anthropics/claude-code/issues/8938.
          """
          token = os.environ.get("CLAUDE_CODE_OAUTH_TOKEN", "").strip()
          if not token:
              print(
                  "[post_install] No CLAUDE_CODE_OAUTH_TOKEN set, skipping onboarding bypass",
                  file=sys.stderr,
              )
              return
      
          # When `CLAUDE_CONFIG_DIR` is set, as is done in `devcontainer.json`,
          # `claude` unexpectedly looks for `.claude.json` in *that* folder,
          # instead of in `~`, contradicting the documentation.
          # See https://github.com/anthropics/claude-code/issues/3833#issuecomment-3694918874
          claude_json_dir = Path(os.environ.get("CLAUDE_CONFIG_DIR", Path.home()))
          claude_json = claude_json_dir / ".claude.json"
      
          print("[post_install] Running claude -p to populate auth state...", file=sys.stderr)
          try:
              result = subprocess.run(
                  ["claude", "-p", "ok"],
                  capture_output=True,
                  text=True,
                  timeout=30,
              )
              if result.returncode != 0:
                  print(
                      f"[post_install] claude -p exited {result.returncode}: {result.stderr.strip()}",
                      file=sys.stderr,
                  )
          except subprocess.TimeoutExpired:
              print(
                  "[post_install] claude -p timed out (expected on cold start)",
                  file=sys.stderr,
              )
          except (FileNotFoundError, OSError) as e:
              print(
                  f"[post_install] Warning: could not run claude ({e}) — onboarding bypass skipped",
                  file=sys.stderr,
              )
              return
      
          if not claude_json.exists():
              print(
                  f"[post_install] Warning: {claude_json} not created by claude -p — "
                  "onboarding bypass skipped",
                  file=sys.stderr,
              )
              return
      
          config: dict = {}
          try:
              config = json.loads(claude_json.read_text())
          except json.JSONDecodeError as e:
              print(
                  f"[post_install] Warning: {claude_json} has invalid JSON ({e}), starting fresh",
                  file=sys.stderr,
              )
      
          config["hasCompletedOnboarding"] = True
      
          claude_json.write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8")
          print(f"[post_install] Onboarding bypass configured: {claude_json}", file=sys.stderr)
      
      
      def setup_claude_settings():
          """Configure Claude Code with bypassPermissions enabled."""
          claude_dir = Path(os.environ.get("CLAUDE_CONFIG_DIR", Path.home() / ".claude"))
          claude_dir.mkdir(parents=True, exist_ok=True)
      
          settings_file = claude_dir / "settings.json"
      
          # Load existing settings or start fresh
          settings = {}
          if settings_file.exists():
              with contextlib.suppress(json.JSONDecodeError):
                  settings = json.loads(settings_file.read_text())
      
          # Set bypassPermissions mode
          if "permissions" not in settings:
              settings["permissions"] = {}
          settings["permissions"]["defaultMode"] = "bypassPermissions"
      
          settings_file.write_text(json.dumps(settings, indent=2) + "\n", encoding="utf-8")
          print(f"[post_install] Claude settings configured: {settings_file}", file=sys.stderr)
      
      
      def setup_tmux_config():
          """Configure tmux with 200k history, mouse support, and vi keys."""
          tmux_conf = Path.home() / ".tmux.conf"
      
          if tmux_conf.exists():
              print("[post_install] Tmux config exists, skipping", file=sys.stderr)
              return
      
          config = """\
      # 200k line scrollback history
      set-option -g history-limit 200000
      
      # Enable mouse support
      set -g mouse on
      
      # Use vi keys in copy mode
      setw -g mode-keys vi
      
      # Start windows and panes at 1, not 0
      set -g base-index 1
      setw -g pane-base-index 1
      
      # Renumber windows when one is closed
      set -g renumber-windows on
      
      # Faster escape time for vim
      set -sg escape-time 10
      
      # True color support
      set -g default-terminal "tmux-256color"
      set -ag terminal-overrides ",xterm-256color:RGB"
      
      # Terminal features (ghostty, cursor shape in vim)
      set -as terminal-features ",xterm-ghostty:RGB"
      set -as terminal-features ",xterm*:RGB"
      set -ga terminal-overrides ",xterm*:colors=256"
      set -ga terminal-overrides '*:Ss=\\E[%p1%d q:Se=\\E[ q'
      
      # Status bar
      set -g status-style 'bg=#333333 fg=#ffffff'
      set -g status-left '[#S] '
      set -g status-right '%Y-%m-%d %H:%M'
      """
          tmux_conf.write_text(config, encoding="utf-8")
          print(f"[post_install] Tmux configured: {tmux_conf}", file=sys.stderr)
      
      
      def fix_directory_ownership():
          """Fix ownership of mounted volumes that may have root ownership."""
          uid = os.getuid()
          gid = os.getgid()
      
          dirs_to_fix = [
              Path.home() / ".claude",
              Path("/commandhistory"),
              Path.home() / ".config" / "gh",
          ]
      
          for dir_path in dirs_to_fix:
              if dir_path.exists():
                  try:
                      # Use sudo to fix ownership if needed
                      stat_info = dir_path.stat()
                      if stat_info.st_uid != uid:
                          subprocess.run(
                              ["sudo", "chown", "-R", f"{uid}:{gid}", str(dir_path)],
                              check=True,
                              capture_output=True,
                          )
                          print(f"[post_install] Fixed ownership: {dir_path}", file=sys.stderr)
                  except (PermissionError, subprocess.CalledProcessError) as e:
                      print(
                          f"[post_install] Warning: Could not fix ownership of {dir_path}: {e}",
                          file=sys.stderr,
                      )
      
      
      def setup_global_gitignore():
          """Set up global gitignore and local git config.
      
          Since ~/.gitconfig is mounted read-only from host, we create a local
          config file that includes the host config and adds container-specific
          settings like core.excludesfile and delta configuration.
      
          GIT_CONFIG_GLOBAL env var (set in devcontainer.json) points git to this
          local config as the "global" config.
          """
          home = Path.home()
          gitignore = home / ".gitignore_global"
          local_gitconfig = home / ".gitconfig.local"
          host_gitconfig = home / ".gitconfig"
      
          # Create global gitignore with common patterns
          patterns = """\
      # Claude Code
      .claude/
      
      # macOS
      .DS_Store
      .AppleDouble
      .LSOverride
      ._*
      
      # Python
      *.pyc
      *.pyo
      __pycache__/
      *.egg-info/
      .eggs/
      *.egg
      .venv/
      venv/
      .mypy_cache/
      .ruff_cache/
      
      # Node
      node_modules/
      .npm/
      
      # Editors
      *.swp
      *.swo
      *~
      .idea/
      .vscode/
      *.sublime-*
      
      # Misc
      *.log
      .env.local
      .env.*.local
      """
          gitignore.write_text(patterns, encoding="utf-8")
          print(f"[post_install] Global gitignore created: {gitignore}", file=sys.stderr)
      
          # Create local git config that includes host config and sets excludesfile + delta
          # Delta config is included here so it works even if host doesn't have it configured
          local_config = f"""\
      # Container-local git config
      # Includes host config (mounted read-only) and adds container settings
      
      [include]
          path = {host_gitconfig}
      
      [core]
          excludesfile = {gitignore}
          pager = delta
      
      [interactive]
          diffFilter = delta --color-only
      
      [delta]
          navigate = true
          light = false
          line-numbers = true
          side-by-side = false
      
      [merge]
          conflictstyle = diff3
      
      [diff]
          colorMoved = default
      
      [gpg "ssh"]
          program = /usr/bin/ssh-keygen
      """
          local_gitconfig.write_text(local_config, encoding="utf-8")
          print(f"[post_install] Local git config created: {local_gitconfig}", file=sys.stderr)
      
      
      def main():
          """Run all post-install configuration."""
          print("[post_install] Starting post-install configuration...", file=sys.stderr)
      
          setup_onboarding_bypass()
          setup_claude_settings()
          setup_tmux_config()
          fix_directory_ownership()
          setup_global_gitignore()
      
          print("[post_install] Configuration complete!", file=sys.stderr)
      
      
      if __name__ == "__main__":
          main()
      
  • SKILL.md 7.9 KB
    ---
    name: devcontainer-setup
    description: Creates devcontainers with Claude Code, language-specific tooling (Python/Node/Rust/Go), and persistent volumes. Use when adding devcontainer support to a project, setting up isolated development environments, or configuring sandboxed Claude Code workspaces.
    ---
    
    # Devcontainer Setup Skill
    
    Creates a pre-configured devcontainer with Claude Code and language-specific tooling.
    
    ## When to Use
    
    - User asks to "set up a devcontainer" or "add devcontainer support"
    - User wants a sandboxed Claude Code development environment
    - User needs isolated development environments with persistent configuration
    
    ## When NOT to Use
    
    - User already has a devcontainer configuration and just needs modifications
    - User is asking about general Docker or container questions
    - User wants to deploy production containers (this is for development only)
    
    ## Workflow
    
    ```mermaid
    flowchart TB
        start([User requests devcontainer])
        recon[1. Project Reconnaissance]
        detect[2. Detect Languages]
        generate[3. Generate Configuration]
        write[4. Write files to .devcontainer/]
        done([Done])
    
        start --> recon
        recon --> detect
        detect --> generate
        generate --> write
        write --> done
    ```
    
    ## Phase 1: Project Reconnaissance
    
    ### Infer Project Name
    
    Check in order (use first match):
    
    1. `package.json` → `name` field
    2. `pyproject.toml` → `project.name`
    3. `Cargo.toml` → `package.name`
    4. `go.mod` → module path (last segment after `/`)
    5. Directory name as fallback
    
    Convert to slug: lowercase, replace spaces/underscores with hyphens.
    
    ### Detect Language Stack
    
    | Language | Detection Files |
    |----------|-----------------|
    | Python | `pyproject.toml`, `*.py` |
    | Node/TypeScript | `package.json`, `tsconfig.json` |
    | Rust | `Cargo.toml` |
    | Go | `go.mod`, `go.sum` |
    
    ### Multi-Language Projects
    
    If multiple languages are detected, configure all of them in the following priority order:
    
    1. **Python** - Primary language, uses Dockerfile for uv + Python installation
    2. **Node/TypeScript** - Uses devcontainer feature
    3. **Rust** - Uses devcontainer feature
    4. **Go** - Uses devcontainer feature
    
    For multi-language `postCreateCommand`, chain all setup commands:
    ```
    uv run /opt/post_install.py && uv sync && npm ci
    ```
    
    Extensions and settings from all detected languages should be merged into the configuration.
    
    ## Phase 2: Generate Configuration
    
    Start with base templates from `resources/` directory. Substitute:
    
    - `{{PROJECT_NAME}}` → Human-readable name (e.g., "My Project")
    - `{{PROJECT_SLUG}}` → Slug for volumes (e.g., "my-project")
    
    Then apply language-specific modifications below.
    
    ## Base Template Features
    
    The base template includes:
    
    - **Claude Code** with marketplace plugins (anthropics/skills, trailofbits/skills, trailofbits/skills-curated)
    - **Sandboxing** via bubblewrap and socat
    - **Python 3.13** via uv (fast binary download)
    - **Node 22** via fnm (Fast Node Manager)
    - **ast-grep** for AST-based code search
    - **Network isolation tools** (iptables, ipset) with NET_ADMIN capability
    - **Security mounts**: `.devcontainer/` mounted read-only to prevent container escape
    - **Token forwarding**: `CLAUDE_CODE_OAUTH_TOKEN` and `ANTHROPIC_API_KEY` via `remoteEnv`
    - **Modern CLI tools**: ripgrep, fd, fzf, tmux, git-delta
    
    ---
    
    ## Language-Specific Sections
    
    ### Python Projects
    
    **Detection:** `pyproject.toml`, `requirements.txt`, `setup.py`, or `*.py` files
    
    **Dockerfile additions:**
    
    The base Dockerfile already includes Python 3.13 via uv. If a different version is required (detected from `pyproject.toml`), modify the Python installation:
    
    ```dockerfile
    # Install Python via uv (fast binary download, not source compilation)
    RUN uv python install <version> --default
    ```
    
    **devcontainer.json extensions:**
    
    Add to `customizations.vscode.extensions`:
    ```json
    "ms-python.python",
    "ms-python.vscode-pylance",
    "charliermarsh.ruff"
    ```
    
    Add to `customizations.vscode.settings`:
    ```json
    "python.defaultInterpreterPath": ".venv/bin/python",
    "[python]": {
      "editor.defaultFormatter": "charliermarsh.ruff",
      "editor.codeActionsOnSave": {
        "source.organizeImports": "explicit"
      }
    }
    ```
    
    **postCreateCommand:**
    If `pyproject.toml` exists, chain commands:
    ```
    rm -rf .venv && uv sync && uv run /opt/post_install.py
    ```
    
    ---
    
    ### Node/TypeScript Projects
    
    **Detection:** `package.json` or `tsconfig.json`
    
    **No Dockerfile additions needed:** The base template includes Node 22 via fnm (Fast Node Manager).
    
    **devcontainer.json extensions:**
    
    Add to `customizations.vscode.extensions`:
    ```json
    "dbaeumer.vscode-eslint",
    "esbenp.prettier-vscode"
    ```
    
    Add to `customizations.vscode.settings`:
    ```json
    "editor.defaultFormatter": "esbenp.prettier-vscode",
    "editor.codeActionsOnSave": {
      "source.fixAll.eslint": "explicit"
    }
    ```
    
    **postCreateCommand:**
    Detect package manager from lockfile and chain with base command:
    - `pnpm-lock.yaml` → `uv run /opt/post_install.py && pnpm install --frozen-lockfile`
    - `yarn.lock` → `uv run /opt/post_install.py && yarn install --frozen-lockfile`
    - `package-lock.json` → `uv run /opt/post_install.py && npm ci`
    - No lockfile → `uv run /opt/post_install.py && npm install`
    
    ---
    
    ### Rust Projects
    
    **Detection:** `Cargo.toml`
    
    **Features to add:**
    
    ```json
    "ghcr.io/devcontainers/features/rust:1": {}
    ```
    
    **devcontainer.json extensions:**
    
    Add to `customizations.vscode.extensions`:
    ```json
    "rust-lang.rust-analyzer",
    "tamasfe.even-better-toml"
    ```
    
    Add to `customizations.vscode.settings`:
    ```json
    "[rust]": {
      "editor.defaultFormatter": "rust-lang.rust-analyzer"
    }
    ```
    
    **postCreateCommand:**
    If `Cargo.lock` exists, use locked builds:
    ```
    uv run /opt/post_install.py && cargo build --locked
    ```
    If no lockfile, use standard build:
    ```
    uv run /opt/post_install.py && cargo build
    ```
    
    ---
    
    ### Go Projects
    
    **Detection:** `go.mod`
    
    **Features to add:**
    
    ```json
    "ghcr.io/devcontainers/features/go:1": {
      "version": "latest"
    }
    ```
    
    **devcontainer.json extensions:**
    
    Add to `customizations.vscode.extensions`:
    ```json
    "golang.go"
    ```
    
    Add to `customizations.vscode.settings`:
    ```json
    "[go]": {
      "editor.defaultFormatter": "golang.go"
    },
    "go.useLanguageServer": true
    ```
    
    **postCreateCommand:**
    ```
    uv run /opt/post_install.py && go mod download
    ```
    
    ---
    
    ## Reference Material
    
    For additional guidance, see:
    - `references/dockerfile-best-practices.md` - Layer optimization, multi-stage builds, architecture support
    - `references/features-vs-dockerfile.md` - When to use devcontainer features vs custom Dockerfile
    
    ---
    
    ## Adding Persistent Volumes
    
    Pattern for new mounts in `devcontainer.json`:
    
    ```json
    "mounts": [
      "source={{PROJECT_SLUG}}-<purpose>-${devcontainerId},target=<container-path>,type=volume"
    ]
    ```
    
    Common additions:
    - `source={{PROJECT_SLUG}}-cargo-${devcontainerId},target=/home/vscode/.cargo,type=volume` (Rust)
    - `source={{PROJECT_SLUG}}-go-${devcontainerId},target=/home/vscode/go,type=volume` (Go)
    
    ---
    
    ## Output Files
    
    Generate these files in the project's `.devcontainer/` directory:
    
    1. `Dockerfile` - Container build instructions
    2. `devcontainer.json` - VS Code/devcontainer configuration
    3. `post_install.py` - Post-creation setup script
    4. `.zshrc` - Shell configuration
    5. `install.sh` - CLI helper for managing the devcontainer (`devc` command)
    
    ---
    
    ## Validation Checklist
    
    Before presenting files to the user, verify:
    
    1. All `{{PROJECT_NAME}}` placeholders are replaced with the human-readable name
    2. All `{{PROJECT_SLUG}}` placeholders are replaced with the slugified name
    3. JSON syntax is valid in `devcontainer.json` (no trailing commas, proper nesting)
    4. Language-specific extensions are added for all detected languages
    5. `postCreateCommand` includes all required setup commands (chained with `&&`)
    
    ---
    
    ## User Instructions
    
    After generating, inform the user:
    
    1. How to start: "Open in VS Code and select 'Reopen in Container'"
    2. Alternative: `devcontainer up --workspace-folder .`
    3. CLI helper: Run `.devcontainer/install.sh self-install` to add the `devc` command to PATH
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related