diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..55e8b44 --- /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/.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/.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." diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..9e9a89d --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,262 @@ +name: "Test" + +on: + pull_request: + push: + branches: [main, develop] + +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: Install uv + uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0 + + - 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: 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 -ln bash -d -i 4 -ci src/ test/ + + - 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 + # 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:| FAIL:" /tmp/scenario-test-output.log; then + echo "ERROR: Test output contains failures." + exit 1 + fi + + - name: Annotate install warnings + if: always() + run: | + 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 + echo "- ${msg}" >> "$GITHUB_STEP_SUMMARY" + echo "::warning title=Install Warning::${msg}" + done + fi + + - 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:3" + - "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 + # 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:| FAIL:" /tmp/test-output.log; then + echo "ERROR: Test output contains failures." + exit 1 + fi + + - name: Annotate install warnings + if: always() + run: | + 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 + echo "- ${msg}" >> "$GITHUB_STEP_SUMMARY" + echo "::warning title=Install Warning::${msg}" + done + fi + + - 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 via QEMU emulation on standard ubuntu-latest runners + test-arm64: + needs: lint + runs-on: ubuntu-latest + timeout-minutes: 60 # QEMU emulation is significantly slower than native + permissions: + contents: read + strategy: + fail-fast: false + max-parallel: 2 + matrix: + image: + - "ubuntu:24.04" + - "alpine:3.21" + 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 + + - name: Install devcontainer CLI + 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 \ + --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|Failed:| FAIL:" /tmp/test-output.log; then + echo "ERROR: Test output contains failures." + exit 1 + fi + + - name: Annotate install warnings + if: always() + run: | + 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 + echo "- ${msg}" >> "$GITHUB_STEP_SUMMARY" + echo "::warning title=Install Warning::${msg}" + done + fi + + - 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2d9785e --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +# OS +.DS_Store +Thumbs.db + +# Editors +*.swp +*.swo +*~ +.vscode/ +.idea/ + +# Node (from npm install in CI) +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/.markdownlint.json b/.markdownlint.json new file mode 100644 index 0000000..92f29a5 --- /dev/null +++ b/.markdownlint.json @@ -0,0 +1,7 @@ +{ + "default": true, + "MD013": false, + "MD031": false, + "MD040": false, + "MD041": false +} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..405c1c0 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,40 @@ +# 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: 3e8a8703264a2f4a69428a0aa4dcb512790b2c8c # 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: 99470f5e12208ff0fb17ab81c3c494f7620a1d8d # v0.11.0 + hooks: + - id: shellcheck + args: ["--severity=warning"] + + - repo: https://github.com/scop/pre-commit-shfmt + rev: e26a818fd47b4f33cefa99035d1265b0849f4b47 # v3.13.0-1 + hooks: + - id: shfmt + args: ["-w", "-ln", "bash", "-i", "4", "-ci"] + + - repo: https://github.com/pre-commit/mirrors-prettier + rev: f12edd9c7be1c20cfa42420fd0e6df71e42b51ea # v4.0.0-alpha.8 + hooks: + - id: prettier + types_or: [json, yaml, markdown] + + - repo: https://github.com/igorshubovych/markdownlint-cli + rev: e72a3ca1632f0b11a07d171449fe447a7ff6795e # v0.48.0 + hooks: + - id: markdownlint + args: ["--fix"] 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..d645695 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + 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 new file mode 100644 index 0000000..d554ddc --- /dev/null +++ b/README.md @@ -0,0 +1,209 @@ +[![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) + +# Claude Code DevContainer Feature + +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. + +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 +{ + "features": { + "ghcr.io/pkramek/claude-devcontainer/claude-code:1": {} + } +} +``` + +## 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. + +**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 Claude Code version (recommended for teams): + +```json +{ + "features": { + "ghcr.io/pkramek/claude-devcontainer/claude-code:1": { + "version": "1.2.3" + } + } +} +``` + +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 + } + } +} +``` + +Custom install path (useful when `/usr/local` is read-only): + +```json +{ + "features": { + "ghcr.io/pkramek/claude-devcontainer/claude-code:1": { + "installPath": "/opt/claude" + } + } +} +``` + +## Authentication + +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: + +```json +{ + "remoteEnv": { + "ANTHROPIC_API_KEY": "${localEnv:ANTHROPIC_API_KEY}" + } +} +``` + +**3. Mount host `~/.claude`.** Share your full host config — API keys, session tokens, preferences +— directly into the container: + +```json +{ + "mounts": [ + "source=${localEnv:HOME}/.claude,target=/home/vscode/.claude,type=bind,consistency=cached,readonly" + ] +} +``` + +> ⚠️ **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. + +## Tested Base Images + +Tested in CI against **27 amd64** base images and **2 arm64** images (QEMU emulation on +`ubuntu-latest` runners): + +**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 + +**DevContainer bases:** `base:debian`, `base:ubuntu`, `base:alpine`, `universal:2` + +**Language-specific:** Python 3, JavaScript/Node, TypeScript/Node, Rust, Go, C++, .NET, Java, +Ruby, PHP + +See [`.github/workflows/test.yml`](.github/workflows/test.yml) for the full matrix. + +## Known Limitations + +**`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. + +**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. + +**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 and clone +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. 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) + +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](LICENSE) 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..aa7326a --- /dev/null +++ b/docs/superpowers/plans/2026-03-31-claude-code-devcontainer-feature.md @@ -0,0 +1,2212 @@ +# 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/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..00942f1 --- /dev/null +++ b/docs/superpowers/plans/2026-04-01-install-fix-formatting-license.md @@ -0,0 +1,867 @@ +# 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..a5841ba --- /dev/null +++ b/docs/superpowers/plans/2026-04-01-repo-rename-branch-setup.md @@ -0,0 +1,468 @@ +# 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` 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..82fa625 --- /dev/null +++ b/docs/superpowers/specs/2026-03-31-claude-code-devcontainer-feature-design.md @@ -0,0 +1,626 @@ +# 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 | 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..cf3f0e7 --- /dev/null +++ b/docs/superpowers/specs/2026-04-01-install-fix-formatting-license-design.md @@ -0,0 +1,303 @@ +# Install Fix, Formatting Enforcement, and License Change Design + +**Date:** 2026-04-01 + +--- + +## Summary + +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) +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 Stdout) + +`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 (log_info Stdout) + +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_debug() { + if [[ "${DEBUG:-false}" == "true" ]]; then + echo "${FEATURE_LOG_PREFIX} DEBUG: $*" >&2 + fi +} +``` + +`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 (log_info Stdout) + +- `src/claude-code/install.sh` lines 51 and 54-57 + +--- + +## 2. CI False Positives: devcontainer features test Exit Code + +### 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 +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 (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 +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`. Each job uses a different log path: + +**test-scenarios** (log: `/tmp/scenario-test-output.log`): + +```bash +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]|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 +# 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 +fi +``` + +### Affected File (CI False Positives) + +- `.github/workflows/test.yml` — all three test job `run:` blocks (note: different log + paths per job) + +--- + +## 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**: 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 + 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 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. 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 + +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 (Formatting Enforcement) + +- `.pre-commit-config.yaml` + +--- + +## 4. License: MIT → Apache 2.0 + +### Changes + +**`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. + +**`NOTICE`** (new file): Create with the copyright attribution: + +``` +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. + +**`README.md`**: The `## License` section currently reads `MIT`. Update to `Apache 2.0`: + +```markdown +## License + +Apache 2.0 +``` + +**`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 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 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 + +- `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` + 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 `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) + +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) diff --git a/src/claude-code/README.md b/src/claude-code/README.md new file mode 100644 index 0000000..e8e5037 --- /dev/null +++ b/src/claude-code/README.md @@ -0,0 +1,82 @@ +# 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-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-devcontainer/claude-code:1": { + "version": "1.0.0" + } + } +} +``` + +Enable MCP servers: + +```json +{ + "features": { + "ghcr.io/pkramek/claude-devcontainer/claude-code:1": { + "enableMcpServers": true + } + } +} +``` + +## Authentication + +Claude Code requires authentication. Three options: + +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}" + } + } + ``` + +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 + +- 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 new file mode 100644 index 0000000..94188ec --- /dev/null +++ b/src/claude-code/devcontainer-feature.json @@ -0,0 +1,53 @@ +{ + "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", + "license": "Apache-2.0", + "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." + } + }, + "containerEnv": { + "CLAUDE_CODE_INSTALLED": "true" + } +} diff --git a/src/claude-code/install.sh b/src/claude-code/install.sh new file mode 100755 index 0000000..d44d50f --- /dev/null +++ b/src/claude-code/install.sh @@ -0,0 +1,677 @@ +#!/bin/sh +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: 2026 PKramek +# +# 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} $*" >&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: $*" >&2 + 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})" + +# --- 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 -Sy --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") + ;; + 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 + + # 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") + command -v tar >/dev/null 2>&1 || missing+=("tar") + ;; + esac + + if [[ ${#missing[@]} -gt 0 ]]; then + log_info "Installing missing dependencies: ${missing[*]}" + install_packages "${missing[@]}" + fi +} + +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 + 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() { + 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 + +# --- 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 </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 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 + } + + 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 +install_claude_code + +# --- 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 + timeout 30 claude completions bash "${bash_comp_dir}/claude" 2>/dev/null || { + log_warn "Failed to install bash completions." + } + fi + + # 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." + } + 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 + timeout 30 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 "NOTE: 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 + +# 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}" +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')" +log_info " Node.js: $(node --version 2>/dev/null || echo 'unknown')" +log_info " OS: ${OS_FAMILY} (${ARCH})" +log_info " User: ${REMOTE_USER}" 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/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 diff --git a/test/claude-code/idempotency.sh b/test/claude-code/idempotency.sh new file mode 100755 index 0000000..234362d --- /dev/null +++ b/test/claude-code/idempotency.sh @@ -0,0 +1,39 @@ +#!/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 ---" +# 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 +} + +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..30734a3 --- /dev/null +++ b/test/claude-code/mcp_enabled.sh @@ -0,0 +1,18 @@ +#!/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)" +check_permissions "${HOME}/.claude" "700" +check_permissions "${MCP_CONFIG}" "600" + +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 diff --git a/test/claude-code/scenarios.json b/test/claude-code/scenarios.json new file mode 100644 index 0000000..85e3a09 --- /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..61eb4d5 --- /dev/null +++ b/test/claude-code/test.sh @@ -0,0 +1,205 @@ +#!/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=$((TESTS_PASSED + 1)) +} + +fail() { + echo " FAIL: $*" >&2 + TESTS_FAILED=$((TESTS_FAILED + 1)) +} + +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 + # -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 + 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 -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 + 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