From 1ba8108dcb01fceadf8d1e485bf764aada6eef98 Mon Sep 17 00:00:00 2001 From: PKramek Date: Tue, 31 Mar 2026 21:58:10 +0200 Subject: [PATCH 01/55] feat: add design spec and implementation plan for Claude Code DevContainer Feature Comprehensive DevContainer Feature for installing Claude Code into any devcontainer. Includes design spec (3 rounds of review) and implementation plan (4 rounds of expert review covering shell, security, CI/CD, and DevContainer ecosystem compliance). --- ...-03-31-claude-code-devcontainer-feature.md | 2197 +++++++++++++++++ ...claude-code-devcontainer-feature-design.md | 606 +++++ 2 files changed, 2803 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-31-claude-code-devcontainer-feature.md create mode 100644 docs/superpowers/specs/2026-03-31-claude-code-devcontainer-feature-design.md diff --git a/docs/superpowers/plans/2026-03-31-claude-code-devcontainer-feature.md b/docs/superpowers/plans/2026-03-31-claude-code-devcontainer-feature.md new file mode 100644 index 0000000..c33e338 --- /dev/null +++ b/docs/superpowers/plans/2026-03-31-claude-code-devcontainer-feature.md @@ -0,0 +1,2197 @@ +# Claude Code DevContainer Feature — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build and ship a universally compatible DevContainer Feature that installs Claude Code into any existing devcontainer. + +**Architecture:** A single DevContainer Feature (`src/claude-code/`) with a bash install script that detects the host OS, ensures Node.js >= 18, installs Claude Code via npm, and optionally configures shell completions and MCP servers. Tested across 25+ base images on amd64/arm64 via GitHub Actions. Published to ghcr.io. + +**Tech Stack:** Bash, DevContainer Features spec, GitHub Actions, ShellCheck, npm, Docker + +**Spec:** `docs/superpowers/specs/2026-03-31-claude-code-devcontainer-feature-design.md` + +--- + +## File Map + +| File | Responsibility | +|---|---| +| `src/claude-code/devcontainer-feature.json` | Feature manifest: metadata, options, env vars | +| `src/claude-code/install.sh` | Installation script: OS detection, Node.js install, Claude Code install, completions, MCP, cleanup | +| `test/claude-code/test.sh` | Shared test helper functions (assertions) | +| `test/claude-code/scenarios.json` | Test scenario definitions (image + feature options) | +| `test/claude-code/default_options.sh` | Test: default options assertions | +| `test/claude-code/completions_disabled.sh` | Test: completions absent when disabled | +| `test/claude-code/mcp_enabled.sh` | Test: MCP config exists and is valid | +| `test/claude-code/custom_version.sh` | Test: pinned version matches exactly | +| `test/claude-code/node_preinstalled.sh` | Test: existing Node.js untouched | +| `test/claude-code/custom_install_path.sh` | Test: binary at custom path, PATH updated | +| `test/claude-code/mount_host_config.sh` | Test: mount snippet in output, no actual mount | +| `test/claude-code/alpine_specific.sh` | Test: bash installed, Alpine-specific paths | +| `test/claude-code/idempotency.sh` | Test: double-install produces same state | +| `test/claude-code/multi_feature_combo.sh` | Test: coexists with separate Node feature | +| `.github/workflows/test.yml` | CI: lint + exhaustive test matrix | +| `.github/workflows/release.yml` | CD: publish to ghcr.io on tag push | +| `.pre-commit-config.yaml` | Pre-commit hook definitions | +| `.shellcheckrc` | ShellCheck config (bash, warning severity) | +| `.editorconfig` | Formatting config (4-space indent for .sh) | +| `.devcontainer/devcontainer.json` | Contributor dev environment | +| `LICENSE` | MIT license | +| `README.md` | Usage docs, examples, CI badge | + +--- + +## Task 1: Repository Foundation + +**Files:** +- Create: `LICENSE` +- Create: `.editorconfig` +- Create: `.shellcheckrc` +- Create: `.gitignore` + +- [ ] **Step 1: Create MIT LICENSE** + +``` +MIT License + +Copyright (c) 2026 Claude Code DevContainer Feature Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +``` + +- [ ] **Step 2: Create `.editorconfig`** + +```ini +root = true + +[*] +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +charset = utf-8 + +[*.sh] +indent_style = space +indent_size = 4 + +[*.{json,yml,yaml}] +indent_style = space +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false + +[Makefile] +indent_style = tab +``` + +- [ ] **Step 3: Create `.shellcheckrc`** + +``` +shell=bash +severity=warning +``` + +- [ ] **Step 4: Create `.gitignore`** + +```gitignore +# OS +.DS_Store +Thumbs.db + +# Editors +*.swp +*.swo +*~ +.vscode/ +.idea/ + +# Node (from npm install in CI) +node_modules/ + +# Pre-commit +.pre-commit-cache/ +``` + +- [ ] **Step 5: Commit** + +```bash +git add LICENSE .editorconfig .shellcheckrc .gitignore +git commit -m "chore: add LICENSE, editorconfig, shellcheckrc, gitignore" +``` + +--- + +## Task 2: Feature Manifest + +**Files:** +- Create: `src/claude-code/devcontainer-feature.json` + +- [ ] **Step 1: Create the feature manifest** + +```json +{ + "id": "claude-code", + "version": "1.0.0", + "name": "Claude Code", + "description": "Install Claude Code CLI into any devcontainer. Supports Debian, Ubuntu, Alpine, Arch, Fedora, RHEL, Rocky, Alma, and Amazon Linux on amd64/arm64.", + "keywords": [ + "claude", + "claude-code", + "anthropic", + "ai", + "cli", + "devcontainer" + ], + "documentationURL": "https://github.com/pkramek/claude-code-devcontainer#readme", + "licenseURL": "https://github.com/pkramek/claude-code-devcontainer/blob/main/LICENSE", + "installsAfter": [ + "ghcr.io/devcontainers/features/node" + ], + "options": { + "version": { + "type": "string", + "default": "latest", + "description": "Claude Code version to install (semver or 'latest'). Recommend pinning for teams." + }, + "nodeVersion": { + "type": "string", + "default": "lts", + "description": "Node.js version to install if not already present (>= 18 required). Resolved via NodeSource." + }, + "installPath": { + "type": "string", + "default": "/usr/local", + "description": "Custom npm global install prefix. Feature ensures /bin is on PATH." + }, + "enableMcpServers": { + "type": "boolean", + "default": false, + "description": "Create a starter MCP server configuration at ~/.claude/mcp_servers.json (create-if-absent)." + }, + "mountHostConfig": { + "type": "boolean", + "default": false, + "description": "Log a mounts snippet for host ~/.claude config passthrough (documentation-only, does NOT auto-mount)." + }, + "shellCompletions": { + "type": "boolean", + "default": true, + "description": "Install shell completions for bash, zsh, and fish." + } + }, + "containerEnv": { + "CLAUDE_CODE_INSTALLED": "true" + } +} +``` + +**Note:** `postCreateCommand` is NOT a valid field in `devcontainer-feature.json` (it belongs in `devcontainer.json`). The runtime verification (`claude --version`) is already handled at the end of `install.sh`. The README documents a recommended `postCreateCommand` for users who want runtime verification. +``` + +- [ ] **Step 2: Validate JSON is well-formed** + +Run: `python3 -m json.tool src/claude-code/devcontainer-feature.json > /dev/null` +Expected: exits 0, no output + +- [ ] **Step 3: Commit** + +```bash +git add src/claude-code/devcontainer-feature.json +git commit -m "feat: add devcontainer-feature.json manifest" +``` + +--- + +## Task 3: Install Script — Bootstrap, Error Handling, Input Validation + +**Files:** +- Create: `src/claude-code/install.sh` + +This task creates the script skeleton with the bootstrap, error handling, logging, input validation, and option parsing. No actual installation logic yet. + +- [ ] **Step 1: Create `install.sh` with bootstrap and error handling** + +```bash +#!/usr/bin/env bash +# +# Claude Code DevContainer Feature — install.sh +# Installs Claude Code CLI into any devcontainer environment. +# +# Options (from devcontainer-feature.json): +# VERSION - Claude Code version (default: "latest") +# NODEVERSION - Node.js version (default: "lts") +# INSTALLPATH - npm global prefix (default: "/usr/local") +# ENABLEMCPSERVERS - Create MCP config (default: "false") +# MOUNTHOSTCONFIG - Log mount snippet (default: "false") +# SHELLCOMPLETIONS - Install completions (default: "true") + +# --- POSIX-compatible bootstrap (runs under /bin/sh on Alpine) --- +if [ -z "${BASH_VERSION:-}" ]; then + if command -v apk > /dev/null 2>&1; then + apk add --no-cache bash > /dev/null || { + echo "[claude-code feature] ERROR: Failed to install bash via apk." >&2 + exit 1 + } + fi + if ! command -v bash > /dev/null 2>&1; then + echo "[claude-code feature] ERROR: bash is required but could not be found or installed." >&2 + exit 1 + fi + exec bash "$0" "$@" +fi +# --- From here on, bash is guaranteed --- + +set -Eeuo pipefail +umask 0022 # Ensure consistent file permissions regardless of build environment + +FEATURE_LOG_PREFIX="[claude-code feature]" + +# Debug mode — WARNING: unset secrets first to prevent leaking via set -x +if [[ "${DEBUG:-false}" == "true" ]]; then + # Unset known secret variables to prevent exposure in trace output + unset ANTHROPIC_API_KEY CLAUDE_API_KEY 2>/dev/null || true + set -x +fi + +# Traps +trap 'echo "${FEATURE_LOG_PREFIX} ERROR: Failed at line ${LINENO}. Exit code: $?" >&2' ERR +trap cleanup EXIT INT TERM + +TEMP_DIR="" +cleanup() { + [[ -n "${TEMP_DIR}" ]] && rm -rf "${TEMP_DIR}" 2>/dev/null || true +} + +# --- Logging --- +log_info() { echo "${FEATURE_LOG_PREFIX} $*"; } +log_warn() { echo "${FEATURE_LOG_PREFIX} WARNING: $*" >&2; } +log_error() { echo "${FEATURE_LOG_PREFIX} ERROR: $*" >&2; } +log_debug() { + if [[ "${DEBUG:-false}" == "true" ]]; then + echo "${FEATURE_LOG_PREFIX} DEBUG: $*" + fi +} + +# --- Input Validation --- +validate_version() { + local ver="$1" + if [[ "${ver}" == "latest" ]]; then return 0; fi + if [[ ! "${ver}" =~ ^[0-9][0-9a-zA-Z.+-]*$ ]]; then + log_error "Invalid version '${ver}'. Must be 'latest' or a valid semver string." + exit 1 + fi +} + +validate_install_path() { + local path="$1" + if [[ ! "${path}" =~ ^/[a-zA-Z0-9/_.-]+$ ]]; then + log_error "Invalid installPath '${path}'. Must be an absolute path with no special characters." + exit 1 + fi +} + +validate_node_version() { + local ver="$1" + if [[ "${ver}" == "lts" ]]; then return 0; fi + if [[ ! "${ver}" =~ ^[0-9]+$ ]]; then + log_error "Invalid nodeVersion '${ver}'. Must be 'lts' or a major version number (e.g., '20')." + exit 1 + fi + if [[ "${ver}" -lt 18 || "${ver}" -gt 99 ]]; then + log_error "nodeVersion '${ver}' out of range. Must be between 18 and 99." + exit 1 + fi +} + +# --- Parse Options --- +VERSION="${VERSION:-latest}" +NODE_VERSION="${NODEVERSION:-lts}" +INSTALL_PATH="${INSTALLPATH:-/usr/local}" +ENABLE_MCP_SERVERS="${ENABLEMCPSERVERS:-false}" +MOUNT_HOST_CONFIG="${MOUNTHOSTCONFIG:-false}" +SHELL_COMPLETIONS="${SHELLCOMPLETIONS:-true}" + +validate_version "${VERSION}" +validate_install_path "${INSTALL_PATH}" +validate_node_version "${NODE_VERSION}" + +log_info "Starting installation..." +log_info " Claude Code version: ${VERSION}" +log_info " Node.js version: ${NODE_VERSION}" +log_info " Install path: ${INSTALL_PATH}" +log_info " MCP servers: ${ENABLE_MCP_SERVERS}" +log_info " Mount host config: ${MOUNT_HOST_CONFIG}" +log_info " Shell completions: ${SHELL_COMPLETIONS}" + +# --- Remote User Detection --- +detect_remote_user() { + if [[ -n "${_REMOTE_USER:-}" ]]; then + echo "${_REMOTE_USER}" + elif [[ -n "${_CONTAINER_USER:-}" ]]; then + echo "${_CONTAINER_USER}" + else + # Find first non-root user with a valid login shell + local user + user=$(getent passwd | awk -F: '$3 >= 1000 && $7 !~ /nologin|false/ { print $1; exit }') + if [[ -n "${user}" ]]; then + echo "${user}" + else + echo "root" + fi + fi +} + +detect_user_home() { + local user="$1" + if [[ -n "${_REMOTE_USER_HOME:-}" ]]; then + echo "${_REMOTE_USER_HOME}" + else + getent passwd "${user}" | cut -d: -f6 + fi +} + +REMOTE_USER=$(detect_remote_user) +REMOTE_USER_HOME=$(detect_user_home "${REMOTE_USER}") +if [[ -z "${REMOTE_USER_HOME}" ]]; then + log_warn "Could not detect home directory for user '${REMOTE_USER}'. Defaulting to /root." + REMOTE_USER_HOME="/root" +fi +log_info " Remote user: ${REMOTE_USER} (home: ${REMOTE_USER_HOME})" + +# Subsequent tasks will add: detect_os, install_dependencies, install_node, +# install_claude_code, setup_completions, setup_mcp, setup_mount_docs, cleanup_caches +log_info "Installation complete." +``` + +- [ ] **Step 2: Make the script executable** + +Run: `chmod +x src/claude-code/install.sh` + +- [ ] **Step 3: Run ShellCheck** + +Run: `shellcheck src/claude-code/install.sh` +Expected: exits 0, no warnings + +- [ ] **Step 4: Commit** + +```bash +git add src/claude-code/install.sh +git commit -m "feat: add install.sh skeleton with bootstrap, logging, validation" +``` + +--- + +## Task 4: Install Script — OS Detection and Dependency Installation + +**Files:** +- Modify: `src/claude-code/install.sh` + +Add OS detection and base dependency installation functions. + +- [ ] **Step 1: Add OS detection function** + +Insert before the `log_info "Installation complete."` line: + +```bash +# --- OS Detection --- +detect_os() { + local id="" + local id_like="" + + if [[ -f /etc/os-release ]]; then + # shellcheck source=/dev/null + . /etc/os-release + id="${ID:-}" + id_like="${ID_LIKE:-}" + fi + + case "${id}" in + debian|ubuntu|linuxmint) + echo "debian" + ;; + alpine) + echo "alpine" + ;; + arch|archarm|endeavouros|manjaro) + echo "arch" + ;; + fedora|rhel|centos|rocky|almalinux|amzn) + echo "rhel" + ;; + *) + # Fallback to ID_LIKE + case "${id_like}" in + *debian*|*ubuntu*) echo "debian" ;; + *arch*) echo "arch" ;; + *fedora*|*rhel*) echo "rhel" ;; + *) + log_error "Unsupported OS: ID=${id}, ID_LIKE=${id_like}" + exit 1 + ;; + esac + ;; + esac +} + +detect_arch() { + local arch + arch=$(uname -m) + case "${arch}" in + x86_64) echo "amd64" ;; + aarch64) echo "arm64" ;; + *) + log_error "Unsupported architecture: ${arch}" + exit 1 + ;; + esac +} + +OS_FAMILY=$(detect_os) +ARCH=$(detect_arch) +log_info " Detected OS family: ${OS_FAMILY}" +log_info " Detected architecture: ${ARCH}" +``` + +- [ ] **Step 2: Add dependency installation function** + +Insert after the OS detection block: + +```bash +# --- Dependency Installation --- +install_packages() { + local packages=("$@") + case "${OS_FAMILY}" in + debian) + # Check if apt lists are populated (not just if cache file exists) + local apt_lists_count + apt_lists_count=$(find /var/lib/apt/lists -maxdepth 1 -type f ! -name 'lock' ! -name 'partial' 2>/dev/null | wc -l) + if [[ "${apt_lists_count}" -eq 0 ]]; then + apt-get update -y -o DPkg::Lock::Timeout=60 + fi + apt-get install -y --no-install-recommends -o DPkg::Lock::Timeout=60 "${packages[@]}" + ;; + alpine) + apk add --no-cache "${packages[@]}" + ;; + arch) + pacman -S --noconfirm --needed "${packages[@]}" + ;; + rhel) + if command -v dnf > /dev/null 2>&1; then + dnf install -y "${packages[@]}" + else + yum install -y "${packages[@]}" + fi + ;; + esac +} + +ensure_base_dependencies() { + local missing=() + + command -v curl > /dev/null 2>&1 || missing+=("curl") + command -v git > /dev/null 2>&1 || missing+=("git") + + # Always ensure ca-certificates for TLS verification (needed before any curl to nodejs.org/npm) + case "${OS_FAMILY}" in + alpine) + command -v update-ca-certificates > /dev/null 2>&1 || missing+=("ca-certificates") + ;; + debian) + [[ -d /etc/ssl/certs ]] && [[ -n "$(ls /etc/ssl/certs/ 2>/dev/null)" ]] || missing+=("ca-certificates") + ;; + # RHEL and Arch include ca-certificates by default + esac + + if [[ ${#missing[@]} -gt 0 ]]; then + log_info "Installing missing dependencies: ${missing[*]}" + install_packages "${missing[@]}" + fi +} + +ensure_base_dependencies +``` + +- [ ] **Step 3: Run ShellCheck** + +Run: `shellcheck src/claude-code/install.sh` +Expected: exits 0, no warnings + +- [ ] **Step 4: Commit** + +```bash +git add src/claude-code/install.sh +git commit -m "feat: add OS detection and dependency installation" +``` + +--- + +## Task 5: Install Script — Node.js Installation + +**Files:** +- Modify: `src/claude-code/install.sh` + +Add Node.js detection, version checking, and installation via NodeSource or distro packages. + +- [ ] **Step 1: Add Node.js version checking and installation** + +Insert after `ensure_base_dependencies`: + +```bash +# --- Node.js Installation --- +NODE_MIN_VERSION=18 + +get_node_major_version() { + local version_string + version_string=$(node --version 2>/dev/null || echo "") + if [[ -z "${version_string}" ]]; then + echo "0" + return + fi + # Strip leading 'v' and extract major version + echo "${version_string}" | sed 's/^v//' | cut -d. -f1 +} + +resolve_node_version() { + local requested="$1" + if [[ "${requested}" == "lts" ]]; then + # Query official Node.js release API for current LTS major version + local lts_version + lts_version=$(curl -fsSL https://nodejs.org/dist/index.json 2>/dev/null \ + | node -e " + const data = JSON.parse(require('fs').readFileSync('/dev/stdin','utf8')); + const lts = data.find(r => r.lts); + console.log(lts ? lts.version.replace(/^v/,'').split('.')[0] : '22'); + " 2>/dev/null || echo "22") + if [[ -z "${lts_version}" ]]; then lts_version="22"; fi + log_info "Resolved LTS to Node.js ${lts_version}" + echo "${lts_version}" + else + echo "${requested}" + fi +} + +install_node_binary() { + local version="$1" + local arch_label + case "$(uname -m)" in + x86_64) arch_label="x64" ;; + aarch64) arch_label="arm64" ;; + *) log_error "Unsupported arch for Node.js binary: $(uname -m)"; exit 1 ;; + esac + + log_info "Installing Node.js ${version} via official binary tarball..." + + TEMP_DIR=$(mktemp -d) + local tarball="node-v${version}.0.0-linux-${arch_label}.tar.xz" + local url="https://nodejs.org/dist/latest-v${version}.x/" + + # Get the exact filename and checksum from the SHASUMS256 file + local shasums + shasums=$(curl -fsSL "${url}SHASUMS256.txt") || { + log_error "Failed to download Node.js SHASUMS256.txt from ${url}" + exit 1 + } + + # Find the linux tarball line + local tarball_line + tarball_line=$(echo "${shasums}" | grep "linux-${arch_label}.tar.xz" | head -1) + if [[ -z "${tarball_line}" ]]; then + log_error "No linux-${arch_label} tarball found in Node.js ${version} release." + exit 1 + fi + + local expected_sha tarball_name + expected_sha=$(echo "${tarball_line}" | awk '{print $1}') + tarball_name=$(echo "${tarball_line}" | awk '{print $2}') + + log_debug "Downloading ${tarball_name} (SHA256: ${expected_sha})" + + curl -fsSL "${url}${tarball_name}" -o "${TEMP_DIR}/${tarball_name}" || { + log_error "Failed to download Node.js from ${url}${tarball_name}" + exit 1 + } + + # Verify SHA256 checksum + local actual_sha + actual_sha=$(sha256sum "${TEMP_DIR}/${tarball_name}" | awk '{print $1}') + if [[ "${actual_sha}" != "${expected_sha}" ]]; then + log_error "SHA256 checksum mismatch for Node.js tarball!" + log_error " Expected: ${expected_sha}" + log_error " Actual: ${actual_sha}" + exit 1 + fi + log_info "SHA256 checksum verified." + + # Extract to /usr/local (merges bin/, lib/, include/, share/) + tar -xJf "${TEMP_DIR}/${tarball_name}" -C /usr/local --strip-components=1 + rm -rf "${TEMP_DIR}" + TEMP_DIR="" + + log_info "Node.js $(node --version) installed and verified." +} + +install_node_distro() { + if [[ "${NODE_VERSION}" != "lts" ]]; then + log_warn "nodeVersion '${NODE_VERSION}' is ignored on ${OS_FAMILY} — distro package version will be used." + fi + log_info "Installing Node.js via distro packages..." + case "${OS_FAMILY}" in + alpine) + install_packages nodejs npm + ;; + arch) + install_packages nodejs npm + ;; + *) + log_error "No distro package strategy for ${OS_FAMILY}." + exit 1 + ;; + esac + + # Post-install version floor check + local installed_major + installed_major=$(get_node_major_version) + if [[ "${installed_major}" -lt "${NODE_MIN_VERSION}" ]]; then + log_error "Node.js v${installed_major} from distro packages is below minimum ${NODE_MIN_VERSION}." + log_error "Use a newer base image or set nodeVersion to install via NodeSource." + exit 1 + fi +} + +ensure_node() { + local current_major + current_major=$(get_node_major_version) + + if [[ "${current_major}" -ge "${NODE_MIN_VERSION}" ]]; then + log_info "Node.js v$(node --version) already installed and meets minimum requirement (>= ${NODE_MIN_VERSION})." + return 0 + fi + + if [[ "${current_major}" -gt 0 ]] && [[ "${current_major}" -lt "${NODE_MIN_VERSION}" ]]; then + log_warn "Node.js v$(node --version) is below minimum ${NODE_MIN_VERSION}. Installing newer version..." + fi + + local resolved_version + resolved_version=$(resolve_node_version "${NODE_VERSION}") + log_debug "Resolved Node.js version: ${resolved_version}" + + case "${OS_FAMILY}" in + debian|rhel) + install_node_binary "${resolved_version}" + ;; + alpine|arch) + install_node_distro + ;; + *) + log_error "No Node.js installation strategy for OS family: ${OS_FAMILY}" + exit 1 + ;; + esac + + log_info "Node.js $(node --version) installed successfully." +} + +ensure_node +``` + +- [ ] **Step 2: Run ShellCheck** + +Run: `shellcheck src/claude-code/install.sh` +Expected: exits 0, no warnings + +- [ ] **Step 3: Commit** + +```bash +git add src/claude-code/install.sh +git commit -m "feat: add Node.js detection and installation" +``` + +--- + +## Task 6: Install Script — PATH Configuration and Claude Code Installation + +**Files:** +- Modify: `src/claude-code/install.sh` + +Add custom PATH setup and the actual Claude Code npm install. + +- [ ] **Step 1: Add PATH configuration and Claude Code install** + +Insert after `ensure_node`: + +```bash +# --- PATH Configuration --- +configure_custom_path() { + if [[ "${INSTALL_PATH}" == "/usr/local" ]]; then + return 0 + fi + + log_info "Configuring custom install path: ${INSTALL_PATH}" + + # Immediate PATH update for this script + export PATH="${INSTALL_PATH}/bin:${PATH}" + + # Persistent PATH for login shells (bash, zsh) + mkdir -p /etc/profile.d + cat > /etc/profile.d/claude-code.sh << PATHEOF +# Added by Claude Code DevContainer Feature +export PATH="${INSTALL_PATH}/bin:\${PATH}" +PATHEOF + chmod 644 /etc/profile.d/claude-code.sh + + # Persistent PATH for Alpine ash non-login shells + if [[ "${OS_FAMILY}" == "alpine" ]]; then + # BusyBox ash reads ENV on startup for non-login shells + # Write to /etc/profile (not /etc/environment which is PAM-specific) + if ! grep -q 'claude-code' /etc/profile 2>/dev/null; then + echo 'export PATH="'"${INSTALL_PATH}"'/bin:${PATH}" # claude-code' >> /etc/profile + fi + fi + + log_info "PATH configured: ${INSTALL_PATH}/bin" +} + +# --- Claude Code Installation --- +install_claude_code() { + local npm_args=(install -g --fetch-retries=3) + + if [[ "${INSTALL_PATH}" != "/usr/local" ]]; then + npm_args+=(--prefix "${INSTALL_PATH}") + fi + + if [[ "${VERSION}" == "latest" ]]; then + npm_args+=("@anthropic-ai/claude-code") + else + npm_args+=("@anthropic-ai/claude-code@${VERSION}") + fi + + log_info "Installing Claude Code (version: ${VERSION})..." + log_debug "npm ${npm_args[*]}" + + timeout 300 npm "${npm_args[@]}" || { + log_error "Failed to install Claude Code." + log_error "Check network connectivity and npm registry access." + exit 1 + } + + # Verify installation + if ! claude --version > /dev/null 2>&1; then + log_error "Claude Code installed but 'claude' not found on PATH." + log_error "PATH=${PATH}" + exit 1 + fi + + log_info "Claude Code $(claude --version) installed successfully." +} + +configure_custom_path +install_claude_code +``` + +- [ ] **Step 2: Run ShellCheck** + +Run: `shellcheck src/claude-code/install.sh` +Expected: exits 0, no warnings + +- [ ] **Step 3: Commit** + +```bash +git add src/claude-code/install.sh +git commit -m "feat: add PATH configuration and Claude Code installation" +``` + +--- + +## Task 7: Install Script — Shell Completions, MCP, Mount Docs, Cleanup + +**Files:** +- Modify: `src/claude-code/install.sh` + +Add the batteries-included features and cleanup. This completes `install.sh`. + +- [ ] **Step 1: Add shell completions** + +Insert after `install_claude_code`: + +```bash +# --- Shell Completions --- +setup_completions() { + if [[ "${SHELL_COMPLETIONS}" != "true" ]]; then + log_debug "Shell completions disabled." + return 0 + fi + + log_info "Installing shell completions..." + + # Bash completions + local bash_comp_dir="" + if [[ -d /usr/share/bash-completion/completions ]]; then + bash_comp_dir="/usr/share/bash-completion/completions" + elif [[ -d /etc/bash_completion.d ]]; then + bash_comp_dir="/etc/bash_completion.d" + fi + if [[ -n "${bash_comp_dir}" ]]; then + claude completions bash > "${bash_comp_dir}/claude" 2>/dev/null || { + log_warn "Failed to install bash completions." + } + fi + + # Zsh completions + if [[ -d /usr/share/zsh/site-functions ]] || mkdir -p /usr/share/zsh/site-functions 2>/dev/null; then + claude completions zsh > /usr/share/zsh/site-functions/_claude 2>/dev/null || { + log_warn "Failed to install zsh completions." + } + fi + + # Fish completions + local fish_comp_dir="" + for dir in /usr/share/fish/vendor_completions.d /usr/share/fish/completions; do + if [[ -d "${dir}" ]]; then + fish_comp_dir="${dir}" + break + fi + done + if [[ -n "${fish_comp_dir}" ]]; then + claude completions fish > "${fish_comp_dir}/claude.fish" 2>/dev/null || { + log_warn "Failed to install fish completions." + } + fi + + log_info "Shell completions installed." +} + +setup_completions +``` + +- [ ] **Step 2: Add MCP server config** + +Insert after `setup_completions`: + +```bash +# --- MCP Server Configuration --- +setup_mcp_servers() { + if [[ "${ENABLE_MCP_SERVERS}" != "true" ]]; then + log_debug "MCP server configuration disabled." + return 0 + fi + + local claude_dir="${REMOTE_USER_HOME}/.claude" + local mcp_config="${claude_dir}/mcp_servers.json" + + if [[ -f "${mcp_config}" ]]; then + log_info "MCP config already exists at ${mcp_config}, skipping." + return 0 + fi + + log_info "Creating starter MCP configuration..." + mkdir -p "${claude_dir}" + + cat > "${mcp_config}" << 'MCPEOF' +{ + "mcpServers": {} +} +MCPEOF + + chmod 700 "${claude_dir}" + chmod 600 "${mcp_config}" + chown "${REMOTE_USER}:$(id -gn "${REMOTE_USER}")" "${claude_dir}" + chown "${REMOTE_USER}:$(id -gn "${REMOTE_USER}")" "${mcp_config}" + log_info "MCP config created at ${mcp_config} (mode 600)" +} + +setup_mcp_servers +``` + +- [ ] **Step 3: Add mount documentation and cache cleanup** + +Insert after `setup_mcp_servers`: + +```bash +# --- Host Config Mount Documentation --- +setup_mount_docs() { + if [[ "${MOUNT_HOST_CONFIG}" != "true" ]]; then + return 0 + fi + + log_info "" + log_info "============================================================" + log_info "HOST CONFIG MOUNTING" + log_info "============================================================" + log_info "To mount your host Claude config, add this to your" + log_info "devcontainer.json:" + log_info "" + log_info ' "mounts": [' + log_info " \"source=\${localEnv:HOME}/.claude,target=${REMOTE_USER_HOME}/.claude,type=bind,consistency=cached,readonly\"" + log_info ' ]' + log_info "" + log_info "WARNING: This exposes your API keys inside the container." + log_info "See README for security considerations." + log_info "============================================================" + log_info "" +} + +setup_mount_docs + +# --- Cache Cleanup --- +cleanup_caches() { + log_info "Cleaning up package manager caches..." + + case "${OS_FAMILY}" in + debian) + apt-get clean + rm -rf /var/lib/apt/lists/* + ;; + alpine) + rm -rf /var/cache/apk/* + ;; + arch) + pacman -Sc --noconfirm 2>/dev/null || true + ;; + rhel) + if command -v dnf > /dev/null 2>&1; then + dnf clean all + else + yum clean all + fi + rm -rf /var/cache/dnf /var/cache/yum + ;; + esac + + npm cache clean --force 2>/dev/null || true + log_info "Cache cleanup complete." +} + +cleanup_caches +``` + +- [ ] **Step 4: Remove the placeholder `log_info "Installation complete."` and replace with final log** + +Replace the old `log_info "Installation complete."` with: + +```bash +log_info "Claude Code DevContainer Feature installation complete." +log_info " Claude Code: $(claude --version 2>/dev/null || echo 'unknown')" +log_info " Node.js: $(node --version 2>/dev/null || echo 'unknown')" +log_info " OS: ${OS_FAMILY} (${ARCH})" +log_info " User: ${REMOTE_USER}" +``` + +- [ ] **Step 5: Run ShellCheck** + +Run: `shellcheck src/claude-code/install.sh` +Expected: exits 0, no warnings + +- [ ] **Step 6: Commit** + +```bash +git add src/claude-code/install.sh +git commit -m "feat: add completions, MCP config, mount docs, cache cleanup" +``` + +--- + +## Task 8: Test Helpers and Scenarios + +**Files:** +- Create: `test/claude-code/test.sh` +- Create: `test/claude-code/scenarios.json` + +- [ ] **Step 1: Create shared test helpers (`test.sh`)** + +```bash +#!/usr/bin/env bash +# +# Shared test helpers for Claude Code DevContainer Feature. +# Sourced by per-scenario test scripts. +# + +set -Eeuo pipefail + +TESTS_PASSED=0 +TESTS_FAILED=0 + +pass() { + echo " PASS: $*" + ((TESTS_PASSED++)) +} + +fail() { + echo " FAIL: $*" >&2 + ((TESTS_FAILED++)) +} + +check_command_exists() { + local cmd="$1" + if command -v "${cmd}" > /dev/null 2>&1; then + pass "${cmd} is on PATH" + else + fail "${cmd} not found on PATH" + fi +} + +check_command_version() { + local cmd="$1" + local expected="$2" + local actual + actual=$("${cmd}" --version 2>&1 || echo "") + if [[ "${actual}" == *"${expected}"* ]]; then + pass "${cmd} version contains '${expected}' (got: ${actual})" + else + fail "${cmd} version mismatch: expected '${expected}', got '${actual}'" + fi +} + +check_command_runs() { + local cmd="$1" + if "${cmd}" --version > /dev/null 2>&1; then + pass "${cmd} --version exits 0" + else + fail "${cmd} --version failed" + fi +} + +check_node_min_version() { + local min="$1" + local major + major=$(node --version 2>/dev/null | sed 's/^v//' | cut -d. -f1) + if [[ "${major}" -ge "${min}" ]]; then + pass "Node.js v${major} >= ${min}" + else + fail "Node.js v${major} < ${min}" + fi +} + +check_file_exists() { + local path="$1" + if [[ -f "${path}" ]]; then + pass "File exists: ${path}" + else + fail "File missing: ${path}" + fi +} + +check_file_absent() { + local path="$1" + if [[ ! -f "${path}" ]]; then + pass "File absent (expected): ${path}" + else + fail "File exists (unexpected): ${path}" + fi +} + +check_dir_exists() { + local path="$1" + if [[ -d "${path}" ]]; then + pass "Directory exists: ${path}" + else + fail "Directory missing: ${path}" + fi +} + +check_env_var() { + local name="$1" + local expected="$2" + local actual="${!name:-}" + if [[ "${actual}" == "${expected}" ]]; then + pass "Env var ${name}='${expected}'" + else + fail "Env var ${name}: expected '${expected}', got '${actual}'" + fi +} + +check_permissions() { + local path="$1" + local expected="$2" + if [[ ! -e "${path}" ]]; then + fail "Cannot check permissions: ${path} does not exist" + return + fi + local actual + actual=$(stat -c '%a' "${path}" 2>/dev/null || stat -f '%Lp' "${path}" 2>/dev/null) + if [[ "${actual}" == "${expected}" ]]; then + pass "Permissions on ${path}: ${expected}" + else + fail "Permissions on ${path}: expected ${expected}, got ${actual}" + fi +} + +check_file_owner() { + local path="$1" + local expected_user="$2" + if [[ ! -e "${path}" ]]; then + fail "Cannot check owner: ${path} does not exist" + return + fi + local actual + actual=$(stat -c '%U' "${path}" 2>/dev/null || stat -f '%Su' "${path}" 2>/dev/null) + if [[ "${actual}" == "${expected_user}" ]]; then + pass "Owner of ${path}: ${expected_user}" + else + fail "Owner of ${path}: expected ${expected_user}, got ${actual}" + fi +} + +check_file_valid_json() { + local path="$1" + # Use node (guaranteed present) with argv to avoid path injection + if node -e "JSON.parse(require('fs').readFileSync(process.argv[1],'utf8'))" "${path}" > /dev/null 2>&1; then + pass "Valid JSON: ${path}" + else + fail "Invalid JSON: ${path}" + fi +} + +check_path_clean() { + local cache_dir="$1" + if [[ ! -d "${cache_dir}" ]]; then + pass "Cache dir absent: ${cache_dir}" + return + fi + local count + count=$(find "${cache_dir}" -type f 2>/dev/null | wc -l) + if [[ "${count}" -eq 0 ]]; then + pass "Cache dir clean: ${cache_dir}" + else + fail "Cache dir has ${count} files: ${cache_dir}" + fi +} + +check_non_root() { + local current_user + current_user=$(whoami) + if [[ "${current_user}" != "root" ]]; then + pass "Running as non-root user: ${current_user}" + else + # Raw OS images (ubuntu:22.04, alpine:3.21, etc.) run as root. + # This is expected — the devcontainer CLI has no non-root user to switch to. + # Only warn, don't fail, since the permission model still works for root. + pass "Running as root (acceptable for raw OS base images)" + fi +} + +# Run core assertions shared by all scenarios +core_assertions() { + echo "--- Core Assertions ---" + check_command_exists "claude" + check_command_runs "claude" + check_command_exists "node" + check_node_min_version 18 + check_env_var "CLAUDE_CODE_INSTALLED" "true" + check_non_root + # Check claude binary permissions (should be executable by all) + local claude_path + claude_path=$(command -v claude) + if [[ -n "${claude_path}" ]]; then + check_permissions "${claude_path}" "755" + fi +} + +# Print summary and exit with appropriate code +test_summary() { + echo "" + echo "--- Results: ${TESTS_PASSED} passed, ${TESTS_FAILED} failed ---" + if [[ "${TESTS_FAILED}" -gt 0 ]]; then + exit 1 + fi +} + +# When executed directly (not sourced), run core assertions. +# This is what `devcontainer features test --skip-scenarios --base-image ` invokes. +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + echo "=== Default test (core assertions) ===" + core_assertions + test_summary +fi +``` + +- [ ] **Step 2: Create `scenarios.json`** + +This file defines per-scenario test configs. The CI workflow also runs the `default_options` scenario across ALL base images in a separate matrix dimension (see Task 11). + +```json +{ + "default_options": { + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "features": { + "claude-code": {} + } + }, + "completions_disabled": { + "image": "mcr.microsoft.com/devcontainers/base:debian", + "features": { + "claude-code": { + "shellCompletions": false + } + } + }, + "mcp_enabled": { + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "features": { + "claude-code": { + "enableMcpServers": true + } + } + }, + "custom_version": { + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "features": { + "claude-code": { + "version": "0.2.57" + } + } + }, + "node_preinstalled": { + "image": "mcr.microsoft.com/devcontainers/javascript-node", + "features": { + "claude-code": {} + } + }, + "custom_install_path": { + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "features": { + "claude-code": { + "installPath": "/opt/claude" + } + } + }, + "mount_host_config": { + "image": "mcr.microsoft.com/devcontainers/base:debian", + "features": { + "claude-code": { + "mountHostConfig": true + } + } + }, + "alpine_specific": { + "image": "mcr.microsoft.com/devcontainers/base:alpine", + "features": { + "claude-code": {} + } + }, + "idempotency": { + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "features": { + "claude-code": {} + } + }, + "multi_feature_combo": { + "image": "mcr.microsoft.com/devcontainers/javascript-node", + "features": { + "ghcr.io/devcontainers/features/node:1": { + "version": "22" + }, + "claude-code": {} + } + } +} +``` + +- [ ] **Step 3: Make test.sh executable, validate JSON** + +Run: `chmod +x test/claude-code/test.sh && python3 -m json.tool test/claude-code/scenarios.json > /dev/null` +Expected: exits 0 + +- [ ] **Step 4: Commit** + +```bash +git add test/claude-code/test.sh test/claude-code/scenarios.json +git commit -m "feat: add test helpers and scenario definitions" +``` + +--- + +## Task 9: Per-Scenario Test Scripts + +**Files:** +- Create: `test/claude-code/default_options.sh` +- Create: `test/claude-code/completions_disabled.sh` +- Create: `test/claude-code/mcp_enabled.sh` +- Create: `test/claude-code/custom_version.sh` +- Create: `test/claude-code/node_preinstalled.sh` +- Create: `test/claude-code/custom_install_path.sh` +- Create: `test/claude-code/mount_host_config.sh` +- Create: `test/claude-code/alpine_specific.sh` +- Create: `test/claude-code/idempotency.sh` + +- [ ] **Step 1: Create `test_default_options.sh`** + +```bash +#!/usr/bin/env bash +set -Eeuo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=test.sh +source "${SCRIPT_DIR}/test.sh" + +echo "=== Scenario: default_options ===" +core_assertions + +echo "--- Completions ---" +# At least one completion directory should have the claude file +FOUND_COMPLETIONS=false +for path in \ + /usr/share/bash-completion/completions/claude \ + /etc/bash_completion.d/claude \ + /usr/share/zsh/site-functions/_claude \ + /usr/share/fish/vendor_completions.d/claude.fish \ + /usr/share/fish/completions/claude.fish; do + if [[ -f "${path}" ]]; then + FOUND_COMPLETIONS=true + pass "Completion file found: ${path}" + fi +done +if [[ "${FOUND_COMPLETIONS}" == "false" ]]; then + fail "No shell completion files found" +fi + +echo "--- MCP config should be absent ---" +check_file_absent "${HOME}/.claude/mcp_servers.json" + +test_summary +``` + +- [ ] **Step 2: Create `test_completions_disabled.sh`** + +```bash +#!/usr/bin/env bash +set -Eeuo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=test.sh +source "${SCRIPT_DIR}/test.sh" + +echo "=== Scenario: completions_disabled ===" +core_assertions + +echo "--- Completions should be absent ---" +check_file_absent /usr/share/bash-completion/completions/claude +check_file_absent /etc/bash_completion.d/claude +check_file_absent /usr/share/zsh/site-functions/_claude +check_file_absent /usr/share/fish/vendor_completions.d/claude.fish +check_file_absent /usr/share/fish/completions/claude.fish + +test_summary +``` + +- [ ] **Step 3: Create `test_mcp_enabled.sh`** + +```bash +#!/usr/bin/env bash +set -Eeuo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=test.sh +source "${SCRIPT_DIR}/test.sh" + +echo "=== Scenario: mcp_enabled ===" +core_assertions + +echo "--- MCP config ---" +MCP_CONFIG="${HOME}/.claude/mcp_servers.json" +check_file_exists "${MCP_CONFIG}" +check_file_valid_json "${MCP_CONFIG}" +check_file_owner "${MCP_CONFIG}" "$(whoami)" + +test_summary +``` + +- [ ] **Step 4: Create `test_custom_version.sh`** + +```bash +#!/usr/bin/env bash +set -Eeuo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=test.sh +source "${SCRIPT_DIR}/test.sh" + +echo "=== Scenario: custom_version ===" +core_assertions + +echo "--- Version check ---" +check_command_version "claude" "0.2.57" + +test_summary +``` + +- [ ] **Step 5: Create `test_node_preinstalled.sh`** + +```bash +#!/usr/bin/env bash +set -Eeuo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=test.sh +source "${SCRIPT_DIR}/test.sh" + +echo "=== Scenario: node_preinstalled ===" +core_assertions + +echo "--- Node.js should be unchanged ---" +# The javascript-node image ships Node.js via nvm. +# Verify Node.js is still available and meets minimum version. +check_command_exists "node" +check_node_min_version 18 + +test_summary +``` + +- [ ] **Step 6: Create `test_custom_install_path.sh`** + +```bash +#!/usr/bin/env bash +set -Eeuo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=test.sh +source "${SCRIPT_DIR}/test.sh" + +echo "=== Scenario: custom_install_path ===" + +echo "--- Binary at custom path ---" +check_file_exists /opt/claude/bin/claude + +echo "--- PATH includes custom path ---" +if echo "${PATH}" | grep -q '/opt/claude/bin'; then + pass "PATH contains /opt/claude/bin" +else + fail "PATH does not contain /opt/claude/bin" +fi + +echo "--- Profile.d script exists ---" +check_file_exists /etc/profile.d/claude-code.sh + +core_assertions +test_summary +``` + +- [ ] **Step 7: Create `test_mount_host_config.sh`** + +```bash +#!/usr/bin/env bash +set -Eeuo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=test.sh +source "${SCRIPT_DIR}/test.sh" + +echo "=== Scenario: mount_host_config ===" +core_assertions + +echo "--- No actual mount should exist ---" +# The feature only logs docs, it does not mount anything +# We just verify claude works and no unexpected mounts exist +pass "mount_host_config is documentation-only (no mount to verify)" + +test_summary +``` + +- [ ] **Step 8: Create `test_alpine_specific.sh`** + +```bash +#!/usr/bin/env bash +set -Eeuo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=test.sh +source "${SCRIPT_DIR}/test.sh" + +echo "=== Scenario: alpine_specific ===" +core_assertions + +echo "--- Bash should be installed ---" +check_command_exists "bash" + +echo "--- APK cache should be clean ---" +APK_CACHE_COUNT=$(find /var/cache/apk/ -type f 2>/dev/null | wc -l) +if [[ "${APK_CACHE_COUNT}" -eq 0 ]]; then + pass "APK cache is clean" +else + fail "APK cache has ${APK_CACHE_COUNT} files" +fi + +test_summary +``` + +- [ ] **Step 9: Create `test_idempotency.sh`** + +```bash +#!/usr/bin/env bash +set -Eeuo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=test.sh +source "${SCRIPT_DIR}/test.sh" + +echo "=== Scenario: idempotency ===" +core_assertions + +echo "--- Idempotency: record state before second install ---" +CLAUDE_VERSION_BEFORE=$(claude --version 2>&1) +NODE_VERSION_BEFORE=$(node --version 2>&1) + +echo "--- Idempotency: run install.sh a second time ---" +# Re-run install as root to simulate a container rebuild +sudo bash /usr/local/share/claude-code/install.sh 2>&1 || { + fail "Second install.sh run failed" + test_summary +} + +echo "--- Idempotency: verify state unchanged ---" +CLAUDE_VERSION_AFTER=$(claude --version 2>&1) +NODE_VERSION_AFTER=$(node --version 2>&1) + +if [[ "${CLAUDE_VERSION_BEFORE}" == "${CLAUDE_VERSION_AFTER}" ]]; then + pass "Claude Code version unchanged after re-install: ${CLAUDE_VERSION_AFTER}" +else + fail "Claude Code version changed: ${CLAUDE_VERSION_BEFORE} -> ${CLAUDE_VERSION_AFTER}" +fi + +if [[ "${NODE_VERSION_BEFORE}" == "${NODE_VERSION_AFTER}" ]]; then + pass "Node.js version unchanged after re-install: ${NODE_VERSION_AFTER}" +else + fail "Node.js version changed: ${NODE_VERSION_BEFORE} -> ${NODE_VERSION_AFTER}" +fi + +test_summary +``` + +- [ ] **Step 10: Create `test_multi_feature_combo.sh`** + +```bash +#!/usr/bin/env bash +set -Eeuo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=test.sh +source "${SCRIPT_DIR}/test.sh" + +echo "=== Scenario: multi_feature_combo ===" +core_assertions + +echo "--- Node.js from separate feature should still work ---" +check_command_exists "node" +check_node_min_version 18 + +echo "--- Claude Code should coexist with separate Node feature ---" +check_command_runs "claude" + +test_summary +``` + +- [ ] **Step 11: Make all test scripts executable** + +Run: `chmod +x test/claude-code/*.sh` + +- [ ] **Step 12: Run ShellCheck on all test scripts** + +Run: `shellcheck test/claude-code/*.sh` +Expected: exits 0, no warnings + +- [ ] **Step 13: Commit** + +```bash +git add test/claude-code/default_options.sh test/claude-code/completions_disabled.sh test/claude-code/mcp_enabled.sh test/claude-code/custom_version.sh test/claude-code/node_preinstalled.sh test/claude-code/custom_install_path.sh test/claude-code/mount_host_config.sh test/claude-code/alpine_specific.sh test/claude-code/idempotency.sh test/claude-code/multi_feature_combo.sh +git commit -m "feat: add per-scenario test scripts" +``` + +--- + +## Task 10: Pre-commit Configuration + +**Files:** +- Create: `.pre-commit-config.yaml` + +- [ ] **Step 1: Create `.pre-commit-config.yaml`** + +```yaml +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v6.0.0 + hooks: + - id: check-json + - id: check-yaml + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-merge-conflict + - id: detect-private-key + - id: check-added-large-files + args: ["--maxkb=500"] + - id: no-commit-to-branch + args: ["--branch", "main"] + + - repo: https://github.com/koalaman/shellcheck-precommit + rev: v0.11.0 + hooks: + - id: shellcheck + args: ["--severity=warning"] + + - repo: https://github.com/scop/pre-commit-shfmt + rev: v3.13.0-1 + hooks: + - id: shfmt + args: ["-i", "4", "-ci"] + + - repo: https://github.com/pre-commit/mirrors-prettier + rev: v4.0.0-alpha.8 + hooks: + - id: prettier + types_or: [json, yaml, markdown] + + - repo: https://github.com/igorshubovych/markdownlint-cli + rev: v0.48.0 + hooks: + - id: markdownlint + args: ["--fix"] +``` + +- [ ] **Step 2: Commit** + +```bash +git add .pre-commit-config.yaml +git commit -m "chore: add pre-commit hooks configuration" +``` + +--- + +## Task 11: CI/CD — Test Workflow + +**Files:** +- Create: `.github/workflows/test.yml` + +- [ ] **Step 1: Create `test.yml`** + +The CI has two test dimensions: +1. **Scenario tests** — run `scenarios.json` (which maps scenario names to `test_.sh` scripts). The devcontainer CLI reads `scenarios.json` directly and invokes the correct per-scenario test script automatically. Do NOT use `--skip-scenarios`. +2. **Image matrix tests** — run the default `test.sh` (core assertions only) across all 25+ base images to verify universal install compatibility. + +```yaml +name: "Test" + +on: + pull_request: + push: + branches: [main] + +concurrency: + group: "${{ github.workflow }}-${{ github.ref }}" + cancel-in-progress: true + +permissions: {} + +jobs: + lint: + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: ShellCheck + uses: ludeeus/action-shellcheck@00cae500b08a931fb5698e11e79bfbd38e612a38 # 2.0.0 + with: + severity: warning + + - name: Validate JSON + run: | + FAIL=0 + while IFS= read -r -d '' f; do + if ! python3 -m json.tool "$f" > /dev/null 2>&1; then + echo "ERROR: Invalid JSON: $f" + FAIL=1 + fi + done < <(find . -name '*.json' -not -path './.git/*' -print0) + exit "$FAIL" + + - name: Validate YAML + run: | + uvx yamllint@1.38.0 -d relaxed .github/workflows/ + + - name: Prettier check + run: | + npx prettier@4.0.0-alpha.8 --check "**/*.{json,yml,yaml,md}" --ignore-path .gitignore + + - name: Markdownlint + run: | + npx markdownlint-cli@0.48.0 "**/*.md" --ignore node_modules + + - name: Check .sh files are executable + run: | + FAIL=0 + while IFS= read -r -d '' f; do + if [[ ! -x "$f" ]]; then + echo "ERROR: $f is not executable" + FAIL=1 + fi + done < <(find . -name '*.sh' -not -path './.git/*' -print0) + exit "$FAIL" + + # Run all per-scenario tests (options-specific assertions) + test-scenarios: + needs: lint + runs-on: ubuntu-latest + timeout-minutes: 60 # 10 scenarios building containers takes time + permissions: + contents: read + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + + - name: Install devcontainer CLI + run: npm install -g @devcontainers/cli@0.85.0 + + - name: Run all scenarios + run: devcontainer features test --project-folder . 2>&1 | tee /tmp/scenario-test-output.log + + - name: Upload logs on failure + if: failure() + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: logs-scenarios + path: /tmp/scenario-test-output.log + retention-days: 7 + + # Run core assertions across all supported base images + test-image-matrix: + needs: lint + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + strategy: + fail-fast: false + max-parallel: 10 + matrix: + image: + # Raw OS images + - "ubuntu:22.04" + - "ubuntu:24.04" + - "debian:bullseye" + - "debian:bookworm" + - "alpine:3.19" + - "alpine:3.20" + - "alpine:3.21" + - "archlinux:latest" + - "fedora:39" + - "fedora:40" + - "rockylinux:9" + - "almalinux:9" + - "amazonlinux:2023" + # DevContainer base images + - "mcr.microsoft.com/devcontainers/base:debian" + - "mcr.microsoft.com/devcontainers/base:ubuntu" + - "mcr.microsoft.com/devcontainers/base:alpine" + - "mcr.microsoft.com/devcontainers/universal:2" + # Language-specific images + - "mcr.microsoft.com/devcontainers/python" + - "mcr.microsoft.com/devcontainers/javascript-node" + - "mcr.microsoft.com/devcontainers/typescript-node" + - "mcr.microsoft.com/devcontainers/rust" + - "mcr.microsoft.com/devcontainers/go" + - "mcr.microsoft.com/devcontainers/cpp" + - "mcr.microsoft.com/devcontainers/dotnet" + - "mcr.microsoft.com/devcontainers/java" + - "mcr.microsoft.com/devcontainers/ruby" + - "mcr.microsoft.com/devcontainers/php" + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + + - name: Install devcontainer CLI + run: npm install -g @devcontainers/cli@0.85.0 + + - name: Test on ${{ matrix.image }} + run: | + devcontainer features test \ + --features claude-code \ + --skip-scenarios \ + --base-image "${{ matrix.image }}" \ + --project-folder . 2>&1 | tee /tmp/test-output.log + + - name: Upload logs on failure + if: failure() + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: logs-amd64-${{ strategy.job-index }} + path: /tmp/test-output.log + retention-days: 7 + + # arm64 tests on native runners (reduced matrix) + test-arm64: + needs: lint + runs-on: ubuntu-24.04-arm64 + timeout-minutes: 30 + permissions: + contents: read + strategy: + fail-fast: false + max-parallel: 2 + matrix: + image: + - "ubuntu:24.04" + - "alpine:3.21" + - "mcr.microsoft.com/devcontainers/base:debian" + - "mcr.microsoft.com/devcontainers/universal:2" + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + + - name: Install devcontainer CLI + run: npm install -g @devcontainers/cli@0.85.0 + + - name: Test on ${{ matrix.image }} (arm64) + run: | + devcontainer features test \ + --features claude-code \ + --skip-scenarios \ + --base-image "${{ matrix.image }}" \ + --project-folder . 2>&1 | tee /tmp/test-output.log + + - name: Upload logs on failure + if: failure() + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: logs-arm64-${{ strategy.job-index }} + path: /tmp/test-output.log + retention-days: 7 +``` + +- [ ] **Step 2: Commit** + +```bash +git add .github/workflows/test.yml +git commit -m "ci: add test workflow with lint and exhaustive matrix" +``` + +--- + +## Task 12: CI/CD — Release Workflow + +**Files:** +- Create: `.github/workflows/release.yml` + +- [ ] **Step 1: Create `release.yml`** + +```yaml +name: "Release" + +on: + push: + tags: ["v*"] + +concurrency: + group: "release-${{ github.repository }}" + cancel-in-progress: false + +permissions: {} + +jobs: + validate: + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: ShellCheck + uses: ludeeus/action-shellcheck@00cae500b08a931fb5698e11e79bfbd38e612a38 # 2.0.0 + with: + severity: warning + + - name: Install devcontainer CLI + run: npm install -g @devcontainers/cli@0.85.0 + + - name: Smoke test (3 representative images) + run: | + for img in \ + "mcr.microsoft.com/devcontainers/base:ubuntu" \ + "mcr.microsoft.com/devcontainers/base:alpine" \ + "mcr.microsoft.com/devcontainers/universal:2"; do + echo "--- Smoke testing: ${img} ---" + devcontainer features test \ + --features claude-code \ + --skip-scenarios \ + --base-image "${img}" \ + --project-folder . + done + + version-check: + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Verify version matches tag + run: | + TAG_VERSION="${GITHUB_REF_NAME#v}" + JSON_VERSION=$(python3 -c " + import json + with open('src/claude-code/devcontainer-feature.json') as f: + print(json.load(f)['version']) + ") + if [[ "${TAG_VERSION}" != "${JSON_VERSION}" ]]; then + echo "ERROR: Tag version (${TAG_VERSION}) does not match feature version (${JSON_VERSION})" + exit 1 + fi + echo "Version match: ${TAG_VERSION}" + + publish: + needs: [validate, version-check] + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Publish feature + uses: devcontainers/action@1082abd5d2bf3a11abccba70eef98df068277772 # v1.4.3 + with: + publish-features: "true" + base-path-to-features: "./src" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + post-publish: + needs: publish + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + packages: read + steps: + - name: Verify published feature + run: | + npm install -g @devcontainers/cli@0.85.0 + TAG_VERSION="${GITHUB_REF_NAME#v}" + FEATURE_REF="ghcr.io/${{ github.repository }}/claude-code:${TAG_VERSION}" + echo "Verifying: ${FEATURE_REF}" + devcontainer features info manifest "${FEATURE_REF}" || { + echo "ERROR: Published feature not accessible at ${FEATURE_REF}" + exit 1 + } + echo "Published feature verified successfully." +``` + +- [ ] **Step 2: Commit** + +```bash +git add .github/workflows/release.yml +git commit -m "ci: add release workflow with version check and publish" +``` + +--- + +## Task 13: Contributor DevContainer and README + +**Files:** +- Create: `.devcontainer/devcontainer.json` +- Create: `README.md` + +- [ ] **Step 1: Create contributor devcontainer** + +```json +{ + "name": "Claude Code Feature Development", + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "features": { + "ghcr.io/devcontainers/features/node:1": { + "version": "22" + } + }, + "postCreateCommand": "npm install -g @devcontainers/cli && pre-commit install || true", + "customizations": { + "vscode": { + "extensions": [ + "timonwong.shellcheck", + "foxundermoon.shell-format", + "esbenp.prettier-vscode" + ] + } + } +} +``` + +- [ ] **Step 2: Create `README.md`** + +```markdown +# Claude Code DevContainer Feature + +[![Test](https://github.com/pkramek/claude-code-devcontainer/actions/workflows/test.yml/badge.svg)](https://github.com/pkramek/claude-code-devcontainer/actions/workflows/test.yml) + +Install [Claude Code](https://docs.anthropic.com/en/docs/claude-code) into any +devcontainer. Supports Debian, Ubuntu, Alpine, Arch, Fedora, RHEL, Rocky, Alma, +and Amazon Linux on amd64 and arm64. + +## Usage + +Add this feature to your `devcontainer.json`: + +```json +{ + "features": { + "ghcr.io/pkramek/claude-code-devcontainer/claude-code:1": {} + } +} +``` + +### Options + +| Option | Type | Default | Description | +|---|---|---|---| +| `version` | string | `latest` | Claude Code version (semver or `latest`) | +| `nodeVersion` | string | `lts` | Node.js version if not present (>= 18) | +| `installPath` | string | `/usr/local` | Custom npm global prefix | +| `enableMcpServers` | boolean | `false` | Create starter MCP config | +| `mountHostConfig` | boolean | `false` | Log mount snippet for host config | +| `shellCompletions` | boolean | `true` | Install bash/zsh/fish completions | + +### Examples + +Pin a specific version: + +```json +{ + "features": { + "ghcr.io/pkramek/claude-code-devcontainer/claude-code:1": { + "version": "1.0.0" + } + } +} +``` + +Enable MCP servers: + +```json +{ + "features": { + "ghcr.io/pkramek/claude-code-devcontainer/claude-code:1": { + "enableMcpServers": true + } + } +} +``` + +## Authentication + +Claude Code requires authentication. Options: + +1. **Environment variable:** Set `ANTHROPIC_API_KEY` in your devcontainer: + + ```json + { + "remoteEnv": { + "ANTHROPIC_API_KEY": "${localEnv:ANTHROPIC_API_KEY}" + } + } + ``` + +2. **Mount host config:** Mount your local `~/.claude` directory: + + ```json + { + "mounts": [ + "source=${localEnv:HOME}/.claude,target=/home/vscode/.claude,type=bind,consistency=cached,readonly" + ] + } + ``` + + > **Security warning:** This exposes your API keys inside the container. + > If the container is compromised, credentials are at risk. + +## Tested Images + +This feature is tested on 25+ base images across amd64 and arm64. See the +[test workflow](.github/workflows/test.yml) for the full matrix. + +## Runtime Verification + +Add this to your `devcontainer.json` to verify Claude Code at container start: + +```json +{ + "postCreateCommand": "claude --version || true" +} +``` + +## Publishing (Maintainers) + +After the first release tag push, the GHCR package is created as **private**. +You must manually change it to public: + +1. Go to the repository's **Packages** tab +2. Click the `claude-code` package +3. Go to **Package settings** +4. Under **Danger Zone**, change visibility to **Public** + +## Contributing + +1. Fork the repository +2. Open in a devcontainer (`.devcontainer/devcontainer.json` is provided) +3. Make changes +4. Run `pre-commit run --all-files` before committing +5. Open a pull request + +## License + +MIT +``` + +- [ ] **Step 3: Commit** + +```bash +git add .devcontainer/devcontainer.json README.md +git commit -m "docs: add contributor devcontainer and README" +``` + +--- + +## Self-Review Checklist + +| Spec Section | Covered By | +|---|---| +| 1. Overview | All tasks combined | +| 2. Repository Structure | Task 1-13 file map | +| 3. Feature Manifest | Task 2 | +| 3. Lifecycle Hooks (postCreateCommand) | Task 13 (README documents recommended postCreateCommand for users) | +| 3. Security (mountHostConfig) | Task 7 (setup_mount_docs), Task 13 (README) | +| 4. Alpine Bootstrap | Task 3 (POSIX bootstrap) | +| 4. Error Handling | Task 3 (set -Eeuo pipefail, traps) | +| 4. Input Validation | Task 3 (validate_version, validate_install_path) | +| 4. OS Detection | Task 4 (detect_os, detect_arch) | +| 4. Dependencies | Task 4 (ensure_base_dependencies) | +| 4. Node.js Installation | Task 5 (ensure_node, decision tree) | +| 4. PATH Configuration | Task 6 (configure_custom_path) | +| 4. Claude Code Installation | Task 6 (install_claude_code) | +| 4. Shell Completions | Task 7 (setup_completions) | +| 4. MCP Servers | Task 7 (setup_mcp_servers) | +| 4. Mount Documentation | Task 7 (setup_mount_docs) | +| 4. Remote User Detection | Task 3 (detect_remote_user, detect_user_home) | +| 4. Cleanup | Task 7 (cleanup_caches) | +| 5. Test Helpers | Task 8 (test.sh) | +| 5. Scenarios | Task 8 (scenarios.json) | +| 5. Per-Scenario Tests | Task 9 (all 9 scripts) | +| 6. CI test.yml | Task 11 | +| 6. CI release.yml | Task 12 | +| 7. Pre-commit Hooks | Task 10 | +| 8. License | Task 1 | +| 10. Success Criteria | Covered by test matrix + CI | +| 11. Versioning | Task 12 (version-check job) | diff --git a/docs/superpowers/specs/2026-03-31-claude-code-devcontainer-feature-design.md b/docs/superpowers/specs/2026-03-31-claude-code-devcontainer-feature-design.md new file mode 100644 index 0000000..e4aa53c --- /dev/null +++ b/docs/superpowers/specs/2026-03-31-claude-code-devcontainer-feature-design.md @@ -0,0 +1,606 @@ +# Claude Code DevContainer Feature — Design Spec + +**Date:** 2026-03-31 +**Status:** Draft (v3 — revised after two rounds of architecture, shell, and CI/CD reviews) +**Goal:** Build an industry-standard, universally compatible DevContainer Feature that installs Claude Code into any existing devcontainer seamlessly. + +--- + +## 1. Overview + +A DevContainer Feature published to `ghcr.io` that installs Claude Code via npm into any devcontainer environment. It auto-detects the host OS and package manager, ensures Node.js is available, installs Claude Code globally, and optionally sets up shell completions, host config mounting documentation, and MCP server stubs. + +The feature must work on Debian, Ubuntu, Alpine, Arch, Fedora/RHEL, Rocky/Alma, and Amazon Linux across amd64 and arm64 architectures with zero disruption to the user's existing devcontainer setup. + +## 2. Repository Structure + +``` +claude-code-devcontainer/ +├── src/ +│ └── claude-code/ +│ ├── devcontainer-feature.json # Feature manifest +│ └── install.sh # Installation script (bash) +├── test/ +│ └── claude-code/ +│ ├── test.sh # Default test (shared assertions) +│ ├── scenarios.json # Test scenario definitions +│ ├── test_default_options.sh # Scenario: default options +│ ├── test_completions_disabled.sh # Scenario: shellCompletions=false +│ ├── test_mcp_enabled.sh # Scenario: enableMcpServers=true +│ ├── test_custom_version.sh # Scenario: pinned version +│ ├── test_node_preinstalled.sh # Scenario: Node.js already present +│ ├── test_custom_install_path.sh # Scenario: custom installPath +│ ├── test_mount_host_config.sh # Scenario: mountHostConfig=true +│ ├── test_alpine_specific.sh # Scenario: Alpine-specific behavior +│ └── test_idempotency.sh # Scenario: double-install idempotency +├── .github/ +│ └── workflows/ +│ ├── test.yml # CI: lint + test matrix (PRs + push to main) +│ └── release.yml # CD: publish to ghcr.io (tag push) +├── .devcontainer/ +│ └── devcontainer.json # Self-development devcontainer for contributors +├── .pre-commit-config.yaml # Pre-commit hooks +├── .shellcheckrc # ShellCheck configuration +├── .editorconfig # Formatting standards (consumed by shfmt) +├── LICENSE # MIT +└── README.md # Usage docs with CI status badge +``` + +Follows the official `devcontainers/feature-template` convention. Per-scenario test scripts enable targeted assertions (positive and negative) for each option combination. + +## 3. Feature Manifest (`devcontainer-feature.json`) + +### Metadata + +- **id:** `claude-code` +- **name:** `Claude Code` +- **version:** `1.0.0` +- **description:** Install Claude Code CLI into any devcontainer +- **documentationURL:** Points to repo README +- **licenseURL:** Points to LICENSE file in repo +- **containerEnv:** Sets `CLAUDE_CODE_INSTALLED=true` — documented as a stable public contract for downstream features/scripts to detect Claude Code presence +- **installsAfter:** `["ghcr.io/devcontainers/features/node"]` — soft ordering hint only, NOT a hard dependency. The install script is fully self-contained and will install Node.js on its own if needed. + +### Options + +| Option | Type | Default | Description | +|---|---|---|---| +| `version` | string | `"latest"` | Claude Code version to install (semver or `"latest"`. Note: `"latest"` is non-deterministic across builds — recommend pinning for teams) | +| `nodeVersion` | string | `"lts"` | Node.js version to install if not already present. Resolved via NodeSource, not distro packages. Minimum floor: Node.js >= 18. | +| `installPath` | string | `"/usr/local"` | Custom npm global install prefix. The feature will ensure `/bin` is on PATH for all shell contexts. | +| `enableMcpServers` | boolean | `false` | Drop a starter MCP configuration file at `~/.claude/mcp_servers.json` (create-if-absent, never overwrite). | +| `mountHostConfig` | boolean | `false` | **Documentation-only.** When true, the feature adds a comment to build output with the `mounts` snippet users should add to their `devcontainer.json`. Does NOT auto-mount. Defaults to false for security. | +| `shellCompletions` | boolean | `true` | Install shell completions for detected shells (bash, zsh, fish). | + +### Lifecycle Hooks + +- **`postCreateCommand`:** Runs `claude --version || true` as a **non-blocking** runtime verification step. Runs as the `remoteUser` (non-root). Logs the installed version for diagnostics. The `|| true` ensures a PATH issue in the runtime context does not prevent container creation — it logs the failure for diagnosis rather than blocking the user. + +### Removed: `autoUpdate` Option + +The original `autoUpdate` option was removed because `install.sh` runs at image build time only. "Update on every rebuild" would require a `postStartCommand` that runs `npm install -g` on every container start, which is slow and network-dependent. Users who want the latest version should rebuild their image (`devcontainer rebuild`). This is the standard pattern for DevContainer Features. + +### Security Considerations for `mountHostConfig` + +Mounting `~/.claude` from the host exposes API keys and tokens inside the container. The feature defaults `mountHostConfig` to `false` and documents: +- The security implications (container compromise = credential exposure) +- The cross-platform mount syntax using `${localEnv:HOME}` for macOS/Linux/WSL2 compatibility +- That `~/.claude` path may change in future Claude Code versions + +The feature does NOT auto-mount. It provides documentation only, leaving the security decision to the user. + +## 4. Installation Script (`install.sh`) + +### Shell Choice: Bash (not POSIX sh) + +The script uses `#!/usr/bin/env bash` explicitly. Rationale: +- POSIX sh (`dash` on Debian, `ash` on Alpine) has unreliable `set -e` semantics and lacks arrays, `[[ ]]`, and `pipefail` +- Bash is pre-installed on Debian, Ubuntu, Fedora, Arch, RHEL, Rocky, Alma, and Amazon Linux +- ShellCheck is configured with `--shell=bash` via `.shellcheckrc` + +### Alpine Bash Bootstrap + +**Critical implementation detail:** The DevContainer CLI invokes `install.sh` via `/bin/sh`, NOT via the shebang. This means the first lines of the script execute under Alpine's BusyBox `ash`. The script must: + +1. Begin with a POSIX-compatible bootstrap section that installs bash +2. Re-exec itself under bash: `exec bash "$0" "$@"` +3. Only then use bash-specific features + +```bash +#!/usr/bin/env bash +# --- POSIX-compatible bootstrap (runs under /bin/sh on Alpine) --- +if [ -z "${BASH_VERSION:-}" ]; then + # Not running under bash — install it and re-exec + if command -v apk > /dev/null 2>&1; then + apk add --no-cache bash > /dev/null 2>&1 + fi + exec bash "$0" "$@" +fi +# --- From here on, bash is guaranteed --- +``` + +This pattern is used by other DevContainer Features (e.g., the official `common-utils` feature) and is the standard approach for bash-dependent install scripts. + +### Error Handling Strategy + +```bash +# (After bash bootstrap — see Alpine Bootstrap section above) +set -Eeuo pipefail +# -E (errtrace): ERR trap propagates into functions and subshells +# -e: exit on error +# -u: error on unset variables +# -o pipefail: pipe fails if any command fails + +FEATURE_LOG_PREFIX="[claude-code feature]" + +# Debug mode: set -x for verbose tracing +if [[ "${DEBUG:-false}" == "true" ]]; then set -x; fi + +# ERR trap for diagnostics (single quotes: $LINENO/$? expand at trap time, not definition time) +trap 'echo "${FEATURE_LOG_PREFIX} ERROR: Failed at line ${LINENO}. Exit code: $?" >&2' ERR + +# EXIT trap for cleanup (also fires on INT/TERM for defense-in-depth) +trap cleanup EXIT INT TERM + +TEMP_DIR="" # Set to mktemp -d result when needed +cleanup() { + [[ -n "${TEMP_DIR}" ]] && rm -rf "${TEMP_DIR}" 2>/dev/null || true +} + +log_info() { echo "${FEATURE_LOG_PREFIX} $*"; } +log_warn() { echo "${FEATURE_LOG_PREFIX} WARNING: $*" >&2; } +log_error() { echo "${FEATURE_LOG_PREFIX} ERROR: $*" >&2; } +log_debug() { if [[ "${DEBUG:-false}" == "true" ]]; then echo "${FEATURE_LOG_PREFIX} DEBUG: $*"; fi; } +``` + +**Debug mode:** Set `DEBUG=true` environment variable to enable verbose logging including `set -x` tracing. Invaluable for CI failure diagnosis. + +### Input Validation + +All user-supplied options are validated before use in shell commands: + +```bash +# Validate version: must be "latest" or semver-like (digits, dots, hyphens, plus) +validate_version() { + local ver="$1" + if [[ "${ver}" == "latest" ]]; then return 0; fi + if [[ ! "${ver}" =~ ^[0-9][0-9a-zA-Z.+-]*$ ]]; then + log_error "Invalid version '${ver}'. Must be 'latest' or a valid semver string." + exit 1 + fi +} + +# Validate installPath: must be absolute, no special characters +validate_install_path() { + local path="$1" + if [[ ! "${path}" =~ ^/[a-zA-Z0-9/_.-]+$ ]]; then + log_error "Invalid installPath '${path}'. Must be an absolute path with no special characters." + exit 1 + fi +} +``` + +These are **safety gates** (preventing shell injection), not correctness gates. npm will reject invalid version strings with its own error message. The validation here prevents dangerous characters from reaching shell interpolation. + +### Step 1: Environment Detection + +- Detect OS family via `/etc/os-release` `ID` and `ID_LIKE` fields +- Supported families and their package managers: + - Debian/Ubuntu: `apt-get` + - Alpine: `apk` + - Arch: `pacman` + - Fedora/RHEL/Rocky/Alma/Amazon Linux: `dnf` (fallback `yum`) +- Detect architecture via `uname -m` (map `x86_64` -> `amd64`, `aarch64` -> `arm64`) +- Check if Node.js is already on PATH and its version + +### Step 2: Dependency Installation + +**On Alpine:** Bash is installed by the POSIX-compatible bootstrap section (see Alpine Bash Bootstrap above). By the time Step 2 runs, bash is already available. + +**Base dependencies** (installed if missing): `curl`, `git`, `ca-certificates` + +All package manager invocations use non-interactive flags: +- `apt-get -y` +- `apk add --no-cache` +- `pacman -S --noconfirm --needed` +- `dnf -y` / `yum -y` + +**Node.js installation decision tree:** + +1. If Node.js exists on PATH AND version >= 18: **use it, do not install** +2. If Node.js exists but version < 18: **log warning, install requested version via NodeSource** which overwrites the system Node.js in-place (same `/usr/bin/node` path). The old version is replaced, not kept alongside. This ensures PATH is unambiguous. +3. If Node.js is absent: **install via NodeSource** (Debian/Ubuntu/Fedora/RHEL) or **distro package** (Alpine `apk add nodejs npm`, Arch `pacman -S nodejs npm`) at the requested `nodeVersion` +4. `"lts"` resolves to the current LTS version number via NodeSource's release metadata +5. **Post-install version check (Alpine/Arch):** After distro package install, verify the installed Node.js is >= 18. If not, fail with a clear error: `"Node.js $(node --version) from distro packages is below minimum 18. Use a newer base image or set nodeVersion to install via NodeSource."` + +**Why NodeSource over nvm/fnm:** DevContainer Features run as root at build time. `nvm` is user-scoped and creates PATH complications for the remote user. NodeSource installs system-wide Node.js that is available to all users and all shell contexts. + +**Alpine musl consideration:** Claude Code is distributed as a JavaScript package via npm (no native binary addons). Therefore, musl vs glibc is NOT a concern — no `libc6-compat` or `gcompat` needed. If this assumption changes (e.g., Claude Code adds native modules), this section must be revisited and Alpine support may need to be reconsidered. + +**Proxy/registry support:** The script respects `HTTP_PROXY`, `HTTPS_PROXY`, `NO_PROXY`, and `npm_config_registry` environment variables. No special handling needed — npm and curl honor these natively. + +**npm retry:** The `npm install` command uses npm's built-in `--fetch-retries=3` to handle transient registry failures (503s, timeouts). Combined with the `timeout 300` wrapper, this provides resilience without custom retry logic. + +### Step 3: PATH Configuration for Custom `installPath` (before install) + +If `installPath` is not `/usr/local` (which is already on PATH), the feature ensures discoverability **before** running `npm install`: + +1. Update `PATH` in the current script context immediately (so `claude` is findable for verification) +2. Create `/etc/profile.d/claude-code.sh` for bash/zsh login shells in future sessions +3. Set `ENV` variable pointing to a script for Alpine ash non-login shells +4. Use `npm install --prefix` flag instead of `npm config set prefix` to avoid polluting the global npmrc (which would affect the remote user's future npm operations) + +### Step 4: Claude Code Installation + +```bash +# Use --prefix flag (less invasive than npm config set prefix) +if [[ "${INSTALL_PATH}" != "/usr/local" ]]; then + export PATH="${INSTALL_PATH}/bin:${PATH}" + timeout 300 npm install -g --prefix "${INSTALL_PATH}" --fetch-retries=3 "@anthropic-ai/claude-code@${VERSION}" || { + log_error "Failed to install Claude Code. Check network connectivity and npm registry access." + exit 1 + } +else + timeout 300 npm install -g --fetch-retries=3 "@anthropic-ai/claude-code@${VERSION}" || { + log_error "Failed to install Claude Code. Check network connectivity and npm registry access." + exit 1 + } +fi + +# Verify installation (PATH is already set from Step 3) +claude --version || { + log_error "Claude Code installed but 'claude' command not found on PATH." + exit 1 +} +``` + +The `timeout 300` (5 minutes) prevents indefinite hangs on network issues during CI or restricted environments. + +### Step 5: Batteries-Included Setup + +**Shell completions** (`shellCompletions=true`): +- Detect available shells by checking for their completion directories +- Bash: `/etc/bash_completion.d/claude` (Debian/Ubuntu/Fedora) or `/usr/share/bash-completion/completions/claude` (Arch/Alpine) +- Zsh: `/usr/share/zsh/site-functions/_claude` +- Fish: `/usr/share/fish/vendor_completions.d/claude.fish` (detected dynamically — path may vary on Alpine depending on Fish installation method) +- **Graceful degradation:** If completion installation fails, log a warning but do NOT abort the build + +**MCP servers** (`enableMcpServers=true`): +- Target file: `${_REMOTE_USER_HOME}/.claude/mcp_servers.json` +- Strategy: **create-if-absent** — never overwrite an existing file +- Content: minimal starter config with comments explaining how to extend +- Owned by `$_REMOTE_USER` + +**Host config documentation** (`mountHostConfig=true`): +- Logs the following snippet to build output: + ``` + To mount your host Claude config, add this to your devcontainer.json: + "mounts": ["source=${localEnv:HOME}/.claude,target=/home/${_REMOTE_USER}/.claude,type=bind,consistency=cached"] + ``` +- Does NOT auto-mount (security decision documented in Section 3) + +### Step 6: Permissions and Cleanup + +**Remote user detection** (using DevContainer-provided variables): +1. Use `$_REMOTE_USER` if set +2. Else use `$_CONTAINER_USER` if set +3. Else detect first non-root user from `/etc/passwd` with a valid shell +4. Else fall back to `root` + +Home directory: use `$_REMOTE_USER_HOME` if set, else look up via `getent passwd "${DETECTED_USER}" | cut -d: -f6`. **Do NOT use `eval echo ~${user}`** — this is a code injection risk if the username contains unexpected characters. + +**Ownership:** Set `chown` on specific paths only (never `chown -R` on the entire home directory): +- `~/.claude/` directory (if created by this feature) +- `~/.claude/mcp_servers.json` (if created by `enableMcpServers`) +- No other files in the home directory are touched + +**Cache cleanup:** +- `apt-get clean && rm -rf /var/lib/apt/lists/*` (Debian/Ubuntu) +- `rm -rf /var/cache/apk/*` (Alpine) +- `pacman -Scc --noconfirm` (Arch) +- `dnf clean all` (Fedora/RHEL) +- `npm cache clean --force` (all distros) + +**Idempotency:** The script is safe to run multiple times. `npm install -g` overwrites cleanly. Dependency installation uses `--needed` (pacman) or is naturally idempotent (apt, apk, dnf). File creation uses create-if-absent patterns. + +## 5. Testing Strategy + +### Test Architecture + +Each scenario in `scenarios.json` has a dedicated test script at `test/claude-code/test_.sh`. This enables: +- **Positive assertions:** verify expected behavior when an option is enabled +- **Negative assertions:** verify things are NOT present when an option is disabled +- **Option-specific verification:** e.g., pinned version matches exactly + +Test scripts run as the **non-root remote user**, not root. This validates the permission model. + +### Shared Test Helpers (`test.sh`) + +Common assertion functions used by all scenario scripts: +- `check_command_exists ` — verify binary on PATH +- `check_command_version ` — verify version output +- `check_file_exists ` — verify file presence +- `check_file_absent ` — verify file absence (for negative tests) +- `check_env_var ` — verify environment variable +- `check_permissions ` — verify file permissions +- `check_path_clean ` — verify cache directories are clean + +### Core Assertions (all scenarios) + +- `claude` binary exists and is on PATH +- `claude --version` exits 0 and outputs a version string +- Node.js is available and >= 18 (`node --version`) +- Non-root user can execute `claude` +- `CLAUDE_CODE_INSTALLED=true` environment variable is set +- `claude` binary has correct permissions (755) +- Package manager caches are cleaned + +### Per-Scenario Assertions + +| Scenario | Assertions | +|---|---| +| `default_options` | Completions exist for detected shells. MCP config absent. | +| `completions_disabled` | Completion files do NOT exist for any shell. | +| `mcp_enabled` | `~/.claude/mcp_servers.json` exists, is valid JSON, owned by remote user. | +| `custom_version` | `claude --version` outputs exact pinned version. | +| `node_preinstalled` | Existing Node.js version unchanged. No second Node.js installation. | +| `custom_install_path` | Binary at `/bin/claude`. PATH includes `/bin`. | +| `mount_host_config` | Build output contains the mount snippet documentation. No actual mount created. | +| `alpine_specific` | Bash was installed. Completions at Alpine-specific paths. `apk` caches cleaned. | +| `idempotency` | Run install twice — no errors, same end state. | + +### Test Matrix (`scenarios.json`) + +Each scenario specifies a base image and feature options. + +**Raw OS images:** +- `ubuntu:22.04`, `ubuntu:24.04` +- `debian:bullseye`, `debian:bookworm` +- `alpine:3.19`, `alpine:3.20`, `alpine:3.21` +- `archlinux:latest` +- `fedora:39`, `fedora:40` +- `rockylinux:9`, `almalinux:9` +- `amazonlinux:2023` + +**DevContainer base images:** +- `mcr.microsoft.com/devcontainers/base:debian` +- `mcr.microsoft.com/devcontainers/base:ubuntu` +- `mcr.microsoft.com/devcontainers/base:alpine` +- `mcr.microsoft.com/devcontainers/universal:2` (Codespaces default — critical) + +**Language-specific devcontainer images:** +- `mcr.microsoft.com/devcontainers/python` +- `mcr.microsoft.com/devcontainers/javascript-node` +- `mcr.microsoft.com/devcontainers/typescript-node` +- `mcr.microsoft.com/devcontainers/rust` +- `mcr.microsoft.com/devcontainers/go` +- `mcr.microsoft.com/devcontainers/cpp` +- `mcr.microsoft.com/devcontainers/dotnet` +- `mcr.microsoft.com/devcontainers/java` +- `mcr.microsoft.com/devcontainers/ruby` +- `mcr.microsoft.com/devcontainers/php` + +**Multi-feature combo scenario:** +- `devcontainers/javascript-node` with `ghcr.io/devcontainers/features/node` already present — validates `installsAfter` ordering and no version conflict + +**Concrete `scenarios.json` example** (subset — full file includes all scenarios): +```json +{ + "default_options": { + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "features": { + "claude-code": {} + } + }, + "completions_disabled": { + "image": "mcr.microsoft.com/devcontainers/base:debian", + "features": { + "claude-code": { + "shellCompletions": false + } + } + }, + "custom_version": { + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "features": { + "claude-code": { + "version": "PINNED_VERSION" + } + } + }, + "node_preinstalled": { + "image": "mcr.microsoft.com/devcontainers/javascript-node", + "features": { + "claude-code": {} + } + }, + "mcp_enabled": { + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "features": { + "claude-code": { + "enableMcpServers": true + } + } + }, + "mount_host_config": { + "image": "mcr.microsoft.com/devcontainers/base:debian", + "features": { + "claude-code": { + "mountHostConfig": true + } + } + }, + "alpine_specific": { + "image": "mcr.microsoft.com/devcontainers/base:alpine", + "features": { + "claude-code": {} + } + }, + "idempotency": { + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "features": { + "claude-code": {} + } + } +} +``` + +**Note on `custom_version`:** The `PINNED_VERSION` placeholder must be replaced with a known-good recent version at implementation time. This version should be updated periodically (or queried from npm in CI) to avoid rot if older versions are unpublished. + +### Architecture Testing Strategy + +**amd64:** Full matrix — all images and all scenarios. Primary gate. + +**arm64:** Reduced matrix on **native** GitHub Actions arm64 runners (`runs-on: ubuntu-24.04-arm`). No QEMU emulation (too slow, too flaky). Tested images: +- `ubuntu:24.04` +- `alpine:3.21` +- `mcr.microsoft.com/devcontainers/base:debian` +- `mcr.microsoft.com/devcontainers/universal:2` + +Matrix `exclude` list for images without arm64 variants (e.g., `archlinux` if no arm64 image exists). + +## 6. CI/CD Pipeline + +### Split into Two Workflows + +**`test.yml`** — runs on PRs and push to main: + +```yaml +concurrency: + group: "${{ github.workflow }}-${{ github.ref }}" + cancel-in-progress: true + +permissions: + contents: read +``` + +**Stages:** + +1. **Lint** (single job, `timeout-minutes: 10`) + - ShellCheck on all `.sh` files (`--shell=bash --severity=warning`) + - JSON schema validation on `devcontainer-feature.json` + - `check-yaml` on all YAML files + - Prettier check on JSON/YAML/Markdown + - `markdownlint` on Markdown files + - Custom step: verify `.sh` files have execute bit set + +2. **Test (amd64)** (matrix job) + - `runs-on: ubuntu-latest` + - `strategy: { fail-fast: false, max-parallel: 10 }` + - Matrix across all images and scenarios + - Uses pinned `@devcontainers/cli` version + - `timeout-minutes: 30` per job + - Docker layer caching via `docker/setup-buildx-action` with `cache-from: type=gha` + - On failure: upload Docker build logs via `actions/upload-artifact` + +3. **Test (arm64)** (matrix job) + - `runs-on: ubuntu-24.04-arm` (native arm64 runner) + - Reduced matrix (4 representative images) + - `strategy: { fail-fast: false, max-parallel: 2 }` (arm64 runners may have lower availability) + - `timeout-minutes: 30` + - Docker layer caching via `docker/setup-buildx-action` with `cache-from: type=gha` (same as amd64) + - On failure: upload Docker build logs via `actions/upload-artifact` (same as amd64) + +**`release.yml`** — runs on `v*` tag push: + +```yaml +permissions: {} # deny all at top level + +concurrency: + group: "release-${{ github.repository }}" + cancel-in-progress: false # never cancel an in-flight release + +on: + push: + tags: ['v*'] +``` + +**Stages** (each job declares its own permissions): + +1. **Validate** (`timeout-minutes: 15`, `permissions: { contents: read }`) — lint + smoke test (3 representative images) +2. **Version check** (`timeout-minutes: 5`, `permissions: { contents: read }`) — verify `devcontainer-feature.json` version matches git tag (strip `v` prefix) +3. **Publish** (`timeout-minutes: 15`, `permissions: { contents: read, packages: write }`) — uses `devcontainers/action@v1` (pinned to SHA) to publish to `ghcr.io` +4. **Post-publish** (`timeout-minutes: 10`, `permissions: { packages: read }`) — verify the published feature is pullable by running `devcontainer features info` against the published OCI artifact + +**Post-first-publish manual step:** Change GHCR package visibility from private to public. Documented in README. + +### CI Security Hardening + +- Top-level `permissions: {}` (deny all), grant per-job +- Third-party actions pinned to SHA hashes +- No secrets in test matrix (Claude Code is installed but not authenticated in CI) + +## 7. Pre-commit Hooks + +### `.shellcheckrc` +``` +shell=bash +severity=warning +``` + +### `.editorconfig` +```ini +[*.sh] +indent_style = space +indent_size = 4 +``` + +### `.pre-commit-config.yaml` + +| Hook | Source | Purpose | +|---|---|---| +| `shellcheck` | `koalaman/shellcheck-precommit` | Lint shell scripts (bash dialect, warning severity) | +| `shfmt` | `scop/pre-commit-shfmt` | Enforce consistent formatting (4-space indent per `.editorconfig`) | +| `check-json` | `pre-commit/pre-commit-hooks` | Validate JSON files | +| `check-yaml` | `pre-commit/pre-commit-hooks` | Validate YAML files | +| `trailing-whitespace` | `pre-commit/pre-commit-hooks` | Remove trailing whitespace | +| `end-of-file-fixer` | `pre-commit/pre-commit-hooks` | Ensure newline at end of files | +| `check-merge-conflict` | `pre-commit/pre-commit-hooks` | Prevent committing merge markers | +| `detect-private-key` | `pre-commit/pre-commit-hooks` | Prevent committing SSH private keys | +| `check-added-large-files` | `pre-commit/pre-commit-hooks` | Prevent committing large binaries | +| `no-commit-to-branch` | `pre-commit/pre-commit-hooks` | Protect `main` branch from direct pushes | +| `prettier` | `pre-commit/mirrors-prettier` | Format JSON, YAML, and Markdown | +| `markdownlint` | `igorshubovych/markdownlint-cli` | Structural Markdown linting | + +**Note:** There is no standard pre-commit hook to enforce `.sh` files have the execute bit set. This will be validated in CI via a custom script step instead. + +## 8. License + +MIT License — open source, free to use. + +## 9. Pre-Implementation Quality Gates + +Before writing any code: +1. **Deep research agent** — Validate this design against the latest devcontainers spec, `devcontainers/action` documentation, and `@devcontainers/cli` test framework behavior +2. **Parallel review agents** — Architecture, shell scripting, and CI/CD review passes (completed: 2 rounds, all critical findings resolved) + +## 10. Success Criteria + +- Feature installs cleanly on every image in the test matrix +- `claude --version` works for both root and non-root users +- Zero warnings from ShellCheck +- CI pipeline passes on all amd64 matrix combinations +- CI pipeline passes on reduced arm64 matrix +- Per-scenario tests validate both positive and negative assertions +- Published to ghcr.io and installable via `"features": { "ghcr.io//claude-code-devcontainer/claude-code:1": {} }` +- README provides clear usage instructions with examples, CI status badge, and security documentation for host config mounting + +## 11. Versioning Strategy + +The feature uses semver. Version in `devcontainer-feature.json` must match the git tag (without `v` prefix). CI enforces this. + +- **Patch:** Bug fixes, dependency updates, new distro support +- **Minor:** New options, new capabilities (backward-compatible) +- **Major:** Breaking changes to option behavior or defaults + +Users pin by major version: `claude-code:1`. This is independent of the Claude Code npm package version. + +## 12. Known Assumptions and Risks + +| Assumption | Risk if Wrong | Mitigation | +|---|---|---| +| Claude Code npm package is pure JS (no native addons) | Alpine support breaks | Remove Alpine from matrix, document limitation | +| `~/.claude` is the config directory | Host config mount breaks | Check `claude` CLI for config path discovery | +| NodeSource supports all target distros | Node.js install fails | Fallback to distro packages for unsupported distros | +| Alpine/Arch distro packages ship Node.js >= 18 | Hard failure with error message directing user to use a newer base image | Older Alpine (< 3.19) and theoretical older Arch images may ship Node < 18. No NodeSource fallback exists for Alpine/Arch — this is a known limitation. | +| GitHub Actions arm64 runners available for public repos | arm64 testing blocked | Fall back to QEMU for a reduced subset | From 3e283ed6ee32574cf6f67ae409b626df7ab41479 Mon Sep 17 00:00:00 2001 From: PKramek Date: Wed, 1 Apr 2026 09:27:22 +0200 Subject: [PATCH 02/55] chore: add LICENSE, editorconfig, shellcheckrc, gitignore --- .editorconfig | 21 +++++++++++++++++++++ .gitignore | 16 ++++++++++++++++ .shellcheckrc | 2 ++ LICENSE | 21 +++++++++++++++++++++ 4 files changed, 60 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitignore create mode 100644 .shellcheckrc create mode 100644 LICENSE diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..9b6497e --- /dev/null +++ b/.editorconfig @@ -0,0 +1,21 @@ +root = true + +[*] +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +charset = utf-8 + +[*.sh] +indent_style = space +indent_size = 4 + +[*.{json,yml,yaml}] +indent_style = space +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false + +[Makefile] +indent_style = tab diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f87b886 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +# OS +.DS_Store +Thumbs.db + +# Editors +*.swp +*.swo +*~ +.vscode/ +.idea/ + +# Node (from npm install in CI) +node_modules/ + +# Pre-commit +.pre-commit-cache/ diff --git a/.shellcheckrc b/.shellcheckrc new file mode 100644 index 0000000..0308127 --- /dev/null +++ b/.shellcheckrc @@ -0,0 +1,2 @@ +shell=bash +severity=warning diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..773830a --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 PKramek + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. From 1bd51a9e02ac97e7ea215a2471d35c0d2d7e2843 Mon Sep 17 00:00:00 2001 From: PKramek Date: Wed, 1 Apr 2026 09:29:30 +0200 Subject: [PATCH 03/55] feat: add devcontainer-feature.json manifest --- src/claude-code/devcontainer-feature.json | 54 +++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 src/claude-code/devcontainer-feature.json diff --git a/src/claude-code/devcontainer-feature.json b/src/claude-code/devcontainer-feature.json new file mode 100644 index 0000000..80e3ec8 --- /dev/null +++ b/src/claude-code/devcontainer-feature.json @@ -0,0 +1,54 @@ +{ + "id": "claude-code", + "version": "1.0.0", + "name": "Claude Code", + "description": "Install Claude Code CLI into any devcontainer. Supports Debian, Ubuntu, Alpine, Arch, Fedora, RHEL, Rocky, Alma, and Amazon Linux on amd64/arm64.", + "keywords": [ + "claude", + "claude-code", + "anthropic", + "ai", + "cli", + "devcontainer" + ], + "documentationURL": "https://github.com/PKramek/claude-code-devcontainer#readme", + "licenseURL": "https://github.com/PKramek/claude-code-devcontainer/blob/main/LICENSE", + "installsAfter": [ + "ghcr.io/devcontainers/features/node" + ], + "options": { + "version": { + "type": "string", + "default": "latest", + "description": "Claude Code version to install (semver or 'latest'). Recommend pinning for teams." + }, + "nodeVersion": { + "type": "string", + "default": "lts", + "description": "Node.js version to install if not already present (>= 18 required)." + }, + "installPath": { + "type": "string", + "default": "/usr/local", + "description": "Custom npm global install prefix. Feature ensures /bin is on PATH." + }, + "enableMcpServers": { + "type": "boolean", + "default": false, + "description": "Create a starter MCP server configuration at ~/.claude/mcp_servers.json (create-if-absent)." + }, + "mountHostConfig": { + "type": "boolean", + "default": false, + "description": "Log a mounts snippet for host ~/.claude config passthrough (documentation-only, does NOT auto-mount)." + }, + "shellCompletions": { + "type": "boolean", + "default": true, + "description": "Install shell completions for bash, zsh, and fish." + } + }, + "containerEnv": { + "CLAUDE_CODE_INSTALLED": "true" + } +} From 110e09c1989bf3931a4784c96c238c44fefad14d Mon Sep 17 00:00:00 2001 From: PKramek Date: Wed, 1 Apr 2026 09:32:10 +0200 Subject: [PATCH 04/55] feat: add install.sh skeleton with bootstrap, logging, validation --- src/claude-code/install.sh | 147 +++++++++++++++++++++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100755 src/claude-code/install.sh diff --git a/src/claude-code/install.sh b/src/claude-code/install.sh new file mode 100755 index 0000000..f5a255f --- /dev/null +++ b/src/claude-code/install.sh @@ -0,0 +1,147 @@ +#!/usr/bin/env bash +# +# Claude Code DevContainer Feature — install.sh +# Installs Claude Code CLI into any devcontainer environment. +# +# Options (from devcontainer-feature.json): +# VERSION - Claude Code version (default: "latest") +# NODEVERSION - Node.js version (default: "lts") +# INSTALLPATH - npm global prefix (default: "/usr/local") +# ENABLEMCPSERVERS - Create MCP config (default: "false") +# MOUNTHOSTCONFIG - Log mount snippet (default: "false") +# SHELLCOMPLETIONS - Install completions (default: "true") + +# --- POSIX-compatible bootstrap (runs under /bin/sh on Alpine) --- +if [ -z "${BASH_VERSION:-}" ]; then + if command -v apk > /dev/null 2>&1; then + apk add --no-cache bash > /dev/null || { + echo "[claude-code feature] ERROR: Failed to install bash via apk." >&2 + exit 1 + } + fi + if ! command -v bash > /dev/null 2>&1; then + echo "[claude-code feature] ERROR: bash is required but could not be found or installed." >&2 + exit 1 + fi + exec bash "$0" "$@" +fi +# --- From here on, bash is guaranteed --- + +set -Eeuo pipefail +umask 0022 + +FEATURE_LOG_PREFIX="[claude-code feature]" + +# Debug mode +if [[ "${DEBUG:-false}" == "true" ]]; then + unset ANTHROPIC_API_KEY CLAUDE_API_KEY 2>/dev/null || true + set -x +fi + +# Traps +trap 'echo "${FEATURE_LOG_PREFIX} ERROR: Failed at line ${LINENO}. Exit code: $?" >&2' ERR +trap cleanup EXIT INT TERM + +TEMP_DIR="" +cleanup() { + [[ -n "${TEMP_DIR}" ]] && rm -rf "${TEMP_DIR}" 2>/dev/null || true +} + +# --- Logging --- +log_info() { echo "${FEATURE_LOG_PREFIX} $*"; } +log_warn() { echo "${FEATURE_LOG_PREFIX} WARNING: $*" >&2; } +log_error() { echo "${FEATURE_LOG_PREFIX} ERROR: $*" >&2; } +log_debug() { + if [[ "${DEBUG:-false}" == "true" ]]; then + echo "${FEATURE_LOG_PREFIX} DEBUG: $*" + fi +} + +# --- Input Validation --- +validate_version() { + local ver="$1" + if [[ "${ver}" == "latest" ]]; then return 0; fi + if [[ ! "${ver}" =~ ^[0-9][0-9a-zA-Z.+-]*$ ]]; then + log_error "Invalid version '${ver}'. Must be 'latest' or a valid semver string." + exit 1 + fi +} + +validate_install_path() { + local path="$1" + if [[ ! "${path}" =~ ^/[a-zA-Z0-9/_.-]+$ ]]; then + log_error "Invalid installPath '${path}'. Must be an absolute path with no special characters." + exit 1 + fi +} + +validate_node_version() { + local ver="$1" + if [[ "${ver}" == "lts" ]]; then return 0; fi + if [[ ! "${ver}" =~ ^[0-9]+$ ]]; then + log_error "Invalid nodeVersion '${ver}'. Must be 'lts' or a major version number (e.g., '20')." + exit 1 + fi + if [[ "${ver}" -lt 18 || "${ver}" -gt 99 ]]; then + log_error "nodeVersion '${ver}' out of range. Must be between 18 and 99." + exit 1 + fi +} + +# --- Parse Options --- +VERSION="${VERSION:-latest}" +NODE_VERSION="${NODEVERSION:-lts}" +INSTALL_PATH="${INSTALLPATH:-/usr/local}" +ENABLE_MCP_SERVERS="${ENABLEMCPSERVERS:-false}" +MOUNT_HOST_CONFIG="${MOUNTHOSTCONFIG:-false}" +SHELL_COMPLETIONS="${SHELLCOMPLETIONS:-true}" + +validate_version "${VERSION}" +validate_install_path "${INSTALL_PATH}" +validate_node_version "${NODE_VERSION}" + +log_info "Starting installation..." +log_info " Claude Code version: ${VERSION}" +log_info " Node.js version: ${NODE_VERSION}" +log_info " Install path: ${INSTALL_PATH}" +log_info " MCP servers: ${ENABLE_MCP_SERVERS}" +log_info " Mount host config: ${MOUNT_HOST_CONFIG}" +log_info " Shell completions: ${SHELL_COMPLETIONS}" + +# --- Remote User Detection --- +detect_remote_user() { + if [[ -n "${_REMOTE_USER:-}" ]]; then + echo "${_REMOTE_USER}" + elif [[ -n "${_CONTAINER_USER:-}" ]]; then + echo "${_CONTAINER_USER}" + else + local user + user=$(getent passwd | awk -F: '$3 >= 1000 && $7 !~ /nologin|false/ { print $1; exit }') + if [[ -n "${user}" ]]; then + echo "${user}" + else + echo "root" + fi + fi +} + +detect_user_home() { + local user="$1" + if [[ -n "${_REMOTE_USER_HOME:-}" ]]; then + echo "${_REMOTE_USER_HOME}" + else + getent passwd "${user}" | cut -d: -f6 + fi +} + +REMOTE_USER=$(detect_remote_user) +REMOTE_USER_HOME=$(detect_user_home "${REMOTE_USER}") +if [[ -z "${REMOTE_USER_HOME}" ]]; then + log_warn "Could not detect home directory for user '${REMOTE_USER}'. Defaulting to /root." + REMOTE_USER_HOME="/root" +fi +log_info " Remote user: ${REMOTE_USER} (home: ${REMOTE_USER_HOME})" + +# Subsequent tasks will add: detect_os, install_dependencies, install_node, +# install_claude_code, setup_completions, setup_mcp, setup_mount_docs, cleanup_caches +log_info "Installation complete." From b7629f5b09919a10aa030d8c8049b712f0ceb911 Mon Sep 17 00:00:00 2001 From: PKramek Date: Wed, 1 Apr 2026 09:36:57 +0200 Subject: [PATCH 05/55] feat: add OS detection and dependency installation --- src/claude-code/install.sh | 112 ++++++++++++++++++++++++++++++++++++- 1 file changed, 110 insertions(+), 2 deletions(-) diff --git a/src/claude-code/install.sh b/src/claude-code/install.sh index f5a255f..4f55d79 100755 --- a/src/claude-code/install.sh +++ b/src/claude-code/install.sh @@ -142,6 +142,114 @@ if [[ -z "${REMOTE_USER_HOME}" ]]; then fi log_info " Remote user: ${REMOTE_USER} (home: ${REMOTE_USER_HOME})" -# Subsequent tasks will add: detect_os, install_dependencies, install_node, -# install_claude_code, setup_completions, setup_mcp, setup_mount_docs, cleanup_caches +# --- OS Detection --- +detect_os() { + local id="" + local id_like="" + + if [[ -f /etc/os-release ]]; then + # shellcheck source=/dev/null + . /etc/os-release + id="${ID:-}" + id_like="${ID_LIKE:-}" + fi + + case "${id}" in + debian|ubuntu|linuxmint) + echo "debian" + ;; + alpine) + echo "alpine" + ;; + arch|archarm|endeavouros|manjaro) + echo "arch" + ;; + fedora|rhel|centos|rocky|almalinux|amzn) + echo "rhel" + ;; + *) + # Fallback to ID_LIKE + case "${id_like}" in + *debian*|*ubuntu*) echo "debian" ;; + *arch*) echo "arch" ;; + *fedora*|*rhel*) echo "rhel" ;; + *) + log_error "Unsupported OS: ID=${id}, ID_LIKE=${id_like}" + exit 1 + ;; + esac + ;; + esac +} + +detect_arch() { + local arch + arch=$(uname -m) + case "${arch}" in + x86_64) echo "amd64" ;; + aarch64) echo "arm64" ;; + *) + log_error "Unsupported architecture: ${arch}" + exit 1 + ;; + esac +} + +OS_FAMILY=$(detect_os) +ARCH=$(detect_arch) +log_info " Detected OS family: ${OS_FAMILY}" +log_info " Detected architecture: ${ARCH}" + +# --- Dependency Installation --- +install_packages() { + local packages=("$@") + case "${OS_FAMILY}" in + debian) + local apt_lists_count + apt_lists_count=$(find /var/lib/apt/lists -maxdepth 1 -type f ! -name 'lock' ! -name 'partial' 2>/dev/null | wc -l) + if [[ "${apt_lists_count}" -eq 0 ]]; then + apt-get update -y -o DPkg::Lock::Timeout=60 + fi + apt-get install -y --no-install-recommends -o DPkg::Lock::Timeout=60 "${packages[@]}" + ;; + alpine) + apk add --no-cache "${packages[@]}" + ;; + arch) + pacman -S --noconfirm --needed "${packages[@]}" + ;; + rhel) + if command -v dnf > /dev/null 2>&1; then + dnf install -y "${packages[@]}" + else + yum install -y "${packages[@]}" + fi + ;; + esac +} + +ensure_base_dependencies() { + local missing=() + + command -v curl > /dev/null 2>&1 || missing+=("curl") + command -v git > /dev/null 2>&1 || missing+=("git") + + # Always ensure ca-certificates for TLS verification + case "${OS_FAMILY}" in + alpine) + command -v update-ca-certificates > /dev/null 2>&1 || missing+=("ca-certificates") + ;; + debian) + [[ -d /etc/ssl/certs ]] && [[ -n "$(ls /etc/ssl/certs/ 2>/dev/null)" ]] || missing+=("ca-certificates") + ;; + esac + + if [[ ${#missing[@]} -gt 0 ]]; then + log_info "Installing missing dependencies: ${missing[*]}" + install_packages "${missing[@]}" + fi +} + +ensure_base_dependencies + log_info "Installation complete." From c656b62be66fb08ab71c463d73eb3be69c4d7a7c Mon Sep 17 00:00:00 2001 From: PKramek Date: Wed, 1 Apr 2026 09:45:53 +0200 Subject: [PATCH 06/55] feat: add Node.js detection and installation --- src/claude-code/install.sh | 148 +++++++++++++++++++++++++++++++++++++ 1 file changed, 148 insertions(+) diff --git a/src/claude-code/install.sh b/src/claude-code/install.sh index 4f55d79..ae4e8c3 100755 --- a/src/claude-code/install.sh +++ b/src/claude-code/install.sh @@ -252,4 +252,152 @@ ensure_base_dependencies() { ensure_base_dependencies +# --- Node.js Installation --- +NODE_MIN_VERSION=18 + +get_node_major_version() { + local version_string + version_string=$(node --version 2>/dev/null || echo "") + if [[ -z "${version_string}" ]]; then + echo "0" + return + fi + echo "${version_string}" | sed 's/^v//' | cut -d. -f1 +} + +resolve_node_version() { + local requested="$1" + if [[ "${requested}" == "lts" ]]; then + local lts_version + lts_version=$(curl -fsSL https://nodejs.org/dist/index.json 2>/dev/null \ + | node -e " + const data = JSON.parse(require('fs').readFileSync('/dev/stdin','utf8')); + const lts = data.find(r => r.lts); + console.log(lts ? lts.version.replace(/^v/,'').split('.')[0] : '22'); + " 2>/dev/null || echo "22") + if [[ -z "${lts_version}" ]]; then lts_version="22"; fi + log_info "Resolved LTS to Node.js ${lts_version}" + echo "${lts_version}" + else + echo "${requested}" + fi +} + +install_node_binary() { + local version="$1" + local arch_label + case "$(uname -m)" in + x86_64) arch_label="x64" ;; + aarch64) arch_label="arm64" ;; + *) log_error "Unsupported arch for Node.js binary: $(uname -m)"; exit 1 ;; + esac + + log_info "Installing Node.js ${version} via official binary tarball..." + + TEMP_DIR=$(mktemp -d) + local url="https://nodejs.org/dist/latest-v${version}.x/" + + local shasums + shasums=$(curl -fsSL "${url}SHASUMS256.txt") || { + log_error "Failed to download Node.js SHASUMS256.txt from ${url}" + exit 1 + } + + local tarball_line + tarball_line=$(echo "${shasums}" | grep "linux-${arch_label}.tar.xz" | head -1) + if [[ -z "${tarball_line}" ]]; then + log_error "No linux-${arch_label} tarball found in Node.js ${version} release." + exit 1 + fi + + local expected_sha tarball_name + expected_sha=$(echo "${tarball_line}" | awk '{print $1}') + tarball_name=$(echo "${tarball_line}" | awk '{print $2}') + + log_debug "Downloading ${tarball_name} (SHA256: ${expected_sha})" + + curl -fsSL "${url}${tarball_name}" -o "${TEMP_DIR}/${tarball_name}" || { + log_error "Failed to download Node.js from ${url}${tarball_name}" + exit 1 + } + + local actual_sha + actual_sha=$(sha256sum "${TEMP_DIR}/${tarball_name}" | awk '{print $1}') + if [[ "${actual_sha}" != "${expected_sha}" ]]; then + log_error "SHA256 checksum mismatch for Node.js tarball!" + log_error " Expected: ${expected_sha}" + log_error " Actual: ${actual_sha}" + exit 1 + fi + log_info "SHA256 checksum verified." + + tar -xJf "${TEMP_DIR}/${tarball_name}" -C /usr/local --strip-components=1 + rm -rf "${TEMP_DIR}" + TEMP_DIR="" + + log_info "Node.js $(node --version) installed and verified." +} + +install_node_distro() { + if [[ "${NODE_VERSION}" != "lts" ]]; then + log_warn "nodeVersion '${NODE_VERSION}' is ignored on ${OS_FAMILY} — distro package version will be used." + fi + log_info "Installing Node.js via distro packages..." + case "${OS_FAMILY}" in + alpine) + install_packages nodejs npm + ;; + arch) + install_packages nodejs npm + ;; + *) + log_error "No distro package strategy for ${OS_FAMILY}." + exit 1 + ;; + esac + + local installed_major + installed_major=$(get_node_major_version) + if [[ "${installed_major}" -lt "${NODE_MIN_VERSION}" ]]; then + log_error "Node.js v${installed_major} from distro packages is below minimum ${NODE_MIN_VERSION}." + log_error "Use a newer base image or set nodeVersion to install via NodeSource." + exit 1 + fi +} + +ensure_node() { + local current_major + current_major=$(get_node_major_version) + + if [[ "${current_major}" -ge "${NODE_MIN_VERSION}" ]]; then + log_info "Node.js v$(node --version) already installed and meets minimum requirement (>= ${NODE_MIN_VERSION})." + return 0 + fi + + if [[ "${current_major}" -gt 0 ]] && [[ "${current_major}" -lt "${NODE_MIN_VERSION}" ]]; then + log_warn "Node.js v$(node --version) is below minimum ${NODE_MIN_VERSION}. Installing newer version..." + fi + + local resolved_version + resolved_version=$(resolve_node_version "${NODE_VERSION}") + log_debug "Resolved Node.js version: ${resolved_version}" + + case "${OS_FAMILY}" in + debian|rhel) + install_node_binary "${resolved_version}" + ;; + alpine|arch) + install_node_distro + ;; + *) + log_error "No Node.js installation strategy for OS family: ${OS_FAMILY}" + exit 1 + ;; + esac + + log_info "Node.js $(node --version) installed successfully." +} + +ensure_node + log_info "Installation complete." From 7dfa47f75e2c0ecd7c1ab54f4af90cf3a0953369 Mon Sep 17 00:00:00 2001 From: PKramek Date: Wed, 1 Apr 2026 09:48:14 +0200 Subject: [PATCH 07/55] feat: add PATH configuration and Claude Code installation --- src/claude-code/install.sh | 68 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/src/claude-code/install.sh b/src/claude-code/install.sh index ae4e8c3..7936d85 100755 --- a/src/claude-code/install.sh +++ b/src/claude-code/install.sh @@ -400,4 +400,72 @@ ensure_node() { ensure_node +# --- PATH Configuration --- +configure_custom_path() { + if [[ "${INSTALL_PATH}" == "/usr/local" ]]; then + return 0 + fi + + log_info "Configuring custom install path: ${INSTALL_PATH}" + + # Immediate PATH update for this script + export PATH="${INSTALL_PATH}/bin:${PATH}" + + # Persistent PATH for login shells (bash, zsh) + mkdir -p /etc/profile.d + cat > /etc/profile.d/claude-code.sh << PATHEOF +# Added by Claude Code DevContainer Feature +export PATH="${INSTALL_PATH}/bin:\${PATH}" +PATHEOF + chmod 644 /etc/profile.d/claude-code.sh + + # Persistent PATH for Alpine ash non-login shells + if [[ "${OS_FAMILY}" == "alpine" ]]; then + # BusyBox ash reads ENV on startup for non-login shells + # Write to /etc/profile (not /etc/environment which is PAM-specific) + if ! grep -q 'claude-code' /etc/profile 2>/dev/null; then + # shellcheck disable=SC2016 # ${PATH} is intentionally literal — expands at shell startup + printf 'export PATH="%s/bin:${PATH}" # claude-code\n' "${INSTALL_PATH}" >> /etc/profile + fi + fi + + log_info "PATH configured: ${INSTALL_PATH}/bin" +} + +# --- Claude Code Installation --- +install_claude_code() { + local npm_args=(install -g --fetch-retries=3) + + if [[ "${INSTALL_PATH}" != "/usr/local" ]]; then + npm_args+=(--prefix "${INSTALL_PATH}") + fi + + if [[ "${VERSION}" == "latest" ]]; then + npm_args+=("@anthropic-ai/claude-code") + else + npm_args+=("@anthropic-ai/claude-code@${VERSION}") + fi + + log_info "Installing Claude Code (version: ${VERSION})..." + log_debug "npm ${npm_args[*]}" + + timeout 300 npm "${npm_args[@]}" || { + log_error "Failed to install Claude Code." + log_error "Check network connectivity and npm registry access." + exit 1 + } + + # Verify installation + if ! claude --version > /dev/null 2>&1; then + log_error "Claude Code installed but 'claude' not found on PATH." + log_error "PATH=${PATH}" + exit 1 + fi + + log_info "Claude Code $(claude --version) installed successfully." +} + +configure_custom_path +install_claude_code + log_info "Installation complete." From f1f911ec1dc87326e0d7e16d6c1151bfcaf9674b Mon Sep 17 00:00:00 2001 From: PKramek Date: Wed, 1 Apr 2026 09:50:01 +0200 Subject: [PATCH 08/55] fix: capture claude version once to avoid redundant subprocess --- src/claude-code/install.sh | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/claude-code/install.sh b/src/claude-code/install.sh index 7936d85..acf977c 100755 --- a/src/claude-code/install.sh +++ b/src/claude-code/install.sh @@ -455,14 +455,15 @@ install_claude_code() { exit 1 } - # Verify installation - if ! claude --version > /dev/null 2>&1; then + # Verify installation and capture version in one invocation + local installed_version + installed_version=$(claude --version 2>/dev/null) || { log_error "Claude Code installed but 'claude' not found on PATH." log_error "PATH=${PATH}" exit 1 - fi + } - log_info "Claude Code $(claude --version) installed successfully." + log_info "Claude Code ${installed_version} installed successfully." } configure_custom_path From 21e34d1ff1116d2fee3c6b05103e5ad389894289 Mon Sep 17 00:00:00 2001 From: PKramek Date: Wed, 1 Apr 2026 09:52:23 +0200 Subject: [PATCH 09/55] feat: add completions, MCP config, mount docs, cache cleanup --- src/claude-code/install.sh | 143 ++++++++++++++++++++++++++++++++++++- 1 file changed, 142 insertions(+), 1 deletion(-) diff --git a/src/claude-code/install.sh b/src/claude-code/install.sh index acf977c..38d2fa1 100755 --- a/src/claude-code/install.sh +++ b/src/claude-code/install.sh @@ -469,4 +469,145 @@ install_claude_code() { configure_custom_path install_claude_code -log_info "Installation complete." +# --- Shell Completions --- +setup_completions() { + if [[ "${SHELL_COMPLETIONS}" != "true" ]]; then + log_debug "Shell completions disabled." + return 0 + fi + + log_info "Installing shell completions..." + + # Bash completions + local bash_comp_dir="" + if [[ -d /usr/share/bash-completion/completions ]]; then + bash_comp_dir="/usr/share/bash-completion/completions" + elif [[ -d /etc/bash_completion.d ]]; then + bash_comp_dir="/etc/bash_completion.d" + fi + if [[ -n "${bash_comp_dir}" ]]; then + claude completions bash > "${bash_comp_dir}/claude" 2>/dev/null || { + log_warn "Failed to install bash completions." + } + fi + + # Zsh completions + if [[ -d /usr/share/zsh/site-functions ]] || mkdir -p /usr/share/zsh/site-functions 2>/dev/null; then + claude completions zsh > /usr/share/zsh/site-functions/_claude 2>/dev/null || { + log_warn "Failed to install zsh completions." + } + fi + + # Fish completions + local fish_comp_dir="" + for dir in /usr/share/fish/vendor_completions.d /usr/share/fish/completions; do + if [[ -d "${dir}" ]]; then + fish_comp_dir="${dir}" + break + fi + done + if [[ -n "${fish_comp_dir}" ]]; then + claude completions fish > "${fish_comp_dir}/claude.fish" 2>/dev/null || { + log_warn "Failed to install fish completions." + } + fi + + log_info "Shell completions installed." +} + +setup_completions + +# --- MCP Server Configuration --- +setup_mcp_servers() { + if [[ "${ENABLE_MCP_SERVERS}" != "true" ]]; then + log_debug "MCP server configuration disabled." + return 0 + fi + + local claude_dir="${REMOTE_USER_HOME}/.claude" + local mcp_config="${claude_dir}/mcp_servers.json" + + if [[ -f "${mcp_config}" ]]; then + log_info "MCP config already exists at ${mcp_config}, skipping." + return 0 + fi + + log_info "Creating starter MCP configuration..." + mkdir -p "${claude_dir}" + + cat > "${mcp_config}" << 'MCPEOF' +{ + "mcpServers": {} +} +MCPEOF + + chmod 700 "${claude_dir}" + chmod 600 "${mcp_config}" + chown "${REMOTE_USER}:$(id -gn "${REMOTE_USER}")" "${claude_dir}" + chown "${REMOTE_USER}:$(id -gn "${REMOTE_USER}")" "${mcp_config}" + log_info "MCP config created at ${mcp_config} (mode 600)" +} + +setup_mcp_servers + +# --- Host Config Mount Documentation --- +setup_mount_docs() { + if [[ "${MOUNT_HOST_CONFIG}" != "true" ]]; then + return 0 + fi + + log_info "" + log_info "============================================================" + log_info "HOST CONFIG MOUNTING" + log_info "============================================================" + log_info "To mount your host Claude config, add this to your" + log_info "devcontainer.json:" + log_info "" + log_info ' "mounts": [' + log_info " \"source=\${localEnv:HOME}/.claude,target=${REMOTE_USER_HOME}/.claude,type=bind,consistency=cached,readonly\"" + log_info ' ]' + log_info "" + log_info "WARNING: This exposes your API keys inside the container." + log_info "See README for security considerations." + log_info "============================================================" + log_info "" +} + +setup_mount_docs + +# --- Cache Cleanup --- +cleanup_caches() { + log_info "Cleaning up package manager caches..." + + case "${OS_FAMILY}" in + debian) + apt-get clean + rm -rf /var/lib/apt/lists/* + ;; + alpine) + rm -rf /var/cache/apk/* + ;; + arch) + pacman -Sc --noconfirm 2>/dev/null || true + ;; + rhel) + if command -v dnf > /dev/null 2>&1; then + dnf clean all + else + yum clean all + fi + rm -rf /var/cache/dnf /var/cache/yum + ;; + esac + + npm cache clean --force 2>/dev/null || true + log_info "Cache cleanup complete." +} + +cleanup_caches + +log_info "Claude Code DevContainer Feature installation complete." +log_info " Claude Code: $(claude --version 2>/dev/null || echo 'unknown')" +log_info " Node.js: $(node --version 2>/dev/null || echo 'unknown')" +log_info " OS: ${OS_FAMILY} (${ARCH})" +log_info " User: ${REMOTE_USER}" From d6b998d1922ddb2939689a1b7fe96ae01adc5a07 Mon Sep 17 00:00:00 2001 From: PKramek Date: Wed, 1 Apr 2026 09:56:00 +0200 Subject: [PATCH 10/55] feat: add test helpers and scenario definitions --- test/claude-code/scenarios.json | 75 ++++++++++++ test/claude-code/test.sh | 203 ++++++++++++++++++++++++++++++++ 2 files changed, 278 insertions(+) create mode 100644 test/claude-code/scenarios.json create mode 100755 test/claude-code/test.sh diff --git a/test/claude-code/scenarios.json b/test/claude-code/scenarios.json new file mode 100644 index 0000000..c6d17bd --- /dev/null +++ b/test/claude-code/scenarios.json @@ -0,0 +1,75 @@ +{ + "default_options": { + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "features": { + "claude-code": {} + } + }, + "completions_disabled": { + "image": "mcr.microsoft.com/devcontainers/base:debian", + "features": { + "claude-code": { + "shellCompletions": false + } + } + }, + "mcp_enabled": { + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "features": { + "claude-code": { + "enableMcpServers": true + } + } + }, + "custom_version": { + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "features": { + "claude-code": { + "version": "0.2.57" + } + } + }, + "node_preinstalled": { + "image": "mcr.microsoft.com/devcontainers/javascript-node", + "features": { + "claude-code": {} + } + }, + "custom_install_path": { + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "features": { + "claude-code": { + "installPath": "/opt/claude" + } + } + }, + "mount_host_config": { + "image": "mcr.microsoft.com/devcontainers/base:debian", + "features": { + "claude-code": { + "mountHostConfig": true + } + } + }, + "alpine_specific": { + "image": "mcr.microsoft.com/devcontainers/base:alpine", + "features": { + "claude-code": {} + } + }, + "idempotency": { + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "features": { + "claude-code": {} + } + }, + "multi_feature_combo": { + "image": "mcr.microsoft.com/devcontainers/javascript-node", + "features": { + "ghcr.io/devcontainers/features/node:1": { + "version": "22" + }, + "claude-code": {} + } + } +} diff --git a/test/claude-code/test.sh b/test/claude-code/test.sh new file mode 100755 index 0000000..ebd0c5e --- /dev/null +++ b/test/claude-code/test.sh @@ -0,0 +1,203 @@ +#!/usr/bin/env bash +# +# Shared test helpers for Claude Code DevContainer Feature. +# Sourced by per-scenario test scripts. +# + +set -Eeuo pipefail + +TESTS_PASSED=0 +TESTS_FAILED=0 + +pass() { + echo " PASS: $*" + ((TESTS_PASSED++)) +} + +fail() { + echo " FAIL: $*" >&2 + ((TESTS_FAILED++)) +} + +check_command_exists() { + local cmd="$1" + if command -v "${cmd}" > /dev/null 2>&1; then + pass "${cmd} is on PATH" + else + fail "${cmd} not found on PATH" + fi +} + +check_command_version() { + local cmd="$1" + local expected="$2" + local actual + actual=$("${cmd}" --version 2>&1 || echo "") + if [[ "${actual}" == *"${expected}"* ]]; then + pass "${cmd} version contains '${expected}' (got: ${actual})" + else + fail "${cmd} version mismatch: expected '${expected}', got '${actual}'" + fi +} + +check_command_runs() { + local cmd="$1" + if "${cmd}" --version > /dev/null 2>&1; then + pass "${cmd} --version exits 0" + else + fail "${cmd} --version failed" + fi +} + +check_node_min_version() { + local min="$1" + local major + major=$(node --version 2>/dev/null | sed 's/^v//' | cut -d. -f1) + if [[ "${major}" -ge "${min}" ]]; then + pass "Node.js v${major} >= ${min}" + else + fail "Node.js v${major} < ${min}" + fi +} + +check_file_exists() { + local path="$1" + if [[ -f "${path}" ]]; then + pass "File exists: ${path}" + else + fail "File missing: ${path}" + fi +} + +check_file_absent() { + local path="$1" + if [[ ! -f "${path}" ]]; then + pass "File absent (expected): ${path}" + else + fail "File exists (unexpected): ${path}" + fi +} + +check_dir_exists() { + local path="$1" + if [[ -d "${path}" ]]; then + pass "Directory exists: ${path}" + else + fail "Directory missing: ${path}" + fi +} + +check_env_var() { + local name="$1" + local expected="$2" + local actual="${!name:-}" + if [[ "${actual}" == "${expected}" ]]; then + pass "Env var ${name}='${expected}'" + else + fail "Env var ${name}: expected '${expected}', got '${actual}'" + fi +} + +check_permissions() { + local path="$1" + local expected="$2" + if [[ ! -e "${path}" ]]; then + fail "Cannot check permissions: ${path} does not exist" + return + fi + local actual + actual=$(stat -c '%a' "${path}" 2>/dev/null || stat -f '%Lp' "${path}" 2>/dev/null) + if [[ "${actual}" == "${expected}" ]]; then + pass "Permissions on ${path}: ${expected}" + else + fail "Permissions on ${path}: expected ${expected}, got ${actual}" + fi +} + +check_file_owner() { + local path="$1" + local expected_user="$2" + if [[ ! -e "${path}" ]]; then + fail "Cannot check owner: ${path} does not exist" + return + fi + local actual + actual=$(stat -c '%U' "${path}" 2>/dev/null || stat -f '%Su' "${path}" 2>/dev/null) + if [[ "${actual}" == "${expected_user}" ]]; then + pass "Owner of ${path}: ${expected_user}" + else + fail "Owner of ${path}: expected ${expected_user}, got ${actual}" + fi +} + +check_file_valid_json() { + local path="$1" + # Use node (guaranteed present) with argv to avoid path injection + if node -e "JSON.parse(require('fs').readFileSync(process.argv[1],'utf8'))" "${path}" > /dev/null 2>&1; then + pass "Valid JSON: ${path}" + else + fail "Invalid JSON: ${path}" + fi +} + +check_path_clean() { + local cache_dir="$1" + if [[ ! -d "${cache_dir}" ]]; then + pass "Cache dir absent: ${cache_dir}" + return + fi + local count + count=$(find "${cache_dir}" -type f 2>/dev/null | wc -l) + if [[ "${count}" -eq 0 ]]; then + pass "Cache dir clean: ${cache_dir}" + else + fail "Cache dir has ${count} files: ${cache_dir}" + fi +} + +check_non_root() { + local current_user + current_user=$(whoami) + if [[ "${current_user}" != "root" ]]; then + pass "Running as non-root user: ${current_user}" + else + # Raw OS images (ubuntu:22.04, alpine:3.21, etc.) run as root. + # This is expected — the devcontainer CLI has no non-root user to switch to. + # Only warn, don't fail, since the permission model still works for root. + pass "Running as root (acceptable for raw OS base images)" + fi +} + +# Run core assertions shared by all scenarios +core_assertions() { + echo "--- Core Assertions ---" + check_command_exists "claude" + check_command_runs "claude" + check_command_exists "node" + check_node_min_version 18 + check_env_var "CLAUDE_CODE_INSTALLED" "true" + check_non_root + # Check claude binary permissions (should be executable by all) + local claude_path + claude_path=$(command -v claude) + if [[ -n "${claude_path}" ]]; then + check_permissions "${claude_path}" "755" + fi +} + +# Print summary and exit with appropriate code +test_summary() { + echo "" + echo "--- Results: ${TESTS_PASSED} passed, ${TESTS_FAILED} failed ---" + if [[ "${TESTS_FAILED}" -gt 0 ]]; then + exit 1 + fi +} + +# When executed directly (not sourced), run core assertions. +# This is what `devcontainer features test --skip-scenarios --base-image ` invokes. +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + echo "=== Default test (core assertions) ===" + core_assertions + test_summary +fi From 9d0149979833228eb99644b53bfba4b0c721e684 Mon Sep 17 00:00:00 2001 From: PKramek Date: Wed, 1 Apr 2026 10:05:21 +0200 Subject: [PATCH 11/55] feat: add per-scenario test scripts --- test/claude-code/alpine_specific.sh | 21 ++++++++++++++ test/claude-code/completions_disabled.sh | 17 +++++++++++ test/claude-code/custom_install_path.sh | 23 +++++++++++++++ test/claude-code/custom_version.sh | 13 +++++++++ test/claude-code/default_options.sh | 31 ++++++++++++++++++++ test/claude-code/idempotency.sh | 37 ++++++++++++++++++++++++ test/claude-code/mcp_enabled.sh | 16 ++++++++++ test/claude-code/mount_host_config.sh | 15 ++++++++++ test/claude-code/multi_feature_combo.sh | 17 +++++++++++ test/claude-code/node_preinstalled.sh | 16 ++++++++++ 10 files changed, 206 insertions(+) create mode 100755 test/claude-code/alpine_specific.sh create mode 100755 test/claude-code/completions_disabled.sh create mode 100755 test/claude-code/custom_install_path.sh create mode 100755 test/claude-code/custom_version.sh create mode 100755 test/claude-code/default_options.sh create mode 100755 test/claude-code/idempotency.sh create mode 100755 test/claude-code/mcp_enabled.sh create mode 100755 test/claude-code/mount_host_config.sh create mode 100755 test/claude-code/multi_feature_combo.sh create mode 100755 test/claude-code/node_preinstalled.sh diff --git a/test/claude-code/alpine_specific.sh b/test/claude-code/alpine_specific.sh new file mode 100755 index 0000000..1bbd0bb --- /dev/null +++ b/test/claude-code/alpine_specific.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=test.sh +source "${SCRIPT_DIR}/test.sh" + +echo "=== Scenario: alpine_specific ===" +core_assertions + +echo "--- Bash should be installed ---" +check_command_exists "bash" + +echo "--- APK cache should be clean ---" +APK_CACHE_COUNT=$(find /var/cache/apk/ -type f 2>/dev/null | wc -l) +if [[ "${APK_CACHE_COUNT}" -eq 0 ]]; then + pass "APK cache is clean" +else + fail "APK cache has ${APK_CACHE_COUNT} files" +fi + +test_summary diff --git a/test/claude-code/completions_disabled.sh b/test/claude-code/completions_disabled.sh new file mode 100755 index 0000000..7cd5ac5 --- /dev/null +++ b/test/claude-code/completions_disabled.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=test.sh +source "${SCRIPT_DIR}/test.sh" + +echo "=== Scenario: completions_disabled ===" +core_assertions + +echo "--- Completions should be absent ---" +check_file_absent /usr/share/bash-completion/completions/claude +check_file_absent /etc/bash_completion.d/claude +check_file_absent /usr/share/zsh/site-functions/_claude +check_file_absent /usr/share/fish/vendor_completions.d/claude.fish +check_file_absent /usr/share/fish/completions/claude.fish + +test_summary diff --git a/test/claude-code/custom_install_path.sh b/test/claude-code/custom_install_path.sh new file mode 100755 index 0000000..954c5d7 --- /dev/null +++ b/test/claude-code/custom_install_path.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=test.sh +source "${SCRIPT_DIR}/test.sh" + +echo "=== Scenario: custom_install_path ===" + +echo "--- Binary at custom path ---" +check_file_exists /opt/claude/bin/claude + +echo "--- PATH includes custom path ---" +if echo "${PATH}" | grep -q '/opt/claude/bin'; then + pass "PATH contains /opt/claude/bin" +else + fail "PATH does not contain /opt/claude/bin" +fi + +echo "--- Profile.d script exists ---" +check_file_exists /etc/profile.d/claude-code.sh + +core_assertions +test_summary diff --git a/test/claude-code/custom_version.sh b/test/claude-code/custom_version.sh new file mode 100755 index 0000000..81f3b71 --- /dev/null +++ b/test/claude-code/custom_version.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=test.sh +source "${SCRIPT_DIR}/test.sh" + +echo "=== Scenario: custom_version ===" +core_assertions + +echo "--- Version check ---" +check_command_version "claude" "0.2.57" + +test_summary diff --git a/test/claude-code/default_options.sh b/test/claude-code/default_options.sh new file mode 100755 index 0000000..102d701 --- /dev/null +++ b/test/claude-code/default_options.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=test.sh +source "${SCRIPT_DIR}/test.sh" + +echo "=== Scenario: default_options ===" +core_assertions + +echo "--- Completions ---" +# At least one completion directory should have the claude file +FOUND_COMPLETIONS=false +for path in \ + /usr/share/bash-completion/completions/claude \ + /etc/bash_completion.d/claude \ + /usr/share/zsh/site-functions/_claude \ + /usr/share/fish/vendor_completions.d/claude.fish \ + /usr/share/fish/completions/claude.fish; do + if [[ -f "${path}" ]]; then + FOUND_COMPLETIONS=true + pass "Completion file found: ${path}" + fi +done +if [[ "${FOUND_COMPLETIONS}" == "false" ]]; then + fail "No shell completion files found" +fi + +echo "--- MCP config should be absent ---" +check_file_absent "${HOME}/.claude/mcp_servers.json" + +test_summary diff --git a/test/claude-code/idempotency.sh b/test/claude-code/idempotency.sh new file mode 100755 index 0000000..4f62477 --- /dev/null +++ b/test/claude-code/idempotency.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=test.sh +source "${SCRIPT_DIR}/test.sh" + +echo "=== Scenario: idempotency ===" +core_assertions + +echo "--- Idempotency: record state before second install ---" +CLAUDE_VERSION_BEFORE=$(claude --version 2>&1) +NODE_VERSION_BEFORE=$(node --version 2>&1) + +echo "--- Idempotency: run install.sh a second time ---" +# Re-run install as root to simulate a container rebuild +sudo bash /usr/local/share/claude-code/install.sh 2>&1 || { + fail "Second install.sh run failed" + test_summary +} + +echo "--- Idempotency: verify state unchanged ---" +CLAUDE_VERSION_AFTER=$(claude --version 2>&1) +NODE_VERSION_AFTER=$(node --version 2>&1) + +if [[ "${CLAUDE_VERSION_BEFORE}" == "${CLAUDE_VERSION_AFTER}" ]]; then + pass "Claude Code version unchanged after re-install: ${CLAUDE_VERSION_AFTER}" +else + fail "Claude Code version changed: ${CLAUDE_VERSION_BEFORE} -> ${CLAUDE_VERSION_AFTER}" +fi + +if [[ "${NODE_VERSION_BEFORE}" == "${NODE_VERSION_AFTER}" ]]; then + pass "Node.js version unchanged after re-install: ${NODE_VERSION_AFTER}" +else + fail "Node.js version changed: ${NODE_VERSION_BEFORE} -> ${NODE_VERSION_AFTER}" +fi + +test_summary diff --git a/test/claude-code/mcp_enabled.sh b/test/claude-code/mcp_enabled.sh new file mode 100755 index 0000000..3dc6479 --- /dev/null +++ b/test/claude-code/mcp_enabled.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=test.sh +source "${SCRIPT_DIR}/test.sh" + +echo "=== Scenario: mcp_enabled ===" +core_assertions + +echo "--- MCP config ---" +MCP_CONFIG="${HOME}/.claude/mcp_servers.json" +check_file_exists "${MCP_CONFIG}" +check_file_valid_json "${MCP_CONFIG}" +check_file_owner "${MCP_CONFIG}" "$(whoami)" + +test_summary diff --git a/test/claude-code/mount_host_config.sh b/test/claude-code/mount_host_config.sh new file mode 100755 index 0000000..dd31b48 --- /dev/null +++ b/test/claude-code/mount_host_config.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=test.sh +source "${SCRIPT_DIR}/test.sh" + +echo "=== Scenario: mount_host_config ===" +core_assertions + +echo "--- No actual mount should exist ---" +# The feature only logs docs, it does not mount anything +# We just verify claude works and no unexpected mounts exist +pass "mount_host_config is documentation-only (no mount to verify)" + +test_summary diff --git a/test/claude-code/multi_feature_combo.sh b/test/claude-code/multi_feature_combo.sh new file mode 100755 index 0000000..3a673fe --- /dev/null +++ b/test/claude-code/multi_feature_combo.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=test.sh +source "${SCRIPT_DIR}/test.sh" + +echo "=== Scenario: multi_feature_combo ===" +core_assertions + +echo "--- Node.js from separate feature should still work ---" +check_command_exists "node" +check_node_min_version 18 + +echo "--- Claude Code should coexist with separate Node feature ---" +check_command_runs "claude" + +test_summary diff --git a/test/claude-code/node_preinstalled.sh b/test/claude-code/node_preinstalled.sh new file mode 100755 index 0000000..791f9bb --- /dev/null +++ b/test/claude-code/node_preinstalled.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=test.sh +source "${SCRIPT_DIR}/test.sh" + +echo "=== Scenario: node_preinstalled ===" +core_assertions + +echo "--- Node.js should be unchanged ---" +# The javascript-node image ships Node.js via nvm. +# Verify Node.js is still available and meets minimum version. +check_command_exists "node" +check_node_min_version 18 + +test_summary From 0ccb35b5506aa4174b9eb5ae0b21e6a6f0f9504e Mon Sep 17 00:00:00 2001 From: PKramek Date: Wed, 1 Apr 2026 10:07:41 +0200 Subject: [PATCH 12/55] chore: add pre-commit hooks configuration --- .pre-commit-config.yaml | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 .pre-commit-config.yaml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..0a464c0 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,38 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v6.0.0 + hooks: + - id: check-json + - id: check-yaml + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-merge-conflict + - id: detect-private-key + - id: check-added-large-files + args: ["--maxkb=500"] + - id: no-commit-to-branch + args: ["--branch", "main"] + + - repo: https://github.com/koalaman/shellcheck-precommit + rev: v0.11.0 + hooks: + - id: shellcheck + args: ["--severity=warning"] + + - repo: https://github.com/scop/pre-commit-shfmt + rev: v3.13.0-1 + hooks: + - id: shfmt + args: ["-i", "4", "-ci"] + + - repo: https://github.com/pre-commit/mirrors-prettier + rev: v4.0.0-alpha.8 + hooks: + - id: prettier + types_or: [json, yaml, markdown] + + - repo: https://github.com/igorshubovych/markdownlint-cli + rev: v0.48.0 + hooks: + - id: markdownlint + args: ["--fix"] From 88f5924db88cbab453796eb4741232d1cd6742b6 Mon Sep 17 00:00:00 2001 From: PKramek Date: Wed, 1 Apr 2026 10:07:43 +0200 Subject: [PATCH 13/55] docs: add contributor devcontainer and README --- .devcontainer/devcontainer.json | 19 +++++ README.md | 120 ++++++++++++++++++++++++++++++++ 2 files changed, 139 insertions(+) create mode 100644 .devcontainer/devcontainer.json create mode 100644 README.md diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..e407adb --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,19 @@ +{ + "name": "Claude Code Feature Development", + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "features": { + "ghcr.io/devcontainers/features/node:1": { + "version": "22" + } + }, + "postCreateCommand": "npm install -g @devcontainers/cli && pre-commit install || true", + "customizations": { + "vscode": { + "extensions": [ + "timonwong.shellcheck", + "foxundermoon.shell-format", + "esbenp.prettier-vscode" + ] + } + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..9379774 --- /dev/null +++ b/README.md @@ -0,0 +1,120 @@ +# Claude Code DevContainer Feature + +[![Test](https://github.com/pkramek/claude-code-devcontainer/actions/workflows/test.yml/badge.svg)](https://github.com/pkramek/claude-code-devcontainer/actions/workflows/test.yml) + +Install [Claude Code](https://docs.anthropic.com/en/docs/claude-code) into any +devcontainer. Supports Debian, Ubuntu, Alpine, Arch, Fedora, RHEL, Rocky, Alma, +and Amazon Linux on amd64 and arm64. + +## Usage + +Add this feature to your `devcontainer.json`: + +```json +{ + "features": { + "ghcr.io/pkramek/claude-code-devcontainer/claude-code:1": {} + } +} +``` + +### Options + +| Option | Type | Default | Description | +|---|---|---|---| +| `version` | string | `latest` | Claude Code version (semver or `latest`) | +| `nodeVersion` | string | `lts` | Node.js version if not present (>= 18) | +| `installPath` | string | `/usr/local` | Custom npm global prefix | +| `enableMcpServers` | boolean | `false` | Create starter MCP config | +| `mountHostConfig` | boolean | `false` | Log mount snippet for host config | +| `shellCompletions` | boolean | `true` | Install bash/zsh/fish completions | + +### Examples + +Pin a specific version: + +```json +{ + "features": { + "ghcr.io/pkramek/claude-code-devcontainer/claude-code:1": { + "version": "1.0.0" + } + } +} +``` + +Enable MCP servers: + +```json +{ + "features": { + "ghcr.io/pkramek/claude-code-devcontainer/claude-code:1": { + "enableMcpServers": true + } + } +} +``` + +## Authentication + +Claude Code requires authentication. Options: + +1. **Environment variable:** Set `ANTHROPIC_API_KEY` in your devcontainer: + + ```json + { + "remoteEnv": { + "ANTHROPIC_API_KEY": "${localEnv:ANTHROPIC_API_KEY}" + } + } + ``` + +2. **Mount host config:** Mount your local `~/.claude` directory: + + ```json + { + "mounts": [ + "source=${localEnv:HOME}/.claude,target=/home/vscode/.claude,type=bind,consistency=cached,readonly" + ] + } + ``` + + > **Security warning:** This exposes your API keys inside the container. + > If the container is compromised, credentials are at risk. + +## Tested Images + +This feature is tested on 25+ base images across amd64 and arm64. See the +[test workflow](.github/workflows/test.yml) for the full matrix. + +## Runtime Verification + +Add this to your `devcontainer.json` to verify Claude Code at container start: + +```json +{ + "postCreateCommand": "claude --version || true" +} +``` + +## Publishing (Maintainers) + +After the first release tag push, the GHCR package is created as **private**. +You must manually change it to public: + +1. Go to the repository's **Packages** tab +2. Click the `claude-code` package +3. Go to **Package settings** +4. Under **Danger Zone**, change visibility to **Public** + +## Contributing + +1. Fork the repository +2. Open in a devcontainer (`.devcontainer/devcontainer.json` is provided) +3. Make changes +4. Run `pre-commit run --all-files` before committing +5. Open a pull request + +## License + +MIT From c58eb4c5740431ca6c46060d2d4e12d1956900ba Mon Sep 17 00:00:00 2001 From: PKramek Date: Wed, 1 Apr 2026 10:07:45 +0200 Subject: [PATCH 14/55] ci: add release workflow with version check and publish --- .github/workflows/release.yml | 101 ++++++++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..447e720 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,101 @@ +name: "Release" + +on: + push: + tags: ["v*"] + +concurrency: + group: "release-${{ github.repository }}" + cancel-in-progress: false + +permissions: {} + +jobs: + validate: + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: ShellCheck + uses: ludeeus/action-shellcheck@00cae500b08a931fb5698e11e79bfbd38e612a38 # 2.0.0 + with: + severity: warning + + - name: Install devcontainer CLI + run: npm install -g @devcontainers/cli@0.85.0 + + - name: Smoke test (3 representative images) + run: | + for img in \ + "mcr.microsoft.com/devcontainers/base:ubuntu" \ + "mcr.microsoft.com/devcontainers/base:alpine" \ + "mcr.microsoft.com/devcontainers/universal:2"; do + echo "--- Smoke testing: ${img} ---" + devcontainer features test \ + --features claude-code \ + --skip-scenarios \ + --base-image "${img}" \ + --project-folder . + done + + version-check: + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Verify version matches tag + run: | + TAG_VERSION="${GITHUB_REF_NAME#v}" + JSON_VERSION=$(python3 -c " + import json + with open('src/claude-code/devcontainer-feature.json') as f: + print(json.load(f)['version']) + ") + if [[ "${TAG_VERSION}" != "${JSON_VERSION}" ]]; then + echo "ERROR: Tag version (${TAG_VERSION}) does not match feature version (${JSON_VERSION})" + exit 1 + fi + echo "Version match: ${TAG_VERSION}" + + publish: + needs: [validate, version-check] + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Publish feature + uses: devcontainers/action@1082abd5d2bf3a11abccba70eef98df068277772 # v1.4.3 + with: + publish-features: "true" + base-path-to-features: "./src" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + post-publish: + needs: publish + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + packages: read + steps: + - name: Verify published feature + run: | + npm install -g @devcontainers/cli@0.85.0 + TAG_VERSION="${GITHUB_REF_NAME#v}" + FEATURE_REF="ghcr.io/${{ github.repository }}/claude-code:${TAG_VERSION}" + echo "Verifying: ${FEATURE_REF}" + devcontainer features info manifest "${FEATURE_REF}" || { + echo "ERROR: Published feature not accessible at ${FEATURE_REF}" + exit 1 + } + echo "Published feature verified successfully." From 3609f5f7e6d5cfb541f1f24e4360515ba1c65f9f Mon Sep 17 00:00:00 2001 From: PKramek Date: Wed, 1 Apr 2026 10:07:55 +0200 Subject: [PATCH 15/55] ci: add test workflow with lint and exhaustive matrix --- .github/workflows/test.yml | 195 +++++++++++++++++++++++++++++++++++++ 1 file changed, 195 insertions(+) create mode 100644 .github/workflows/test.yml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..62eca7d --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,195 @@ +name: "Test" + +on: + pull_request: + push: + branches: [main] + +concurrency: + group: "${{ github.workflow }}-${{ github.ref }}" + cancel-in-progress: true + +permissions: {} + +jobs: + lint: + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: ShellCheck + uses: ludeeus/action-shellcheck@00cae500b08a931fb5698e11e79bfbd38e612a38 # 2.0.0 + with: + severity: warning + + - name: Validate JSON + run: | + FAIL=0 + while IFS= read -r -d '' f; do + if ! python3 -m json.tool "$f" > /dev/null 2>&1; then + echo "ERROR: Invalid JSON: $f" + FAIL=1 + fi + done < <(find . -name '*.json' -not -path './.git/*' -print0) + exit "$FAIL" + + - name: Validate YAML + run: | + uvx yamllint@1.38.0 -d relaxed .github/workflows/ + + - name: Prettier check + run: | + npx prettier@4.0.0-alpha.8 --check "**/*.{json,yml,yaml,md}" --ignore-path .gitignore + + - name: Markdownlint + run: | + npx markdownlint-cli@0.48.0 "**/*.md" --ignore node_modules + + - name: Check .sh files are executable + run: | + FAIL=0 + while IFS= read -r -d '' f; do + if [[ ! -x "$f" ]]; then + echo "ERROR: $f is not executable" + FAIL=1 + fi + done < <(find . -name '*.sh' -not -path './.git/*' -print0) + exit "$FAIL" + + # Run all per-scenario tests (options-specific assertions) + test-scenarios: + needs: lint + runs-on: ubuntu-latest + timeout-minutes: 60 # 10 scenarios building containers takes time + permissions: + contents: read + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + + - name: Install devcontainer CLI + run: npm install -g @devcontainers/cli@0.85.0 + + - name: Run all scenarios + run: devcontainer features test --project-folder . 2>&1 | tee /tmp/scenario-test-output.log + + - name: Upload logs on failure + if: failure() + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: logs-scenarios + path: /tmp/scenario-test-output.log + retention-days: 7 + + # Run core assertions across all supported base images + test-image-matrix: + needs: lint + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + strategy: + fail-fast: false + max-parallel: 10 + matrix: + image: + # Raw OS images + - "ubuntu:22.04" + - "ubuntu:24.04" + - "debian:bullseye" + - "debian:bookworm" + - "alpine:3.19" + - "alpine:3.20" + - "alpine:3.21" + - "archlinux:latest" + - "fedora:39" + - "fedora:40" + - "rockylinux:9" + - "almalinux:9" + - "amazonlinux:2023" + # DevContainer base images + - "mcr.microsoft.com/devcontainers/base:debian" + - "mcr.microsoft.com/devcontainers/base:ubuntu" + - "mcr.microsoft.com/devcontainers/base:alpine" + - "mcr.microsoft.com/devcontainers/universal:2" + # Language-specific images + - "mcr.microsoft.com/devcontainers/python" + - "mcr.microsoft.com/devcontainers/javascript-node" + - "mcr.microsoft.com/devcontainers/typescript-node" + - "mcr.microsoft.com/devcontainers/rust" + - "mcr.microsoft.com/devcontainers/go" + - "mcr.microsoft.com/devcontainers/cpp" + - "mcr.microsoft.com/devcontainers/dotnet" + - "mcr.microsoft.com/devcontainers/java" + - "mcr.microsoft.com/devcontainers/ruby" + - "mcr.microsoft.com/devcontainers/php" + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + + - name: Install devcontainer CLI + run: npm install -g @devcontainers/cli@0.85.0 + + - name: Test on ${{ matrix.image }} + run: | + devcontainer features test \ + --features claude-code \ + --skip-scenarios \ + --base-image "${{ matrix.image }}" \ + --project-folder . 2>&1 | tee /tmp/test-output.log + + - name: Upload logs on failure + if: failure() + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: logs-amd64-${{ strategy.job-index }} + path: /tmp/test-output.log + retention-days: 7 + + # arm64 tests on native runners (reduced matrix) + test-arm64: + needs: lint + runs-on: ubuntu-24.04-arm64 + timeout-minutes: 30 + permissions: + contents: read + strategy: + fail-fast: false + max-parallel: 2 + matrix: + image: + - "ubuntu:24.04" + - "alpine:3.21" + - "mcr.microsoft.com/devcontainers/base:debian" + - "mcr.microsoft.com/devcontainers/universal:2" + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + + - name: Install devcontainer CLI + run: npm install -g @devcontainers/cli@0.85.0 + + - name: Test on ${{ matrix.image }} (arm64) + run: | + devcontainer features test \ + --features claude-code \ + --skip-scenarios \ + --base-image "${{ matrix.image }}" \ + --project-folder . 2>&1 | tee /tmp/test-output.log + + - name: Upload logs on failure + if: failure() + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: logs-arm64-${{ strategy.job-index }} + path: /tmp/test-output.log + retention-days: 7 From 550fdd9cf837d4069af00eaa6f2e1a3c67641af2 Mon Sep 17 00:00:00 2001 From: PKramek Date: Wed, 1 Apr 2026 10:55:40 +0200 Subject: [PATCH 16/55] fix: resolve Node.js LTS via awk/index.tab, add ca-certs for arch/rhel, persist install script --- src/claude-code/install.sh | 60 +++++++++++++++++++++++++++++--------- 1 file changed, 47 insertions(+), 13 deletions(-) diff --git a/src/claude-code/install.sh b/src/claude-code/install.sh index 38d2fa1..422d576 100755 --- a/src/claude-code/install.sh +++ b/src/claude-code/install.sh @@ -234,7 +234,7 @@ ensure_base_dependencies() { command -v curl > /dev/null 2>&1 || missing+=("curl") command -v git > /dev/null 2>&1 || missing+=("git") - # Always ensure ca-certificates for TLS verification + # Always ensure ca-certificates for TLS verification (needed before any curl to nodejs.org/npm) case "${OS_FAMILY}" in alpine) command -v update-ca-certificates > /dev/null 2>&1 || missing+=("ca-certificates") @@ -242,6 +242,14 @@ ensure_base_dependencies() { debian) [[ -d /etc/ssl/certs ]] && [[ -n "$(ls /etc/ssl/certs/ 2>/dev/null)" ]] || missing+=("ca-certificates") ;; + arch) + # ca-certificates may be absent on raw archlinux:latest images + [[ -d /etc/ssl/certs ]] && [[ -n "$(ls /etc/ssl/certs/ 2>/dev/null)" ]] || missing+=("ca-certificates") + ;; + rhel) + # ca-certificates may be absent on minimal Amazon Linux / Rocky / Alma images + [[ -d /etc/pki/tls/certs ]] && [[ -n "$(ls /etc/pki/tls/certs/ 2>/dev/null)" ]] || missing+=("ca-certificates") + ;; esac if [[ ${#missing[@]} -gt 0 ]]; then @@ -267,20 +275,37 @@ get_node_major_version() { resolve_node_version() { local requested="$1" - if [[ "${requested}" == "lts" ]]; then - local lts_version - lts_version=$(curl -fsSL https://nodejs.org/dist/index.json 2>/dev/null \ - | node -e " - const data = JSON.parse(require('fs').readFileSync('/dev/stdin','utf8')); - const lts = data.find(r => r.lts); - console.log(lts ? lts.version.replace(/^v/,'').split('.')[0] : '22'); - " 2>/dev/null || echo "22") - if [[ -z "${lts_version}" ]]; then lts_version="22"; fi - log_info "Resolved LTS to Node.js ${lts_version}" - echo "${lts_version}" - else + if [[ "${requested}" != "lts" ]]; then echo "${requested}" + return fi + + local lts_version="" + + # Primary: parse index.tab (TSV) via awk — requires no JSON parser, no python3, no node. + # The header row identifies the "lts" column; non-LTS rows contain "-" in that column. + lts_version=$(curl -fsSL https://nodejs.org/dist/index.tab 2>/dev/null \ + | awk 'NR==1 { for (i=1;i<=NF;i++) { if ($i=="lts") lts_col=i } next } + lts_col && $lts_col!="-" { gsub(/^v/,"",$1); split($1,v,"."); print v[1]; exit }' \ + 2>/dev/null || echo "") + + # Fallback: grep/sed on index.json — compatible with all POSIX systems. + # LTS entries have "lts":"Codename"; non-LTS entries have "lts":false. + if [[ -z "${lts_version}" ]]; then + lts_version=$(curl -fsSL https://nodejs.org/dist/index.json 2>/dev/null \ + | grep -m 1 '"lts":"' \ + | grep -o '"version":"v[0-9]*' \ + | sed 's/.*v//' \ + 2>/dev/null || echo "") + fi + + if [[ -z "${lts_version}" ]]; then + log_warn "Could not resolve Node.js LTS version from nodejs.org, defaulting to 22" + lts_version="22" + fi + + log_info "Resolved LTS to Node.js ${lts_version}" + echo "${lts_version}" } install_node_binary() { @@ -606,6 +631,15 @@ cleanup_caches() { cleanup_caches +# Persist this script so tests and postCreateCommand hooks can re-invoke it. +# The devcontainer CLI removes /tmp/dev-container-features/ after installation, +# so we copy to a stable path before that cleanup occurs. +PERSIST_DIR="/usr/local/share/devcontainer-features/claude-code" +mkdir -p "${PERSIST_DIR}" +cp "$0" "${PERSIST_DIR}/install.sh" +chmod +x "${PERSIST_DIR}/install.sh" +log_debug "Install script persisted to ${PERSIST_DIR}/install.sh" + log_info "Claude Code DevContainer Feature installation complete." log_info " Claude Code: $(claude --version 2>/dev/null || echo 'unknown')" log_info " Node.js: $(node --version 2>/dev/null || echo 'unknown')" From 85d0ee993b437c258fda924d0918a3c01b81b450 Mon Sep 17 00:00:00 2001 From: PKramek Date: Wed, 1 Apr 2026 11:00:39 +0200 Subject: [PATCH 17/55] fix: correct idempotency path, add MCP permission checks, add shfmt to CI, add registry README --- .github/workflows/test.yml | 6 ++ README.md | 2 +- src/claude-code/README.md | 76 +++++++++++++++++++++++ src/claude-code/devcontainer-feature.json | 4 +- test/claude-code/idempotency.sh | 6 +- test/claude-code/mcp_enabled.sh | 2 + 6 files changed, 91 insertions(+), 5 deletions(-) create mode 100644 src/claude-code/README.md diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 62eca7d..d5ccb71 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -48,6 +48,12 @@ jobs: run: | npx markdownlint-cli@0.48.0 "**/*.md" --ignore node_modules + - name: shfmt format check + run: | + curl -fsSL https://github.com/mvdan/sh/releases/download/v3.13.0/shfmt_v3.13.0_linux_amd64 \ + -o /usr/local/bin/shfmt && chmod +x /usr/local/bin/shfmt + shfmt -d -i 4 -ci src/ test/ + - name: Check .sh files are executable run: | FAIL=0 diff --git a/README.md b/README.md index 9379774..61111b3 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ Claude Code requires authentication. Options: ## Tested Images -This feature is tested on 25+ base images across amd64 and arm64. See the +This feature is tested on 27 amd64 + 4 arm64 base images. See the [test workflow](.github/workflows/test.yml) for the full matrix. ## Runtime Verification diff --git a/src/claude-code/README.md b/src/claude-code/README.md new file mode 100644 index 0000000..cad1211 --- /dev/null +++ b/src/claude-code/README.md @@ -0,0 +1,76 @@ +# Claude Code + +Install [Claude Code](https://docs.anthropic.com/en/docs/claude-code) CLI into +any devcontainer. Supports Debian, Ubuntu, Alpine, Arch, Fedora, RHEL, Rocky, +Alma, and Amazon Linux on amd64 and arm64. + +## Usage + +```json +{ + "features": { + "ghcr.io/pkramek/claude-code-devcontainer/claude-code:1": {} + } +} +``` + +## Options + +| Option | Type | Default | Description | +|---|---|---|---| +| `version` | string | `latest` | Claude Code version (semver or `latest`) | +| `nodeVersion` | string | `lts` | Node.js version if not present (>= 18) | +| `installPath` | string | `/usr/local` | Custom npm global prefix | +| `enableMcpServers` | boolean | `false` | Create starter MCP config at `~/.claude/mcp_servers.json` | +| `mountHostConfig` | boolean | `false` | Log mount snippet for host `~/.claude` passthrough | +| `shellCompletions` | boolean | `true` | Install bash/zsh/fish completions | + +## Examples + +Pin a specific version: + +```json +{ + "features": { + "ghcr.io/pkramek/claude-code-devcontainer/claude-code:1": { + "version": "1.0.0" + } + } +} +``` + +Enable MCP servers: + +```json +{ + "features": { + "ghcr.io/pkramek/claude-code-devcontainer/claude-code:1": { + "enableMcpServers": true + } + } +} +``` + +## Authentication + +Claude Code requires authentication. Set `ANTHROPIC_API_KEY` in your +devcontainer: + +```json +{ + "remoteEnv": { + "ANTHROPIC_API_KEY": "${localEnv:ANTHROPIC_API_KEY}" + } +} +``` + +Or mount your host `~/.claude` directory (see `mountHostConfig` option). + +## Notes + +- Node.js >= 18 is required. If not present, this feature installs the current + LTS release automatically. +- Shell completions are installed for bash, zsh, and fish if those directories + exist in the container. +- The `enableMcpServers` option creates a starter config with secure permissions + (`chmod 600`) owned by the container user. diff --git a/src/claude-code/devcontainer-feature.json b/src/claude-code/devcontainer-feature.json index 80e3ec8..b2726d1 100644 --- a/src/claude-code/devcontainer-feature.json +++ b/src/claude-code/devcontainer-feature.json @@ -11,8 +11,8 @@ "cli", "devcontainer" ], - "documentationURL": "https://github.com/PKramek/claude-code-devcontainer#readme", - "licenseURL": "https://github.com/PKramek/claude-code-devcontainer/blob/main/LICENSE", + "documentationURL": "https://github.com/pkramek/claude-code-devcontainer#readme", + "licenseURL": "https://github.com/pkramek/claude-code-devcontainer/blob/main/LICENSE", "installsAfter": [ "ghcr.io/devcontainers/features/node" ], diff --git a/test/claude-code/idempotency.sh b/test/claude-code/idempotency.sh index 4f62477..234362d 100755 --- a/test/claude-code/idempotency.sh +++ b/test/claude-code/idempotency.sh @@ -12,8 +12,10 @@ CLAUDE_VERSION_BEFORE=$(claude --version 2>&1) NODE_VERSION_BEFORE=$(node --version 2>&1) echo "--- Idempotency: run install.sh a second time ---" -# Re-run install as root to simulate a container rebuild -sudo bash /usr/local/share/claude-code/install.sh 2>&1 || { +# install.sh copies itself to this stable path at the end of installation +# (see PERSIST_DIR block). The devcontainer CLI purges /tmp/ after installation, +# so we cannot re-invoke from /tmp/dev-container-features/. +sudo bash /usr/local/share/devcontainer-features/claude-code/install.sh 2>&1 || { fail "Second install.sh run failed" test_summary } diff --git a/test/claude-code/mcp_enabled.sh b/test/claude-code/mcp_enabled.sh index 3dc6479..30734a3 100755 --- a/test/claude-code/mcp_enabled.sh +++ b/test/claude-code/mcp_enabled.sh @@ -12,5 +12,7 @@ MCP_CONFIG="${HOME}/.claude/mcp_servers.json" check_file_exists "${MCP_CONFIG}" check_file_valid_json "${MCP_CONFIG}" check_file_owner "${MCP_CONFIG}" "$(whoami)" +check_permissions "${HOME}/.claude" "700" +check_permissions "${MCP_CONFIG}" "600" test_summary From f262aeffcc5f442b47bd997d2163fa59ce1da3bf Mon Sep 17 00:00:00 2001 From: PKramek Date: Wed, 1 Apr 2026 11:25:43 +0200 Subject: [PATCH 18/55] fix: ensure xz-utils present before Node.js tarball extract, improve auth docs --- README.md | 15 ++++++++++----- src/claude-code/README.md | 26 ++++++++++++++++---------- src/claude-code/install.sh | 11 +++++++++++ 3 files changed, 37 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 61111b3..622a968 100644 --- a/README.md +++ b/README.md @@ -57,9 +57,13 @@ Enable MCP servers: ## Authentication -Claude Code requires authentication. Options: +Claude Code requires authentication. Three options: -1. **Environment variable:** Set `ANTHROPIC_API_KEY` in your devcontainer: +1. **Browser login (recommended):** Run `claude login` in the container terminal. + Claude Code opens a browser window for OAuth authentication. Works out of the + box in VS Code's integrated terminal with no configuration needed. + +2. **Environment variable:** Set `ANTHROPIC_API_KEY` in your devcontainer: ```json { @@ -69,18 +73,19 @@ Claude Code requires authentication. Options: } ``` -2. **Mount host config:** Mount your local `~/.claude` directory: +3. **Mount host config:** Mount your local `~/.claude` directory: ```json { "mounts": [ - "source=${localEnv:HOME}/.claude,target=/home/vscode/.claude,type=bind,consistency=cached,readonly" + "source=${localEnv:HOME}/.claude,target=${localEnv:HOME}/.claude,type=bind,consistency=cached,readonly" ] } ``` + > **Note:** Replace the `target` path with your container user's home directory + > (e.g., `/home/vscode`, `/root`, or `/home/node` depending on your base image). > **Security warning:** This exposes your API keys inside the container. - > If the container is compromised, credentials are at risk. ## Tested Images diff --git a/src/claude-code/README.md b/src/claude-code/README.md index cad1211..fe8b61f 100644 --- a/src/claude-code/README.md +++ b/src/claude-code/README.md @@ -53,18 +53,24 @@ Enable MCP servers: ## Authentication -Claude Code requires authentication. Set `ANTHROPIC_API_KEY` in your -devcontainer: +Claude Code requires authentication. Three options: -```json -{ - "remoteEnv": { - "ANTHROPIC_API_KEY": "${localEnv:ANTHROPIC_API_KEY}" - } -} -``` +1. **Browser login (recommended):** Run `claude login` in the container terminal. + Works out of the box in VS Code's integrated terminal. + +2. **Environment variable:** + + ```json + { + "remoteEnv": { + "ANTHROPIC_API_KEY": "${localEnv:ANTHROPIC_API_KEY}" + } + } + ``` -Or mount your host `~/.claude` directory (see `mountHostConfig` option). +3. **Mount host config:** Use the `mountHostConfig` option to get the mount + snippet, then add it to `mounts` in your `devcontainer.json`. Adjust the + `target` path to match your container user's home directory. ## Notes diff --git a/src/claude-code/install.sh b/src/claude-code/install.sh index 422d576..f3cc7d1 100755 --- a/src/claude-code/install.sh +++ b/src/claude-code/install.sh @@ -252,6 +252,17 @@ ensure_base_dependencies() { ;; esac + # xz decompression is required for Node.js binary tarballs (.tar.xz) on debian/rhel. + # xz-utils (Debian) / xz (RHEL) may be absent on minimal base images. + case "${OS_FAMILY}" in + debian) + command -v xz > /dev/null 2>&1 || missing+=("xz-utils") + ;; + rhel) + command -v xz > /dev/null 2>&1 || missing+=("xz") + ;; + esac + if [[ ${#missing[@]} -gt 0 ]]; then log_info "Installing missing dependencies: ${missing[*]}" install_packages "${missing[@]}" From 134b69a73a23d56897ab7e071f696c09b805ea78 Mon Sep 17 00:00:00 2001 From: PKramek Date: Wed, 1 Apr 2026 17:42:47 +0200 Subject: [PATCH 19/55] docs: add repo rename and branch setup design spec --- ...6-04-01-repo-rename-branch-setup-design.md | 200 ++++++++++++++++++ 1 file changed, 200 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-01-repo-rename-branch-setup-design.md diff --git a/docs/superpowers/specs/2026-04-01-repo-rename-branch-setup-design.md b/docs/superpowers/specs/2026-04-01-repo-rename-branch-setup-design.md new file mode 100644 index 0000000..edadc51 --- /dev/null +++ b/docs/superpowers/specs/2026-04-01-repo-rename-branch-setup-design.md @@ -0,0 +1,200 @@ +# Repo Rename + Branch Setup Design + +**Goal:** Rename the repository from `claude-code-devcontainer` to `claude-devcontainer`, push the existing implementation to GitHub, and establish a PR-based GitHub Flow branching strategy with protected `main` and `develop` branches. + +**Date:** 2026-04-01 + +--- + +## Branch Strategy + +GitHub Flow with `develop` as the integration branch: + +``` +main ← protected, tagged releases only (v1.0.0, v1.1.0, ...) + └── develop ← protected, all feature PRs land here + └── feat/ ← short-lived feature branches +``` + +- All new work: feature branch → PR → `develop` +- Releases: `develop` → PR → `main` → git tag → CI publishes to GHCR +- No direct pushes to `main` or `develop` + +--- + +## Rename Changes + +8 occurrences of `claude-code-devcontainer` across 3 files, updated to `claude-devcontainer`: + +| File | Change | +|---|---| +| `README.md` | Badge URL + 3× GHCR feature references | +| `src/claude-code/README.md` | 3× GHCR feature references | +| `src/claude-code/devcontainer-feature.json` | `documentationURL` + `licenseURL` | + +GHCR reference after rename: `ghcr.io/pkramek/claude-devcontainer/claude-code:1` + +Single commit: `chore: rename repo to claude-devcontainer` + +--- + +## Execution Order + +1. Apply rename commit on current local branch +2. Rename local branch to `feat/initial-implementation` +3. Add remote: `git@github.com:PKramek/claude-devcontainer.git` +4. Create `main` as an empty orphan branch and push it +5. Create `develop` from `main` and push it +6. Push `feat/initial-implementation` to remote +7. Apply branch protection rulesets (via `gh api`) +8. Open PR: `feat/initial-implementation` → `develop` + +--- + +## Branch Protection Rulesets + +Applied via GitHub Repository Rules API (`gh api`). Both rules use enforcement `active`. + +### `protect-develop` ruleset + +Targets: `refs/heads/develop` + +| Rule | Value | +|---|---| +| deletion | blocked | +| non_fast_forward (force push) | blocked | +| required_approving_review_count | 0 | +| dismiss_stale_reviews_on_push | true | +| required_review_thread_resolution | true | +| allowed_merge_methods | `squash` only | +| required_status_checks | `lint` (GitHub Actions, integration_id: 15368) | +| do_not_enforce_on_create | false | +| strict_required_status_checks_policy | false | + +### `protect-main` ruleset + +Targets: `refs/heads/main` + +| Rule | Value | +|---|---| +| deletion | blocked | +| non_fast_forward (force push) | blocked | +| required_approving_review_count | 0 (solo project) | +| dismiss_stale_reviews_on_push | true | +| required_review_thread_resolution | true | +| allowed_merge_methods | `merge` only (preserves squashed history from develop) | +| required_status_checks | `lint` (GitHub Actions, integration_id: 15368) | +| do_not_enforce_on_create | false | +| strict_required_status_checks_policy | false | + +No bypass actors (personal repo, owner can merge their own PRs with 0 required reviews). + +--- + +## Protection Rule Payloads + +### develop + +```json +{ + "name": "protect-develop", + "target": "branch", + "enforcement": "active", + "conditions": { + "ref_name": { + "exclude": [], + "include": ["refs/heads/develop"] + } + }, + "rules": [ + { "type": "deletion" }, + { "type": "non_fast_forward" }, + { + "type": "pull_request", + "parameters": { + "required_approving_review_count": 0, + "dismiss_stale_reviews_on_push": true, + "required_reviewers": [], + "require_code_owner_review": false, + "require_last_push_approval": false, + "required_review_thread_resolution": true, + "allowed_merge_methods": ["squash"] + } + }, + { + "type": "required_status_checks", + "parameters": { + "strict_required_status_checks_policy": false, + "do_not_enforce_on_create": false, + "required_status_checks": [ + { "context": "lint", "integration_id": 15368 } + ] + } + } + ] +} +``` + +### main + +```json +{ + "name": "protect-main", + "target": "branch", + "enforcement": "active", + "conditions": { + "ref_name": { + "exclude": [], + "include": ["refs/heads/main"] + } + }, + "rules": [ + { "type": "deletion" }, + { "type": "non_fast_forward" }, + { + "type": "pull_request", + "parameters": { + "required_approving_review_count": 0, + "dismiss_stale_reviews_on_push": true, + "required_reviewers": [], + "require_code_owner_review": false, + "require_last_push_approval": false, + "required_review_thread_resolution": true, + "allowed_merge_methods": ["merge"] + } + }, + { + "type": "required_status_checks", + "parameters": { + "strict_required_status_checks_policy": false, + "do_not_enforce_on_create": false, + "required_status_checks": [ + { "context": "lint", "integration_id": 15368 } + ] + } + } + ] +} +``` + +--- + +## PR Description (Initial Implementation) + +Title: `feat: initial Claude Code DevContainer Feature implementation` + +Body: +- Universal DevContainer Feature installing Claude Code CLI into any container +- Supports Debian, Ubuntu, Alpine, Arch, Fedora, RHEL, Rocky, Alma, Amazon Linux +- amd64 + arm64 via SHA256-verified Node.js binary tarballs +- Shell completions (bash/zsh/fish), MCP config, mount docs, cache cleanup +- 10 test scenarios + 27-image amd64 CI matrix + 4-image arm64 matrix +- Pre-commit hooks (ShellCheck, shfmt, Prettier, markdownlint) + +--- + +## Files to Delete After Setup + +The example protection JSON files are reference material and must not ship in the repo: +- `protect-develop (2).json` +- `protect-master (2).json` From 19dc0f68a53b75a1ef29dd86e965a9b91418dffe Mon Sep 17 00:00:00 2001 From: PKramek Date: Wed, 1 Apr 2026 18:02:37 +0200 Subject: [PATCH 20/55] chore: untrack repo rename planning doc, gitignore protect-*.json --- .gitignore | 6 + ...6-04-01-repo-rename-branch-setup-design.md | 200 ------------------ 2 files changed, 6 insertions(+), 200 deletions(-) delete mode 100644 docs/superpowers/specs/2026-04-01-repo-rename-branch-setup-design.md diff --git a/.gitignore b/.gitignore index f87b886..2d9785e 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,9 @@ node_modules/ # Pre-commit .pre-commit-cache/ + +# Internal repo management docs (not for public repo) +docs/superpowers/specs/2026-04-01-repo-rename-branch-setup-design.md + +# Example branch protection rule files +protect-*.json diff --git a/docs/superpowers/specs/2026-04-01-repo-rename-branch-setup-design.md b/docs/superpowers/specs/2026-04-01-repo-rename-branch-setup-design.md deleted file mode 100644 index edadc51..0000000 --- a/docs/superpowers/specs/2026-04-01-repo-rename-branch-setup-design.md +++ /dev/null @@ -1,200 +0,0 @@ -# Repo Rename + Branch Setup Design - -**Goal:** Rename the repository from `claude-code-devcontainer` to `claude-devcontainer`, push the existing implementation to GitHub, and establish a PR-based GitHub Flow branching strategy with protected `main` and `develop` branches. - -**Date:** 2026-04-01 - ---- - -## Branch Strategy - -GitHub Flow with `develop` as the integration branch: - -``` -main ← protected, tagged releases only (v1.0.0, v1.1.0, ...) - └── develop ← protected, all feature PRs land here - └── feat/ ← short-lived feature branches -``` - -- All new work: feature branch → PR → `develop` -- Releases: `develop` → PR → `main` → git tag → CI publishes to GHCR -- No direct pushes to `main` or `develop` - ---- - -## Rename Changes - -8 occurrences of `claude-code-devcontainer` across 3 files, updated to `claude-devcontainer`: - -| File | Change | -|---|---| -| `README.md` | Badge URL + 3× GHCR feature references | -| `src/claude-code/README.md` | 3× GHCR feature references | -| `src/claude-code/devcontainer-feature.json` | `documentationURL` + `licenseURL` | - -GHCR reference after rename: `ghcr.io/pkramek/claude-devcontainer/claude-code:1` - -Single commit: `chore: rename repo to claude-devcontainer` - ---- - -## Execution Order - -1. Apply rename commit on current local branch -2. Rename local branch to `feat/initial-implementation` -3. Add remote: `git@github.com:PKramek/claude-devcontainer.git` -4. Create `main` as an empty orphan branch and push it -5. Create `develop` from `main` and push it -6. Push `feat/initial-implementation` to remote -7. Apply branch protection rulesets (via `gh api`) -8. Open PR: `feat/initial-implementation` → `develop` - ---- - -## Branch Protection Rulesets - -Applied via GitHub Repository Rules API (`gh api`). Both rules use enforcement `active`. - -### `protect-develop` ruleset - -Targets: `refs/heads/develop` - -| Rule | Value | -|---|---| -| deletion | blocked | -| non_fast_forward (force push) | blocked | -| required_approving_review_count | 0 | -| dismiss_stale_reviews_on_push | true | -| required_review_thread_resolution | true | -| allowed_merge_methods | `squash` only | -| required_status_checks | `lint` (GitHub Actions, integration_id: 15368) | -| do_not_enforce_on_create | false | -| strict_required_status_checks_policy | false | - -### `protect-main` ruleset - -Targets: `refs/heads/main` - -| Rule | Value | -|---|---| -| deletion | blocked | -| non_fast_forward (force push) | blocked | -| required_approving_review_count | 0 (solo project) | -| dismiss_stale_reviews_on_push | true | -| required_review_thread_resolution | true | -| allowed_merge_methods | `merge` only (preserves squashed history from develop) | -| required_status_checks | `lint` (GitHub Actions, integration_id: 15368) | -| do_not_enforce_on_create | false | -| strict_required_status_checks_policy | false | - -No bypass actors (personal repo, owner can merge their own PRs with 0 required reviews). - ---- - -## Protection Rule Payloads - -### develop - -```json -{ - "name": "protect-develop", - "target": "branch", - "enforcement": "active", - "conditions": { - "ref_name": { - "exclude": [], - "include": ["refs/heads/develop"] - } - }, - "rules": [ - { "type": "deletion" }, - { "type": "non_fast_forward" }, - { - "type": "pull_request", - "parameters": { - "required_approving_review_count": 0, - "dismiss_stale_reviews_on_push": true, - "required_reviewers": [], - "require_code_owner_review": false, - "require_last_push_approval": false, - "required_review_thread_resolution": true, - "allowed_merge_methods": ["squash"] - } - }, - { - "type": "required_status_checks", - "parameters": { - "strict_required_status_checks_policy": false, - "do_not_enforce_on_create": false, - "required_status_checks": [ - { "context": "lint", "integration_id": 15368 } - ] - } - } - ] -} -``` - -### main - -```json -{ - "name": "protect-main", - "target": "branch", - "enforcement": "active", - "conditions": { - "ref_name": { - "exclude": [], - "include": ["refs/heads/main"] - } - }, - "rules": [ - { "type": "deletion" }, - { "type": "non_fast_forward" }, - { - "type": "pull_request", - "parameters": { - "required_approving_review_count": 0, - "dismiss_stale_reviews_on_push": true, - "required_reviewers": [], - "require_code_owner_review": false, - "require_last_push_approval": false, - "required_review_thread_resolution": true, - "allowed_merge_methods": ["merge"] - } - }, - { - "type": "required_status_checks", - "parameters": { - "strict_required_status_checks_policy": false, - "do_not_enforce_on_create": false, - "required_status_checks": [ - { "context": "lint", "integration_id": 15368 } - ] - } - } - ] -} -``` - ---- - -## PR Description (Initial Implementation) - -Title: `feat: initial Claude Code DevContainer Feature implementation` - -Body: -- Universal DevContainer Feature installing Claude Code CLI into any container -- Supports Debian, Ubuntu, Alpine, Arch, Fedora, RHEL, Rocky, Alma, Amazon Linux -- amd64 + arm64 via SHA256-verified Node.js binary tarballs -- Shell completions (bash/zsh/fish), MCP config, mount docs, cache cleanup -- 10 test scenarios + 27-image amd64 CI matrix + 4-image arm64 matrix -- Pre-commit hooks (ShellCheck, shfmt, Prettier, markdownlint) - ---- - -## Files to Delete After Setup - -The example protection JSON files are reference material and must not ship in the repo: -- `protect-develop (2).json` -- `protect-master (2).json` From d99582131af30570bb8c35b63aca5591cbd18d93 Mon Sep 17 00:00:00 2001 From: PKramek Date: Wed, 1 Apr 2026 18:37:34 +0200 Subject: [PATCH 21/55] ci: trigger CI on develop branch push --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d5ccb71..4eff1ae 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -3,7 +3,7 @@ name: "Test" on: pull_request: push: - branches: [main] + branches: [main, develop] concurrency: group: "${{ github.workflow }}-${{ github.ref }}" From 7dfd19c5a30932e75f4bfb037320f8bea4cd92f8 Mon Sep 17 00:00:00 2001 From: PKramek Date: Wed, 1 Apr 2026 18:39:19 +0200 Subject: [PATCH 22/55] chore: rename repo to claude-devcontainer --- README.md | 8 ++++---- src/claude-code/README.md | 6 +++--- src/claude-code/devcontainer-feature.json | 4 ++-- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 622a968..cb06c30 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Claude Code DevContainer Feature -[![Test](https://github.com/pkramek/claude-code-devcontainer/actions/workflows/test.yml/badge.svg)](https://github.com/pkramek/claude-code-devcontainer/actions/workflows/test.yml) +[![Test](https://github.com/pkramek/claude-devcontainer/actions/workflows/test.yml/badge.svg)](https://github.com/pkramek/claude-devcontainer/actions/workflows/test.yml) Install [Claude Code](https://docs.anthropic.com/en/docs/claude-code) into any devcontainer. Supports Debian, Ubuntu, Alpine, Arch, Fedora, RHEL, Rocky, Alma, @@ -13,7 +13,7 @@ Add this feature to your `devcontainer.json`: ```json { "features": { - "ghcr.io/pkramek/claude-code-devcontainer/claude-code:1": {} + "ghcr.io/pkramek/claude-devcontainer/claude-code:1": {} } } ``` @@ -36,7 +36,7 @@ Pin a specific version: ```json { "features": { - "ghcr.io/pkramek/claude-code-devcontainer/claude-code:1": { + "ghcr.io/pkramek/claude-devcontainer/claude-code:1": { "version": "1.0.0" } } @@ -48,7 +48,7 @@ Enable MCP servers: ```json { "features": { - "ghcr.io/pkramek/claude-code-devcontainer/claude-code:1": { + "ghcr.io/pkramek/claude-devcontainer/claude-code:1": { "enableMcpServers": true } } diff --git a/src/claude-code/README.md b/src/claude-code/README.md index fe8b61f..243073e 100644 --- a/src/claude-code/README.md +++ b/src/claude-code/README.md @@ -9,7 +9,7 @@ Alma, and Amazon Linux on amd64 and arm64. ```json { "features": { - "ghcr.io/pkramek/claude-code-devcontainer/claude-code:1": {} + "ghcr.io/pkramek/claude-devcontainer/claude-code:1": {} } } ``` @@ -32,7 +32,7 @@ Pin a specific version: ```json { "features": { - "ghcr.io/pkramek/claude-code-devcontainer/claude-code:1": { + "ghcr.io/pkramek/claude-devcontainer/claude-code:1": { "version": "1.0.0" } } @@ -44,7 +44,7 @@ Enable MCP servers: ```json { "features": { - "ghcr.io/pkramek/claude-code-devcontainer/claude-code:1": { + "ghcr.io/pkramek/claude-devcontainer/claude-code:1": { "enableMcpServers": true } } diff --git a/src/claude-code/devcontainer-feature.json b/src/claude-code/devcontainer-feature.json index b2726d1..a766985 100644 --- a/src/claude-code/devcontainer-feature.json +++ b/src/claude-code/devcontainer-feature.json @@ -11,8 +11,8 @@ "cli", "devcontainer" ], - "documentationURL": "https://github.com/pkramek/claude-code-devcontainer#readme", - "licenseURL": "https://github.com/pkramek/claude-code-devcontainer/blob/main/LICENSE", + "documentationURL": "https://github.com/pkramek/claude-devcontainer#readme", + "licenseURL": "https://github.com/pkramek/claude-devcontainer/blob/main/LICENSE", "installsAfter": [ "ghcr.io/devcontainers/features/node" ], From a8f3158661cf916d8efe5461d8fe13d79fb1492b Mon Sep 17 00:00:00 2001 From: PKramek Date: Wed, 1 Apr 2026 19:50:21 +0200 Subject: [PATCH 23/55] ci: add setup-uv step before yamllint to fix missing uvx on runner --- .github/workflows/test.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4eff1ae..8e12c11 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -36,6 +36,9 @@ jobs: done < <(find . -name '*.json' -not -path './.git/*' -print0) exit "$FAIL" + - name: Install uv + uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0 + - name: Validate YAML run: | uvx yamllint@1.38.0 -d relaxed .github/workflows/ From bb770c6096abc523a22bdc3701c440eda87b2e46 Mon Sep 17 00:00:00 2001 From: PKramek Date: Wed, 1 Apr 2026 19:55:39 +0200 Subject: [PATCH 24/55] style: apply Prettier formatting to all staged files --- .devcontainer/devcontainer.json | 32 +- README.md | 52 +- ...-03-31-claude-code-devcontainer-feature.md | 459 +++++++++--------- ...claude-code-devcontainer-feature-design.md | 106 ++-- src/claude-code/README.md | 44 +- src/claude-code/devcontainer-feature.json | 98 ++-- test/claude-code/scenarios.json | 144 +++--- 7 files changed, 484 insertions(+), 451 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index e407adb..55e8b44 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,19 +1,19 @@ { - "name": "Claude Code Feature Development", - "image": "mcr.microsoft.com/devcontainers/base:ubuntu", - "features": { - "ghcr.io/devcontainers/features/node:1": { - "version": "22" - } - }, - "postCreateCommand": "npm install -g @devcontainers/cli && pre-commit install || true", - "customizations": { - "vscode": { - "extensions": [ - "timonwong.shellcheck", - "foxundermoon.shell-format", - "esbenp.prettier-vscode" - ] - } + "name": "Claude Code Feature Development", + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "features": { + "ghcr.io/devcontainers/features/node:1": { + "version": "22" } + }, + "postCreateCommand": "npm install -g @devcontainers/cli && pre-commit install || true", + "customizations": { + "vscode": { + "extensions": [ + "timonwong.shellcheck", + "foxundermoon.shell-format", + "esbenp.prettier-vscode" + ] + } + } } diff --git a/README.md b/README.md index cb06c30..19c50e2 100644 --- a/README.md +++ b/README.md @@ -12,22 +12,22 @@ Add this feature to your `devcontainer.json`: ```json { - "features": { - "ghcr.io/pkramek/claude-devcontainer/claude-code:1": {} - } + "features": { + "ghcr.io/pkramek/claude-devcontainer/claude-code:1": {} + } } ``` ### Options -| Option | Type | Default | Description | -|---|---|---|---| -| `version` | string | `latest` | Claude Code version (semver or `latest`) | -| `nodeVersion` | string | `lts` | Node.js version if not present (>= 18) | -| `installPath` | string | `/usr/local` | Custom npm global prefix | -| `enableMcpServers` | boolean | `false` | Create starter MCP config | -| `mountHostConfig` | boolean | `false` | Log mount snippet for host config | -| `shellCompletions` | boolean | `true` | Install bash/zsh/fish completions | +| Option | Type | Default | Description | +| ------------------ | ------- | ------------ | ---------------------------------------- | +| `version` | string | `latest` | Claude Code version (semver or `latest`) | +| `nodeVersion` | string | `lts` | Node.js version if not present (>= 18) | +| `installPath` | string | `/usr/local` | Custom npm global prefix | +| `enableMcpServers` | boolean | `false` | Create starter MCP config | +| `mountHostConfig` | boolean | `false` | Log mount snippet for host config | +| `shellCompletions` | boolean | `true` | Install bash/zsh/fish completions | ### Examples @@ -35,11 +35,11 @@ Pin a specific version: ```json { - "features": { - "ghcr.io/pkramek/claude-devcontainer/claude-code:1": { - "version": "1.0.0" - } + "features": { + "ghcr.io/pkramek/claude-devcontainer/claude-code:1": { + "version": "1.0.0" } + } } ``` @@ -47,11 +47,11 @@ Enable MCP servers: ```json { - "features": { - "ghcr.io/pkramek/claude-devcontainer/claude-code:1": { - "enableMcpServers": true - } + "features": { + "ghcr.io/pkramek/claude-devcontainer/claude-code:1": { + "enableMcpServers": true } + } } ``` @@ -67,9 +67,9 @@ Claude Code requires authentication. Three options: ```json { - "remoteEnv": { - "ANTHROPIC_API_KEY": "${localEnv:ANTHROPIC_API_KEY}" - } + "remoteEnv": { + "ANTHROPIC_API_KEY": "${localEnv:ANTHROPIC_API_KEY}" + } } ``` @@ -77,9 +77,9 @@ Claude Code requires authentication. Three options: ```json { - "mounts": [ - "source=${localEnv:HOME}/.claude,target=${localEnv:HOME}/.claude,type=bind,consistency=cached,readonly" - ] + "mounts": [ + "source=${localEnv:HOME}/.claude,target=${localEnv:HOME}/.claude,type=bind,consistency=cached,readonly" + ] } ``` @@ -98,7 +98,7 @@ Add this to your `devcontainer.json` to verify Claude Code at container start: ```json { - "postCreateCommand": "claude --version || true" + "postCreateCommand": "claude --version || true" } ``` diff --git a/docs/superpowers/plans/2026-03-31-claude-code-devcontainer-feature.md b/docs/superpowers/plans/2026-03-31-claude-code-devcontainer-feature.md index c33e338..aa7326a 100644 --- a/docs/superpowers/plans/2026-03-31-claude-code-devcontainer-feature.md +++ b/docs/superpowers/plans/2026-03-31-claude-code-devcontainer-feature.md @@ -14,36 +14,37 @@ ## File Map -| File | Responsibility | -|---|---| -| `src/claude-code/devcontainer-feature.json` | Feature manifest: metadata, options, env vars | -| `src/claude-code/install.sh` | Installation script: OS detection, Node.js install, Claude Code install, completions, MCP, cleanup | -| `test/claude-code/test.sh` | Shared test helper functions (assertions) | -| `test/claude-code/scenarios.json` | Test scenario definitions (image + feature options) | -| `test/claude-code/default_options.sh` | Test: default options assertions | -| `test/claude-code/completions_disabled.sh` | Test: completions absent when disabled | -| `test/claude-code/mcp_enabled.sh` | Test: MCP config exists and is valid | -| `test/claude-code/custom_version.sh` | Test: pinned version matches exactly | -| `test/claude-code/node_preinstalled.sh` | Test: existing Node.js untouched | -| `test/claude-code/custom_install_path.sh` | Test: binary at custom path, PATH updated | -| `test/claude-code/mount_host_config.sh` | Test: mount snippet in output, no actual mount | -| `test/claude-code/alpine_specific.sh` | Test: bash installed, Alpine-specific paths | -| `test/claude-code/idempotency.sh` | Test: double-install produces same state | -| `test/claude-code/multi_feature_combo.sh` | Test: coexists with separate Node feature | -| `.github/workflows/test.yml` | CI: lint + exhaustive test matrix | -| `.github/workflows/release.yml` | CD: publish to ghcr.io on tag push | -| `.pre-commit-config.yaml` | Pre-commit hook definitions | -| `.shellcheckrc` | ShellCheck config (bash, warning severity) | -| `.editorconfig` | Formatting config (4-space indent for .sh) | -| `.devcontainer/devcontainer.json` | Contributor dev environment | -| `LICENSE` | MIT license | -| `README.md` | Usage docs, examples, CI badge | +| File | Responsibility | +| ------------------------------------------- | -------------------------------------------------------------------------------------------------- | +| `src/claude-code/devcontainer-feature.json` | Feature manifest: metadata, options, env vars | +| `src/claude-code/install.sh` | Installation script: OS detection, Node.js install, Claude Code install, completions, MCP, cleanup | +| `test/claude-code/test.sh` | Shared test helper functions (assertions) | +| `test/claude-code/scenarios.json` | Test scenario definitions (image + feature options) | +| `test/claude-code/default_options.sh` | Test: default options assertions | +| `test/claude-code/completions_disabled.sh` | Test: completions absent when disabled | +| `test/claude-code/mcp_enabled.sh` | Test: MCP config exists and is valid | +| `test/claude-code/custom_version.sh` | Test: pinned version matches exactly | +| `test/claude-code/node_preinstalled.sh` | Test: existing Node.js untouched | +| `test/claude-code/custom_install_path.sh` | Test: binary at custom path, PATH updated | +| `test/claude-code/mount_host_config.sh` | Test: mount snippet in output, no actual mount | +| `test/claude-code/alpine_specific.sh` | Test: bash installed, Alpine-specific paths | +| `test/claude-code/idempotency.sh` | Test: double-install produces same state | +| `test/claude-code/multi_feature_combo.sh` | Test: coexists with separate Node feature | +| `.github/workflows/test.yml` | CI: lint + exhaustive test matrix | +| `.github/workflows/release.yml` | CD: publish to ghcr.io on tag push | +| `.pre-commit-config.yaml` | Pre-commit hook definitions | +| `.shellcheckrc` | ShellCheck config (bash, warning severity) | +| `.editorconfig` | Formatting config (4-space indent for .sh) | +| `.devcontainer/devcontainer.json` | Contributor dev environment | +| `LICENSE` | MIT license | +| `README.md` | Usage docs, examples, CI badge | --- ## Task 1: Repository Foundation **Files:** + - Create: `LICENSE` - Create: `.editorconfig` - Create: `.shellcheckrc` @@ -141,69 +142,69 @@ git commit -m "chore: add LICENSE, editorconfig, shellcheckrc, gitignore" ## Task 2: Feature Manifest **Files:** + - Create: `src/claude-code/devcontainer-feature.json` - [ ] **Step 1: Create the feature manifest** ```json { - "id": "claude-code", - "version": "1.0.0", - "name": "Claude Code", - "description": "Install Claude Code CLI into any devcontainer. Supports Debian, Ubuntu, Alpine, Arch, Fedora, RHEL, Rocky, Alma, and Amazon Linux on amd64/arm64.", - "keywords": [ - "claude", - "claude-code", - "anthropic", - "ai", - "cli", - "devcontainer" - ], - "documentationURL": "https://github.com/pkramek/claude-code-devcontainer#readme", - "licenseURL": "https://github.com/pkramek/claude-code-devcontainer/blob/main/LICENSE", - "installsAfter": [ - "ghcr.io/devcontainers/features/node" - ], - "options": { - "version": { - "type": "string", - "default": "latest", - "description": "Claude Code version to install (semver or 'latest'). Recommend pinning for teams." - }, - "nodeVersion": { - "type": "string", - "default": "lts", - "description": "Node.js version to install if not already present (>= 18 required). Resolved via NodeSource." - }, - "installPath": { - "type": "string", - "default": "/usr/local", - "description": "Custom npm global install prefix. Feature ensures /bin is on PATH." - }, - "enableMcpServers": { - "type": "boolean", - "default": false, - "description": "Create a starter MCP server configuration at ~/.claude/mcp_servers.json (create-if-absent)." - }, - "mountHostConfig": { - "type": "boolean", - "default": false, - "description": "Log a mounts snippet for host ~/.claude config passthrough (documentation-only, does NOT auto-mount)." - }, - "shellCompletions": { - "type": "boolean", - "default": true, - "description": "Install shell completions for bash, zsh, and fish." - } + "id": "claude-code", + "version": "1.0.0", + "name": "Claude Code", + "description": "Install Claude Code CLI into any devcontainer. Supports Debian, Ubuntu, Alpine, Arch, Fedora, RHEL, Rocky, Alma, and Amazon Linux on amd64/arm64.", + "keywords": [ + "claude", + "claude-code", + "anthropic", + "ai", + "cli", + "devcontainer" + ], + "documentationURL": "https://github.com/pkramek/claude-code-devcontainer#readme", + "licenseURL": "https://github.com/pkramek/claude-code-devcontainer/blob/main/LICENSE", + "installsAfter": ["ghcr.io/devcontainers/features/node"], + "options": { + "version": { + "type": "string", + "default": "latest", + "description": "Claude Code version to install (semver or 'latest'). Recommend pinning for teams." + }, + "nodeVersion": { + "type": "string", + "default": "lts", + "description": "Node.js version to install if not already present (>= 18 required). Resolved via NodeSource." + }, + "installPath": { + "type": "string", + "default": "/usr/local", + "description": "Custom npm global install prefix. Feature ensures /bin is on PATH." }, - "containerEnv": { - "CLAUDE_CODE_INSTALLED": "true" + "enableMcpServers": { + "type": "boolean", + "default": false, + "description": "Create a starter MCP server configuration at ~/.claude/mcp_servers.json (create-if-absent)." + }, + "mountHostConfig": { + "type": "boolean", + "default": false, + "description": "Log a mounts snippet for host ~/.claude config passthrough (documentation-only, does NOT auto-mount)." + }, + "shellCompletions": { + "type": "boolean", + "default": true, + "description": "Install shell completions for bash, zsh, and fish." } + }, + "containerEnv": { + "CLAUDE_CODE_INSTALLED": "true" + } } ``` **Note:** `postCreateCommand` is NOT a valid field in `devcontainer-feature.json` (it belongs in `devcontainer.json`). The runtime verification (`claude --version`) is already handled at the end of `install.sh`. The README documents a recommended `postCreateCommand` for users who want runtime verification. -``` + +```` - [ ] **Step 2: Validate JSON is well-formed** @@ -215,13 +216,14 @@ Expected: exits 0, no output ```bash git add src/claude-code/devcontainer-feature.json git commit -m "feat: add devcontainer-feature.json manifest" -``` +```` --- ## Task 3: Install Script — Bootstrap, Error Handling, Input Validation **Files:** + - Create: `src/claude-code/install.sh` This task creates the script skeleton with the bootstrap, error handling, logging, input validation, and option parsing. No actual installation logic yet. @@ -401,6 +403,7 @@ git commit -m "feat: add install.sh skeleton with bootstrap, logging, validation ## Task 4: Install Script — OS Detection and Dependency Installation **Files:** + - Modify: `src/claude-code/install.sh` Add OS detection and base dependency installation functions. @@ -546,6 +549,7 @@ git commit -m "feat: add OS detection and dependency installation" ## Task 5: Install Script — Node.js Installation **Files:** + - Modify: `src/claude-code/install.sh` Add Node.js detection, version checking, and installation via NodeSource or distro packages. @@ -729,6 +733,7 @@ git commit -m "feat: add Node.js detection and installation" ## Task 6: Install Script — PATH Configuration and Claude Code Installation **Files:** + - Modify: `src/claude-code/install.sh` Add custom PATH setup and the actual Claude Code npm install. @@ -823,6 +828,7 @@ git commit -m "feat: add PATH configuration and Claude Code installation" ## Task 7: Install Script — Shell Completions, MCP, Mount Docs, Cleanup **Files:** + - Modify: `src/claude-code/install.sh` Add the batteries-included features and cleanup. This completes `install.sh`. @@ -1011,6 +1017,7 @@ git commit -m "feat: add completions, MCP config, mount docs, cache cleanup" ## Task 8: Test Helpers and Scenarios **Files:** + - Create: `test/claude-code/test.sh` - Create: `test/claude-code/scenarios.json` @@ -1228,79 +1235,79 @@ This file defines per-scenario test configs. The CI workflow also runs the `defa ```json { - "default_options": { - "image": "mcr.microsoft.com/devcontainers/base:ubuntu", - "features": { - "claude-code": {} - } - }, - "completions_disabled": { - "image": "mcr.microsoft.com/devcontainers/base:debian", - "features": { - "claude-code": { - "shellCompletions": false - } - } - }, - "mcp_enabled": { - "image": "mcr.microsoft.com/devcontainers/base:ubuntu", - "features": { - "claude-code": { - "enableMcpServers": true - } - } - }, - "custom_version": { - "image": "mcr.microsoft.com/devcontainers/base:ubuntu", - "features": { - "claude-code": { - "version": "0.2.57" - } - } - }, - "node_preinstalled": { - "image": "mcr.microsoft.com/devcontainers/javascript-node", - "features": { - "claude-code": {} - } - }, - "custom_install_path": { - "image": "mcr.microsoft.com/devcontainers/base:ubuntu", - "features": { - "claude-code": { - "installPath": "/opt/claude" - } - } - }, - "mount_host_config": { - "image": "mcr.microsoft.com/devcontainers/base:debian", - "features": { - "claude-code": { - "mountHostConfig": true - } - } - }, - "alpine_specific": { - "image": "mcr.microsoft.com/devcontainers/base:alpine", - "features": { - "claude-code": {} - } - }, - "idempotency": { - "image": "mcr.microsoft.com/devcontainers/base:ubuntu", - "features": { - "claude-code": {} - } - }, - "multi_feature_combo": { - "image": "mcr.microsoft.com/devcontainers/javascript-node", - "features": { - "ghcr.io/devcontainers/features/node:1": { - "version": "22" - }, - "claude-code": {} - } + "default_options": { + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "features": { + "claude-code": {} + } + }, + "completions_disabled": { + "image": "mcr.microsoft.com/devcontainers/base:debian", + "features": { + "claude-code": { + "shellCompletions": false + } + } + }, + "mcp_enabled": { + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "features": { + "claude-code": { + "enableMcpServers": true + } + } + }, + "custom_version": { + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "features": { + "claude-code": { + "version": "0.2.57" + } + } + }, + "node_preinstalled": { + "image": "mcr.microsoft.com/devcontainers/javascript-node", + "features": { + "claude-code": {} + } + }, + "custom_install_path": { + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "features": { + "claude-code": { + "installPath": "/opt/claude" + } } + }, + "mount_host_config": { + "image": "mcr.microsoft.com/devcontainers/base:debian", + "features": { + "claude-code": { + "mountHostConfig": true + } + } + }, + "alpine_specific": { + "image": "mcr.microsoft.com/devcontainers/base:alpine", + "features": { + "claude-code": {} + } + }, + "idempotency": { + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "features": { + "claude-code": {} + } + }, + "multi_feature_combo": { + "image": "mcr.microsoft.com/devcontainers/javascript-node", + "features": { + "ghcr.io/devcontainers/features/node:1": { + "version": "22" + }, + "claude-code": {} + } + } } ``` @@ -1321,6 +1328,7 @@ git commit -m "feat: add test helpers and scenario definitions" ## Task 9: Per-Scenario Test Scripts **Files:** + - Create: `test/claude-code/default_options.sh` - Create: `test/claude-code/completions_disabled.sh` - Create: `test/claude-code/mcp_enabled.sh` @@ -1608,6 +1616,7 @@ git commit -m "feat: add per-scenario test scripts" ## Task 10: Pre-commit Configuration **Files:** + - Create: `.pre-commit-config.yaml` - [ ] **Step 1: Create `.pre-commit-config.yaml`** @@ -1665,11 +1674,13 @@ git commit -m "chore: add pre-commit hooks configuration" ## Task 11: CI/CD — Test Workflow **Files:** + - Create: `.github/workflows/test.yml` - [ ] **Step 1: Create `test.yml`** The CI has two test dimensions: + 1. **Scenario tests** — run `scenarios.json` (which maps scenario names to `test_.sh` scripts). The devcontainer CLI reads `scenarios.json` directly and invokes the correct per-scenario test script automatically. Do NOT use `--skip-scenarios`. 2. **Image matrix tests** — run the default `test.sh` (core assertions only) across all 25+ base images to verify universal install compatibility. @@ -1739,7 +1750,7 @@ jobs: test-scenarios: needs: lint runs-on: ubuntu-latest - timeout-minutes: 60 # 10 scenarios building containers takes time + timeout-minutes: 60 # 10 scenarios building containers takes time permissions: contents: read steps: @@ -1883,6 +1894,7 @@ git commit -m "ci: add test workflow with lint and exhaustive matrix" ## Task 12: CI/CD — Release Workflow **Files:** + - Create: `.github/workflows/release.yml` - [ ] **Step 1: Create `release.yml`** @@ -2003,6 +2015,7 @@ git commit -m "ci: add release workflow with version check and publish" ## Task 13: Contributor DevContainer and README **Files:** + - Create: `.devcontainer/devcontainer.json` - Create: `README.md` @@ -2010,29 +2023,29 @@ git commit -m "ci: add release workflow with version check and publish" ```json { - "name": "Claude Code Feature Development", - "image": "mcr.microsoft.com/devcontainers/base:ubuntu", - "features": { - "ghcr.io/devcontainers/features/node:1": { - "version": "22" - } - }, - "postCreateCommand": "npm install -g @devcontainers/cli && pre-commit install || true", - "customizations": { - "vscode": { - "extensions": [ - "timonwong.shellcheck", - "foxundermoon.shell-format", - "esbenp.prettier-vscode" - ] - } + "name": "Claude Code Feature Development", + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "features": { + "ghcr.io/devcontainers/features/node:1": { + "version": "22" } + }, + "postCreateCommand": "npm install -g @devcontainers/cli && pre-commit install || true", + "customizations": { + "vscode": { + "extensions": [ + "timonwong.shellcheck", + "foxundermoon.shell-format", + "esbenp.prettier-vscode" + ] + } + } } ``` - [ ] **Step 2: Create `README.md`** -```markdown +````markdown # Claude Code DevContainer Feature [![Test](https://github.com/pkramek/claude-code-devcontainer/actions/workflows/test.yml/badge.svg)](https://github.com/pkramek/claude-code-devcontainer/actions/workflows/test.yml) @@ -2047,22 +2060,23 @@ Add this feature to your `devcontainer.json`: ```json { - "features": { - "ghcr.io/pkramek/claude-code-devcontainer/claude-code:1": {} - } + "features": { + "ghcr.io/pkramek/claude-code-devcontainer/claude-code:1": {} + } } ``` +```` ### Options -| Option | Type | Default | Description | -|---|---|---|---| -| `version` | string | `latest` | Claude Code version (semver or `latest`) | -| `nodeVersion` | string | `lts` | Node.js version if not present (>= 18) | -| `installPath` | string | `/usr/local` | Custom npm global prefix | -| `enableMcpServers` | boolean | `false` | Create starter MCP config | -| `mountHostConfig` | boolean | `false` | Log mount snippet for host config | -| `shellCompletions` | boolean | `true` | Install bash/zsh/fish completions | +| Option | Type | Default | Description | +| ------------------ | ------- | ------------ | ---------------------------------------- | +| `version` | string | `latest` | Claude Code version (semver or `latest`) | +| `nodeVersion` | string | `lts` | Node.js version if not present (>= 18) | +| `installPath` | string | `/usr/local` | Custom npm global prefix | +| `enableMcpServers` | boolean | `false` | Create starter MCP config | +| `mountHostConfig` | boolean | `false` | Log mount snippet for host config | +| `shellCompletions` | boolean | `true` | Install bash/zsh/fish completions | ### Examples @@ -2070,11 +2084,11 @@ Pin a specific version: ```json { - "features": { - "ghcr.io/pkramek/claude-code-devcontainer/claude-code:1": { - "version": "1.0.0" - } + "features": { + "ghcr.io/pkramek/claude-code-devcontainer/claude-code:1": { + "version": "1.0.0" } + } } ``` @@ -2082,11 +2096,11 @@ Enable MCP servers: ```json { - "features": { - "ghcr.io/pkramek/claude-code-devcontainer/claude-code:1": { - "enableMcpServers": true - } + "features": { + "ghcr.io/pkramek/claude-code-devcontainer/claude-code:1": { + "enableMcpServers": true } + } } ``` @@ -2098,9 +2112,9 @@ Claude Code requires authentication. Options: ```json { - "remoteEnv": { - "ANTHROPIC_API_KEY": "${localEnv:ANTHROPIC_API_KEY}" - } + "remoteEnv": { + "ANTHROPIC_API_KEY": "${localEnv:ANTHROPIC_API_KEY}" + } } ``` @@ -2108,9 +2122,9 @@ Claude Code requires authentication. Options: ```json { - "mounts": [ - "source=${localEnv:HOME}/.claude,target=/home/vscode/.claude,type=bind,consistency=cached,readonly" - ] + "mounts": [ + "source=${localEnv:HOME}/.claude,target=/home/vscode/.claude,type=bind,consistency=cached,readonly" + ] } ``` @@ -2128,7 +2142,7 @@ Add this to your `devcontainer.json` to verify Claude Code at container start: ```json { - "postCreateCommand": "claude --version || true" + "postCreateCommand": "claude --version || true" } ``` @@ -2153,45 +2167,46 @@ You must manually change it to public: ## License MIT -``` + +```` - [ ] **Step 3: Commit** ```bash git add .devcontainer/devcontainer.json README.md git commit -m "docs: add contributor devcontainer and README" -``` +```` --- ## Self-Review Checklist -| Spec Section | Covered By | -|---|---| -| 1. Overview | All tasks combined | -| 2. Repository Structure | Task 1-13 file map | -| 3. Feature Manifest | Task 2 | +| Spec Section | Covered By | +| -------------------------------------- | ------------------------------------------------------------------ | +| 1. Overview | All tasks combined | +| 2. Repository Structure | Task 1-13 file map | +| 3. Feature Manifest | Task 2 | | 3. Lifecycle Hooks (postCreateCommand) | Task 13 (README documents recommended postCreateCommand for users) | -| 3. Security (mountHostConfig) | Task 7 (setup_mount_docs), Task 13 (README) | -| 4. Alpine Bootstrap | Task 3 (POSIX bootstrap) | -| 4. Error Handling | Task 3 (set -Eeuo pipefail, traps) | -| 4. Input Validation | Task 3 (validate_version, validate_install_path) | -| 4. OS Detection | Task 4 (detect_os, detect_arch) | -| 4. Dependencies | Task 4 (ensure_base_dependencies) | -| 4. Node.js Installation | Task 5 (ensure_node, decision tree) | -| 4. PATH Configuration | Task 6 (configure_custom_path) | -| 4. Claude Code Installation | Task 6 (install_claude_code) | -| 4. Shell Completions | Task 7 (setup_completions) | -| 4. MCP Servers | Task 7 (setup_mcp_servers) | -| 4. Mount Documentation | Task 7 (setup_mount_docs) | -| 4. Remote User Detection | Task 3 (detect_remote_user, detect_user_home) | -| 4. Cleanup | Task 7 (cleanup_caches) | -| 5. Test Helpers | Task 8 (test.sh) | -| 5. Scenarios | Task 8 (scenarios.json) | -| 5. Per-Scenario Tests | Task 9 (all 9 scripts) | -| 6. CI test.yml | Task 11 | -| 6. CI release.yml | Task 12 | -| 7. Pre-commit Hooks | Task 10 | -| 8. License | Task 1 | -| 10. Success Criteria | Covered by test matrix + CI | -| 11. Versioning | Task 12 (version-check job) | +| 3. Security (mountHostConfig) | Task 7 (setup_mount_docs), Task 13 (README) | +| 4. Alpine Bootstrap | Task 3 (POSIX bootstrap) | +| 4. Error Handling | Task 3 (set -Eeuo pipefail, traps) | +| 4. Input Validation | Task 3 (validate_version, validate_install_path) | +| 4. OS Detection | Task 4 (detect_os, detect_arch) | +| 4. Dependencies | Task 4 (ensure_base_dependencies) | +| 4. Node.js Installation | Task 5 (ensure_node, decision tree) | +| 4. PATH Configuration | Task 6 (configure_custom_path) | +| 4. Claude Code Installation | Task 6 (install_claude_code) | +| 4. Shell Completions | Task 7 (setup_completions) | +| 4. MCP Servers | Task 7 (setup_mcp_servers) | +| 4. Mount Documentation | Task 7 (setup_mount_docs) | +| 4. Remote User Detection | Task 3 (detect_remote_user, detect_user_home) | +| 4. Cleanup | Task 7 (cleanup_caches) | +| 5. Test Helpers | Task 8 (test.sh) | +| 5. Scenarios | Task 8 (scenarios.json) | +| 5. Per-Scenario Tests | Task 9 (all 9 scripts) | +| 6. CI test.yml | Task 11 | +| 6. CI release.yml | Task 12 | +| 7. Pre-commit Hooks | Task 10 | +| 8. License | Task 1 | +| 10. Success Criteria | Covered by test matrix + CI | +| 11. Versioning | Task 12 (version-check job) | diff --git a/docs/superpowers/specs/2026-03-31-claude-code-devcontainer-feature-design.md b/docs/superpowers/specs/2026-03-31-claude-code-devcontainer-feature-design.md index e4aa53c..82fa625 100644 --- a/docs/superpowers/specs/2026-03-31-claude-code-devcontainer-feature-design.md +++ b/docs/superpowers/specs/2026-03-31-claude-code-devcontainer-feature-design.md @@ -63,14 +63,14 @@ Follows the official `devcontainers/feature-template` convention. Per-scenario t ### Options -| Option | Type | Default | Description | -|---|---|---|---| -| `version` | string | `"latest"` | Claude Code version to install (semver or `"latest"`. Note: `"latest"` is non-deterministic across builds — recommend pinning for teams) | -| `nodeVersion` | string | `"lts"` | Node.js version to install if not already present. Resolved via NodeSource, not distro packages. Minimum floor: Node.js >= 18. | -| `installPath` | string | `"/usr/local"` | Custom npm global install prefix. The feature will ensure `/bin` is on PATH for all shell contexts. | -| `enableMcpServers` | boolean | `false` | Drop a starter MCP configuration file at `~/.claude/mcp_servers.json` (create-if-absent, never overwrite). | -| `mountHostConfig` | boolean | `false` | **Documentation-only.** When true, the feature adds a comment to build output with the `mounts` snippet users should add to their `devcontainer.json`. Does NOT auto-mount. Defaults to false for security. | -| `shellCompletions` | boolean | `true` | Install shell completions for detected shells (bash, zsh, fish). | +| Option | Type | Default | Description | +| ------------------ | ------- | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `version` | string | `"latest"` | Claude Code version to install (semver or `"latest"`. Note: `"latest"` is non-deterministic across builds — recommend pinning for teams) | +| `nodeVersion` | string | `"lts"` | Node.js version to install if not already present. Resolved via NodeSource, not distro packages. Minimum floor: Node.js >= 18. | +| `installPath` | string | `"/usr/local"` | Custom npm global install prefix. The feature will ensure `/bin` is on PATH for all shell contexts. | +| `enableMcpServers` | boolean | `false` | Drop a starter MCP configuration file at `~/.claude/mcp_servers.json` (create-if-absent, never overwrite). | +| `mountHostConfig` | boolean | `false` | **Documentation-only.** When true, the feature adds a comment to build output with the `mounts` snippet users should add to their `devcontainer.json`. Does NOT auto-mount. Defaults to false for security. | +| `shellCompletions` | boolean | `true` | Install shell completions for detected shells (bash, zsh, fish). | ### Lifecycle Hooks @@ -83,6 +83,7 @@ The original `autoUpdate` option was removed because `install.sh` runs at image ### Security Considerations for `mountHostConfig` Mounting `~/.claude` from the host exposes API keys and tokens inside the container. The feature defaults `mountHostConfig` to `false` and documents: + - The security implications (container compromise = credential exposure) - The cross-platform mount syntax using `${localEnv:HOME}` for macOS/Linux/WSL2 compatibility - That `~/.claude` path may change in future Claude Code versions @@ -94,6 +95,7 @@ The feature does NOT auto-mount. It provides documentation only, leaving the sec ### Shell Choice: Bash (not POSIX sh) The script uses `#!/usr/bin/env bash` explicitly. Rationale: + - POSIX sh (`dash` on Debian, `ash` on Alpine) has unreliable `set -e` semantics and lacks arrays, `[[ ]]`, and `pipefail` - Bash is pre-installed on Debian, Ubuntu, Fedora, Arch, RHEL, Rocky, Alma, and Amazon Linux - ShellCheck is configured with `--shell=bash` via `.shellcheckrc` @@ -200,6 +202,7 @@ These are **safety gates** (preventing shell injection), not correctness gates. **Base dependencies** (installed if missing): `curl`, `git`, `ca-certificates` All package manager invocations use non-interactive flags: + - `apt-get -y` - `apk add --no-cache` - `pacman -S --noconfirm --needed` @@ -259,6 +262,7 @@ The `timeout 300` (5 minutes) prevents indefinite hangs on network issues during ### Step 5: Batteries-Included Setup **Shell completions** (`shellCompletions=true`): + - Detect available shells by checking for their completion directories - Bash: `/etc/bash_completion.d/claude` (Debian/Ubuntu/Fedora) or `/usr/share/bash-completion/completions/claude` (Arch/Alpine) - Zsh: `/usr/share/zsh/site-functions/_claude` @@ -266,12 +270,14 @@ The `timeout 300` (5 minutes) prevents indefinite hangs on network issues during - **Graceful degradation:** If completion installation fails, log a warning but do NOT abort the build **MCP servers** (`enableMcpServers=true`): + - Target file: `${_REMOTE_USER_HOME}/.claude/mcp_servers.json` - Strategy: **create-if-absent** — never overwrite an existing file - Content: minimal starter config with comments explaining how to extend - Owned by `$_REMOTE_USER` **Host config documentation** (`mountHostConfig=true`): + - Logs the following snippet to build output: ``` To mount your host Claude config, add this to your devcontainer.json: @@ -282,6 +288,7 @@ The `timeout 300` (5 minutes) prevents indefinite hangs on network issues during ### Step 6: Permissions and Cleanup **Remote user detection** (using DevContainer-provided variables): + 1. Use `$_REMOTE_USER` if set 2. Else use `$_CONTAINER_USER` if set 3. Else detect first non-root user from `/etc/passwd` with a valid shell @@ -290,11 +297,13 @@ The `timeout 300` (5 minutes) prevents indefinite hangs on network issues during Home directory: use `$_REMOTE_USER_HOME` if set, else look up via `getent passwd "${DETECTED_USER}" | cut -d: -f6`. **Do NOT use `eval echo ~${user}`** — this is a code injection risk if the username contains unexpected characters. **Ownership:** Set `chown` on specific paths only (never `chown -R` on the entire home directory): + - `~/.claude/` directory (if created by this feature) - `~/.claude/mcp_servers.json` (if created by `enableMcpServers`) - No other files in the home directory are touched **Cache cleanup:** + - `apt-get clean && rm -rf /var/lib/apt/lists/*` (Debian/Ubuntu) - `rm -rf /var/cache/apk/*` (Alpine) - `pacman -Scc --noconfirm` (Arch) @@ -308,6 +317,7 @@ Home directory: use `$_REMOTE_USER_HOME` if set, else look up via `getent passwd ### Test Architecture Each scenario in `scenarios.json` has a dedicated test script at `test/claude-code/test_.sh`. This enables: + - **Positive assertions:** verify expected behavior when an option is enabled - **Negative assertions:** verify things are NOT present when an option is disabled - **Option-specific verification:** e.g., pinned version matches exactly @@ -317,6 +327,7 @@ Test scripts run as the **non-root remote user**, not root. This validates the p ### Shared Test Helpers (`test.sh`) Common assertion functions used by all scenario scripts: + - `check_command_exists ` — verify binary on PATH - `check_command_version ` — verify version output - `check_file_exists ` — verify file presence @@ -337,23 +348,24 @@ Common assertion functions used by all scenario scripts: ### Per-Scenario Assertions -| Scenario | Assertions | -|---|---| -| `default_options` | Completions exist for detected shells. MCP config absent. | -| `completions_disabled` | Completion files do NOT exist for any shell. | -| `mcp_enabled` | `~/.claude/mcp_servers.json` exists, is valid JSON, owned by remote user. | -| `custom_version` | `claude --version` outputs exact pinned version. | -| `node_preinstalled` | Existing Node.js version unchanged. No second Node.js installation. | -| `custom_install_path` | Binary at `/bin/claude`. PATH includes `/bin`. | -| `mount_host_config` | Build output contains the mount snippet documentation. No actual mount created. | -| `alpine_specific` | Bash was installed. Completions at Alpine-specific paths. `apk` caches cleaned. | -| `idempotency` | Run install twice — no errors, same end state. | +| Scenario | Assertions | +| ---------------------- | ------------------------------------------------------------------------------- | +| `default_options` | Completions exist for detected shells. MCP config absent. | +| `completions_disabled` | Completion files do NOT exist for any shell. | +| `mcp_enabled` | `~/.claude/mcp_servers.json` exists, is valid JSON, owned by remote user. | +| `custom_version` | `claude --version` outputs exact pinned version. | +| `node_preinstalled` | Existing Node.js version unchanged. No second Node.js installation. | +| `custom_install_path` | Binary at `/bin/claude`. PATH includes `/bin`. | +| `mount_host_config` | Build output contains the mount snippet documentation. No actual mount created. | +| `alpine_specific` | Bash was installed. Completions at Alpine-specific paths. `apk` caches cleaned. | +| `idempotency` | Run install twice — no errors, same end state. | ### Test Matrix (`scenarios.json`) Each scenario specifies a base image and feature options. **Raw OS images:** + - `ubuntu:22.04`, `ubuntu:24.04` - `debian:bullseye`, `debian:bookworm` - `alpine:3.19`, `alpine:3.20`, `alpine:3.21` @@ -363,12 +375,14 @@ Each scenario specifies a base image and feature options. - `amazonlinux:2023` **DevContainer base images:** + - `mcr.microsoft.com/devcontainers/base:debian` - `mcr.microsoft.com/devcontainers/base:ubuntu` - `mcr.microsoft.com/devcontainers/base:alpine` - `mcr.microsoft.com/devcontainers/universal:2` (Codespaces default — critical) **Language-specific devcontainer images:** + - `mcr.microsoft.com/devcontainers/python` - `mcr.microsoft.com/devcontainers/javascript-node` - `mcr.microsoft.com/devcontainers/typescript-node` @@ -381,9 +395,11 @@ Each scenario specifies a base image and feature options. - `mcr.microsoft.com/devcontainers/php` **Multi-feature combo scenario:** + - `devcontainers/javascript-node` with `ghcr.io/devcontainers/features/node` already present — validates `installsAfter` ordering and no version conflict **Concrete `scenarios.json` example** (subset — full file includes all scenarios): + ```json { "default_options": { @@ -452,6 +468,7 @@ Each scenario specifies a base image and feature options. **amd64:** Full matrix — all images and all scenarios. Primary gate. **arm64:** Reduced matrix on **native** GitHub Actions arm64 runners (`runs-on: ubuntu-24.04-arm`). No QEMU emulation (too slow, too flaky). Tested images: + - `ubuntu:24.04` - `alpine:3.21` - `mcr.microsoft.com/devcontainers/base:debian` @@ -504,15 +521,15 @@ permissions: **`release.yml`** — runs on `v*` tag push: ```yaml -permissions: {} # deny all at top level +permissions: {} # deny all at top level concurrency: group: "release-${{ github.repository }}" - cancel-in-progress: false # never cancel an in-flight release + cancel-in-progress: false # never cancel an in-flight release on: push: - tags: ['v*'] + tags: ["v*"] ``` **Stages** (each job declares its own permissions): @@ -533,12 +550,14 @@ on: ## 7. Pre-commit Hooks ### `.shellcheckrc` + ``` shell=bash severity=warning ``` ### `.editorconfig` + ```ini [*.sh] indent_style = space @@ -547,20 +566,20 @@ indent_size = 4 ### `.pre-commit-config.yaml` -| Hook | Source | Purpose | -|---|---|---| -| `shellcheck` | `koalaman/shellcheck-precommit` | Lint shell scripts (bash dialect, warning severity) | -| `shfmt` | `scop/pre-commit-shfmt` | Enforce consistent formatting (4-space indent per `.editorconfig`) | -| `check-json` | `pre-commit/pre-commit-hooks` | Validate JSON files | -| `check-yaml` | `pre-commit/pre-commit-hooks` | Validate YAML files | -| `trailing-whitespace` | `pre-commit/pre-commit-hooks` | Remove trailing whitespace | -| `end-of-file-fixer` | `pre-commit/pre-commit-hooks` | Ensure newline at end of files | -| `check-merge-conflict` | `pre-commit/pre-commit-hooks` | Prevent committing merge markers | -| `detect-private-key` | `pre-commit/pre-commit-hooks` | Prevent committing SSH private keys | -| `check-added-large-files` | `pre-commit/pre-commit-hooks` | Prevent committing large binaries | -| `no-commit-to-branch` | `pre-commit/pre-commit-hooks` | Protect `main` branch from direct pushes | -| `prettier` | `pre-commit/mirrors-prettier` | Format JSON, YAML, and Markdown | -| `markdownlint` | `igorshubovych/markdownlint-cli` | Structural Markdown linting | +| Hook | Source | Purpose | +| ------------------------- | -------------------------------- | ------------------------------------------------------------------ | +| `shellcheck` | `koalaman/shellcheck-precommit` | Lint shell scripts (bash dialect, warning severity) | +| `shfmt` | `scop/pre-commit-shfmt` | Enforce consistent formatting (4-space indent per `.editorconfig`) | +| `check-json` | `pre-commit/pre-commit-hooks` | Validate JSON files | +| `check-yaml` | `pre-commit/pre-commit-hooks` | Validate YAML files | +| `trailing-whitespace` | `pre-commit/pre-commit-hooks` | Remove trailing whitespace | +| `end-of-file-fixer` | `pre-commit/pre-commit-hooks` | Ensure newline at end of files | +| `check-merge-conflict` | `pre-commit/pre-commit-hooks` | Prevent committing merge markers | +| `detect-private-key` | `pre-commit/pre-commit-hooks` | Prevent committing SSH private keys | +| `check-added-large-files` | `pre-commit/pre-commit-hooks` | Prevent committing large binaries | +| `no-commit-to-branch` | `pre-commit/pre-commit-hooks` | Protect `main` branch from direct pushes | +| `prettier` | `pre-commit/mirrors-prettier` | Format JSON, YAML, and Markdown | +| `markdownlint` | `igorshubovych/markdownlint-cli` | Structural Markdown linting | **Note:** There is no standard pre-commit hook to enforce `.sh` files have the execute bit set. This will be validated in CI via a custom script step instead. @@ -571,6 +590,7 @@ MIT License — open source, free to use. ## 9. Pre-Implementation Quality Gates Before writing any code: + 1. **Deep research agent** — Validate this design against the latest devcontainers spec, `devcontainers/action` documentation, and `@devcontainers/cli` test framework behavior 2. **Parallel review agents** — Architecture, shell scripting, and CI/CD review passes (completed: 2 rounds, all critical findings resolved) @@ -597,10 +617,10 @@ Users pin by major version: `claude-code:1`. This is independent of the Claude C ## 12. Known Assumptions and Risks -| Assumption | Risk if Wrong | Mitigation | -|---|---|---| -| Claude Code npm package is pure JS (no native addons) | Alpine support breaks | Remove Alpine from matrix, document limitation | -| `~/.claude` is the config directory | Host config mount breaks | Check `claude` CLI for config path discovery | -| NodeSource supports all target distros | Node.js install fails | Fallback to distro packages for unsupported distros | -| Alpine/Arch distro packages ship Node.js >= 18 | Hard failure with error message directing user to use a newer base image | Older Alpine (< 3.19) and theoretical older Arch images may ship Node < 18. No NodeSource fallback exists for Alpine/Arch — this is a known limitation. | -| GitHub Actions arm64 runners available for public repos | arm64 testing blocked | Fall back to QEMU for a reduced subset | +| Assumption | Risk if Wrong | Mitigation | +| ------------------------------------------------------- | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Claude Code npm package is pure JS (no native addons) | Alpine support breaks | Remove Alpine from matrix, document limitation | +| `~/.claude` is the config directory | Host config mount breaks | Check `claude` CLI for config path discovery | +| NodeSource supports all target distros | Node.js install fails | Fallback to distro packages for unsupported distros | +| Alpine/Arch distro packages ship Node.js >= 18 | Hard failure with error message directing user to use a newer base image | Older Alpine (< 3.19) and theoretical older Arch images may ship Node < 18. No NodeSource fallback exists for Alpine/Arch — this is a known limitation. | +| GitHub Actions arm64 runners available for public repos | arm64 testing blocked | Fall back to QEMU for a reduced subset | diff --git a/src/claude-code/README.md b/src/claude-code/README.md index 243073e..e8e5037 100644 --- a/src/claude-code/README.md +++ b/src/claude-code/README.md @@ -8,22 +8,22 @@ Alma, and Amazon Linux on amd64 and arm64. ```json { - "features": { - "ghcr.io/pkramek/claude-devcontainer/claude-code:1": {} - } + "features": { + "ghcr.io/pkramek/claude-devcontainer/claude-code:1": {} + } } ``` ## Options -| Option | Type | Default | Description | -|---|---|---|---| -| `version` | string | `latest` | Claude Code version (semver or `latest`) | -| `nodeVersion` | string | `lts` | Node.js version if not present (>= 18) | -| `installPath` | string | `/usr/local` | Custom npm global prefix | -| `enableMcpServers` | boolean | `false` | Create starter MCP config at `~/.claude/mcp_servers.json` | -| `mountHostConfig` | boolean | `false` | Log mount snippet for host `~/.claude` passthrough | -| `shellCompletions` | boolean | `true` | Install bash/zsh/fish completions | +| Option | Type | Default | Description | +| ------------------ | ------- | ------------ | --------------------------------------------------------- | +| `version` | string | `latest` | Claude Code version (semver or `latest`) | +| `nodeVersion` | string | `lts` | Node.js version if not present (>= 18) | +| `installPath` | string | `/usr/local` | Custom npm global prefix | +| `enableMcpServers` | boolean | `false` | Create starter MCP config at `~/.claude/mcp_servers.json` | +| `mountHostConfig` | boolean | `false` | Log mount snippet for host `~/.claude` passthrough | +| `shellCompletions` | boolean | `true` | Install bash/zsh/fish completions | ## Examples @@ -31,11 +31,11 @@ Pin a specific version: ```json { - "features": { - "ghcr.io/pkramek/claude-devcontainer/claude-code:1": { - "version": "1.0.0" - } + "features": { + "ghcr.io/pkramek/claude-devcontainer/claude-code:1": { + "version": "1.0.0" } + } } ``` @@ -43,11 +43,11 @@ Enable MCP servers: ```json { - "features": { - "ghcr.io/pkramek/claude-devcontainer/claude-code:1": { - "enableMcpServers": true - } + "features": { + "ghcr.io/pkramek/claude-devcontainer/claude-code:1": { + "enableMcpServers": true } + } } ``` @@ -62,9 +62,9 @@ Claude Code requires authentication. Three options: ```json { - "remoteEnv": { - "ANTHROPIC_API_KEY": "${localEnv:ANTHROPIC_API_KEY}" - } + "remoteEnv": { + "ANTHROPIC_API_KEY": "${localEnv:ANTHROPIC_API_KEY}" + } } ``` diff --git a/src/claude-code/devcontainer-feature.json b/src/claude-code/devcontainer-feature.json index a766985..cabb5e8 100644 --- a/src/claude-code/devcontainer-feature.json +++ b/src/claude-code/devcontainer-feature.json @@ -1,54 +1,52 @@ { - "id": "claude-code", - "version": "1.0.0", - "name": "Claude Code", - "description": "Install Claude Code CLI into any devcontainer. Supports Debian, Ubuntu, Alpine, Arch, Fedora, RHEL, Rocky, Alma, and Amazon Linux on amd64/arm64.", - "keywords": [ - "claude", - "claude-code", - "anthropic", - "ai", - "cli", - "devcontainer" - ], - "documentationURL": "https://github.com/pkramek/claude-devcontainer#readme", - "licenseURL": "https://github.com/pkramek/claude-devcontainer/blob/main/LICENSE", - "installsAfter": [ - "ghcr.io/devcontainers/features/node" - ], - "options": { - "version": { - "type": "string", - "default": "latest", - "description": "Claude Code version to install (semver or 'latest'). Recommend pinning for teams." - }, - "nodeVersion": { - "type": "string", - "default": "lts", - "description": "Node.js version to install if not already present (>= 18 required)." - }, - "installPath": { - "type": "string", - "default": "/usr/local", - "description": "Custom npm global install prefix. Feature ensures /bin is on PATH." - }, - "enableMcpServers": { - "type": "boolean", - "default": false, - "description": "Create a starter MCP server configuration at ~/.claude/mcp_servers.json (create-if-absent)." - }, - "mountHostConfig": { - "type": "boolean", - "default": false, - "description": "Log a mounts snippet for host ~/.claude config passthrough (documentation-only, does NOT auto-mount)." - }, - "shellCompletions": { - "type": "boolean", - "default": true, - "description": "Install shell completions for bash, zsh, and fish." - } + "id": "claude-code", + "version": "1.0.0", + "name": "Claude Code", + "description": "Install Claude Code CLI into any devcontainer. Supports Debian, Ubuntu, Alpine, Arch, Fedora, RHEL, Rocky, Alma, and Amazon Linux on amd64/arm64.", + "keywords": [ + "claude", + "claude-code", + "anthropic", + "ai", + "cli", + "devcontainer" + ], + "documentationURL": "https://github.com/pkramek/claude-devcontainer#readme", + "licenseURL": "https://github.com/pkramek/claude-devcontainer/blob/main/LICENSE", + "installsAfter": ["ghcr.io/devcontainers/features/node"], + "options": { + "version": { + "type": "string", + "default": "latest", + "description": "Claude Code version to install (semver or 'latest'). Recommend pinning for teams." }, - "containerEnv": { - "CLAUDE_CODE_INSTALLED": "true" + "nodeVersion": { + "type": "string", + "default": "lts", + "description": "Node.js version to install if not already present (>= 18 required)." + }, + "installPath": { + "type": "string", + "default": "/usr/local", + "description": "Custom npm global install prefix. Feature ensures /bin is on PATH." + }, + "enableMcpServers": { + "type": "boolean", + "default": false, + "description": "Create a starter MCP server configuration at ~/.claude/mcp_servers.json (create-if-absent)." + }, + "mountHostConfig": { + "type": "boolean", + "default": false, + "description": "Log a mounts snippet for host ~/.claude config passthrough (documentation-only, does NOT auto-mount)." + }, + "shellCompletions": { + "type": "boolean", + "default": true, + "description": "Install shell completions for bash, zsh, and fish." } + }, + "containerEnv": { + "CLAUDE_CODE_INSTALLED": "true" + } } diff --git a/test/claude-code/scenarios.json b/test/claude-code/scenarios.json index c6d17bd..85e3a09 100644 --- a/test/claude-code/scenarios.json +++ b/test/claude-code/scenarios.json @@ -1,75 +1,75 @@ { - "default_options": { - "image": "mcr.microsoft.com/devcontainers/base:ubuntu", - "features": { - "claude-code": {} - } - }, - "completions_disabled": { - "image": "mcr.microsoft.com/devcontainers/base:debian", - "features": { - "claude-code": { - "shellCompletions": false - } - } - }, - "mcp_enabled": { - "image": "mcr.microsoft.com/devcontainers/base:ubuntu", - "features": { - "claude-code": { - "enableMcpServers": true - } - } - }, - "custom_version": { - "image": "mcr.microsoft.com/devcontainers/base:ubuntu", - "features": { - "claude-code": { - "version": "0.2.57" - } - } - }, - "node_preinstalled": { - "image": "mcr.microsoft.com/devcontainers/javascript-node", - "features": { - "claude-code": {} - } - }, - "custom_install_path": { - "image": "mcr.microsoft.com/devcontainers/base:ubuntu", - "features": { - "claude-code": { - "installPath": "/opt/claude" - } - } - }, - "mount_host_config": { - "image": "mcr.microsoft.com/devcontainers/base:debian", - "features": { - "claude-code": { - "mountHostConfig": true - } - } - }, - "alpine_specific": { - "image": "mcr.microsoft.com/devcontainers/base:alpine", - "features": { - "claude-code": {} - } - }, - "idempotency": { - "image": "mcr.microsoft.com/devcontainers/base:ubuntu", - "features": { - "claude-code": {} - } - }, - "multi_feature_combo": { - "image": "mcr.microsoft.com/devcontainers/javascript-node", - "features": { - "ghcr.io/devcontainers/features/node:1": { - "version": "22" - }, - "claude-code": {} - } + "default_options": { + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "features": { + "claude-code": {} } + }, + "completions_disabled": { + "image": "mcr.microsoft.com/devcontainers/base:debian", + "features": { + "claude-code": { + "shellCompletions": false + } + } + }, + "mcp_enabled": { + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "features": { + "claude-code": { + "enableMcpServers": true + } + } + }, + "custom_version": { + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "features": { + "claude-code": { + "version": "0.2.57" + } + } + }, + "node_preinstalled": { + "image": "mcr.microsoft.com/devcontainers/javascript-node", + "features": { + "claude-code": {} + } + }, + "custom_install_path": { + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "features": { + "claude-code": { + "installPath": "/opt/claude" + } + } + }, + "mount_host_config": { + "image": "mcr.microsoft.com/devcontainers/base:debian", + "features": { + "claude-code": { + "mountHostConfig": true + } + } + }, + "alpine_specific": { + "image": "mcr.microsoft.com/devcontainers/base:alpine", + "features": { + "claude-code": {} + } + }, + "idempotency": { + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "features": { + "claude-code": {} + } + }, + "multi_feature_combo": { + "image": "mcr.microsoft.com/devcontainers/javascript-node", + "features": { + "ghcr.io/devcontainers/features/node:1": { + "version": "22" + }, + "claude-code": {} + } + } } From 192e4e17ad603911d0724dfe98e7c83b1469ed6d Mon Sep 17 00:00:00 2001 From: PKramek Date: Wed, 1 Apr 2026 20:06:32 +0200 Subject: [PATCH 25/55] ci: add markdownlint config disabling MD013/MD031/MD040 --- .markdownlint.json | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .markdownlint.json diff --git a/.markdownlint.json b/.markdownlint.json new file mode 100644 index 0000000..80dd19f --- /dev/null +++ b/.markdownlint.json @@ -0,0 +1,6 @@ +{ + "default": true, + "MD013": false, + "MD031": false, + "MD040": false +} From f1542a07edcba49c83242f536541044f10621f4f Mon Sep 17 00:00:00 2001 From: PKramek Date: Wed, 1 Apr 2026 20:14:03 +0200 Subject: [PATCH 26/55] style: apply shfmt formatting to install.sh and test.sh --- src/claude-code/install.sh | 77 ++++++++++++++++++++------------------ test/claude-code/test.sh | 6 +-- 2 files changed, 43 insertions(+), 40 deletions(-) diff --git a/src/claude-code/install.sh b/src/claude-code/install.sh index f3cc7d1..b244898 100755 --- a/src/claude-code/install.sh +++ b/src/claude-code/install.sh @@ -13,13 +13,13 @@ # --- POSIX-compatible bootstrap (runs under /bin/sh on Alpine) --- if [ -z "${BASH_VERSION:-}" ]; then - if command -v apk > /dev/null 2>&1; then - apk add --no-cache bash > /dev/null || { + if command -v apk >/dev/null 2>&1; then + apk add --no-cache bash >/dev/null || { echo "[claude-code feature] ERROR: Failed to install bash via apk." >&2 exit 1 } fi - if ! command -v bash > /dev/null 2>&1; then + if ! command -v bash >/dev/null 2>&1; then echo "[claude-code feature] ERROR: bash is required but could not be found or installed." >&2 exit 1 fi @@ -48,8 +48,8 @@ cleanup() { } # --- Logging --- -log_info() { echo "${FEATURE_LOG_PREFIX} $*"; } -log_warn() { echo "${FEATURE_LOG_PREFIX} WARNING: $*" >&2; } +log_info() { echo "${FEATURE_LOG_PREFIX} $*"; } +log_warn() { echo "${FEATURE_LOG_PREFIX} WARNING: $*" >&2; } log_error() { echo "${FEATURE_LOG_PREFIX} ERROR: $*" >&2; } log_debug() { if [[ "${DEBUG:-false}" == "true" ]]; then @@ -155,24 +155,24 @@ detect_os() { fi case "${id}" in - debian|ubuntu|linuxmint) + debian | ubuntu | linuxmint) echo "debian" ;; alpine) echo "alpine" ;; - arch|archarm|endeavouros|manjaro) + arch | archarm | endeavouros | manjaro) echo "arch" ;; - fedora|rhel|centos|rocky|almalinux|amzn) + fedora | rhel | centos | rocky | almalinux | amzn) echo "rhel" ;; *) # Fallback to ID_LIKE case "${id_like}" in - *debian*|*ubuntu*) echo "debian" ;; - *arch*) echo "arch" ;; - *fedora*|*rhel*) echo "rhel" ;; + *debian* | *ubuntu*) echo "debian" ;; + *arch*) echo "arch" ;; + *fedora* | *rhel*) echo "rhel" ;; *) log_error "Unsupported OS: ID=${id}, ID_LIKE=${id_like}" exit 1 @@ -186,7 +186,7 @@ detect_arch() { local arch arch=$(uname -m) case "${arch}" in - x86_64) echo "amd64" ;; + x86_64) echo "amd64" ;; aarch64) echo "arm64" ;; *) log_error "Unsupported architecture: ${arch}" @@ -219,7 +219,7 @@ install_packages() { pacman -S --noconfirm --needed "${packages[@]}" ;; rhel) - if command -v dnf > /dev/null 2>&1; then + if command -v dnf >/dev/null 2>&1; then dnf install -y "${packages[@]}" else yum install -y "${packages[@]}" @@ -231,13 +231,13 @@ install_packages() { ensure_base_dependencies() { local missing=() - command -v curl > /dev/null 2>&1 || missing+=("curl") - command -v git > /dev/null 2>&1 || missing+=("git") + command -v curl >/dev/null 2>&1 || missing+=("curl") + command -v git >/dev/null 2>&1 || missing+=("git") # Always ensure ca-certificates for TLS verification (needed before any curl to nodejs.org/npm) case "${OS_FAMILY}" in alpine) - command -v update-ca-certificates > /dev/null 2>&1 || missing+=("ca-certificates") + command -v update-ca-certificates >/dev/null 2>&1 || missing+=("ca-certificates") ;; debian) [[ -d /etc/ssl/certs ]] && [[ -n "$(ls /etc/ssl/certs/ 2>/dev/null)" ]] || missing+=("ca-certificates") @@ -256,10 +256,10 @@ ensure_base_dependencies() { # xz-utils (Debian) / xz (RHEL) may be absent on minimal base images. case "${OS_FAMILY}" in debian) - command -v xz > /dev/null 2>&1 || missing+=("xz-utils") + command -v xz >/dev/null 2>&1 || missing+=("xz-utils") ;; rhel) - command -v xz > /dev/null 2>&1 || missing+=("xz") + command -v xz >/dev/null 2>&1 || missing+=("xz") ;; esac @@ -295,19 +295,19 @@ resolve_node_version() { # Primary: parse index.tab (TSV) via awk — requires no JSON parser, no python3, no node. # The header row identifies the "lts" column; non-LTS rows contain "-" in that column. - lts_version=$(curl -fsSL https://nodejs.org/dist/index.tab 2>/dev/null \ - | awk 'NR==1 { for (i=1;i<=NF;i++) { if ($i=="lts") lts_col=i } next } + lts_version=$(curl -fsSL https://nodejs.org/dist/index.tab 2>/dev/null | + awk 'NR==1 { for (i=1;i<=NF;i++) { if ($i=="lts") lts_col=i } next } lts_col && $lts_col!="-" { gsub(/^v/,"",$1); split($1,v,"."); print v[1]; exit }' \ - 2>/dev/null || echo "") + 2>/dev/null || echo "") # Fallback: grep/sed on index.json — compatible with all POSIX systems. # LTS entries have "lts":"Codename"; non-LTS entries have "lts":false. if [[ -z "${lts_version}" ]]; then - lts_version=$(curl -fsSL https://nodejs.org/dist/index.json 2>/dev/null \ - | grep -m 1 '"lts":"' \ - | grep -o '"version":"v[0-9]*' \ - | sed 's/.*v//' \ - 2>/dev/null || echo "") + lts_version=$(curl -fsSL https://nodejs.org/dist/index.json 2>/dev/null | + grep -m 1 '"lts":"' | + grep -o '"version":"v[0-9]*' | + sed 's/.*v//' \ + 2>/dev/null || echo "") fi if [[ -z "${lts_version}" ]]; then @@ -323,9 +323,12 @@ install_node_binary() { local version="$1" local arch_label case "$(uname -m)" in - x86_64) arch_label="x64" ;; + x86_64) arch_label="x64" ;; aarch64) arch_label="arm64" ;; - *) log_error "Unsupported arch for Node.js binary: $(uname -m)"; exit 1 ;; + *) + log_error "Unsupported arch for Node.js binary: $(uname -m)" + exit 1 + ;; esac log_info "Installing Node.js ${version} via official binary tarball..." @@ -419,10 +422,10 @@ ensure_node() { log_debug "Resolved Node.js version: ${resolved_version}" case "${OS_FAMILY}" in - debian|rhel) + debian | rhel) install_node_binary "${resolved_version}" ;; - alpine|arch) + alpine | arch) install_node_distro ;; *) @@ -449,7 +452,7 @@ configure_custom_path() { # Persistent PATH for login shells (bash, zsh) mkdir -p /etc/profile.d - cat > /etc/profile.d/claude-code.sh << PATHEOF + cat >/etc/profile.d/claude-code.sh </dev/null; then # shellcheck disable=SC2016 # ${PATH} is intentionally literal — expands at shell startup - printf 'export PATH="%s/bin:${PATH}" # claude-code\n' "${INSTALL_PATH}" >> /etc/profile + printf 'export PATH="%s/bin:${PATH}" # claude-code\n' "${INSTALL_PATH}" >>/etc/profile fi fi @@ -522,14 +525,14 @@ setup_completions() { bash_comp_dir="/etc/bash_completion.d" fi if [[ -n "${bash_comp_dir}" ]]; then - claude completions bash > "${bash_comp_dir}/claude" 2>/dev/null || { + claude completions bash >"${bash_comp_dir}/claude" 2>/dev/null || { log_warn "Failed to install bash completions." } fi # Zsh completions if [[ -d /usr/share/zsh/site-functions ]] || mkdir -p /usr/share/zsh/site-functions 2>/dev/null; then - claude completions zsh > /usr/share/zsh/site-functions/_claude 2>/dev/null || { + claude completions zsh >/usr/share/zsh/site-functions/_claude 2>/dev/null || { log_warn "Failed to install zsh completions." } fi @@ -543,7 +546,7 @@ setup_completions() { fi done if [[ -n "${fish_comp_dir}" ]]; then - claude completions fish > "${fish_comp_dir}/claude.fish" 2>/dev/null || { + claude completions fish >"${fish_comp_dir}/claude.fish" 2>/dev/null || { log_warn "Failed to install fish completions." } fi @@ -571,7 +574,7 @@ setup_mcp_servers() { log_info "Creating starter MCP configuration..." mkdir -p "${claude_dir}" - cat > "${mcp_config}" << 'MCPEOF' + cat >"${mcp_config}" <<'MCPEOF' { "mcpServers": {} } @@ -627,7 +630,7 @@ cleanup_caches() { pacman -Sc --noconfirm 2>/dev/null || true ;; rhel) - if command -v dnf > /dev/null 2>&1; then + if command -v dnf >/dev/null 2>&1; then dnf clean all else yum clean all diff --git a/test/claude-code/test.sh b/test/claude-code/test.sh index ebd0c5e..7d3a614 100755 --- a/test/claude-code/test.sh +++ b/test/claude-code/test.sh @@ -21,7 +21,7 @@ fail() { check_command_exists() { local cmd="$1" - if command -v "${cmd}" > /dev/null 2>&1; then + if command -v "${cmd}" >/dev/null 2>&1; then pass "${cmd} is on PATH" else fail "${cmd} not found on PATH" @@ -42,7 +42,7 @@ check_command_version() { check_command_runs() { local cmd="$1" - if "${cmd}" --version > /dev/null 2>&1; then + if "${cmd}" --version >/dev/null 2>&1; then pass "${cmd} --version exits 0" else fail "${cmd} --version failed" @@ -133,7 +133,7 @@ check_file_owner() { check_file_valid_json() { local path="$1" # Use node (guaranteed present) with argv to avoid path injection - if node -e "JSON.parse(require('fs').readFileSync(process.argv[1],'utf8'))" "${path}" > /dev/null 2>&1; then + if node -e "JSON.parse(require('fs').readFileSync(process.argv[1],'utf8'))" "${path}" >/dev/null 2>&1; then pass "Valid JSON: ${path}" else fail "Invalid JSON: ${path}" From 545feff2742ceac1e2977685baf2ef000b4c53b8 Mon Sep 17 00:00:00 2001 From: PKramek Date: Wed, 1 Apr 2026 20:36:52 +0200 Subject: [PATCH 27/55] docs: add install fix, formatting enforcement, and license change design spec --- ...1-install-fix-formatting-license-design.md | 203 ++++++++++++++++++ 1 file changed, 203 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-01-install-fix-formatting-license-design.md diff --git a/docs/superpowers/specs/2026-04-01-install-fix-formatting-license-design.md b/docs/superpowers/specs/2026-04-01-install-fix-formatting-license-design.md new file mode 100644 index 0000000..21650f2 --- /dev/null +++ b/docs/superpowers/specs/2026-04-01-install-fix-formatting-license-design.md @@ -0,0 +1,203 @@ +# Install Fix, Formatting Enforcement, and License Change Design + +**Date:** 2026-04-01 + +--- + +## Summary + +Four changes grouped into one implementation cycle: + +1. Fix `log_info` stdout contamination bug (breaks install on all images) +2. Fix CI false positives (test steps pass despite feature install failures) +3. Two-layer formatting enforcement (pre-commit auto-formats; CI blocks unformatted PRs) +4. Change license from MIT to Apache 2.0 + +--- + +## 1. install.sh Bug: log_info Stdout Contamination + +### Root Cause + +`log_info()` writes to **stdout**: + +```bash +log_info() { echo "${FEATURE_LOG_PREFIX} $*"; } +``` + +`ensure_node` calls `resolve_node_version` via command substitution: + +```bash +resolved_version=$(resolve_node_version "${NODE_VERSION}") +``` + +Inside `resolve_node_version`, `log_info "Resolved LTS to Node.js ${lts_version}"` is called +before `echo "${lts_version}"`. Both writes go to stdout. The command substitution captures +**all** stdout, so `resolved_version` becomes: + +``` +[claude-code feature] Resolved LTS to Node.js 24 +24 +``` + +That multi-line string is then used to build the Node.js download URL: + +``` +https://nodejs.org/dist/latest-v[claude-code feature] Resolved LTS to Node.js 24 +24.x/SHASUMS256.txt +``` + +curl rejects this with `bad range in URL position 34` (the `[` bracket). Every image fails. + +### Fix + +Change `log_info` to write to stderr, consistent with `log_warn` and `log_error`: + +```bash +log_info() { echo "${FEATURE_LOG_PREFIX} $*" >&2; } +``` + +One character change (`>&2`). No other logging functions need to change — `log_warn` and +`log_error` already write to stderr. + +### Affected File + +- `src/claude-code/install.sh` line 51 + +--- + +## 2. CI False Positives: devcontainer features test Exit Code + +### Root Cause + +`devcontainer features test` (CLI v0.85.0) exits **0** even when the feature install fails +inside Docker. The container build failure is printed to output but does not set a non-zero +exit code on the CLI process. The `| tee` pipeline in the workflow steps does not compensate. + +### Evidence + +GitHub Actions shows the `test-image-matrix (ubuntu:22.04)` job as **succeeded** while the +log contains: + +``` +Exit code 1 +[-] Failed to launch container +``` + +### Fix + +After each `devcontainer features test` step, grep the captured log for known failure +strings. If any are found, force `exit 1`: + +```bash +devcontainer features test ... 2>&1 | tee /tmp/test-output.log +if grep -qE "Exit code [^0]|failed to install|Failed to launch" /tmp/test-output.log; then + echo "ERROR: Test output contains failures." + exit 1 +fi +``` + +This pattern is applied to all three test jobs: `test-scenarios`, `test-image-matrix`, +`test-arm64`. + +### Affected File + +- `.github/workflows/test.yml` — all three test job `run:` blocks + +--- + +## 3. Two-Layer Formatting Enforcement + +### Architecture + +| Layer | Tool | Mode | Bypassable? | +|---|---|---|---| +| Pre-commit (local) | shfmt, prettier, markdownlint | **Write** (auto-format) | Yes (`--no-verify`) | +| CI lint job | shfmt, prettier, markdownlint | **Check** (fail on diff) | No (required status check) | + +Developers get auto-formatting on commit (no manual formatting step). If they skip hooks +with `--no-verify`, the CI lint job catches it and blocks the PR from merging. + +### Pre-commit Changes + +**shfmt**: Add `-w` explicitly so the hook writes formatted files in place: + +```yaml +- repo: https://github.com/scop/pre-commit-shfmt + rev: v3.13.0-1 + hooks: + - id: shfmt + args: ["-w", "-i", "4", "-ci"] +``` + +**prettier** (`mirrors-prettier`): Already writes in pre-commit context. No change. + +**markdownlint** (`--fix`): Already writes in pre-commit context. No change. + +**no-commit-to-branch**: Add `develop` alongside `main` to prevent direct commits to both +protected branches: + +```yaml +- id: no-commit-to-branch + args: ["--branch", "main", "--branch", "develop"] +``` + +### CI Changes + +No changes to check mode. The CI lint job already runs: + +- `shfmt -d -i 4 -ci src/ test/` — diff mode, fails if files would change +- `npx prettier --check` — fails if files would change +- `npx markdownlint-cli` — fails on lint errors + +These remain unchanged. They are the enforcement layer. + +### Affected File + +- `.pre-commit-config.yaml` + +--- + +## 4. License: MIT → Apache 2.0 + +### Changes + +**`LICENSE`**: Replace MIT text with Apache 2.0 text. Keep `Copyright (c) 2026 PKramek`. + +**`src/claude-code/devcontainer-feature.json`**: Update `licenseURL` from pointing to the +MIT license to Apache 2.0: + +```json +"licenseURL": "https://github.com/pkramek/claude-devcontainer/blob/main/LICENSE" +``` + +(The URL itself does not change — it points to the `LICENSE` file whose content is +replaced. No URL update needed.) + +**`README.md`**: No change needed — README does not mention the license name inline. + +### Affected Files + +- `LICENSE` +- No other files require changes (licenseURL already points to the file, not the license name) + +--- + +## Implementation Order + +1. `log_info` fix in `install.sh` (unblocks all test runs) +2. License file replacement +3. Pre-commit config update (shfmt `-w`, no-commit-to-branch `develop`) +4. CI false positive grep checks in `test.yml` + +Each change is independent. All four can land in a single commit or separate commits per +logical group. + +--- + +## What This Does NOT Change + +- CI check-mode commands (shfmt `-d`, prettier `--check`) — these stay as-is +- Node.js version resolution logic — only the logging output destination changes +- Any test scenarios or test assertions +- Branch protection rulesets (handled separately in Task 6) From aa45a1d60466d77355757eebdf94772212d88b23 Mon Sep 17 00:00:00 2001 From: PKramek Date: Wed, 1 Apr 2026 20:45:19 +0200 Subject: [PATCH 28/55] docs: update design spec with expert review corrections --- ...1-install-fix-formatting-license-design.md | 98 +++++++++++++------ 1 file changed, 70 insertions(+), 28 deletions(-) diff --git a/docs/superpowers/specs/2026-04-01-install-fix-formatting-license-design.md b/docs/superpowers/specs/2026-04-01-install-fix-formatting-license-design.md index 21650f2..1057dd0 100644 --- a/docs/superpowers/specs/2026-04-01-install-fix-formatting-license-design.md +++ b/docs/superpowers/specs/2026-04-01-install-fix-formatting-license-design.md @@ -6,9 +6,9 @@ ## Summary -Four changes grouped into one implementation cycle: +Five changes grouped into one implementation cycle: -1. Fix `log_info` stdout contamination bug (breaks install on all images) +1. Fix `log_info` and `log_debug` stdout contamination bug (breaks install on all images) 2. Fix CI false positives (test steps pass despite feature install failures) 3. Two-layer formatting enforcement (pre-commit auto-formats; CI blocks unformatted PRs) 4. Change license from MIT to Apache 2.0 @@ -51,18 +51,27 @@ curl rejects this with `bad range in URL position 34` (the `[` bracket). Every i ### Fix -Change `log_info` to write to stderr, consistent with `log_warn` and `log_error`: +Change both `log_info` and `log_debug` to write to stderr, consistent with `log_warn` and +`log_error`: ```bash -log_info() { echo "${FEATURE_LOG_PREFIX} $*" >&2; } +log_info() { echo "${FEATURE_LOG_PREFIX} $*" >&2; } +log_debug() { + if [[ "${DEBUG:-false}" == "true" ]]; then + echo "${FEATURE_LOG_PREFIX} DEBUG: $*" >&2 + fi +} ``` -One character change (`>&2`). No other logging functions need to change — `log_warn` and -`log_error` already write to stderr. +`log_debug` has the same stdout defect (line 54-57). While it is not a live bug today +(no command-substitution call site currently uses a function that calls `log_debug`), the +same class of issue applies and fixing it is required for defensive correctness. + +`log_warn` and `log_error` already write to stderr — no change needed. ### Affected File -- `src/claude-code/install.sh` line 51 +- `src/claude-code/install.sh` lines 51 and 54-57 --- @@ -86,23 +95,41 @@ Exit code 1 ### Fix +This is a **heuristic workaround** for a devcontainer CLI bug (exits 0 on container build +failure). The real fix would be a CLI patch. The workaround is tied to the CLI's current +output format and must be revisited if the CLI version is upgraded. + After each `devcontainer features test` step, grep the captured log for known failure -strings. If any are found, force `exit 1`: +strings. If any are found, force `exit 1`. Each job uses a different log path: + +**test-scenarios** (log: `/tmp/scenario-test-output.log`): ```bash -devcontainer features test ... 2>&1 | tee /tmp/test-output.log -if grep -qE "Exit code [^0]|failed to install|Failed to launch" /tmp/test-output.log; then +devcontainer features test --project-folder . \ + 2>&1 | tee /tmp/scenario-test-output.log +if grep -qE "Exit code [^0]|failed to install|Failed to launch" \ + /tmp/scenario-test-output.log; then echo "ERROR: Test output contains failures." exit 1 fi ``` -This pattern is applied to all three test jobs: `test-scenarios`, `test-image-matrix`, -`test-arm64`. +**test-image-matrix and test-arm64** (log: `/tmp/test-output.log`): + +```bash +devcontainer features test ... \ + 2>&1 | tee /tmp/test-output.log +if grep -qE "Exit code [^0]|failed to install|Failed to launch" \ + /tmp/test-output.log; then + echo "ERROR: Test output contains failures." + exit 1 +fi +``` ### Affected File -- `.github/workflows/test.yml` — all three test job `run:` blocks +- `.github/workflows/test.yml` — all three test job `run:` blocks (note: different log + paths per job) --- @@ -120,7 +147,9 @@ with `--no-verify`, the CI lint job catches it and blocks the PR from merging. ### Pre-commit Changes -**shfmt**: Add `-w` explicitly so the hook writes formatted files in place: +**shfmt**: The `scop/pre-commit-shfmt` hook already runs in write mode (`-w`) by default. +The `-w` flag in user args is redundant but harmless (shfmt tolerates duplicate `-w`). +Keep it for explicitness — it documents intent clearly: ```yaml - repo: https://github.com/scop/pre-commit-shfmt @@ -162,33 +191,46 @@ These remain unchanged. They are the enforcement layer. ### Changes -**`LICENSE`**: Replace MIT text with Apache 2.0 text. Keep `Copyright (c) 2026 PKramek`. +**`LICENSE`**: Replace MIT text with the standard, verbatim Apache License 2.0 text +(OSI-approved format). Do not embed the copyright line inside the license body — Apache 2.0 +copyright attribution goes in a separate `NOTICE` file. -**`src/claude-code/devcontainer-feature.json`**: Update `licenseURL` from pointing to the -MIT license to Apache 2.0: +**`NOTICE`** (new file): Create with the copyright attribution: -```json -"licenseURL": "https://github.com/pkramek/claude-devcontainer/blob/main/LICENSE" ``` +claude-devcontainer +Copyright (c) 2026 PKramek +``` + +The Apache 2.0 license (Section 4(d)) requires redistributors to include the NOTICE file. +Creating it now ensures compliance. -(The URL itself does not change — it points to the `LICENSE` file whose content is -replaced. No URL update needed.) +**`README.md`**: The `## License` section currently reads `MIT`. Update to `Apache 2.0`: + +```markdown +## License + +Apache 2.0 +``` -**`README.md`**: No change needed — README does not mention the license name inline. +**`src/claude-code/devcontainer-feature.json`**: The `licenseURL` value already points to +`https://github.com/pkramek/claude-devcontainer/blob/main/LICENSE` — no URL change needed, +the file content replacement is sufficient. ### Affected Files -- `LICENSE` -- No other files require changes (licenseURL already points to the file, not the license name) +- `LICENSE` (content replaced) +- `NOTICE` (new file) +- `README.md` (license name updated) --- ## Implementation Order -1. `log_info` fix in `install.sh` (unblocks all test runs) -2. License file replacement -3. Pre-commit config update (shfmt `-w`, no-commit-to-branch `develop`) -4. CI false positive grep checks in `test.yml` +1. `log_info` + `log_debug` fix in `install.sh` (unblocks all test runs) +2. License: replace `LICENSE`, create `NOTICE`, update `README.md` +3. Pre-commit config update (shfmt `-w` explicit, no-commit-to-branch `develop`) +4. CI false positive grep checks in `test.yml` (correct log path per job) Each change is independent. All four can land in a single commit or separate commits per logical group. From 58535a8c98d3a077fdb407cb8e1a25ebd01f8d8d Mon Sep 17 00:00:00 2001 From: PKramek Date: Wed, 1 Apr 2026 20:50:27 +0200 Subject: [PATCH 29/55] docs: incorporate expert review findings into design spec --- ...1-install-fix-formatting-license-design.md | 66 +++++++++++++++---- 1 file changed, 52 insertions(+), 14 deletions(-) diff --git a/docs/superpowers/specs/2026-04-01-install-fix-formatting-license-design.md b/docs/superpowers/specs/2026-04-01-install-fix-formatting-license-design.md index 1057dd0..942b2fa 100644 --- a/docs/superpowers/specs/2026-04-01-install-fix-formatting-license-design.md +++ b/docs/superpowers/specs/2026-04-01-install-fix-formatting-license-design.md @@ -107,19 +107,26 @@ strings. If any are found, force `exit 1`. Each job uses a different log path: ```bash devcontainer features test --project-folder . \ 2>&1 | tee /tmp/scenario-test-output.log -if grep -qE "Exit code [^0]|failed to install|Failed to launch" \ +# Workaround: devcontainers/cli@0.85.0 exits 0 even when feature install fails. +# Grep for known failure strings and fail explicitly. Revisit on CLI upgrade. +if grep -qE "Exit code [1-9]|failed to install|Failed to launch" \ /tmp/scenario-test-output.log; then echo "ERROR: Test output contains failures." exit 1 fi ``` +`[1-9]` (not `[^0]`) matches only numeric non-zero exit codes, preventing false matches +on stray non-numeric characters after "Exit code". + **test-image-matrix and test-arm64** (log: `/tmp/test-output.log`): ```bash devcontainer features test ... \ 2>&1 | tee /tmp/test-output.log -if grep -qE "Exit code [^0]|failed to install|Failed to launch" \ +# Workaround: devcontainers/cli@0.85.0 exits 0 even when feature install fails. +# Grep for known failure strings and fail explicitly. Revisit on CLI upgrade. +if grep -qE "Exit code [1-9]|failed to install|Failed to launch" \ /tmp/test-output.log; then echo "ERROR: Test output contains failures." exit 1 @@ -163,14 +170,23 @@ Keep it for explicitness — it documents intent clearly: **markdownlint** (`--fix`): Already writes in pre-commit context. No change. -**no-commit-to-branch**: Add `develop` alongside `main` to prevent direct commits to both -protected branches: +**no-commit-to-branch**: Add `develop` alongside `main` to prevent accidental direct +commits from a developer's local machine: ```yaml - id: no-commit-to-branch args: ["--branch", "main", "--branch", "develop"] ``` +Note: this is a local convenience guard only — bypassed by `--no-verify` and ineffective +for bots/automated tooling. GitHub branch protection rulesets (Task 6) are the +authoritative server-side enforcement layer. + +**Hook SHA pinning**: The current hooks use version tags (`v6.0.0`, `v3.13.0-1`, etc.). +Tags are mutable — a compromised upstream could force-push a tag to malicious code. Run +`pre-commit autoupdate --freeze` to convert all `rev` values to commit SHAs. This is the +approach taken by security-conscious OSS projects and pre-commit's own documentation. + ### CI Changes No changes to check mode. The CI lint job already runs: @@ -213,24 +229,46 @@ Creating it now ensures compliance. Apache 2.0 ``` -**`src/claude-code/devcontainer-feature.json`**: The `licenseURL` value already points to -`https://github.com/pkramek/claude-devcontainer/blob/main/LICENSE` — no URL change needed, -the file content replacement is sufficient. +**`src/claude-code/devcontainer-feature.json`**: Add the `license` SPDX field alongside the +existing `licenseURL`. The DevContainers spec and GHCR registry use this field for display +and filtering. Without it, the feature appears as unlicensed in registry listings: + +```json +"license": "Apache-2.0", +"licenseURL": "https://github.com/pkramek/claude-devcontainer/blob/main/LICENSE" +``` + +The `licenseURL` URL itself does not change — the file content replacement is sufficient. + +**`src/claude-code/install.sh`**: Add an SPDX license identifier to the file header +(industry standard for machine-parseable license detection by FOSSA, Snyk, GitHub): + +```bash +# SPDX-License-Identifier: Apache-2.0 +``` + +Add this as the second line of the file (after the shebang). ### Affected Files -- `LICENSE` (content replaced) -- `NOTICE` (new file) -- `README.md` (license name updated) +- `LICENSE` (content replaced with verbatim Apache 2.0 text) +- `NOTICE` (new file with copyright attribution) +- `README.md` (license section updated from "MIT" to "Apache 2.0"; Contributing section + updated to instruct non-devcontainer contributors to run `pre-commit install`) +- `src/claude-code/devcontainer-feature.json` (`"license": "Apache-2.0"` field added) +- `src/claude-code/install.sh` (SPDX header added) --- ## Implementation Order -1. `log_info` + `log_debug` fix in `install.sh` (unblocks all test runs) -2. License: replace `LICENSE`, create `NOTICE`, update `README.md` -3. Pre-commit config update (shfmt `-w` explicit, no-commit-to-branch `develop`) -4. CI false positive grep checks in `test.yml` (correct log path per job) +1. `log_info` + `log_debug` fix in `install.sh` + SPDX header (unblocks all test runs) +2. License: replace `LICENSE`, create `NOTICE`, update `README.md` (license + Contributing + section), add `"license"` field to `devcontainer-feature.json` +3. Pre-commit config: shfmt `-w` explicit, `no-commit-to-branch` `develop`, SHA pinning + via `pre-commit autoupdate --freeze` +4. CI false positive grep checks in `test.yml` (tightened regex, inline comments, + correct log path per job) Each change is independent. All four can land in a single commit or separate commits per logical group. From fa775edc78aafb39a532e623cf943cb175fa84b6 Mon Sep 17 00:00:00 2001 From: PKramek Date: Wed, 1 Apr 2026 20:58:18 +0200 Subject: [PATCH 30/55] docs: fix spec count, SHA pinning approach, add SPDX-FileCopyrightText --- ...1-install-fix-formatting-license-design.md | 36 ++++++++++++++----- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/docs/superpowers/specs/2026-04-01-install-fix-formatting-license-design.md b/docs/superpowers/specs/2026-04-01-install-fix-formatting-license-design.md index 942b2fa..e2adef2 100644 --- a/docs/superpowers/specs/2026-04-01-install-fix-formatting-license-design.md +++ b/docs/superpowers/specs/2026-04-01-install-fix-formatting-license-design.md @@ -6,7 +6,7 @@ ## Summary -Five changes grouped into one implementation cycle: +Four changes grouped into one implementation cycle: 1. Fix `log_info` and `log_debug` stdout contamination bug (breaks install on all images) 2. Fix CI false positives (test steps pass despite feature install failures) @@ -183,9 +183,26 @@ for bots/automated tooling. GitHub branch protection rulesets (Task 6) are the authoritative server-side enforcement layer. **Hook SHA pinning**: The current hooks use version tags (`v6.0.0`, `v3.13.0-1`, etc.). -Tags are mutable — a compromised upstream could force-push a tag to malicious code. Run -`pre-commit autoupdate --freeze` to convert all `rev` values to commit SHAs. This is the -approach taken by security-conscious OSS projects and pre-commit's own documentation. +Tags are mutable — a compromised upstream could force-push a tag to malicious code. The +safe approach is to freeze the **current** pinned versions at their exact commit SHAs +without pulling in any version upgrades. + +**Do NOT run `pre-commit autoupdate --freeze` without care** — `autoupdate` also updates to +the latest available tag before freezing. This could silently pull in shfmt v4.x (which +changed formatting defaults from v3.x), a prettier stable release, or other breaking +changes that would reformat all files and require a large unrelated diff. + +Correct approach: resolve each current tag to its commit SHA using `git ls-remote`, then +edit `.pre-commit-config.yaml` directly: + +```bash +# Example: resolve current tag to SHA without updating +git ls-remote https://github.com/pre-commit/pre-commit-hooks refs/tags/v6.0.0 +# Use the commit SHA (not the tag object SHA — use the ^{} dereferenced SHA if it exists) +``` + +Repeat for all five repos. After editing, run `pre-commit run --all-files` to verify no +formatting regressions before committing. ### CI Changes @@ -240,14 +257,17 @@ and filtering. Without it, the feature appears as unlicensed in registry listing The `licenseURL` URL itself does not change — the file content replacement is sufficient. -**`src/claude-code/install.sh`**: Add an SPDX license identifier to the file header -(industry standard for machine-parseable license detection by FOSSA, Snyk, GitHub): +**`src/claude-code/install.sh`**: Add SPDX headers to the file after the shebang +(industry standard for machine-parseable license detection by FOSSA, Snyk, GitHub). +Include both the license identifier and the copyright text for full REUSE spec compliance: ```bash # SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: 2026 PKramek ``` -Add this as the second line of the file (after the shebang). +Add these as lines 2-3 of the file (after the shebang, before the existing `#` block). +Comment lines are ignored by all POSIX shells — no compatibility risk. ### Affected Files @@ -266,7 +286,7 @@ Add this as the second line of the file (after the shebang). 2. License: replace `LICENSE`, create `NOTICE`, update `README.md` (license + Contributing section), add `"license"` field to `devcontainer-feature.json` 3. Pre-commit config: shfmt `-w` explicit, `no-commit-to-branch` `develop`, SHA pinning - via `pre-commit autoupdate --freeze` + via `git ls-remote` tag resolution (NOT `autoupdate --freeze` which also upgrades versions) 4. CI false positive grep checks in `test.yml` (tightened regex, inline comments, correct log path per job) From 92f9d05c173aab775aa669d5c223b68f6236f8ea Mon Sep 17 00:00:00 2001 From: PKramek Date: Wed, 1 Apr 2026 21:15:06 +0200 Subject: [PATCH 31/55] fix: redirect log_info and log_debug to stderr, add SPDX headers --- src/claude-code/install.sh | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/claude-code/install.sh b/src/claude-code/install.sh index b244898..bd1f7f6 100755 --- a/src/claude-code/install.sh +++ b/src/claude-code/install.sh @@ -1,4 +1,6 @@ #!/usr/bin/env bash +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: 2026 PKramek # # Claude Code DevContainer Feature — install.sh # Installs Claude Code CLI into any devcontainer environment. @@ -48,12 +50,12 @@ cleanup() { } # --- Logging --- -log_info() { echo "${FEATURE_LOG_PREFIX} $*"; } -log_warn() { echo "${FEATURE_LOG_PREFIX} WARNING: $*" >&2; } +log_info() { echo "${FEATURE_LOG_PREFIX} $*" >&2; } +log_warn() { echo "${FEATURE_LOG_PREFIX} WARNING: $*" >&2; } log_error() { echo "${FEATURE_LOG_PREFIX} ERROR: $*" >&2; } log_debug() { if [[ "${DEBUG:-false}" == "true" ]]; then - echo "${FEATURE_LOG_PREFIX} DEBUG: $*" + echo "${FEATURE_LOG_PREFIX} DEBUG: $*" >&2 fi } From 5a8bcf1fb59e45f411883767672d518f5a878c69 Mon Sep 17 00:00:00 2001 From: PKramek Date: Wed, 1 Apr 2026 21:15:18 +0200 Subject: [PATCH 32/55] ci: detect feature install failures in test output (workaround cli exit-code bug) --- .github/workflows/test.yml | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8e12c11..707a353 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -85,7 +85,14 @@ jobs: run: npm install -g @devcontainers/cli@0.85.0 - name: Run all scenarios - run: devcontainer features test --project-folder . 2>&1 | tee /tmp/scenario-test-output.log + run: | + devcontainer features test --project-folder . 2>&1 | tee /tmp/scenario-test-output.log + # Workaround: devcontainers/cli@0.85.0 exits 0 even when feature install fails. + # Grep for known failure strings and fail explicitly. Revisit on CLI upgrade. + if grep -qE "Exit code [1-9][0-9]*|failed to install|Failed to launch" /tmp/scenario-test-output.log; then + echo "ERROR: Test output contains failures." + exit 1 + fi - name: Upload logs on failure if: failure() @@ -153,6 +160,12 @@ jobs: --skip-scenarios \ --base-image "${{ matrix.image }}" \ --project-folder . 2>&1 | tee /tmp/test-output.log + # Workaround: devcontainers/cli@0.85.0 exits 0 even when feature install fails. + # Grep for known failure strings and fail explicitly. Revisit on CLI upgrade. + if grep -qE "Exit code [1-9][0-9]*|failed to install|Failed to launch" /tmp/test-output.log; then + echo "ERROR: Test output contains failures." + exit 1 + fi - name: Upload logs on failure if: failure() @@ -194,6 +207,12 @@ jobs: --skip-scenarios \ --base-image "${{ matrix.image }}" \ --project-folder . 2>&1 | tee /tmp/test-output.log + # Workaround: devcontainers/cli@0.85.0 exits 0 even when feature install fails. + # Grep for known failure strings and fail explicitly. Revisit on CLI upgrade. + if grep -qE "Exit code [1-9][0-9]*|failed to install|Failed to launch" /tmp/test-output.log; then + echo "ERROR: Test output contains failures." + exit 1 + fi - name: Upload logs on failure if: failure() From 590261c2c28eba37b068b4d99dda3fbd6d0a2d85 Mon Sep 17 00:00:00 2001 From: PKramek Date: Wed, 1 Apr 2026 21:15:19 +0200 Subject: [PATCH 33/55] chore: change license from MIT to Apache 2.0 --- LICENSE | 223 ++++++++++++++++++++-- NOTICE | 2 + README.md | 5 +- src/claude-code/devcontainer-feature.json | 1 + 4 files changed, 208 insertions(+), 23 deletions(-) create mode 100644 NOTICE diff --git a/LICENSE b/LICENSE index 773830a..d645695 100644 --- a/LICENSE +++ b/LICENSE @@ -1,21 +1,202 @@ -MIT License - -Copyright (c) 2026 PKramek - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..f247018 --- /dev/null +++ b/NOTICE @@ -0,0 +1,2 @@ +claude-devcontainer +Copyright (c) 2026 PKramek diff --git a/README.md b/README.md index 19c50e2..5925965 100644 --- a/README.md +++ b/README.md @@ -115,11 +115,12 @@ You must manually change it to public: ## Contributing 1. Fork the repository -2. Open in a devcontainer (`.devcontainer/devcontainer.json` is provided) +2. Open in a devcontainer (`.devcontainer/devcontainer.json` runs `pre-commit install` + automatically), **or** run `pre-commit install` manually after cloning 3. Make changes 4. Run `pre-commit run --all-files` before committing 5. Open a pull request ## License -MIT +Apache 2.0 diff --git a/src/claude-code/devcontainer-feature.json b/src/claude-code/devcontainer-feature.json index cabb5e8..94188ec 100644 --- a/src/claude-code/devcontainer-feature.json +++ b/src/claude-code/devcontainer-feature.json @@ -12,6 +12,7 @@ "devcontainer" ], "documentationURL": "https://github.com/pkramek/claude-devcontainer#readme", + "license": "Apache-2.0", "licenseURL": "https://github.com/pkramek/claude-devcontainer/blob/main/LICENSE", "installsAfter": ["ghcr.io/devcontainers/features/node"], "options": { From bca67c1e3cd84d05c8df991bd978df22de8bee42 Mon Sep 17 00:00:00 2001 From: PKramek Date: Wed, 1 Apr 2026 21:18:19 +0200 Subject: [PATCH 34/55] chore: pin pre-commit hooks to commit SHAs, add develop branch protection --- .pre-commit-config.yaml | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0a464c0..7653300 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,8 @@ +# WARNING: Do not run `pre-commit autoupdate`. Hook revisions are pinned to +# immutable commit SHAs for supply chain security. Update manually via git ls-remote. repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v6.0.0 + rev: 3e8a8703264a2f4a69428a0aa4dcb512790b2c8c # v6.0.0 hooks: - id: check-json - id: check-yaml @@ -11,28 +13,28 @@ repos: - id: check-added-large-files args: ["--maxkb=500"] - id: no-commit-to-branch - args: ["--branch", "main"] + args: ["--branch", "main", "--branch", "develop"] - repo: https://github.com/koalaman/shellcheck-precommit - rev: v0.11.0 + rev: 99470f5e12208ff0fb17ab81c3c494f7620a1d8d # v0.11.0 hooks: - id: shellcheck args: ["--severity=warning"] - repo: https://github.com/scop/pre-commit-shfmt - rev: v3.13.0-1 + rev: e26a818fd47b4f33cefa99035d1265b0849f4b47 # v3.13.0-1 hooks: - id: shfmt - args: ["-i", "4", "-ci"] + args: ["-w", "-i", "4", "-ci"] - repo: https://github.com/pre-commit/mirrors-prettier - rev: v4.0.0-alpha.8 + rev: f12edd9c7be1c20cfa42420fd0e6df71e42b51ea # v4.0.0-alpha.8 hooks: - id: prettier types_or: [json, yaml, markdown] - repo: https://github.com/igorshubovych/markdownlint-cli - rev: v0.48.0 + rev: e72a3ca1632f0b11a07d171449fe447a7ff6795e # v0.48.0 hooks: - id: markdownlint args: ["--fix"] From 2fc68dc314622b6098ee8670889c1ac353514884 Mon Sep 17 00:00:00 2001 From: PKramek Date: Wed, 1 Apr 2026 21:21:54 +0200 Subject: [PATCH 35/55] style: apply shfmt and prettier formatting --- .../2026-04-01-install-fix-formatting-license-design.md | 8 ++++---- src/claude-code/install.sh | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/superpowers/specs/2026-04-01-install-fix-formatting-license-design.md b/docs/superpowers/specs/2026-04-01-install-fix-formatting-license-design.md index e2adef2..4c4ec59 100644 --- a/docs/superpowers/specs/2026-04-01-install-fix-formatting-license-design.md +++ b/docs/superpowers/specs/2026-04-01-install-fix-formatting-license-design.md @@ -144,10 +144,10 @@ fi ### Architecture -| Layer | Tool | Mode | Bypassable? | -|---|---|---|---| -| Pre-commit (local) | shfmt, prettier, markdownlint | **Write** (auto-format) | Yes (`--no-verify`) | -| CI lint job | shfmt, prettier, markdownlint | **Check** (fail on diff) | No (required status check) | +| Layer | Tool | Mode | Bypassable? | +| ------------------ | ----------------------------- | ------------------------ | -------------------------- | +| Pre-commit (local) | shfmt, prettier, markdownlint | **Write** (auto-format) | Yes (`--no-verify`) | +| CI lint job | shfmt, prettier, markdownlint | **Check** (fail on diff) | No (required status check) | Developers get auto-formatting on commit (no manual formatting step). If they skip hooks with `--no-verify`, the CI lint job catches it and blocks the PR from merging. diff --git a/src/claude-code/install.sh b/src/claude-code/install.sh index bd1f7f6..3a0ab8f 100755 --- a/src/claude-code/install.sh +++ b/src/claude-code/install.sh @@ -50,8 +50,8 @@ cleanup() { } # --- Logging --- -log_info() { echo "${FEATURE_LOG_PREFIX} $*" >&2; } -log_warn() { echo "${FEATURE_LOG_PREFIX} WARNING: $*" >&2; } +log_info() { echo "${FEATURE_LOG_PREFIX} $*" >&2; } +log_warn() { echo "${FEATURE_LOG_PREFIX} WARNING: $*" >&2; } log_error() { echo "${FEATURE_LOG_PREFIX} ERROR: $*" >&2; } log_debug() { if [[ "${DEBUG:-false}" == "true" ]]; then From ce1c5b1bea9123c51e5e46b08e5c4817fe19fd7e Mon Sep 17 00:00:00 2001 From: PKramek Date: Wed, 1 Apr 2026 21:22:32 +0200 Subject: [PATCH 36/55] docs: add implementation plans for CI fixes and repo setup --- ...26-04-01-install-fix-formatting-license.md | 815 ++++++++++++++++++ .../2026-04-01-repo-rename-branch-setup.md | 436 ++++++++++ 2 files changed, 1251 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-01-install-fix-formatting-license.md create mode 100644 docs/superpowers/plans/2026-04-01-repo-rename-branch-setup.md diff --git a/docs/superpowers/plans/2026-04-01-install-fix-formatting-license.md b/docs/superpowers/plans/2026-04-01-install-fix-formatting-license.md new file mode 100644 index 0000000..7fb1069 --- /dev/null +++ b/docs/superpowers/plans/2026-04-01-install-fix-formatting-license.md @@ -0,0 +1,815 @@ +# Install Fix, Formatting Enforcement, and License Change Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Fix the install.sh stdout contamination bug that breaks every image, add CI false-positive protection, enforce two-layer formatting, and change the license to Apache 2.0. + +**Architecture:** Four independent, ordered changes: (1) fix logging functions so they write to stderr — unblocking all test runs; (2) update the license files, README, and feature manifest; (3) pin pre-commit hooks to commit SHAs and add develop branch protection; (4) add grep-based failure detection to all three CI test jobs to work around a devcontainer CLI exit-code bug. + +**Tech Stack:** Bash, GitHub Actions, pre-commit, Apache 2.0 + +**Git author for all commits:** `PKramek ` + +**CRITICAL:** No Co-Authored-By, no AI attribution in any commit message. + +--- + +## File Map + +| File | Change | +|---|---| +| `src/claude-code/install.sh` | `log_info` → stderr; `log_debug` → stderr; SPDX headers added | +| `LICENSE` | Replaced with verbatim Apache 2.0 text | +| `NOTICE` | New file — copyright attribution | +| `README.md` | License section: MIT → Apache 2.0; Contributing: add `pre-commit install` step | +| `src/claude-code/devcontainer-feature.json` | Add `"license": "Apache-2.0"` field | +| `.pre-commit-config.yaml` | shfmt `-w` explicit; `no-commit-to-branch` adds `develop`; all `rev:` values replaced with commit SHAs | +| `.github/workflows/test.yml` | All three test job `run:` blocks get grep-based failure detection | + +--- + +## Task 1: Fix install.sh Logging and Add SPDX Headers + +**Files:** +- Modify: `src/claude-code/install.sh` lines 1-2 (SPDX headers), 51 (`log_info`), 54-57 (`log_debug`) + +**Context:** `log_info()` currently writes to stdout. When `resolve_node_version` is called +via `$(...)`, the log message contaminates the captured return value, producing a malformed +Node.js download URL that curl rejects with "bad range in URL". Every image fails silently. +`log_debug` has the same class of defect. The fix is one character per function: `>&2`. + +- [ ] **Step 1: Add SPDX headers after the shebang** + +The current file starts: +```bash +#!/usr/bin/env bash +# +# Claude Code DevContainer Feature — install.sh +``` + +Edit `src/claude-code/install.sh` — INSERT two new lines after line 1 (do NOT replace the +existing comment block). The result must be: +```bash +#!/usr/bin/env bash +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: 2026 PKramek +# +# Claude Code DevContainer Feature — install.sh +``` + +This adds 2 net new lines. Every line below the shebang shifts down by 2. + +- [ ] **Step 2: Fix log_info to write to stderr** + +Current line 51 (will be line 53 after step 1): +```bash +log_info() { echo "${FEATURE_LOG_PREFIX} $*"; } +``` + +Change to: +```bash +log_info() { echo "${FEATURE_LOG_PREFIX} $*" >&2; } +``` + +- [ ] **Step 3: Fix log_debug to write to stderr** + +Current lines 54-57 (will be lines 56-59 after steps 1-2): +```bash +log_debug() { + if [[ "${DEBUG:-false}" == "true" ]]; then + echo "${FEATURE_LOG_PREFIX} DEBUG: $*" + fi +} +``` + +Change to: +```bash +log_debug() { + if [[ "${DEBUG:-false}" == "true" ]]; then + echo "${FEATURE_LOG_PREFIX} DEBUG: $*" >&2 + fi +} +``` + +- [ ] **Step 4: Verify log_warn and log_error already write to stderr** + +Run: +```bash +grep -n "log_info\|log_warn\|log_error\|log_debug" src/claude-code/install.sh | head -8 +``` + +Expected output (line numbers will be offset by 2 from step 1): +``` +53:log_info() { echo "${FEATURE_LOG_PREFIX} $*" >&2; } +54:log_warn() { echo "${FEATURE_LOG_PREFIX} WARNING: $*" >&2; } +55:log_error() { echo "${FEATURE_LOG_PREFIX} ERROR: $*" >&2; } +56:log_debug() { +``` + +All four functions must end with `>&2`. If `log_warn` or `log_error` are missing `>&2`, stop and fix them too. + +- [ ] **Step 5: Verify no stdout-writing log functions remain** + +Run: +```bash +grep -n 'echo.*FEATURE_LOG_PREFIX' src/claude-code/install.sh | grep -v '>&2' +``` + +Expected: no output. Any line without `>&2` is a bug. + +- [ ] **Step 6: Verify SPDX headers are correct** + +Run: +```bash +head -5 src/claude-code/install.sh +``` + +Expected: +``` +#!/usr/bin/env bash +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: 2026 PKramek +# +# Claude Code DevContainer Feature — install.sh +``` + +- [ ] **Step 7: Run ShellCheck to verify no regressions** + +Run (requires `shellcheck` installed — install via `brew install shellcheck` on macOS or +`apt install shellcheck` on Debian/Ubuntu if not present): +```bash +shellcheck --severity=warning src/claude-code/install.sh +``` + +Expected: no output (no warnings or errors). + +- [ ] **Step 8: Run pre-commit on install.sh to ensure it is already clean** + +This step prevents Task 3's `pre-commit run --all-files` from reformatting this file and +contaminating the Task 3 commit with changes from Task 1. + +Run: +```bash +pre-commit run shfmt --files src/claude-code/install.sh +pre-commit run shellcheck --files src/claude-code/install.sh +``` + +Expected: both exit 0. If shfmt modifies the file, stage the reformatted version before +committing — the commit should include the clean, shfmt-formatted version. + +- [ ] **Step 9: Commit** + +```bash +git add src/claude-code/install.sh +git commit --author="PKramek " \ + -m "fix: redirect log_info and log_debug to stderr, add SPDX headers" +``` + +--- + +## Task 2: Update License to Apache 2.0 + +**Files:** +- Modify: `LICENSE` +- Create: `NOTICE` +- Modify: `README.md` +- Modify: `src/claude-code/devcontainer-feature.json` + +**Context:** The project is changing from MIT to Apache 2.0. Apache 2.0 requires a separate +NOTICE file for copyright attribution (not embedded in LICENSE). The devcontainer-feature.json +needs a `"license"` SPDX field so the GHCR registry displays the license correctly. The +README Contributing section needs a `pre-commit install` step for non-devcontainer contributors. + +- [ ] **Step 1: Replace LICENSE with verbatim Apache 2.0 text** + +Download the canonical, byte-for-byte Apache 2.0 text from the official source. Do NOT +copy-paste from a website or embed text in a script — copy-paste strips the APPENDIX and +introduces subtle differences. Do NOT add a copyright line inside this file — copyright +goes in NOTICE. + +```bash +curl -o LICENSE https://www.apache.org/licenses/LICENSE-2.0.txt +``` + +Verify the APPENDIX section is present (it follows "END OF TERMS AND CONDITIONS"): +```bash +grep -c "APPENDIX" LICENSE +``` +Expected: `1` + +Verify no copyright line was added inside: +```bash +head -5 LICENSE +``` +Expected: the file starts with whitespace + "Apache License" header, no copyright line. + +The full canonical text starts as follows and includes the APPENDIX section at the end: +``` + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship made available under + the License, as indicated by a copyright notice that is included in + or attached to the work (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean, as submitted to the Licensor for inclusion + in the Work by the copyright owner or by an individual or Legal Entity + authorized to submit on behalf of the copyright owner. For the purposes + of this definition, "submitted" means any form of electronic, verbal, + or written communication sent to the Licensor or its representatives, + including but not limited to communication on electronic mailing lists, + source code control systems, and issue tracking systems that are managed + by, or on behalf of, the Licensor for the purpose of discussing and + improving the Work, but excluding communication that is conspicuously + marked or designated in writing by the copyright owner as "Not a + Contribution." + + "Contributor" shall mean Licensor and any Legal Entity on behalf of + whom a Contribution has been received by the Licensor and included + within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by the combination of their Contributions + with the Work to which such Contributions were submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or Derivative + Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, You must include a readable copy of the + attribution notices contained within such NOTICE file, in + at least one of the following places: within a NOTICE text + file distributed as part of the Derivative Works; within + the Source form or documentation, if provided along with the + Derivative Works; or, within a display generated by the + Derivative Works, if and wherever such third-party notices + normally appear. The contents of the NOTICE file are for + informational purposes only and do not modify the License. + You may add Your own attribution notices within Derivative + Works that You distribute, alongside or in addition to the + NOTICE text from the Work, provided that such additional + attribution notices cannot be construed as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional grant of rights to use, reproduce, modify, + prepare Derivative Works of, publicly display, publicly perform, + sublicense, and distribute such modifications and such Derivative + Works. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any conditions of TITLE, + MERCHANTIBILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR + PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or exemplary damages of any character arising as a + result of this License or out of the use or inability to use the + Work (even if such Contributor has been advised of the possibility + of such damages). + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may offer such + obligations only on your own behalf and on your sole responsibility, + not on behalf of any other Contributor, and only if You agree to + indemnify, defend, and hold each Contributor harmless for any + liability incurred by, or claims asserted against, such Contributor + by reason of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. [...] +``` + +*(The code block above is a truncated reference only. `curl` in Step 1 fetches the complete +text including the full APPENDIX section.)* + +- [ ] **Step 2: Verify LICENSE contains no MIT text** + +Run: +```bash +grep -i "mit\|permission is hereby granted\|software and associated" LICENSE +``` + +Expected: no output. + +- [ ] **Step 3: Create NOTICE file** + +Create `NOTICE` with this exact content: +``` +claude-devcontainer +Copyright (c) 2026 PKramek +``` + +- [ ] **Step 4: Update README.md — license section and contributing section** + +In `README.md`, the `## License` section currently reads: +```markdown +## License + +MIT +``` + +Change to: +```markdown +## License + +Apache 2.0 +``` + +In `README.md`, the `## Contributing` section currently reads: +```markdown +## Contributing + +1. Fork the repository +2. Open in a devcontainer (`.devcontainer/devcontainer.json` is provided) +``` + +The full Contributing section currently ends with: +```markdown +4. Run `pre-commit run --all-files` before committing +5. Open a pull request +``` + +Change step 2 and add a new step 3 (renumbering the rest): +```markdown +## Contributing + +1. Fork the repository +2. Open in a devcontainer (`.devcontainer/devcontainer.json` runs `pre-commit install` + automatically), **or** run `pre-commit install` manually after cloning +3. Make your changes +4. Run `pre-commit run --all-files` before committing +5. Open a pull request +``` + +- [ ] **Step 5: Add `license` field to devcontainer-feature.json** + +In `src/claude-code/devcontainer-feature.json`, the current lines 14-15 are: +```json + "documentationURL": "https://github.com/pkramek/claude-devcontainer#readme", + "licenseURL": "https://github.com/pkramek/claude-devcontainer/blob/main/LICENSE", +``` + +Change to: +```json + "documentationURL": "https://github.com/pkramek/claude-devcontainer#readme", + "license": "Apache-2.0", + "licenseURL": "https://github.com/pkramek/claude-devcontainer/blob/main/LICENSE", +``` + +- [ ] **Step 6: Validate JSON is still valid** + +Run: +```bash +python3 -m json.tool src/claude-code/devcontainer-feature.json > /dev/null && echo "OK" +``` + +Expected: `OK` + +- [ ] **Step 7: Verify no MIT references remain in tracked source files** + +Run: +```bash +grep -ri '\bMIT\b' --include='*.md' --include='*.json' --include='*.yml' --include='*.yaml' --include='*.sh' . +``` + +Expected: no output (ignore `.git/` which git grep automatically excludes when using `git grep`). +If any results appear, investigate each one — some may be in third-party lock files or +node_modules (acceptable) but any in `src/`, `README.md`, `LICENSE`, or workflows must be fixed. + +- [ ] **Step 8: Run pre-commit on changed files to ensure formatting is clean** + +This prevents Task 3's `pre-commit run --all-files` from reformatting these files and +contaminating the Task 3 commit. + +Run: +```bash +pre-commit run prettier --files README.md +pre-commit run markdownlint --files README.md +pre-commit run check-json --files src/claude-code/devcontainer-feature.json +``` + +Expected: all exit 0. If prettier reformats README.md, stage the result before committing. + +- [ ] **Step 9: Commit** + +```bash +git add LICENSE NOTICE README.md src/claude-code/devcontainer-feature.json +git commit --author="PKramek " \ + -m "chore: change license from MIT to Apache 2.0" +``` + +--- + +## Task 3: Pin Pre-commit Hooks to Commit SHAs + +**Files:** +- Modify: `.pre-commit-config.yaml` + +**Context:** All five hook repos use mutable version tags. A tag can be force-pushed to +point at malicious code. The fix is to replace each `rev:` tag with the immutable commit +SHA it points to. We resolve the current tags — do NOT use `pre-commit autoupdate --freeze` +as that also upgrades versions and could pull in breaking shfmt formatting changes. +We also add `-w` to shfmt args (explicit write-mode documentation) and add `develop` to +`no-commit-to-branch`. + +- [ ] **Step 1: Resolve all five current tags to commit SHAs** + +**Important:** Use the `^{}` dereferenced SHA (commit SHA), NOT the tag object SHA. +For annotated tags, `git ls-remote` returns two lines: the tag object SHA and the +`^{}` dereferenced commit SHA. Always use the `^{}` line. +For lightweight tags, only one line is returned — use that one. + +Use these one-liners to extract only the correct SHA (the `^{}` dereferenced commit SHA, +falling back to the tag SHA if no `^{}` exists): + +```bash +# 1. pre-commit-hooks v6.0.0 +git ls-remote https://github.com/pre-commit/pre-commit-hooks refs/tags/v6.0.0 refs/tags/v6.0.0^{} \ + | awk '/\^\{\}$/ {print $1; found=1} END {if (!found) print prev} {prev=$1}' + +# 2. shellcheck-precommit v0.11.0 +git ls-remote https://github.com/koalaman/shellcheck-precommit refs/tags/v0.11.0 refs/tags/v0.11.0^{} \ + | awk '/\^\{\}$/ {print $1; found=1} END {if (!found) print prev} {prev=$1}' + +# 3. pre-commit-shfmt v3.13.0-1 +git ls-remote https://github.com/scop/pre-commit-shfmt refs/tags/v3.13.0-1 refs/tags/v3.13.0-1^{} \ + | awk '/\^\{\}$/ {print $1; found=1} END {if (!found) print prev} {prev=$1}' + +# 4. mirrors-prettier v4.0.0-alpha.8 +git ls-remote https://github.com/pre-commit/mirrors-prettier refs/tags/v4.0.0-alpha.8 refs/tags/v4.0.0-alpha.8^{} \ + | awk '/\^\{\}$/ {print $1; found=1} END {if (!found) print prev} {prev=$1}' + +# 5. markdownlint-cli v0.48.0 +git ls-remote https://github.com/igorshubovych/markdownlint-cli refs/tags/v0.48.0 refs/tags/v0.48.0^{} \ + | awk '/\^\{\}$/ {print $1; found=1} END {if (!found) print prev} {prev=$1}' +``` + +Each command prints a single 40-character commit SHA. Record all five before proceeding. + +- [ ] **Step 2: Update .pre-commit-config.yaml with resolved SHAs and other changes** + +**NEVER run `pre-commit autoupdate` in any form.** It upgrades hook versions AND freezes, +which would silently pull in breaking shfmt v4.x formatting changes. All SHA resolution +must be done manually via `git ls-remote` as done in Step 1. + +**Note on shfmt `-w`:** Adding `-w` changes the shfmt hook from check-only mode to +auto-fix (write) mode. This is **intentional** — the goal is auto-formatting on commit. +Without `-w`, the hook only reports errors but does not fix them. The CI lint job continues +to run `shfmt -d` in check-only mode as the enforcement layer. + +First, save a backup of the current config to verify no hooks are accidentally dropped: +```bash +cp .pre-commit-config.yaml .pre-commit-config.yaml.bak +``` + +Replace the entire `.pre-commit-config.yaml` with the following, substituting the actual +SHAs resolved in Step 1 for each `` placeholder: + +```yaml +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: # v6.0.0 + hooks: + - id: check-json + - id: check-yaml + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-merge-conflict + - id: detect-private-key + - id: check-added-large-files + args: ["--maxkb=500"] + - id: no-commit-to-branch + args: ["--branch", "main", "--branch", "develop"] + + - repo: https://github.com/koalaman/shellcheck-precommit + rev: # v0.11.0 + hooks: + - id: shellcheck + args: ["--severity=warning"] + + - repo: https://github.com/scop/pre-commit-shfmt + rev: # v3.13.0-1 + hooks: + - id: shfmt + args: ["-w", "-i", "4", "-ci"] + + - repo: https://github.com/pre-commit/mirrors-prettier + rev: # v4.0.0-alpha.8 + hooks: + - id: prettier + types_or: [json, yaml, markdown] + + - repo: https://github.com/igorshubovych/markdownlint-cli + rev: # v0.48.0 + hooks: + - id: markdownlint + args: ["--fix"] +``` + +- [ ] **Step 3: Verify all rev values are 40-character SHAs** + +Run: +```bash +grep '^\s*rev:' .pre-commit-config.yaml +``` + +Expected: every line shows a 40-character hex string, not a version tag. Example: +``` + rev: cef0300de252776ee95f6c2c833b3c4dc39974e3 # v6.0.0 +``` + +If any line still shows a version tag (e.g., `v6.0.0`), go back and fix it. + +- [ ] **Step 3b: Verify no hooks were accidentally dropped** + +Run a structural diff to ensure only `rev:` lines and the two intentional content changes +(shfmt `-w` added, `no-commit-to-branch` `develop` added) differ: + +```bash +diff .pre-commit-config.yaml.bak .pre-commit-config.yaml +``` + +Expected diff should show: +- 5 `rev:` changes (tags → SHAs) +- 1 `args` change in the `shfmt` hook (adding `-w`) +- 1 `args` change in `no-commit-to-branch` (adding `"--branch", "develop"`) + +Any other changes (removed hooks, changed hook IDs, altered args) are unintended. Fix them +before continuing. Clean up the backup after verification: +```bash +rm .pre-commit-config.yaml.bak +``` + +- [ ] **Step 4: Run pre-commit on all files to verify no formatting regressions** + +Run: +```bash +pre-commit run --all-files +``` + +Expected: all hooks pass (exit 0). If any hook modifies files, it means formatting was +not clean. Stage the changes and re-run until clean. If shfmt rewrites files significantly, +stop — this indicates a version change was introduced. Re-check Step 1 SHA resolution. + +- [ ] **Step 5: Verify no-commit-to-branch now protects develop** + +Run: +```bash +grep -A2 'no-commit-to-branch' .pre-commit-config.yaml +``` + +Expected: +```yaml + - id: no-commit-to-branch + args: ["--branch", "main", "--branch", "develop"] +``` + +- [ ] **Step 6: Commit** + +```bash +git add .pre-commit-config.yaml +git commit --author="PKramek " \ + -m "chore: pin pre-commit hooks to commit SHAs, add develop branch protection" +``` + +--- + +## Task 4: Fix CI False Positives in test.yml + +**Files:** +- Modify: `.github/workflows/test.yml` — three `run:` blocks + +**Context:** `devcontainer features test` (CLI v0.85.0) exits 0 even when the feature +install fails inside Docker. The test job steps are reporting success despite the feature +failing to install. The fix greps the captured log output for known failure strings and +exits 1 if any are found. This is a heuristic workaround — comment it clearly so future +maintainers know to revisit it when upgrading the CLI. + +Note: Each of the three jobs uses a different log file path: +- `test-scenarios` → `/tmp/scenario-test-output.log` +- `test-image-matrix` → `/tmp/test-output.log` +- `test-arm64` → `/tmp/test-output.log` + +- [ ] **Step 1: Fix the test-scenarios job run block** + +In `.github/workflows/test.yml`, find the `test-scenarios` job run step (currently line 88): +```yaml + - name: Run all scenarios + run: devcontainer features test --project-folder . 2>&1 | tee /tmp/scenario-test-output.log +``` + +Change to: +```yaml + - name: Run all scenarios + run: | + devcontainer features test --project-folder . 2>&1 | tee /tmp/scenario-test-output.log + # Workaround: devcontainers/cli@0.85.0 exits 0 even when feature install fails. + # Grep for known failure strings and fail explicitly. Revisit on CLI upgrade. + if grep -qE "Exit code [1-9][0-9]*|failed to install|Failed to launch" /tmp/scenario-test-output.log; then + echo "ERROR: Test output contains failures." + exit 1 + fi +``` + +- [ ] **Step 2: Fix the test-image-matrix job run block** + +Find the `test-image-matrix` job run step (currently lines 150-155): +```yaml + - name: Test on ${{ matrix.image }} + run: | + devcontainer features test \ + --features claude-code \ + --skip-scenarios \ + --base-image "${{ matrix.image }}" \ + --project-folder . 2>&1 | tee /tmp/test-output.log +``` + +Change to: +```yaml + - name: Test on ${{ matrix.image }} + run: | + devcontainer features test \ + --features claude-code \ + --skip-scenarios \ + --base-image "${{ matrix.image }}" \ + --project-folder . 2>&1 | tee /tmp/test-output.log + # Workaround: devcontainers/cli@0.85.0 exits 0 even when feature install fails. + # Grep for known failure strings and fail explicitly. Revisit on CLI upgrade. + if grep -qE "Exit code [1-9][0-9]*|failed to install|Failed to launch" /tmp/test-output.log; then + echo "ERROR: Test output contains failures." + exit 1 + fi +``` + +- [ ] **Step 3: Fix the test-arm64 job run block** + +Find the `test-arm64` job run step (currently lines 191-196): +```yaml + - name: Test on ${{ matrix.image }} (arm64) + run: | + devcontainer features test \ + --features claude-code \ + --skip-scenarios \ + --base-image "${{ matrix.image }}" \ + --project-folder . 2>&1 | tee /tmp/test-output.log +``` + +Change to: +```yaml + - name: Test on ${{ matrix.image }} (arm64) + run: | + devcontainer features test \ + --features claude-code \ + --skip-scenarios \ + --base-image "${{ matrix.image }}" \ + --project-folder . 2>&1 | tee /tmp/test-output.log + # Workaround: devcontainers/cli@0.85.0 exits 0 even when feature install fails. + # Grep for known failure strings and fail explicitly. Revisit on CLI upgrade. + if grep -qE "Exit code [1-9][0-9]*|failed to install|Failed to launch" /tmp/test-output.log; then + echo "ERROR: Test output contains failures." + exit 1 + fi +``` + +- [ ] **Step 4: Validate YAML is still valid** + +Run: +```bash +python3 -c "import yaml; yaml.safe_load(open('.github/workflows/test.yml'))" && echo "OK" +``` + +Expected: `OK` + +- [ ] **Step 5: Verify all three jobs have the grep check and correct log paths** + +Run: +```bash +grep -n "grep -qE\|scenario-test-output\|test-output" .github/workflows/test.yml +``` + +Expected output (line numbers will vary): +``` +88: if grep -qE "Exit code [1-9][0-9]*|failed to install|Failed to launch" /tmp/scenario-test-output.log; then +151: if grep -qE "Exit code [1-9][0-9]*|failed to install|Failed to launch" /tmp/test-output.log; then +196: if grep -qE "Exit code [1-9][0-9]*|failed to install|Failed to launch" /tmp/test-output.log; then +``` + +Verify: `test-scenarios` uses `scenario-test-output.log`, the other two use `test-output.log`. + +- [ ] **Step 6: Run pre-commit on test.yml before committing** + +Task 3's `pre-commit run --all-files` did not cover test.yml (it ran before this task). +Run prettier and yaml checks now to ensure the CI file is clean: + +```bash +pre-commit run prettier --files .github/workflows/test.yml +pre-commit run check-yaml --files .github/workflows/test.yml +``` + +Expected: both exit 0. If prettier reformats the file, stage the result before committing. + +- [ ] **Step 7: Commit** + +```bash +git add .github/workflows/test.yml +git commit --author="PKramek " \ + -m "ci: detect feature install failures in test output (workaround cli exit-code bug)" +``` diff --git a/docs/superpowers/plans/2026-04-01-repo-rename-branch-setup.md b/docs/superpowers/plans/2026-04-01-repo-rename-branch-setup.md new file mode 100644 index 0000000..f84bc1f --- /dev/null +++ b/docs/superpowers/plans/2026-04-01-repo-rename-branch-setup.md @@ -0,0 +1,436 @@ +# Repo Rename + Branch Setup Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Rename all repo references from `claude-code-devcontainer` to `claude-devcontainer`, set up `main`/`develop`/`feat/initial-implementation` branches on GitHub with protection rulesets, and open the initial PR. + +**Architecture:** Single local `main` branch (20 commits) becomes `feat/initial-implementation`. A new orphan `main` and a `develop` branched from it are pushed as empty baseline branches. The PR is opened first so CI runs and reveals the exact status check context name; branch protection rulesets are applied after that is confirmed. The initial PR squash-merges all implementation work into `develop`. + +**Tech Stack:** Bash, git, gh CLI, GitHub Rulesets API + +**Git author for all commits:** `PKramek ` + +**CRITICAL:** No Co-Authored-By, no AI attribution in any commit message. + +--- + +## File Map + +| File | Change | +|---|---| +| `README.md` | 10 occurrences: badge image URL + badge link URL + 3× GHCR feature ref (5 total) | +| `src/claude-code/README.md` | 3× GHCR feature ref | +| `src/claude-code/devcontainer-feature.json` | `documentationURL` + `licenseURL` (2 total) | +| `.github/workflows/test.yml` | Add `develop` to `push.branches` trigger | + +--- + +## Task 1: Update Workflow Push Trigger + +**Files:** +- Modify: `.github/workflows/test.yml` + +The workflow currently only triggers on push to `main`. `develop` is the integration branch — CI must run on it too. + +- [ ] **Step 1: Add `develop` to the push trigger** + +Edit `.github/workflows/test.yml`. Change: + +```yaml +on: + pull_request: + push: + branches: [main] +``` + +To: + +```yaml +on: + pull_request: + push: + branches: [main, develop] +``` + +- [ ] **Step 2: Validate YAML** + +```bash +python3 -c "import yaml; yaml.safe_load(open('.github/workflows/test.yml'))" && echo "OK" +``` +Expected: `OK` + +- [ ] **Step 3: Commit** + +```bash +git add .github/workflows/test.yml +git commit --author="PKramek " -m "ci: trigger CI on develop branch push" +``` + +--- + +## Task 2: Apply Rename Commit + +**Files:** +- Modify: `README.md` +- Modify: `src/claude-code/README.md` +- Modify: `src/claude-code/devcontainer-feature.json` + +- [ ] **Step 1: Replace all occurrences in README.md** + +```bash +sed -i '' 's|claude-code-devcontainer|claude-devcontainer|g' README.md +``` + +Verify: +```bash +grep -c "claude-code-devcontainer" README.md +``` +Expected: `0` + +- [ ] **Step 2: Replace all occurrences in src/claude-code/README.md** + +```bash +sed -i '' 's|claude-code-devcontainer|claude-devcontainer|g' src/claude-code/README.md +``` + +Verify: +```bash +grep -c "claude-code-devcontainer" src/claude-code/README.md +``` +Expected: `0` + +- [ ] **Step 3: Replace all occurrences in devcontainer-feature.json** + +```bash +sed -i '' 's|claude-code-devcontainer|claude-devcontainer|g' src/claude-code/devcontainer-feature.json +``` + +Verify: +```bash +grep -c "claude-code-devcontainer" src/claude-code/devcontainer-feature.json +``` +Expected: `0` + +- [ ] **Step 4: Confirm zero remaining occurrences across the whole repo** + +```bash +grep -r "claude-code-devcontainer" src/ README.md .github/ --include="*.json" --include="*.md" --include="*.yml" +``` +Expected: no output + +- [ ] **Step 5: Validate JSON is still valid** + +```bash +python3 -m json.tool src/claude-code/devcontainer-feature.json > /dev/null && echo "OK" +``` +Expected: `OK` + +- [ ] **Step 6: Commit** + +```bash +git add README.md src/claude-code/README.md src/claude-code/devcontainer-feature.json +git commit --author="PKramek " -m "chore: rename repo to claude-devcontainer" +``` + +--- + +## Task 3: Set Up Local Branch Structure + +No files modified. Pure git branch operations. + +- [ ] **Step 1: Rename current local `main` to `feat/initial-implementation`** + +```bash +git branch -m main feat/initial-implementation +``` + +Verify: +```bash +git branch +``` +Expected: `* feat/initial-implementation` + +- [ ] **Step 2: Create orphan `main` with a single empty init commit** + +`git checkout --orphan` stages all files from the previous branch. `git rm -r --cached .` clears the index without touching the working directory. The working tree will have untracked files until Step 4 restores the feature branch. + +```bash +git checkout --orphan main +git rm -r --cached . --quiet +git commit --allow-empty --author="PKramek " -m "chore: initialize repository" +``` + +Verify: +```bash +git log --oneline +``` +Expected: exactly 1 commit — `chore: initialize repository` + +- [ ] **Step 3: Create `develop` branched from `main`** + +```bash +git checkout -b develop +``` + +Verify: +```bash +git log --oneline +``` +Expected: same single `chore: initialize repository` commit + +- [ ] **Step 4: Return to feature branch** + +```bash +git checkout feat/initial-implementation +``` + +Verify: +```bash +git log --oneline | wc -l +``` +Expected: `22` (20 original + 1 workflow commit + 1 rename commit) + +--- + +## Task 4: Connect Remote and Push All Branches + +- [ ] **Step 1: Add the remote** + +```bash +git remote add origin git@github.com:PKramek/claude-devcontainer.git +``` + +Verify: +```bash +git remote -v +``` +Expected: +``` +origin git@github.com:PKramek/claude-devcontainer.git (fetch) +origin git@github.com:PKramek/claude-devcontainer.git (push) +``` + +- [ ] **Step 2: Push `main` with tracking** + +```bash +git push -u origin main +``` +Expected: `Branch 'main' set up to track remote branch 'main' from 'origin'.` + +- [ ] **Step 3: Push `develop` with tracking** + +```bash +git push -u origin develop +``` +Expected: `Branch 'develop' set up to track remote branch 'develop' from 'origin'.` + +- [ ] **Step 4: Push `feat/initial-implementation` with tracking** + +```bash +git push -u origin feat/initial-implementation +``` +Expected: `Branch 'feat/initial-implementation' set up to track remote branch 'feat/initial-implementation' from 'origin'.` + +- [ ] **Step 5: Set `develop` as the default branch** + +```bash +gh api repos/PKramek/claude-devcontainer \ + --method PATCH \ + --field default_branch=develop \ + --jq '.default_branch' +``` +Expected: `develop` + +--- + +## Task 5: Open the Initial PR + +Open the PR before applying protection rulesets. This triggers CI and reveals the exact status check context name needed for the rulesets. + +- [ ] **Step 1: Open PR from `feat/initial-implementation` → `develop`** + +```bash +gh pr create \ + --repo PKramek/claude-devcontainer \ + --base develop \ + --head feat/initial-implementation \ + --title "feat: initial Claude Code DevContainer Feature implementation" \ + --body "$(cat <<'EOF' +## Summary + +- Universal DevContainer Feature that installs Claude Code CLI into any container +- Supports Debian, Ubuntu, Alpine, Arch, Fedora, RHEL, Rocky, Alma, Amazon Linux on amd64 + arm64 +- SHA256-verified Node.js binary install; distro packages for Alpine/Arch +- Shell completions (bash/zsh/fish), MCP config, mount docs, per-distro cache cleanup +- 10 test scenarios + 27-image amd64 CI matrix + 4-image arm64 matrix +- Pre-commit hooks: ShellCheck, shfmt, Prettier, markdownlint + +## Test plan + +- [ ] CI lint job passes on this PR +- [ ] `src/claude-code/devcontainer-feature.json` references `claude-devcontainer` (not `claude-code-devcontainer`) +- [ ] `README.md` badge and GHCR refs use `claude-devcontainer` +EOF +)" +``` + +Expected: PR URL printed, e.g. `https://github.com/PKramek/claude-devcontainer/pull/1` + +- [ ] **Step 2: Verify PR opened against correct base** + +```bash +gh pr view 1 --repo PKramek/claude-devcontainer --json baseRefName,headRefName,title \ + --jq '{base: .baseRefName, head: .headRefName, title: .title}' +``` +Expected: +```json +{ + "base": "develop", + "head": "feat/initial-implementation", + "title": "feat: initial Claude Code DevContainer Feature implementation" +} +``` + +- [ ] **Step 3: Wait for CI to complete, then verify the exact check context name** + +Wait ~5 minutes for the lint job to run, then: + +```bash +gh api repos/PKramek/claude-devcontainer/commits/$(git rev-parse feat/initial-implementation)/check-runs \ + --jq '.check_runs[].name' +``` + +Note the exact name reported for the lint job. It will be either `lint` or `Test / lint`. Use this value in Task 6 for the `"context"` field. + +--- + +## Task 6: Apply Branch Protection Rulesets + +Uses the GitHub Rulesets API (`POST /repos/{owner}/{repo}/rulesets`). Replace `"context": "lint"` with the exact value confirmed in Task 5 Step 3 if different. + +- [ ] **Step 1: Apply `protect-develop` ruleset** + +```bash +gh api repos/PKramek/claude-devcontainer/rulesets \ + --method POST \ + --header "Content-Type: application/json" \ + --input - << 'EOF' +{ + "name": "protect-develop", + "target": "branch", + "enforcement": "active", + "conditions": { + "ref_name": { + "exclude": [], + "include": ["refs/heads/develop"] + } + }, + "rules": [ + { "type": "deletion" }, + { "type": "non_fast_forward" }, + { + "type": "pull_request", + "parameters": { + "required_approving_review_count": 0, + "dismiss_stale_reviews_on_push": true, + "required_reviewers": [], + "require_code_owner_review": false, + "require_last_push_approval": false, + "required_review_thread_resolution": true, + "allowed_merge_methods": ["squash"] + } + }, + { + "type": "required_status_checks", + "parameters": { + "strict_required_status_checks_policy": false, + "do_not_enforce_on_create": false, + "required_status_checks": [ + { "context": "lint", "integration_id": 15368 } + ] + } + } + ] +} +EOF +``` + +Verify the response contains `"name": "protect-develop"` and `"enforcement": "active"`. + +- [ ] **Step 2: Apply `protect-main` ruleset** + +```bash +gh api repos/PKramek/claude-devcontainer/rulesets \ + --method POST \ + --header "Content-Type: application/json" \ + --input - << 'EOF' +{ + "name": "protect-main", + "target": "branch", + "enforcement": "active", + "conditions": { + "ref_name": { + "exclude": [], + "include": ["refs/heads/main"] + } + }, + "rules": [ + { "type": "deletion" }, + { "type": "non_fast_forward" }, + { + "type": "pull_request", + "parameters": { + "required_approving_review_count": 0, + "dismiss_stale_reviews_on_push": true, + "required_reviewers": [], + "require_code_owner_review": false, + "require_last_push_approval": false, + "required_review_thread_resolution": true, + "allowed_merge_methods": ["merge"] + } + }, + { + "type": "required_status_checks", + "parameters": { + "strict_required_status_checks_policy": false, + "do_not_enforce_on_create": false, + "required_status_checks": [ + { "context": "lint", "integration_id": 15368 } + ] + } + } + ] +} +EOF +``` + +Verify the response contains `"name": "protect-main"` and `"enforcement": "active"`. + +- [ ] **Step 3: Confirm both rulesets are active** + +```bash +gh api repos/PKramek/claude-devcontainer/rulesets --jq '.[].name' +``` +Expected: +``` +protect-develop +protect-main +``` + +--- + +## Task 7: Cleanup + +- [ ] **Step 1: Delete the example protection JSON files from disk** + +These were reference material and are now gitignored. Delete them: + +```bash +rm "protect-develop (2).json" "protect-master (2).json" +``` + +Verify: +```bash +ls protect-*.json 2>/dev/null || echo "clean" +``` +Expected: `clean` From e81540ca2947360fbf9d9e4defc41bc577755ed8 Mon Sep 17 00:00:00 2001 From: PKramek Date: Wed, 1 Apr 2026 21:23:53 +0200 Subject: [PATCH 37/55] style: fix Prettier formatting on plans markdown files --- ...26-04-01-install-fix-formatting-license.md | 180 +++++++++++------- .../2026-04-01-repo-rename-branch-setup.md | 44 ++++- 2 files changed, 154 insertions(+), 70 deletions(-) diff --git a/docs/superpowers/plans/2026-04-01-install-fix-formatting-license.md b/docs/superpowers/plans/2026-04-01-install-fix-formatting-license.md index 7fb1069..00942f1 100644 --- a/docs/superpowers/plans/2026-04-01-install-fix-formatting-license.md +++ b/docs/superpowers/plans/2026-04-01-install-fix-formatting-license.md @@ -16,21 +16,22 @@ ## File Map -| File | Change | -|---|---| -| `src/claude-code/install.sh` | `log_info` → stderr; `log_debug` → stderr; SPDX headers added | -| `LICENSE` | Replaced with verbatim Apache 2.0 text | -| `NOTICE` | New file — copyright attribution | -| `README.md` | License section: MIT → Apache 2.0; Contributing: add `pre-commit install` step | -| `src/claude-code/devcontainer-feature.json` | Add `"license": "Apache-2.0"` field | -| `.pre-commit-config.yaml` | shfmt `-w` explicit; `no-commit-to-branch` adds `develop`; all `rev:` values replaced with commit SHAs | -| `.github/workflows/test.yml` | All three test job `run:` blocks get grep-based failure detection | +| File | Change | +| ------------------------------------------- | ------------------------------------------------------------------------------------------------------ | +| `src/claude-code/install.sh` | `log_info` → stderr; `log_debug` → stderr; SPDX headers added | +| `LICENSE` | Replaced with verbatim Apache 2.0 text | +| `NOTICE` | New file — copyright attribution | +| `README.md` | License section: MIT → Apache 2.0; Contributing: add `pre-commit install` step | +| `src/claude-code/devcontainer-feature.json` | Add `"license": "Apache-2.0"` field | +| `.pre-commit-config.yaml` | shfmt `-w` explicit; `no-commit-to-branch` adds `develop`; all `rev:` values replaced with commit SHAs | +| `.github/workflows/test.yml` | All three test job `run:` blocks get grep-based failure detection | --- ## Task 1: Fix install.sh Logging and Add SPDX Headers **Files:** + - Modify: `src/claude-code/install.sh` lines 1-2 (SPDX headers), 51 (`log_info`), 54-57 (`log_debug`) **Context:** `log_info()` currently writes to stdout. When `resolve_node_version` is called @@ -41,6 +42,7 @@ Node.js download URL that curl rejects with "bad range in URL". Every image fail - [ ] **Step 1: Add SPDX headers after the shebang** The current file starts: + ```bash #!/usr/bin/env bash # @@ -49,6 +51,7 @@ The current file starts: Edit `src/claude-code/install.sh` — INSERT two new lines after line 1 (do NOT replace the existing comment block). The result must be: + ```bash #!/usr/bin/env bash # SPDX-License-Identifier: Apache-2.0 @@ -62,11 +65,13 @@ This adds 2 net new lines. Every line below the shebang shifts down by 2. - [ ] **Step 2: Fix log_info to write to stderr** Current line 51 (will be line 53 after step 1): + ```bash log_info() { echo "${FEATURE_LOG_PREFIX} $*"; } ``` Change to: + ```bash log_info() { echo "${FEATURE_LOG_PREFIX} $*" >&2; } ``` @@ -74,6 +79,7 @@ log_info() { echo "${FEATURE_LOG_PREFIX} $*" >&2; } - [ ] **Step 3: Fix log_debug to write to stderr** Current lines 54-57 (will be lines 56-59 after steps 1-2): + ```bash log_debug() { if [[ "${DEBUG:-false}" == "true" ]]; then @@ -83,6 +89,7 @@ log_debug() { ``` Change to: + ```bash log_debug() { if [[ "${DEBUG:-false}" == "true" ]]; then @@ -94,11 +101,13 @@ log_debug() { - [ ] **Step 4: Verify log_warn and log_error already write to stderr** Run: + ```bash grep -n "log_info\|log_warn\|log_error\|log_debug" src/claude-code/install.sh | head -8 ``` Expected output (line numbers will be offset by 2 from step 1): + ``` 53:log_info() { echo "${FEATURE_LOG_PREFIX} $*" >&2; } 54:log_warn() { echo "${FEATURE_LOG_PREFIX} WARNING: $*" >&2; } @@ -111,6 +120,7 @@ All four functions must end with `>&2`. If `log_warn` or `log_error` are missing - [ ] **Step 5: Verify no stdout-writing log functions remain** Run: + ```bash grep -n 'echo.*FEATURE_LOG_PREFIX' src/claude-code/install.sh | grep -v '>&2' ``` @@ -120,11 +130,13 @@ Expected: no output. Any line without `>&2` is a bug. - [ ] **Step 6: Verify SPDX headers are correct** Run: + ```bash head -5 src/claude-code/install.sh ``` Expected: + ``` #!/usr/bin/env bash # SPDX-License-Identifier: Apache-2.0 @@ -137,6 +149,7 @@ Expected: Run (requires `shellcheck` installed — install via `brew install shellcheck` on macOS or `apt install shellcheck` on Debian/Ubuntu if not present): + ```bash shellcheck --severity=warning src/claude-code/install.sh ``` @@ -149,6 +162,7 @@ This step prevents Task 3's `pre-commit run --all-files` from reformatting this contaminating the Task 3 commit with changes from Task 1. Run: + ```bash pre-commit run shfmt --files src/claude-code/install.sh pre-commit run shellcheck --files src/claude-code/install.sh @@ -170,6 +184,7 @@ git commit --author="PKramek " \ ## Task 2: Update License to Apache 2.0 **Files:** + - Modify: `LICENSE` - Create: `NOTICE` - Modify: `README.md` @@ -192,18 +207,23 @@ curl -o LICENSE https://www.apache.org/licenses/LICENSE-2.0.txt ``` Verify the APPENDIX section is present (it follows "END OF TERMS AND CONDITIONS"): + ```bash grep -c "APPENDIX" LICENSE ``` + Expected: `1` Verify no copyright line was added inside: + ```bash head -5 LICENSE ``` + Expected: the file starts with whitespace + "Apache License" header, no copyright line. The full canonical text starts as follows and includes the APPENDIX section at the end: + ``` Apache License Version 2.0, January 2004 @@ -377,12 +397,13 @@ The full canonical text starts as follows and includes the APPENDIX section at t APPENDIX: How to apply the Apache License to your work. [...] ``` -*(The code block above is a truncated reference only. `curl` in Step 1 fetches the complete -text including the full APPENDIX section.)* +_(The code block above is a truncated reference only. `curl` in Step 1 fetches the complete +text including the full APPENDIX section.)_ - [ ] **Step 2: Verify LICENSE contains no MIT text** Run: + ```bash grep -i "mit\|permission is hereby granted\|software and associated" LICENSE ``` @@ -392,6 +413,7 @@ Expected: no output. - [ ] **Step 3: Create NOTICE file** Create `NOTICE` with this exact content: + ``` claude-devcontainer Copyright (c) 2026 PKramek @@ -400,6 +422,7 @@ Copyright (c) 2026 PKramek - [ ] **Step 4: Update README.md — license section and contributing section** In `README.md`, the `## License` section currently reads: + ```markdown ## License @@ -407,6 +430,7 @@ MIT ``` Change to: + ```markdown ## License @@ -414,6 +438,7 @@ Apache 2.0 ``` In `README.md`, the `## Contributing` section currently reads: + ```markdown ## Contributing @@ -422,12 +447,14 @@ In `README.md`, the `## Contributing` section currently reads: ``` The full Contributing section currently ends with: + ```markdown 4. Run `pre-commit run --all-files` before committing 5. Open a pull request ``` Change step 2 and add a new step 3 (renumbering the rest): + ```markdown ## Contributing @@ -442,12 +469,14 @@ Change step 2 and add a new step 3 (renumbering the rest): - [ ] **Step 5: Add `license` field to devcontainer-feature.json** In `src/claude-code/devcontainer-feature.json`, the current lines 14-15 are: + ```json "documentationURL": "https://github.com/pkramek/claude-devcontainer#readme", "licenseURL": "https://github.com/pkramek/claude-devcontainer/blob/main/LICENSE", ``` Change to: + ```json "documentationURL": "https://github.com/pkramek/claude-devcontainer#readme", "license": "Apache-2.0", @@ -457,6 +486,7 @@ Change to: - [ ] **Step 6: Validate JSON is still valid** Run: + ```bash python3 -m json.tool src/claude-code/devcontainer-feature.json > /dev/null && echo "OK" ``` @@ -466,6 +496,7 @@ Expected: `OK` - [ ] **Step 7: Verify no MIT references remain in tracked source files** Run: + ```bash grep -ri '\bMIT\b' --include='*.md' --include='*.json' --include='*.yml' --include='*.yaml' --include='*.sh' . ``` @@ -480,6 +511,7 @@ This prevents Task 3's `pre-commit run --all-files` from reformatting these file contaminating the Task 3 commit. Run: + ```bash pre-commit run prettier --files README.md pre-commit run markdownlint --files README.md @@ -501,6 +533,7 @@ git commit --author="PKramek " \ ## Task 3: Pin Pre-commit Hooks to Commit SHAs **Files:** + - Modify: `.pre-commit-config.yaml` **Context:** All five hook repos use mutable version tags. A tag can be force-pushed to @@ -556,6 +589,7 @@ Without `-w`, the hook only reports errors but does not fix them. The CI lint jo to run `shfmt -d` in check-only mode as the enforcement layer. First, save a backup of the current config to verify no hooks are accidentally dropped: + ```bash cp .pre-commit-config.yaml .pre-commit-config.yaml.bak ``` @@ -607,11 +641,13 @@ repos: - [ ] **Step 3: Verify all rev values are 40-character SHAs** Run: + ```bash grep '^\s*rev:' .pre-commit-config.yaml ``` Expected: every line shows a 40-character hex string, not a version tag. Example: + ``` rev: cef0300de252776ee95f6c2c833b3c4dc39974e3 # v6.0.0 ``` @@ -628,12 +664,14 @@ diff .pre-commit-config.yaml.bak .pre-commit-config.yaml ``` Expected diff should show: + - 5 `rev:` changes (tags → SHAs) - 1 `args` change in the `shfmt` hook (adding `-w`) - 1 `args` change in `no-commit-to-branch` (adding `"--branch", "develop"`) Any other changes (removed hooks, changed hook IDs, altered args) are unintended. Fix them before continuing. Clean up the backup after verification: + ```bash rm .pre-commit-config.yaml.bak ``` @@ -641,6 +679,7 @@ rm .pre-commit-config.yaml.bak - [ ] **Step 4: Run pre-commit on all files to verify no formatting regressions** Run: + ```bash pre-commit run --all-files ``` @@ -652,14 +691,16 @@ stop — this indicates a version change was introduced. Re-check Step 1 SHA res - [ ] **Step 5: Verify no-commit-to-branch now protects develop** Run: + ```bash grep -A2 'no-commit-to-branch' .pre-commit-config.yaml ``` Expected: + ```yaml - - id: no-commit-to-branch - args: ["--branch", "main", "--branch", "develop"] +- id: no-commit-to-branch + args: ["--branch", "main", "--branch", "develop"] ``` - [ ] **Step 6: Commit** @@ -675,6 +716,7 @@ git commit --author="PKramek " \ ## Task 4: Fix CI False Positives in test.yml **Files:** + - Modify: `.github/workflows/test.yml` — three `run:` blocks **Context:** `devcontainer features test` (CLI v0.85.0) exits 0 even when the feature @@ -684,6 +726,7 @@ exits 1 if any are found. This is a heuristic workaround — comment it clearly maintainers know to revisit it when upgrading the CLI. Note: Each of the three jobs uses a different log file path: + - `test-scenarios` → `/tmp/scenario-test-output.log` - `test-image-matrix` → `/tmp/test-output.log` - `test-arm64` → `/tmp/test-output.log` @@ -691,87 +734,94 @@ Note: Each of the three jobs uses a different log file path: - [ ] **Step 1: Fix the test-scenarios job run block** In `.github/workflows/test.yml`, find the `test-scenarios` job run step (currently line 88): + ```yaml - - name: Run all scenarios - run: devcontainer features test --project-folder . 2>&1 | tee /tmp/scenario-test-output.log +- name: Run all scenarios + run: devcontainer features test --project-folder . 2>&1 | tee /tmp/scenario-test-output.log ``` Change to: + ```yaml - - name: Run all scenarios - run: | - devcontainer features test --project-folder . 2>&1 | tee /tmp/scenario-test-output.log - # Workaround: devcontainers/cli@0.85.0 exits 0 even when feature install fails. - # Grep for known failure strings and fail explicitly. Revisit on CLI upgrade. - if grep -qE "Exit code [1-9][0-9]*|failed to install|Failed to launch" /tmp/scenario-test-output.log; then - echo "ERROR: Test output contains failures." - exit 1 - fi +- name: Run all scenarios + run: | + devcontainer features test --project-folder . 2>&1 | tee /tmp/scenario-test-output.log + # Workaround: devcontainers/cli@0.85.0 exits 0 even when feature install fails. + # Grep for known failure strings and fail explicitly. Revisit on CLI upgrade. + if grep -qE "Exit code [1-9][0-9]*|failed to install|Failed to launch" /tmp/scenario-test-output.log; then + echo "ERROR: Test output contains failures." + exit 1 + fi ``` - [ ] **Step 2: Fix the test-image-matrix job run block** Find the `test-image-matrix` job run step (currently lines 150-155): + ```yaml - - name: Test on ${{ matrix.image }} - run: | - devcontainer features test \ - --features claude-code \ - --skip-scenarios \ - --base-image "${{ matrix.image }}" \ - --project-folder . 2>&1 | tee /tmp/test-output.log +- name: Test on ${{ matrix.image }} + run: | + devcontainer features test \ + --features claude-code \ + --skip-scenarios \ + --base-image "${{ matrix.image }}" \ + --project-folder . 2>&1 | tee /tmp/test-output.log ``` Change to: + ```yaml - - name: Test on ${{ matrix.image }} - run: | - devcontainer features test \ - --features claude-code \ - --skip-scenarios \ - --base-image "${{ matrix.image }}" \ - --project-folder . 2>&1 | tee /tmp/test-output.log - # Workaround: devcontainers/cli@0.85.0 exits 0 even when feature install fails. - # Grep for known failure strings and fail explicitly. Revisit on CLI upgrade. - if grep -qE "Exit code [1-9][0-9]*|failed to install|Failed to launch" /tmp/test-output.log; then - echo "ERROR: Test output contains failures." - exit 1 - fi +- name: Test on ${{ matrix.image }} + run: | + devcontainer features test \ + --features claude-code \ + --skip-scenarios \ + --base-image "${{ matrix.image }}" \ + --project-folder . 2>&1 | tee /tmp/test-output.log + # Workaround: devcontainers/cli@0.85.0 exits 0 even when feature install fails. + # Grep for known failure strings and fail explicitly. Revisit on CLI upgrade. + if grep -qE "Exit code [1-9][0-9]*|failed to install|Failed to launch" /tmp/test-output.log; then + echo "ERROR: Test output contains failures." + exit 1 + fi ``` - [ ] **Step 3: Fix the test-arm64 job run block** Find the `test-arm64` job run step (currently lines 191-196): + ```yaml - - name: Test on ${{ matrix.image }} (arm64) - run: | - devcontainer features test \ - --features claude-code \ - --skip-scenarios \ - --base-image "${{ matrix.image }}" \ - --project-folder . 2>&1 | tee /tmp/test-output.log +- name: Test on ${{ matrix.image }} (arm64) + run: | + devcontainer features test \ + --features claude-code \ + --skip-scenarios \ + --base-image "${{ matrix.image }}" \ + --project-folder . 2>&1 | tee /tmp/test-output.log ``` Change to: + ```yaml - - name: Test on ${{ matrix.image }} (arm64) - run: | - devcontainer features test \ - --features claude-code \ - --skip-scenarios \ - --base-image "${{ matrix.image }}" \ - --project-folder . 2>&1 | tee /tmp/test-output.log - # Workaround: devcontainers/cli@0.85.0 exits 0 even when feature install fails. - # Grep for known failure strings and fail explicitly. Revisit on CLI upgrade. - if grep -qE "Exit code [1-9][0-9]*|failed to install|Failed to launch" /tmp/test-output.log; then - echo "ERROR: Test output contains failures." - exit 1 - fi +- name: Test on ${{ matrix.image }} (arm64) + run: | + devcontainer features test \ + --features claude-code \ + --skip-scenarios \ + --base-image "${{ matrix.image }}" \ + --project-folder . 2>&1 | tee /tmp/test-output.log + # Workaround: devcontainers/cli@0.85.0 exits 0 even when feature install fails. + # Grep for known failure strings and fail explicitly. Revisit on CLI upgrade. + if grep -qE "Exit code [1-9][0-9]*|failed to install|Failed to launch" /tmp/test-output.log; then + echo "ERROR: Test output contains failures." + exit 1 + fi ``` - [ ] **Step 4: Validate YAML is still valid** Run: + ```bash python3 -c "import yaml; yaml.safe_load(open('.github/workflows/test.yml'))" && echo "OK" ``` @@ -781,11 +831,13 @@ Expected: `OK` - [ ] **Step 5: Verify all three jobs have the grep check and correct log paths** Run: + ```bash grep -n "grep -qE\|scenario-test-output\|test-output" .github/workflows/test.yml ``` Expected output (line numbers will vary): + ``` 88: if grep -qE "Exit code [1-9][0-9]*|failed to install|Failed to launch" /tmp/scenario-test-output.log; then 151: if grep -qE "Exit code [1-9][0-9]*|failed to install|Failed to launch" /tmp/test-output.log; then diff --git a/docs/superpowers/plans/2026-04-01-repo-rename-branch-setup.md b/docs/superpowers/plans/2026-04-01-repo-rename-branch-setup.md index f84bc1f..a5841ba 100644 --- a/docs/superpowers/plans/2026-04-01-repo-rename-branch-setup.md +++ b/docs/superpowers/plans/2026-04-01-repo-rename-branch-setup.md @@ -16,18 +16,19 @@ ## File Map -| File | Change | -|---|---| -| `README.md` | 10 occurrences: badge image URL + badge link URL + 3× GHCR feature ref (5 total) | -| `src/claude-code/README.md` | 3× GHCR feature ref | -| `src/claude-code/devcontainer-feature.json` | `documentationURL` + `licenseURL` (2 total) | -| `.github/workflows/test.yml` | Add `develop` to `push.branches` trigger | +| File | Change | +| ------------------------------------------- | -------------------------------------------------------------------------------- | +| `README.md` | 10 occurrences: badge image URL + badge link URL + 3× GHCR feature ref (5 total) | +| `src/claude-code/README.md` | 3× GHCR feature ref | +| `src/claude-code/devcontainer-feature.json` | `documentationURL` + `licenseURL` (2 total) | +| `.github/workflows/test.yml` | Add `develop` to `push.branches` trigger | --- ## Task 1: Update Workflow Push Trigger **Files:** + - Modify: `.github/workflows/test.yml` The workflow currently only triggers on push to `main`. `develop` is the integration branch — CI must run on it too. @@ -57,6 +58,7 @@ on: ```bash python3 -c "import yaml; yaml.safe_load(open('.github/workflows/test.yml'))" && echo "OK" ``` + Expected: `OK` - [ ] **Step 3: Commit** @@ -71,6 +73,7 @@ git commit --author="PKramek " -m "ci: trigger CI on deve ## Task 2: Apply Rename Commit **Files:** + - Modify: `README.md` - Modify: `src/claude-code/README.md` - Modify: `src/claude-code/devcontainer-feature.json` @@ -82,9 +85,11 @@ sed -i '' 's|claude-code-devcontainer|claude-devcontainer|g' README.md ``` Verify: + ```bash grep -c "claude-code-devcontainer" README.md ``` + Expected: `0` - [ ] **Step 2: Replace all occurrences in src/claude-code/README.md** @@ -94,9 +99,11 @@ sed -i '' 's|claude-code-devcontainer|claude-devcontainer|g' src/claude-code/REA ``` Verify: + ```bash grep -c "claude-code-devcontainer" src/claude-code/README.md ``` + Expected: `0` - [ ] **Step 3: Replace all occurrences in devcontainer-feature.json** @@ -106,9 +113,11 @@ sed -i '' 's|claude-code-devcontainer|claude-devcontainer|g' src/claude-code/dev ``` Verify: + ```bash grep -c "claude-code-devcontainer" src/claude-code/devcontainer-feature.json ``` + Expected: `0` - [ ] **Step 4: Confirm zero remaining occurrences across the whole repo** @@ -116,6 +125,7 @@ Expected: `0` ```bash grep -r "claude-code-devcontainer" src/ README.md .github/ --include="*.json" --include="*.md" --include="*.yml" ``` + Expected: no output - [ ] **Step 5: Validate JSON is still valid** @@ -123,6 +133,7 @@ Expected: no output ```bash python3 -m json.tool src/claude-code/devcontainer-feature.json > /dev/null && echo "OK" ``` + Expected: `OK` - [ ] **Step 6: Commit** @@ -145,9 +156,11 @@ git branch -m main feat/initial-implementation ``` Verify: + ```bash git branch ``` + Expected: `* feat/initial-implementation` - [ ] **Step 2: Create orphan `main` with a single empty init commit** @@ -161,9 +174,11 @@ git commit --allow-empty --author="PKramek " -m "chore: i ``` Verify: + ```bash git log --oneline ``` + Expected: exactly 1 commit — `chore: initialize repository` - [ ] **Step 3: Create `develop` branched from `main`** @@ -173,9 +188,11 @@ git checkout -b develop ``` Verify: + ```bash git log --oneline ``` + Expected: same single `chore: initialize repository` commit - [ ] **Step 4: Return to feature branch** @@ -185,9 +202,11 @@ git checkout feat/initial-implementation ``` Verify: + ```bash git log --oneline | wc -l ``` + Expected: `22` (20 original + 1 workflow commit + 1 rename commit) --- @@ -201,10 +220,13 @@ git remote add origin git@github.com:PKramek/claude-devcontainer.git ``` Verify: + ```bash git remote -v ``` + Expected: + ``` origin git@github.com:PKramek/claude-devcontainer.git (fetch) origin git@github.com:PKramek/claude-devcontainer.git (push) @@ -215,6 +237,7 @@ origin git@github.com:PKramek/claude-devcontainer.git (push) ```bash git push -u origin main ``` + Expected: `Branch 'main' set up to track remote branch 'main' from 'origin'.` - [ ] **Step 3: Push `develop` with tracking** @@ -222,6 +245,7 @@ Expected: `Branch 'main' set up to track remote branch 'main' from 'origin'.` ```bash git push -u origin develop ``` + Expected: `Branch 'develop' set up to track remote branch 'develop' from 'origin'.` - [ ] **Step 4: Push `feat/initial-implementation` with tracking** @@ -229,6 +253,7 @@ Expected: `Branch 'develop' set up to track remote branch 'develop' from 'origin ```bash git push -u origin feat/initial-implementation ``` + Expected: `Branch 'feat/initial-implementation' set up to track remote branch 'feat/initial-implementation' from 'origin'.` - [ ] **Step 5: Set `develop` as the default branch** @@ -239,6 +264,7 @@ gh api repos/PKramek/claude-devcontainer \ --field default_branch=develop \ --jq '.default_branch' ``` + Expected: `develop` --- @@ -282,7 +308,9 @@ Expected: PR URL printed, e.g. `https://github.com/PKramek/claude-devcontainer/p gh pr view 1 --repo PKramek/claude-devcontainer --json baseRefName,headRefName,title \ --jq '{base: .baseRefName, head: .headRefName, title: .title}' ``` + Expected: + ```json { "base": "develop", @@ -411,7 +439,9 @@ Verify the response contains `"name": "protect-main"` and `"enforcement": "activ ```bash gh api repos/PKramek/claude-devcontainer/rulesets --jq '.[].name' ``` + Expected: + ``` protect-develop protect-main @@ -430,7 +460,9 @@ rm "protect-develop (2).json" "protect-master (2).json" ``` Verify: + ```bash ls protect-*.json 2>/dev/null || echo "clean" ``` + Expected: `clean` From 6702d8e08f1801d141c85b48cf56f05f0f84b412 Mon Sep 17 00:00:00 2001 From: PKramek Date: Wed, 1 Apr 2026 21:31:14 +0200 Subject: [PATCH 38/55] style: fix MD024 duplicate headings in install-fix spec --- ...-04-01-install-fix-formatting-license-design.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/superpowers/specs/2026-04-01-install-fix-formatting-license-design.md b/docs/superpowers/specs/2026-04-01-install-fix-formatting-license-design.md index 4c4ec59..cf3f0e7 100644 --- a/docs/superpowers/specs/2026-04-01-install-fix-formatting-license-design.md +++ b/docs/superpowers/specs/2026-04-01-install-fix-formatting-license-design.md @@ -17,7 +17,7 @@ Four changes grouped into one implementation cycle: ## 1. install.sh Bug: log_info Stdout Contamination -### Root Cause +### Root Cause (log_info Stdout) `log_info()` writes to **stdout**: @@ -49,7 +49,7 @@ https://nodejs.org/dist/latest-v[claude-code feature] Resolved LTS to Node.js 24 curl rejects this with `bad range in URL position 34` (the `[` bracket). Every image fails. -### Fix +### Fix (log_info Stdout) Change both `log_info` and `log_debug` to write to stderr, consistent with `log_warn` and `log_error`: @@ -69,7 +69,7 @@ same class of issue applies and fixing it is required for defensive correctness. `log_warn` and `log_error` already write to stderr — no change needed. -### Affected File +### Affected File (log_info Stdout) - `src/claude-code/install.sh` lines 51 and 54-57 @@ -77,7 +77,7 @@ same class of issue applies and fixing it is required for defensive correctness. ## 2. CI False Positives: devcontainer features test Exit Code -### Root Cause +### Root Cause (CI False Positives) `devcontainer features test` (CLI v0.85.0) exits **0** even when the feature install fails inside Docker. The container build failure is printed to output but does not set a non-zero @@ -93,7 +93,7 @@ Exit code 1 [-] Failed to launch container ``` -### Fix +### Fix (CI False Positives) This is a **heuristic workaround** for a devcontainer CLI bug (exits 0 on container build failure). The real fix would be a CLI patch. The workaround is tied to the CLI's current @@ -133,7 +133,7 @@ if grep -qE "Exit code [1-9]|failed to install|Failed to launch" \ fi ``` -### Affected File +### Affected File (CI False Positives) - `.github/workflows/test.yml` — all three test job `run:` blocks (note: different log paths per job) @@ -214,7 +214,7 @@ No changes to check mode. The CI lint job already runs: These remain unchanged. They are the enforcement layer. -### Affected File +### Affected File (Formatting Enforcement) - `.pre-commit-config.yaml` From 9ba32a02de7c60d856bb11d2e7898f99ab673477 Mon Sep 17 00:00:00 2001 From: PKramek Date: Wed, 1 Apr 2026 21:59:54 +0200 Subject: [PATCH 39/55] fix: use /bin/sh shebang for Alpine bootstrap, sync pacman db, add tar dep, fix completions hang - Change shebang to #!/bin/sh so Alpine's ash can execute the bootstrap preamble; the existing re-exec-with-bash preamble was already correct but unreachable with #!/usr/bin/env bash (Alpine has no bash pre-installed) - Add -ln bash flag to shfmt in CI and pre-commit so bash constructs (local, arrays) are not flagged as POSIX violations despite the /bin/sh shebang - pacman -Sy: sync package database before install (archlinux:latest has no DB) - Add tar to rhel dependency check (amazonlinux:2023 ships without tar) - Add timeout 30 and /dev/null 2>&1; then @@ -262,6 +262,7 @@ ensure_base_dependencies() { ;; rhel) command -v xz >/dev/null 2>&1 || missing+=("xz") + command -v tar >/dev/null 2>&1 || missing+=("tar") ;; esac @@ -527,14 +528,14 @@ setup_completions() { bash_comp_dir="/etc/bash_completion.d" fi if [[ -n "${bash_comp_dir}" ]]; then - claude completions bash >"${bash_comp_dir}/claude" 2>/dev/null || { + timeout 30 claude completions bash "${bash_comp_dir}/claude" 2>/dev/null || { log_warn "Failed to install bash completions." } fi # Zsh completions if [[ -d /usr/share/zsh/site-functions ]] || mkdir -p /usr/share/zsh/site-functions 2>/dev/null; then - claude completions zsh >/usr/share/zsh/site-functions/_claude 2>/dev/null || { + timeout 30 claude completions zsh /usr/share/zsh/site-functions/_claude 2>/dev/null || { log_warn "Failed to install zsh completions." } fi @@ -548,7 +549,7 @@ setup_completions() { fi done if [[ -n "${fish_comp_dir}" ]]; then - claude completions fish >"${fish_comp_dir}/claude.fish" 2>/dev/null || { + timeout 30 claude completions fish "${fish_comp_dir}/claude.fish" 2>/dev/null || { log_warn "Failed to install fish completions." } fi From 7c96b68561d226e41995943b37d667a7d3f1bdf7 Mon Sep 17 00:00:00 2001 From: PKramek Date: Wed, 1 Apr 2026 22:32:42 +0200 Subject: [PATCH 40/55] fix: correct set -e arithmetic bug and catch CLI test failure marker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ((var++)) returns exit code 1 when var is 0, killing test.sh under set -Eeuo pipefail after the first assertion's echo. Replace with $((var + 1)) assignment which is always safe under strict mode. Also extend CI grep pattern to catch "Failed:" so the devcontainer CLI's own test report line (❌ Failed: 'claude-code') is detected and causes the job to exit 1 rather than silently passing. --- .github/workflows/test.yml | 6 +++--- test/claude-code/test.sh | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5a26649..4ffaa1e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -89,7 +89,7 @@ jobs: devcontainer features test --project-folder . 2>&1 | tee /tmp/scenario-test-output.log # Workaround: devcontainers/cli@0.85.0 exits 0 even when feature install fails. # Grep for known failure strings and fail explicitly. Revisit on CLI upgrade. - if grep -qE "Exit code [1-9][0-9]*|failed to install|Failed to launch" /tmp/scenario-test-output.log; then + if grep -qE "Exit code [1-9][0-9]*|failed to install|Failed to launch|Failed:" /tmp/scenario-test-output.log; then echo "ERROR: Test output contains failures." exit 1 fi @@ -162,7 +162,7 @@ jobs: --project-folder . 2>&1 | tee /tmp/test-output.log # Workaround: devcontainers/cli@0.85.0 exits 0 even when feature install fails. # Grep for known failure strings and fail explicitly. Revisit on CLI upgrade. - if grep -qE "Exit code [1-9][0-9]*|failed to install|Failed to launch" /tmp/test-output.log; then + if grep -qE "Exit code [1-9][0-9]*|failed to install|Failed to launch|Failed:" /tmp/test-output.log; then echo "ERROR: Test output contains failures." exit 1 fi @@ -209,7 +209,7 @@ jobs: --project-folder . 2>&1 | tee /tmp/test-output.log # Workaround: devcontainers/cli@0.85.0 exits 0 even when feature install fails. # Grep for known failure strings and fail explicitly. Revisit on CLI upgrade. - if grep -qE "Exit code [1-9][0-9]*|failed to install|Failed to launch" /tmp/test-output.log; then + if grep -qE "Exit code [1-9][0-9]*|failed to install|Failed to launch|Failed:" /tmp/test-output.log; then echo "ERROR: Test output contains failures." exit 1 fi diff --git a/test/claude-code/test.sh b/test/claude-code/test.sh index 7d3a614..e5668d8 100755 --- a/test/claude-code/test.sh +++ b/test/claude-code/test.sh @@ -11,12 +11,12 @@ TESTS_FAILED=0 pass() { echo " PASS: $*" - ((TESTS_PASSED++)) + TESTS_PASSED=$((TESTS_PASSED + 1)) } fail() { echo " FAIL: $*" >&2 - ((TESTS_FAILED++)) + TESTS_FAILED=$((TESTS_FAILED + 1)) } check_command_exists() { From f8caefac3adc18292c96e0f458637241b34dd404 Mon Sep 17 00:00:00 2001 From: PKramek Date: Thu, 2 Apr 2026 09:13:43 +0200 Subject: [PATCH 41/55] fix: enforce 755 on claude binary after npm install npm global install creates the bin wrapper with 777 permissions. Explicitly chmod 755 after install so the binary is not world-writable. --- src/claude-code/install.sh | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/claude-code/install.sh b/src/claude-code/install.sh index 81be1f8..b2926b5 100755 --- a/src/claude-code/install.sh +++ b/src/claude-code/install.sh @@ -506,6 +506,11 @@ install_claude_code() { } log_info "Claude Code ${installed_version} installed successfully." + + # npm may create the binary with 777; enforce 755 for security. + local claude_bin + claude_bin=$(command -v claude) + chmod 755 "${claude_bin}" } configure_custom_path From 0526dd14083bde89f847eec7604a7279e9cb9daa Mon Sep 17 00:00:00 2001 From: PKramek Date: Thu, 2 Apr 2026 09:22:07 +0200 Subject: [PATCH 42/55] fix: dereference symlinks in stat calls and catch FAIL assertions in CI - check_permissions: stat -Lc '%a' follows symlinks to the real file (Linux symlinks always show 0777 via lstat; chmod modifies the target) - check_file_owner: stat -Lc '%U' for the same reason - CI grep: add ' FAIL:' pattern so test assertion failures are caught even if the devcontainer CLI swallows the test script exit code --- .github/workflows/test.yml | 6 +++--- test/claude-code/test.sh | 6 ++++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4ffaa1e..335665b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -89,7 +89,7 @@ jobs: devcontainer features test --project-folder . 2>&1 | tee /tmp/scenario-test-output.log # Workaround: devcontainers/cli@0.85.0 exits 0 even when feature install fails. # Grep for known failure strings and fail explicitly. Revisit on CLI upgrade. - if grep -qE "Exit code [1-9][0-9]*|failed to install|Failed to launch|Failed:" /tmp/scenario-test-output.log; then + if grep -qE "Exit code [1-9][0-9]*|failed to install|Failed to launch|Failed:| FAIL:" /tmp/scenario-test-output.log; then echo "ERROR: Test output contains failures." exit 1 fi @@ -162,7 +162,7 @@ jobs: --project-folder . 2>&1 | tee /tmp/test-output.log # Workaround: devcontainers/cli@0.85.0 exits 0 even when feature install fails. # Grep for known failure strings and fail explicitly. Revisit on CLI upgrade. - if grep -qE "Exit code [1-9][0-9]*|failed to install|Failed to launch|Failed:" /tmp/test-output.log; then + if grep -qE "Exit code [1-9][0-9]*|failed to install|Failed to launch|Failed:| FAIL:" /tmp/test-output.log; then echo "ERROR: Test output contains failures." exit 1 fi @@ -209,7 +209,7 @@ jobs: --project-folder . 2>&1 | tee /tmp/test-output.log # Workaround: devcontainers/cli@0.85.0 exits 0 even when feature install fails. # Grep for known failure strings and fail explicitly. Revisit on CLI upgrade. - if grep -qE "Exit code [1-9][0-9]*|failed to install|Failed to launch|Failed:" /tmp/test-output.log; then + if grep -qE "Exit code [1-9][0-9]*|failed to install|Failed to launch|Failed:| FAIL:" /tmp/test-output.log; then echo "ERROR: Test output contains failures." exit 1 fi diff --git a/test/claude-code/test.sh b/test/claude-code/test.sh index e5668d8..61eb4d5 100755 --- a/test/claude-code/test.sh +++ b/test/claude-code/test.sh @@ -106,7 +106,9 @@ check_permissions() { return fi local actual - actual=$(stat -c '%a' "${path}" 2>/dev/null || stat -f '%Lp' "${path}" 2>/dev/null) + # -L/-L: dereference symlinks so we check the target file, not the symlink itself + # (Linux symlinks always report 0777 via lstat; stat -L follows to the real file) + actual=$(stat -Lc '%a' "${path}" 2>/dev/null || stat -f '%Lp' "${path}" 2>/dev/null) if [[ "${actual}" == "${expected}" ]]; then pass "Permissions on ${path}: ${expected}" else @@ -122,7 +124,7 @@ check_file_owner() { return fi local actual - actual=$(stat -c '%U' "${path}" 2>/dev/null || stat -f '%Su' "${path}" 2>/dev/null) + actual=$(stat -Lc '%U' "${path}" 2>/dev/null || stat -f '%Su' "${path}" 2>/dev/null) if [[ "${actual}" == "${expected_user}" ]]; then pass "Owner of ${path}: ${expected_user}" else From e9acaeda8518c55ec14f4d3ba3d222c48c3584a7 Mon Sep 17 00:00:00 2001 From: PKramek Date: Thu, 2 Apr 2026 09:33:35 +0200 Subject: [PATCH 43/55] fix: add duplicate.sh for idempotency and switch arm64 to QEMU - duplicate.sh: satisfies devcontainer framework's idempotency test (sources test.sh and runs core_assertions after a second feature install) - test-arm64: switch from ubuntu-24.04-arm64 (native, never picked up) to ubuntu-latest + docker/setup-qemu-action v3.7.0 with DOCKER_DEFAULT_PLATFORM=linux/arm64; increase timeout to 60m for QEMU --- .github/workflows/test.yml | 11 ++++++++--- test/claude-code/duplicate.sh | 11 +++++++++++ 2 files changed, 19 insertions(+), 3 deletions(-) create mode 100755 test/claude-code/duplicate.sh diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 335665b..12b0586 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -175,11 +175,11 @@ jobs: path: /tmp/test-output.log retention-days: 7 - # arm64 tests on native runners (reduced matrix) + # arm64 tests via QEMU emulation on standard ubuntu-latest runners test-arm64: needs: lint - runs-on: ubuntu-24.04-arm64 - timeout-minutes: 30 + runs-on: ubuntu-latest + timeout-minutes: 60 # QEMU emulation is significantly slower than native permissions: contents: read strategy: @@ -194,6 +194,9 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Set up QEMU for arm64 emulation + uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 + - name: Set up Docker Buildx uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 @@ -201,6 +204,8 @@ jobs: run: npm install -g @devcontainers/cli@0.85.0 - name: Test on ${{ matrix.image }} (arm64) + env: + DOCKER_DEFAULT_PLATFORM: linux/arm64 run: | devcontainer features test \ --features claude-code \ diff --git a/test/claude-code/duplicate.sh b/test/claude-code/duplicate.sh new file mode 100755 index 0000000..f0610aa --- /dev/null +++ b/test/claude-code/duplicate.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +# Idempotency test: run by the devcontainer framework after installing the feature +# a second time. Verifies the feature is still fully functional after re-install. +set -Eeuo pipefail + +# shellcheck source=test.sh +source "$(dirname "$0")/test.sh" + +echo "=== Duplicate Install Test (idempotency) ===" +core_assertions +test_summary From f9af0270e77c55265a2598be34a79c3d895a5b02 Mon Sep 17 00:00:00 2001 From: PKramek Date: Thu, 2 Apr 2026 09:49:28 +0200 Subject: [PATCH 44/55] fix: guard self-copy in install.sh to prevent idempotency failure When install.sh is re-run from its persisted path (/usr/local/share/devcontainer-features/claude-code/install.sh), the `cp "$0" "${PERSIST_DIR}/install.sh"` was a same-file copy that exits non-zero under `set -e`, failing the second run ~5s in. Resolve canonical paths via readlink -f and skip the copy when source and destination are the same file. --- src/claude-code/install.sh | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/claude-code/install.sh b/src/claude-code/install.sh index b2926b5..0ee406c 100755 --- a/src/claude-code/install.sh +++ b/src/claude-code/install.sh @@ -656,11 +656,18 @@ cleanup_caches # Persist this script so tests and postCreateCommand hooks can re-invoke it. # The devcontainer CLI removes /tmp/dev-container-features/ after installation, # so we copy to a stable path before that cleanup occurs. +# Guard: skip copy when already running from the persisted path (idempotent re-run). PERSIST_DIR="/usr/local/share/devcontainer-features/claude-code" mkdir -p "${PERSIST_DIR}" -cp "$0" "${PERSIST_DIR}/install.sh" -chmod +x "${PERSIST_DIR}/install.sh" -log_debug "Install script persisted to ${PERSIST_DIR}/install.sh" +SCRIPT_REAL=$(readlink -f "$0" 2>/dev/null || echo "$0") +PERSIST_REAL=$(readlink -f "${PERSIST_DIR}/install.sh" 2>/dev/null || echo "${PERSIST_DIR}/install.sh") +if [[ "${SCRIPT_REAL}" != "${PERSIST_REAL}" ]]; then + cp "$0" "${PERSIST_DIR}/install.sh" + chmod +x "${PERSIST_DIR}/install.sh" + log_debug "Install script persisted to ${PERSIST_DIR}/install.sh" +else + log_debug "Already running from ${PERSIST_DIR}/install.sh — skipping self-copy." +fi log_info "Claude Code DevContainer Feature installation complete." log_info " Claude Code: $(claude --version 2>/dev/null || echo 'unknown')" From d81e92444a73b181d575e7f97763e7c41606b44f Mon Sep 17 00:00:00 2001 From: PKramek Date: Thu, 2 Apr 2026 09:49:58 +0200 Subject: [PATCH 45/55] fix: remove devcontainer base images from arm64 test matrix devcontainer CLI v0.85.0 hardcodes --platform linux/amd64 in its internal updateUID.Dockerfile step, which remaps the non-root vscode user UID. On arm64 this step fails with "Failed to launch container". Raw OS images (ubuntu, alpine) have no pre-existing non-root user so the UID fixup is skipped and arm64 works. Remove the two devcontainer base images that trigger the remapping until the CLI bug is fixed. --- .github/workflows/test.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 12b0586..14ce3b5 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -189,8 +189,6 @@ jobs: image: - "ubuntu:24.04" - "alpine:3.21" - - "mcr.microsoft.com/devcontainers/base:debian" - - "mcr.microsoft.com/devcontainers/universal:2" steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 From 2911d2d3365f0041678c26e776f85a470bed93cf Mon Sep 17 00:00:00 2001 From: PKramek Date: Thu, 2 Apr 2026 18:44:45 +0200 Subject: [PATCH 46/55] ci: annotate install warnings as non-blocking yellow markers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add 'Annotate install warnings' step (if: always()) to all three test jobs. Greps the log for '[claude-code feature] WARNING:' lines and emits ::warning:: annotations — visible as yellow in the PR checks UI without blocking the build. --- .github/workflows/test.yml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 14ce3b5..6d9f8d8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -94,6 +94,12 @@ jobs: exit 1 fi + - name: Annotate install warnings + if: always() + run: | + grep -oP '(?<=claude-code feature\] WARNING: ).*' /tmp/scenario-test-output.log 2>/dev/null \ + | while IFS= read -r msg; do echo "::warning title=Install Warning::${msg}"; done || true + - name: Upload logs on failure if: failure() uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 @@ -167,6 +173,12 @@ jobs: exit 1 fi + - name: Annotate install warnings + if: always() + run: | + grep -oP '(?<=claude-code feature\] WARNING: ).*' /tmp/test-output.log 2>/dev/null \ + | while IFS= read -r msg; do echo "::warning title=Install Warning::${msg}"; done || true + - name: Upload logs on failure if: failure() uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 @@ -217,6 +229,12 @@ jobs: exit 1 fi + - name: Annotate install warnings + if: always() + run: | + grep -oP '(?<=claude-code feature\] WARNING: ).*' /tmp/test-output.log 2>/dev/null \ + | while IFS= read -r msg; do echo "::warning title=Install Warning::${msg}"; done || true + - name: Upload logs on failure if: failure() uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 From 6282916d46eacc942d0c34ca80157445469f6ad8 Mon Sep 17 00:00:00 2001 From: PKramek Date: Thu, 2 Apr 2026 19:01:53 +0200 Subject: [PATCH 47/55] fix: skip zsh completions if zsh absent; make warning step exit yellow - install.sh: gate zsh completions behind 'command -v zsh' so the attempt is skipped entirely on images without zsh (mkdir -p was silently creating the dir and causing a spurious WARNING on every raw OS image) - test.yml: add continue-on-error: true + exit 1 when warnings found so the Annotate step shows orange/yellow while the overall job stays green --- .github/workflows/test.yml | 18 ++++++++++++------ src/claude-code/install.sh | 5 +++-- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6d9f8d8..d371211 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -96,9 +96,11 @@ jobs: - name: Annotate install warnings if: always() + continue-on-error: true run: | - grep -oP '(?<=claude-code feature\] WARNING: ).*' /tmp/scenario-test-output.log 2>/dev/null \ - | while IFS= read -r msg; do echo "::warning title=Install Warning::${msg}"; done || true + mapfile -t warnings < <(grep -oP '(?<=claude-code feature\] WARNING: ).*' /tmp/scenario-test-output.log 2>/dev/null || true) + for msg in "${warnings[@]}"; do echo "::warning title=Install Warning::${msg}"; done + [[ ${#warnings[@]} -eq 0 ]] - name: Upload logs on failure if: failure() @@ -175,9 +177,11 @@ jobs: - name: Annotate install warnings if: always() + continue-on-error: true run: | - grep -oP '(?<=claude-code feature\] WARNING: ).*' /tmp/test-output.log 2>/dev/null \ - | while IFS= read -r msg; do echo "::warning title=Install Warning::${msg}"; done || true + mapfile -t warnings < <(grep -oP '(?<=claude-code feature\] WARNING: ).*' /tmp/test-output.log 2>/dev/null || true) + for msg in "${warnings[@]}"; do echo "::warning title=Install Warning::${msg}"; done + [[ ${#warnings[@]} -eq 0 ]] - name: Upload logs on failure if: failure() @@ -231,9 +235,11 @@ jobs: - name: Annotate install warnings if: always() + continue-on-error: true run: | - grep -oP '(?<=claude-code feature\] WARNING: ).*' /tmp/test-output.log 2>/dev/null \ - | while IFS= read -r msg; do echo "::warning title=Install Warning::${msg}"; done || true + mapfile -t warnings < <(grep -oP '(?<=claude-code feature\] WARNING: ).*' /tmp/test-output.log 2>/dev/null || true) + for msg in "${warnings[@]}"; do echo "::warning title=Install Warning::${msg}"; done + [[ ${#warnings[@]} -eq 0 ]] - name: Upload logs on failure if: failure() diff --git a/src/claude-code/install.sh b/src/claude-code/install.sh index 0ee406c..7250804 100755 --- a/src/claude-code/install.sh +++ b/src/claude-code/install.sh @@ -538,8 +538,9 @@ setup_completions() { } fi - # Zsh completions - if [[ -d /usr/share/zsh/site-functions ]] || mkdir -p /usr/share/zsh/site-functions 2>/dev/null; then + # Zsh completions — only if zsh is installed + if command -v zsh >/dev/null 2>&1; then + mkdir -p /usr/share/zsh/site-functions 2>/dev/null || true timeout 30 claude completions zsh /usr/share/zsh/site-functions/_claude 2>/dev/null || { log_warn "Failed to install zsh completions." } From 572e2336f75aeb0ccc000da6765920598689f3bd Mon Sep 17 00:00:00 2001 From: PKramek Date: Thu, 2 Apr 2026 19:15:11 +0200 Subject: [PATCH 48/55] fix: use log_warn consistently and clean up warning annotations - setup_mount_docs: replace log_info 'WARNING: ...' with log_warn so the API keys notice is emitted via the same code path as all other warnings - CI annotation steps: drop continue-on-error + exit 1 (misleading orange step); annotations are the signal, job stays cleanly green or red - Add sort -u to deduplicate repeated warnings within a single job run --- .github/workflows/test.yml | 12 +++--------- src/claude-code/install.sh | 2 +- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d371211..0540cd6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -96,11 +96,9 @@ jobs: - name: Annotate install warnings if: always() - continue-on-error: true run: | - mapfile -t warnings < <(grep -oP '(?<=claude-code feature\] WARNING: ).*' /tmp/scenario-test-output.log 2>/dev/null || true) + mapfile -t warnings < <(grep -oP '(?<=claude-code feature\] WARNING: ).*' /tmp/scenario-test-output.log 2>/dev/null | sort -u || true) for msg in "${warnings[@]}"; do echo "::warning title=Install Warning::${msg}"; done - [[ ${#warnings[@]} -eq 0 ]] - name: Upload logs on failure if: failure() @@ -177,11 +175,9 @@ jobs: - name: Annotate install warnings if: always() - continue-on-error: true run: | - mapfile -t warnings < <(grep -oP '(?<=claude-code feature\] WARNING: ).*' /tmp/test-output.log 2>/dev/null || true) + mapfile -t warnings < <(grep -oP '(?<=claude-code feature\] WARNING: ).*' /tmp/test-output.log 2>/dev/null | sort -u || true) for msg in "${warnings[@]}"; do echo "::warning title=Install Warning::${msg}"; done - [[ ${#warnings[@]} -eq 0 ]] - name: Upload logs on failure if: failure() @@ -235,11 +231,9 @@ jobs: - name: Annotate install warnings if: always() - continue-on-error: true run: | - mapfile -t warnings < <(grep -oP '(?<=claude-code feature\] WARNING: ).*' /tmp/test-output.log 2>/dev/null || true) + mapfile -t warnings < <(grep -oP '(?<=claude-code feature\] WARNING: ).*' /tmp/test-output.log 2>/dev/null | sort -u || true) for msg in "${warnings[@]}"; do echo "::warning title=Install Warning::${msg}"; done - [[ ${#warnings[@]} -eq 0 ]] - name: Upload logs on failure if: failure() diff --git a/src/claude-code/install.sh b/src/claude-code/install.sh index 7250804..59c3ba5 100755 --- a/src/claude-code/install.sh +++ b/src/claude-code/install.sh @@ -615,7 +615,7 @@ setup_mount_docs() { log_info " \"source=\${localEnv:HOME}/.claude,target=${REMOTE_USER_HOME}/.claude,type=bind,consistency=cached,readonly\"" log_info ' ]' log_info "" - log_info "WARNING: This exposes your API keys inside the container." + log_warn "This exposes your API keys inside the container." log_info "See README for security considerations." log_info "============================================================" log_info "" From 6cd77c10484b174495acc034f2b980a9787a2872 Mon Sep 17 00:00:00 2001 From: PKramek Date: Thu, 2 Apr 2026 19:27:27 +0200 Subject: [PATCH 49/55] fix: pin devcontainers/python to :3 tag (no latest manifest on MCR) --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0540cd6..bdbd2b3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -140,7 +140,7 @@ jobs: - "mcr.microsoft.com/devcontainers/base:alpine" - "mcr.microsoft.com/devcontainers/universal:2" # Language-specific images - - "mcr.microsoft.com/devcontainers/python" + - "mcr.microsoft.com/devcontainers/python:3" - "mcr.microsoft.com/devcontainers/javascript-node" - "mcr.microsoft.com/devcontainers/typescript-node" - "mcr.microsoft.com/devcontainers/rust" From f7c391fec58f993db0fa59e06a37c41c04377bf5 Mon Sep 17 00:00:00 2001 From: PKramek Date: Thu, 2 Apr 2026 19:46:56 +0200 Subject: [PATCH 50/55] ci: write warnings to job step summary for visibility Warnings now appear as a formatted list in the job Summary tab (GITHUB_STEP_SUMMARY) in addition to the ::warning:: annotations, so they are visible without expanding the Annotations section. --- .github/workflows/test.yml | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index bdbd2b3..dee3b99 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -98,7 +98,13 @@ jobs: if: always() run: | mapfile -t warnings < <(grep -oP '(?<=claude-code feature\] WARNING: ).*' /tmp/scenario-test-output.log 2>/dev/null | sort -u || true) - for msg in "${warnings[@]}"; do echo "::warning title=Install Warning::${msg}"; done + if [[ ${#warnings[@]} -gt 0 ]]; then + echo "## :warning: Install Warnings" >> "$GITHUB_STEP_SUMMARY" + for msg in "${warnings[@]}"; do + echo "- ${msg}" >> "$GITHUB_STEP_SUMMARY" + echo "::warning title=Install Warning::${msg}" + done + fi - name: Upload logs on failure if: failure() @@ -177,7 +183,13 @@ jobs: if: always() run: | mapfile -t warnings < <(grep -oP '(?<=claude-code feature\] WARNING: ).*' /tmp/test-output.log 2>/dev/null | sort -u || true) - for msg in "${warnings[@]}"; do echo "::warning title=Install Warning::${msg}"; done + if [[ ${#warnings[@]} -gt 0 ]]; then + echo "## :warning: Install Warnings" >> "$GITHUB_STEP_SUMMARY" + for msg in "${warnings[@]}"; do + echo "- ${msg}" >> "$GITHUB_STEP_SUMMARY" + echo "::warning title=Install Warning::${msg}" + done + fi - name: Upload logs on failure if: failure() @@ -233,7 +245,13 @@ jobs: if: always() run: | mapfile -t warnings < <(grep -oP '(?<=claude-code feature\] WARNING: ).*' /tmp/test-output.log 2>/dev/null | sort -u || true) - for msg in "${warnings[@]}"; do echo "::warning title=Install Warning::${msg}"; done + if [[ ${#warnings[@]} -gt 0 ]]; then + echo "## :warning: Install Warnings" >> "$GITHUB_STEP_SUMMARY" + for msg in "${warnings[@]}"; do + echo "- ${msg}" >> "$GITHUB_STEP_SUMMARY" + echo "::warning title=Install Warning::${msg}" + done + fi - name: Upload logs on failure if: failure() From b7b265000ddfa700b190bcddf8c3400974181dec Mon Sep 17 00:00:00 2001 From: PKramek Date: Thu, 2 Apr 2026 20:29:29 +0200 Subject: [PATCH 51/55] fix: strip carriage returns before sort -u to fix warning deduplication Docker log lines contain \r (CRLF), causing sort -u to treat identical warning messages as distinct lines. Pipe through tr -d '\r' before sort -u so duplicate warnings collapse to a single annotation entry. --- .github/workflows/test.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index dee3b99..9e9a89d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -97,7 +97,7 @@ jobs: - name: Annotate install warnings if: always() run: | - mapfile -t warnings < <(grep -oP '(?<=claude-code feature\] WARNING: ).*' /tmp/scenario-test-output.log 2>/dev/null | sort -u || true) + mapfile -t warnings < <(grep -oP '(?<=claude-code feature\] WARNING: ).*' /tmp/scenario-test-output.log 2>/dev/null | tr -d '\r' | sort -u || true) if [[ ${#warnings[@]} -gt 0 ]]; then echo "## :warning: Install Warnings" >> "$GITHUB_STEP_SUMMARY" for msg in "${warnings[@]}"; do @@ -182,7 +182,7 @@ jobs: - name: Annotate install warnings if: always() run: | - mapfile -t warnings < <(grep -oP '(?<=claude-code feature\] WARNING: ).*' /tmp/test-output.log 2>/dev/null | sort -u || true) + mapfile -t warnings < <(grep -oP '(?<=claude-code feature\] WARNING: ).*' /tmp/test-output.log 2>/dev/null | tr -d '\r' | sort -u || true) if [[ ${#warnings[@]} -gt 0 ]]; then echo "## :warning: Install Warnings" >> "$GITHUB_STEP_SUMMARY" for msg in "${warnings[@]}"; do @@ -244,7 +244,7 @@ jobs: - name: Annotate install warnings if: always() run: | - mapfile -t warnings < <(grep -oP '(?<=claude-code feature\] WARNING: ).*' /tmp/test-output.log 2>/dev/null | sort -u || true) + mapfile -t warnings < <(grep -oP '(?<=claude-code feature\] WARNING: ).*' /tmp/test-output.log 2>/dev/null | tr -d '\r' | sort -u || true) if [[ ${#warnings[@]} -gt 0 ]]; then echo "## :warning: Install Warnings" >> "$GITHUB_STEP_SUMMARY" for msg in "${warnings[@]}"; do From da17f783e8e0c0eb37652d9796c792181a571b86 Mon Sep 17 00:00:00 2001 From: PKramek Date: Thu, 2 Apr 2026 20:42:25 +0200 Subject: [PATCH 52/55] fix: demote mountHostConfig notice from WARNING to INFO level The API keys message is documentation printed when mountHostConfig=true is explicitly set by the user. It is expected output, not an install failure. Emitting it as WARNING caused CI annotation noise whenever the mount_host_config scenario ran. --- src/claude-code/install.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/claude-code/install.sh b/src/claude-code/install.sh index 59c3ba5..d44d50f 100755 --- a/src/claude-code/install.sh +++ b/src/claude-code/install.sh @@ -615,7 +615,7 @@ setup_mount_docs() { log_info " \"source=\${localEnv:HOME}/.claude,target=${REMOTE_USER_HOME}/.claude,type=bind,consistency=cached,readonly\"" log_info ' ]' log_info "" - log_warn "This exposes your API keys inside the container." + log_info "NOTE: This exposes your API keys inside the container." log_info "See README for security considerations." log_info "============================================================" log_info "" From b473ab054f4af31b1157c57222eea701868fdca4 Mon Sep 17 00:00:00 2001 From: PKramek Date: Thu, 2 Apr 2026 21:02:57 +0200 Subject: [PATCH 53/55] docs: rewrite README with enterprise framing and accurate technical claims Add "Why Not Just Use the Official Image?" section explaining the golden image, size overhead (~120 MB vs 1.5 GB replacement), and composability arguments. Document known limitations: nodeVersion ignored on Alpine/Arch, arm64 via QEMU only, readonly mount prevents write-back. Fix arm64 count (2 images, not 4). Correct the CI badge alt text to match workflow name. --- README.md | 178 ++++++++++++++++++++++++++++++++++-------------------- 1 file changed, 113 insertions(+), 65 deletions(-) diff --git a/README.md b/README.md index 5925965..2e18366 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,14 @@ -# Claude Code DevContainer Feature - -[![Test](https://github.com/pkramek/claude-devcontainer/actions/workflows/test.yml/badge.svg)](https://github.com/pkramek/claude-devcontainer/actions/workflows/test.yml) +[![Test](https://github.com/pkramek/claude-devcontainer/actions/workflows/test.yml/badge.svg)](https://github.com/pkramek/claude-devcontainer/actions/workflows/test.yml) [![License: Apache 2.0](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](LICENSE) [![Tested: 27 images](https://img.shields.io/badge/tested-27%20base%20images-brightgreen.svg)](.github/workflows/test.yml) -Install [Claude Code](https://docs.anthropic.com/en/docs/claude-code) into any -devcontainer. Supports Debian, Ubuntu, Alpine, Arch, Fedora, RHEL, Rocky, Alma, -and Amazon Linux on amd64 and arm64. +# Claude Code DevContainer Feature -## Usage +The official Anthropic devcontainer image forces you into a heavy Node.js base (~1.5 GB). Your team +already has a carefully tuned Python, Rust, Go, or Alpine image — likely one that's been through your +security team's review and lives in your approved registry. You should not need to throw that away to +use Claude Code. -Add this feature to your `devcontainer.json`: +This feature adds Claude Code CLI to **any** devcontainer base image with a single line in +`devcontainer.json`. Your base image stays yours; the feature handles the rest. ```json { @@ -18,35 +18,55 @@ Add this feature to your `devcontainer.json`: } ``` -### Options +## Why Not Just Use the Official Image? + +Three reasons that actually matter in practice: + +**1. Your image is already approved.** +In most enterprises, adding a new base image means a vulnerability scan, a security review, and a +long wait. Your golden image — the hardened `ubuntu:22.04`, the approved `python:3.11`, the internal +RHEL derivative — is already through all of that. This feature installs on top of it. -| Option | Type | Default | Description | -| ------------------ | ------- | ------------ | ---------------------------------------- | -| `version` | string | `latest` | Claude Code version (semver or `latest`) | -| `nodeVersion` | string | `lts` | Node.js version if not present (>= 18) | -| `installPath` | string | `/usr/local` | Custom npm global prefix | -| `enableMcpServers` | boolean | `false` | Create starter MCP config | -| `mountHostConfig` | boolean | `false` | Log mount snippet for host config | -| `shellCompletions` | boolean | `true` | Install bash/zsh/fish completions | +**2. Size is a real cost.** +Switching from an Alpine or slim Python base to the full Anthropic Node.js image means replacing your +entire environment with a ~1.5 GB monolith. With this feature, you add Claude Code and its Node.js +runtime (~120 MB of overhead) to your existing image instead of discarding it. + +**3. Composability.** +DevContainers features exist so you can assemble an environment from pieces. The official Anthropic +image combines base OS + Node.js + Claude Code into one thing you can't extend cleanly. This feature +is the composable alternative: bring your own base, add only what you need. + +## Options + +| Option | Type | Default | Description | +| ------------------ | ------- | ------------ | ---------------------------------------------------------------------------- | +| `version` | string | `latest` | Claude Code version (semver or `latest`). Pin this for teams. | +| `nodeVersion` | string | `lts` | Node.js version to install if not already present (≥ 18 required). | +| `installPath` | string | `/usr/local` | Custom npm global prefix. Adds `/bin` to PATH automatically. | +| `enableMcpServers` | boolean | `false` | Create a starter MCP server config at `~/.claude/mcp_servers.json`. | +| `mountHostConfig` | boolean | `false` | Print a `devcontainer.json` mount snippet to wire up your host `~/.claude`. | +| `shellCompletions` | boolean | `true` | Install bash/zsh/fish completions (silently skipped if the shell is absent). | ### Examples -Pin a specific version: +Pin a specific Claude Code version (recommended for teams): ```json { "features": { "ghcr.io/pkramek/claude-devcontainer/claude-code:1": { - "version": "1.0.0" + "version": "1.2.3" } } } ``` -Enable MCP servers: +Python base with MCP servers enabled: ```json { + "image": "mcr.microsoft.com/devcontainers/python:3", "features": { "ghcr.io/pkramek/claude-devcontainer/claude-code:1": { "enableMcpServers": true @@ -55,72 +75,100 @@ Enable MCP servers: } ``` +Custom install path (useful when `/usr/local` is read-only): + +```json +{ + "features": { + "ghcr.io/pkramek/claude-devcontainer/claude-code:1": { + "installPath": "/opt/claude" + } + } +} +``` + ## Authentication -Claude Code requires authentication. Three options: +Three ways to authenticate inside the container: + +**1. Browser login (recommended).** Run `claude login` in the container terminal. Opens an OAuth +browser window. Works in VS Code's integrated terminal with no extra configuration. + +**2. Environment variable.** Pass your API key from the host: -1. **Browser login (recommended):** Run `claude login` in the container terminal. - Claude Code opens a browser window for OAuth authentication. Works out of the - box in VS Code's integrated terminal with no configuration needed. +```json +{ + "remoteEnv": { + "ANTHROPIC_API_KEY": "${localEnv:ANTHROPIC_API_KEY}" + } +} +``` -2. **Environment variable:** Set `ANTHROPIC_API_KEY` in your devcontainer: +**3. Mount host `~/.claude`.** Share your full host config — API keys, session tokens, preferences +— directly into the container: - ```json - { - "remoteEnv": { - "ANTHROPIC_API_KEY": "${localEnv:ANTHROPIC_API_KEY}" - } - } - ``` +```json +{ + "mounts": [ + "source=${localEnv:HOME}/.claude,target=/home/vscode/.claude,type=bind,consistency=cached,readonly" + ] +} +``` -3. **Mount host config:** Mount your local `~/.claude` directory: +> ⚠️ **Security:** This exposes your API keys inside the container. Only do this in trusted +> environments. Adjust the `target` path to match your container user's home directory (`/root`, +> `/home/node`, etc.). The `readonly` flag prevents Claude Code from writing settings or session +> data back to the host — if you need bidirectional sync, remove it and accept the risk. +> +> Set `mountHostConfig: true` in the feature options to get this snippet printed at build time with +> your actual home directory path pre-filled. - ```json - { - "mounts": [ - "source=${localEnv:HOME}/.claude,target=${localEnv:HOME}/.claude,type=bind,consistency=cached,readonly" - ] - } - ``` +## Tested Base Images - > **Note:** Replace the `target` path with your container user's home directory - > (e.g., `/home/vscode`, `/root`, or `/home/node` depending on your base image). - > **Security warning:** This exposes your API keys inside the container. +Tested in CI against **27 amd64** base images and **2 arm64** images (QEMU emulation on +`ubuntu-latest` runners): -## Tested Images +**Raw OS:** Ubuntu 22.04 / 24.04, Debian Bullseye / Bookworm, Alpine 3.19 / 3.20 / 3.21, +Arch Linux, Fedora 39 / 40, Rocky Linux 9, AlmaLinux 9, Amazon Linux 2023 -This feature is tested on 27 amd64 + 4 arm64 base images. See the -[test workflow](.github/workflows/test.yml) for the full matrix. +**DevContainer bases:** `base:debian`, `base:ubuntu`, `base:alpine`, `universal:2` -## Runtime Verification +**Language-specific:** Python 3, JavaScript/Node, TypeScript/Node, Rust, Go, C++, .NET, Java, +Ruby, PHP -Add this to your `devcontainer.json` to verify Claude Code at container start: +See [`.github/workflows/test.yml`](.github/workflows/test.yml) for the full matrix. -```json -{ - "postCreateCommand": "claude --version || true" -} -``` +## Known Limitations -## Publishing (Maintainers) +**`nodeVersion` on Alpine and Arch.** Node.js is installed from the distro package manager +(`apk add nodejs` / `pacman -S nodejs`). The `nodeVersion` option is silently ignored on these +distributions — you get whatever version the distro ships. On Debian, Ubuntu, and the RHEL family, +the requested version is fetched via NodeSource. -After the first release tag push, the GHCR package is created as **private**. -You must manually change it to public: +**arm64 coverage.** The arm64 matrix covers Ubuntu 24.04 and Alpine 3.21 via QEMU emulation. Other +arm64 distributions should work but are not currently CI-tested. -1. Go to the repository's **Packages** tab -2. Click the `claude-code` package -3. Go to **Package settings** -4. Under **Danger Zone**, change visibility to **Public** +**Claude Code and native addons.** Claude Code is currently pure JavaScript with no native addons, +so it runs on Alpine (musl libc) without issues. If that changes upstream, Alpine images may break +before a musl-compatible build is published. Check the CI badge before upgrading. ## Contributing -1. Fork the repository -2. Open in a devcontainer (`.devcontainer/devcontainer.json` runs `pre-commit install` +1. Fork and clone +2. Open in the devcontainer (`.devcontainer/devcontainer.json` runs `pre-commit install` automatically), **or** run `pre-commit install` manually after cloning -3. Make changes +3. Changes live in `src/claude-code/install.sh` and `test/claude-code/` 4. Run `pre-commit run --all-files` before committing -5. Open a pull request +5. Open a pull request against `develop` + +## Publishing (Maintainers) + +After the first release tag push, the GHCR package is created as **private**. To make it public: + +1. Go to the repository's **Packages** tab +2. Click the `claude-code` package → **Package settings** +3. Under **Danger Zone**, change visibility to **Public** ## License -Apache 2.0 +[Apache 2.0](LICENSE) From 089c6f6a35754aa0f122ffd159add08eca780581 Mon Sep 17 00:00:00 2001 From: PKramek Date: Thu, 2 Apr 2026 21:09:15 +0200 Subject: [PATCH 54/55] style: apply Prettier formatting to README; disable MD041 for badges-first convention --- .markdownlint.json | 3 ++- README.md | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.markdownlint.json b/.markdownlint.json index 80dd19f..92f29a5 100644 --- a/.markdownlint.json +++ b/.markdownlint.json @@ -2,5 +2,6 @@ "default": true, "MD013": false, "MD031": false, - "MD040": false + "MD040": false, + "MD041": false } diff --git a/README.md b/README.md index 2e18366..9bd7cb8 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ is the composable alternative: bring your own base, add only what you need. | Option | Type | Default | Description | | ------------------ | ------- | ------------ | ---------------------------------------------------------------------------- | | `version` | string | `latest` | Claude Code version (semver or `latest`). Pin this for teams. | -| `nodeVersion` | string | `lts` | Node.js version to install if not already present (≥ 18 required). | +| `nodeVersion` | string | `lts` | Node.js version to install if not already present (≥ 18 required). | | `installPath` | string | `/usr/local` | Custom npm global prefix. Adds `/bin` to PATH automatically. | | `enableMcpServers` | boolean | `false` | Create a starter MCP server config at `~/.claude/mcp_servers.json`. | | `mountHostConfig` | boolean | `false` | Print a `devcontainer.json` mount snippet to wire up your host `~/.claude`. | From 66b80ba16aa236c4327c081d4a9b6c652a6eb7b0 Mon Sep 17 00:00:00 2001 From: PKramek Date: Thu, 2 Apr 2026 21:10:21 +0200 Subject: [PATCH 55/55] docs: document pre-commit hooks in Contributing section --- README.md | 43 +++++++++++++++++++++++++++++++++++++++---- 1 file changed, 39 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 9bd7cb8..d554ddc 100644 --- a/README.md +++ b/README.md @@ -155,11 +155,46 @@ before a musl-compatible build is published. Check the CI badge before upgrading ## Contributing 1. Fork and clone -2. Open in the devcontainer (`.devcontainer/devcontainer.json` runs `pre-commit install` - automatically), **or** run `pre-commit install` manually after cloning +2. Install the git hooks (one-time setup): + + ```bash + pip install pre-commit + pre-commit install # runs on git commit + pre-commit install --hook-type pre-push # runs on git push + ``` + + If you open the repo in the devcontainer, this runs automatically. + 3. Changes live in `src/claude-code/install.sh` and `test/claude-code/` -4. Run `pre-commit run --all-files` before committing -5. Open a pull request against `develop` +4. Open a pull request against `develop` + +### Pre-commit hooks + +The following checks run automatically on every `git commit` and `git push`: + +| Hook | What it checks | +| --------------------- | --------------------------------------------- | +| `shellcheck` | Shell script correctness (warnings and above) | +| `shfmt` | Shell script formatting (`-i 4 -ci`) | +| `prettier` | JSON, YAML, and Markdown formatting | +| `markdownlint` | Markdown style rules | +| `check-json` | JSON syntax validity | +| `check-yaml` | YAML syntax validity | +| `trailing-whitespace` | No trailing whitespace | +| `detect-private-key` | No accidentally committed secrets | +| `no-commit-to-branch` | Blocks direct commits to `main` and `develop` | + +**Running manually:** + +```bash +pre-commit run --all-files # check everything +pre-commit run prettier # check one hook +pre-commit run --files src/claude-code/install.sh # check one file +``` + +**If a hook fails:** fix the flagged issue and `git add` the changes before retrying. Prettier and +shfmt auto-fix in place — just stage the result. ShellCheck and markdownlint report what to fix but +won't rewrite your code. ## Publishing (Maintainers)