Skip to content

Replace npm Claude Code with native binary installation#11

Merged
AnExiledDev merged 3 commits intomainfrom
feat/native-claude-code
Feb 26, 2026
Merged

Replace npm Claude Code with native binary installation#11
AnExiledDev merged 3 commits intomainfrom
feat/native-claude-code

Conversation

@AnExiledDev
Copy link
Owner

@AnExiledDev AnExiledDev commented Feb 25, 2026

Summary

  • Replaced ghcr.io/anthropics/devcontainer-features/claude-code:1.0.5 (npm-based) with new ./features/claude-code-native feature using Anthropic's official native installer (https://claude.ai/install.sh)
  • Fixed in-session auto-updater — native binary at ~/.local/bin/claude is owned by the container user, so claude update works without root permission issues
  • Simplified setup-update-claude.sh to native-binary-only with 60s timeout and transitional npm cleanup
  • Simplified setup-aliases.sh — single _CLAUDE_BIN path instead of 3-branch fallback
  • Fixed setup.sh log capture — unhandled errors now reach the update log instead of being discarded

Root cause

The Anthropic devcontainer feature installed Claude Code via npm install -g as root during build. The resulting package at /usr/local/share/nvm/versions/node/*/lib/node_modules/@anthropic-ai/claude-code/ was owned by root:nvm with 2755 permissions. The vscode user (in nvm group) had read+execute but no write, so the in-session auto-updater failed with EACCES on every update attempt.

Test plan

  • Rebuild container — verify ~/.local/bin/claude exists, owned by vscode:vscode
  • Verify no npm residue: npm list -g @anthropic-ai/claude-code shows empty
  • cc --version and claude --version resolve to native binary
  • Run bash .devcontainer/scripts/setup-update-claude.sh — check log at /workspaces/.tmp/claude-update.log
  • Start Claude Code session — verify auto-updater reports no permission errors

Summary by CodeRabbit

  • Documentation

    • Updated devcontainer docs to describe native Claude Code installation and added comprehensive feature README and changelog entries.
  • Chores

    • Switched Claude Code delivery from container feature to native binary installer and updated feature mappings.
    • Added installer, onboarding hook, and improved install/update workflow and logging.
  • Bug Fixes

    • Simplified binary detection and made update/error handling more explicit.

@coderabbitai
Copy link

coderabbitai bot commented Feb 25, 2026

📝 Walkthrough

Walkthrough

Migrates Claude Code installation to a native binary using Anthropic's official installer, adds a new devcontainer feature and install script, updates devcontainer configuration and feature references, and adjusts setup/update scripts and documentation to use the native binary path and logging.

Changes

Cohort / File(s) Summary
Documentation
\.devcontainer/CHANGELOG.md, \.devcontainer/CLAUDE.md
Updated changelog and feature docs to describe native binary installation via Anthropic's installer instead of the prior container/npm descriptions.
Devcontainer Configuration & Feature Manifest
\.devcontainer/devcontainer.json, \.devcontainer/features/claude-code-native/devcontainer-feature.json, \.devcontainer/features/claude-code-native/README.md
Added local claude-code-native feature manifest and README; replaced references to remote/ npm-based Claude feature with the local native feature in devcontainer config.
Native Installer Script
\.devcontainer/features/claude-code-native/install.sh
New installer script: user detection, directory creation/ownership, downloads/runs Anthropic installer, verifies binary at ~/.local/bin/claude, and writes a post-start onboarding hook.
Setup / Update Scripts
\.devcontainer/scripts/setup-aliases.sh, \.devcontainer/scripts/setup-update-claude.sh, \.devcontainer/scripts/setup.sh, \.devcontainer/scripts/check-setup.sh
Changed hardcoded CLAUDE_BIN to native path, simplified detection, added npm-based uninstall transitional step, added timeout for updates, improved logging (preserve update output to log), and tightened existence checks to ~/.local/bin/claude.
Dependent Feature References
\.devcontainer/features/mcp-qdrant/devcontainer-feature.json, \.devcontainer/features/notify-hook/devcontainer-feature.json
Updated installsAfter references to point to the local ./features/claude-code-native instead of the remote Claude feature.

Sequence Diagram

sequenceDiagram
    participant Devcontainer as Devcontainer Build
    participant Feature as claude-code-native Feature
    participant Script as install.sh
    participant System as Host/Container
    participant Installer as Anthropic Installer
    participant Claude as claude Binary

    Devcontainer->>Feature: run feature install
    Feature->>Script: invoke install.sh
    Script->>System: detect target user & home
    Script->>System: create ~/.local/bin and data dirs
    Script->>Installer: download & execute official installer
    Installer->>System: place binary and verify checksum
    System->>Claude: write binary to ~/.local/bin/claude
    Script->>Claude: verify executable and query version
    Script->>Devcontainer: report success or error
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 I hopped through docs and scripts to play,

I fetched a binary and tucked npm away.
Installer danced, placed Claude in line,
Home in ~/.local/bin, all tidy and fine.
Carrots for logs, and updates that stay—hooray! 🥕

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: replacing the npm-based Claude Code installation with a native binary installation approach.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/native-claude-code

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (1)
.devcontainer/scripts/setup.sh (1)

101-101: LGTM — log capture fix is correct.

Redirecting both streams to the log file rather than /dev/null directly addresses the stated goal and is a straightforward improvement.

One thing worth keeping in mind: with >> (append) mode, if TMPDIR points to a persistent path (e.g., a workspace-mounted directory), the log grows unboundedly across container restarts with no rotation. Additionally, when TMPDIR is unset the log resolves to /tmp/claude-update.log, which diverges from /workspaces/.tmp/claude-update.log referenced in the PR test plan — testers may not find the log where expected.

💡 Optional: pin the log to a stable, discoverable path
-    bash "$SCRIPT_DIR/setup-update-claude.sh" >>"${TMPDIR:-/tmp}/claude-update.log" 2>&1 &
+    CLAUDE_UPDATE_LOG="${CLAUDE_UPDATE_LOG:-/workspaces/.tmp/claude-update.log}"
+    mkdir -p "$(dirname "$CLAUDE_UPDATE_LOG")"
+    bash "$SCRIPT_DIR/setup-update-claude.sh" >>"$CLAUDE_UPDATE_LOG" 2>&1 &

This makes the log location explicit, consistent with the test plan, and easily overridable via .env.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.devcontainer/scripts/setup.sh at line 101, Replace the current background
invocation that uses TMPDIR default ("bash
\"$SCRIPT_DIR/setup-update-claude.sh\" >>\"${TMPDIR:-/tmp}/claude-update.log\"
2>&1 &") with a pinned, discoverable log path (e.g. use a dedicated env var like
CLAUDE_LOG or default to /workspaces/.tmp/claude-update.log) so testers can
reliably find the log; keep the redirection of both stdout/stderr and
backgrounding, ensure you still reference SCRIPT_DIR when launching
setup-update-claude.sh, and allow overriding the log location via environment
variable.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In @.devcontainer/CHANGELOG.md:
- Around line 8-13: Rewrite the changelog block into user-facing outcome bullets
and move implementation/fix details under a new "### Fixed" section: describe
user-visible changes for the native-claude feature (e.g., "Claude now installed
via official native installer; in-session updates work without root" and
"background updates now log output"), then under "### Fixed" list the specific
fixes referencing the scripts and items by name (setup-update-claude.sh,
setup-aliases.sh, setup.sh, and the new ./features/claude-code-native feature),
converting lines that are currently implementation-centric (npm fallback
removal, timeout, alias resolution changes, logging fix) into brief fix entries
rather than technical how-to statements.

In @.devcontainer/features/claude-code-native/install.sh:
- Around line 17-21: The install script currently validates that either curl or
wget exists but later always calls curl (lines invoking curl) and leaves
${TARGET} unquoted in the su invocation; update the install logic to detect
which downloader is available (prefer curl then wget), store that choice in a
variable (e.g., DOWNLOADER) and use it for both download invocations instead of
hard-coding curl, and ensure the su command uses a quoted "${TARGET}" everywhere
to avoid word-splitting/injection; adjust references in the script to use
DOWNLOADER and quoted "${TARGET}" (and handle wget flags/behavior as needed) so
wget-only environments work and quoting is consistent.
- Around line 54-58: The script currently uses unvalidated ${VERSION} to set
TARGET and then injects TARGET unquoted into the non-root su command (su -
"${USERNAME}" -c "... sh -s -- ${TARGET}"), risking command injection; update
the logic that sets TARGET (based on VERSION/allowed values) to validate VERSION
against a safe whitelist or regex (e.g., only alphanumerics, dots, hyphens or
specifically allow "latest"/"stable") and fail fast on invalid values, and
ensure TARGET is always quoted when passed into commands invoked as another user
(change the su - "${USERNAME}" -c invocation to use "${TARGET}" instead of
${TARGET}); reference symbols: VERSION, TARGET, USERNAME and the su - ... -c
"... sh -s -- ${TARGET}" invocation.

In @.devcontainer/scripts/setup-update-claude.sh:
- Around line 57-66: The pipeline using timeout and tee checks only tee's exit
status, so failed or timed-out updates are treated as success; enable pipe
failure handling by either setting "set -o pipefail" before the pipeline or
explicitly capture the pipeline exit code via PIPESTATUS (e.g., check
PIPESTATUS[0] after running timeout "$NATIVE_BIN" update | tee -a "$LOG_FILE")
and use that exit code to decide success/failure when determining
UPDATED_VERSION and logging; update the conditional that references NATIVE_BIN,
CURRENT_VERSION, UPDATED_VERSION, and LOG_FILE to rely on the real
timeout/update exit code rather than tee's exit status.

---

Nitpick comments:
In @.devcontainer/scripts/setup.sh:
- Line 101: Replace the current background invocation that uses TMPDIR default
("bash \"$SCRIPT_DIR/setup-update-claude.sh\"
>>\"${TMPDIR:-/tmp}/claude-update.log\" 2>&1 &") with a pinned, discoverable log
path (e.g. use a dedicated env var like CLAUDE_LOG or default to
/workspaces/.tmp/claude-update.log) so testers can reliably find the log; keep
the redirection of both stdout/stderr and backgrounding, ensure you still
reference SCRIPT_DIR when launching setup-update-claude.sh, and allow overriding
the log location via environment variable.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between d2ba55e and 5fa1954.

📒 Files selected for processing (9)
  • .devcontainer/CHANGELOG.md
  • .devcontainer/CLAUDE.md
  • .devcontainer/devcontainer.json
  • .devcontainer/features/claude-code-native/README.md
  • .devcontainer/features/claude-code-native/devcontainer-feature.json
  • .devcontainer/features/claude-code-native/install.sh
  • .devcontainer/scripts/setup-aliases.sh
  • .devcontainer/scripts/setup-update-claude.sh
  • .devcontainer/scripts/setup.sh

Comment on lines 17 to 21
if ! command -v curl &>/dev/null && ! command -v wget &>/dev/null; then
echo "[claude-code-native] ERROR: curl or wget is required"
echo " Ensure common-utils feature is installed first"
exit 1
fi
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n .devcontainer/features/claude-code-native/install.sh

Repository: AnExiledDev/CodeForge

Length of output: 3481


Dependency check accepts wget, but installation code hard-requires curl.

Lines 17–21 validate that either curl or wget is available. However, both lines 68 and 70 unconditionally invoke curl, causing failure in wget-only environments despite passing validation.

Additionally, line 70 leaves ${TARGET} unquoted within the su command, creating an inconsistency with line 68 (which quotes it). If ${TARGET} contains spaces or special characters, this causes argument splitting or injection issues.

Proposed fix: detect available downloader and use it consistently. Also quote ${TARGET} on line 70 to match line 68.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.devcontainer/features/claude-code-native/install.sh around lines 17 - 21,
The install script currently validates that either curl or wget exists but later
always calls curl (lines invoking curl) and leaves ${TARGET} unquoted in the su
invocation; update the install logic to detect which downloader is available
(prefer curl then wget), store that choice in a variable (e.g., DOWNLOADER) and
use it for both download invocations instead of hard-coding curl, and ensure the
su command uses a quoted "${TARGET}" everywhere to avoid
word-splitting/injection; adjust references in the script to use DOWNLOADER and
quoted "${TARGET}" (and handle wget flags/behavior as needed) so wget-only
environments work and quoting is consistent.

@AnExiledDev
Copy link
Owner Author

Bug: install.sh fails during container build (2 issues)

Issue 1: Anthropic installer piped to sh instead of bash

Error:

sh: 9: Syntax error: "(" unexpected (expecting "then")

Cause: Lines 71 and 73 pipe claude.ai/install.sh to sh -s. On Debian/Ubuntu, /bin/sh is dash. The Anthropic installer uses bash syntax ([[ ]] on line 9), which dash can't parse.

Fix (install.sh lines 71, 73):

-	curl -fsSL https://claude.ai/install.sh | sh -s -- "${TARGET}"
+	curl -fsSL https://claude.ai/install.sh | bash -s -- "${TARGET}"
-	su - "${USERNAME}" -c "curl -fsSL https://claude.ai/install.sh | sh -s -- \"${TARGET}\""
+	su - "${USERNAME}" -c "curl -fsSL https://claude.ai/install.sh | bash -s -- \"${TARGET}\""

Note: The &>/dev/null on line 18 is also a bashism (same root cause — devcontainer features run under /bin/sh). Fix: >/dev/null 2>&1.


Issue 2: EACCES: permission denied, mkdir '/home/vscode/.local/state'

Error:

EACCES: permission denied, mkdir '/home/vscode/.local/state'

Cause: The script pre-creates ~/.local/bin and ~/.local/share/claude but the Anthropic installer also needs ~/.local/state and ~/.claude. When su - vscode drops privileges, the installer can't create these directories.

Fix (install.sh lines 48-50):

 mkdir -p "${USER_HOME}/.local/bin"
 mkdir -p "${USER_HOME}/.local/share/claude"
-chown -R "${USERNAME}:" "${USER_HOME}/.local/bin" "${USER_HOME}/.local/share/claude"
+mkdir -p "${USER_HOME}/.local/state"
+mkdir -p "${USER_HOME}/.claude"
+chown -R "${USERNAME}:" "${USER_HOME}/.local" "${USER_HOME}/.claude"

Using chown -R on ~/.local (the parent) instead of individual subdirs ensures any future paths the installer needs are also writable.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In @.devcontainer/features/claude-code-native/install.sh:
- Around line 112-121: The current block only injects hasCompletedOnboarding
when missing; change it to unconditionally set hasCompletedOnboarding to true in
the JSON file referenced by CLAUDE_JSON: if jq is available, use jq to assign
.hasCompletedOnboarding = true (overwrite existing value) and atomically mv the
temp file back to CLAUDE_JSON; otherwise use sed to replace any existing
"hasCompletedOnboarding": ... entry with "hasCompletedOnboarding": true or
append the field if missing. Keep references to CLAUDE_JSON, jq, sed and ensure
the script logs the same echo after forcing the value.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 5fa1954 and 9bd6e65.

📒 Files selected for processing (7)
  • .devcontainer/CHANGELOG.md
  • .devcontainer/features/claude-code-native/install.sh
  • .devcontainer/features/mcp-qdrant/devcontainer-feature.json
  • .devcontainer/features/notify-hook/devcontainer-feature.json
  • .devcontainer/scripts/check-setup.sh
  • .devcontainer/scripts/setup-update-claude.sh
  • .devcontainer/scripts/setup.sh
✅ Files skipped from review due to trivial changes (1)
  • .devcontainer/CHANGELOG.md

Comment on lines +112 to +121
if [ -f "$CLAUDE_JSON" ]; then
if ! grep -q '"hasCompletedOnboarding"' "$CLAUDE_JSON" 2>/dev/null; then
if command -v jq >/dev/null 2>&1; then
jq '. + {"hasCompletedOnboarding": true}' "$CLAUDE_JSON" > "${CLAUDE_JSON}.tmp" && \
mv "${CLAUDE_JSON}.tmp" "$CLAUDE_JSON"
else
sed -i '$ s/}$/,\n "hasCompletedOnboarding": true\n}/' "$CLAUDE_JSON"
fi
echo "[claude-onboarding] Injected hasCompletedOnboarding into .claude.json"
fi
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Ensure onboarding is forced to true, not just present.

Current logic only patches when the key is missing. If .claude.json already has "hasCompletedOnboarding": false, it will remain false.

🔧 Suggested fix
 if [ -f "$CLAUDE_JSON" ]; then
-    if ! grep -q '"hasCompletedOnboarding"' "$CLAUDE_JSON" 2>/dev/null; then
-        if command -v jq >/dev/null 2>&1; then
-            jq '. + {"hasCompletedOnboarding": true}' "$CLAUDE_JSON" > "${CLAUDE_JSON}.tmp" && \
-                mv "${CLAUDE_JSON}.tmp" "$CLAUDE_JSON"
-        else
-            sed -i '$ s/}$/,\n  "hasCompletedOnboarding": true\n}/' "$CLAUDE_JSON"
-        fi
-        echo "[claude-onboarding] Injected hasCompletedOnboarding into .claude.json"
-    fi
+    if command -v jq >/dev/null 2>&1; then
+        jq '.hasCompletedOnboarding = true' "$CLAUDE_JSON" > "${CLAUDE_JSON}.tmp" && \
+            mv "${CLAUDE_JSON}.tmp" "$CLAUDE_JSON"
+        echo "[claude-onboarding] Ensured hasCompletedOnboarding=true in .claude.json"
+    else
+        if grep -q '"hasCompletedOnboarding"[[:space:]]*:[[:space:]]*false' "$CLAUDE_JSON" 2>/dev/null; then
+            sed -i 's/"hasCompletedOnboarding"[[:space:]]*:[[:space:]]*false/"hasCompletedOnboarding": true/' "$CLAUDE_JSON"
+            echo "[claude-onboarding] Updated hasCompletedOnboarding to true in .claude.json"
+        elif ! grep -q '"hasCompletedOnboarding"' "$CLAUDE_JSON" 2>/dev/null; then
+            sed -i '$ s/}$/,\n  "hasCompletedOnboarding": true\n}/' "$CLAUDE_JSON"
+            echo "[claude-onboarding] Injected hasCompletedOnboarding into .claude.json"
+        fi
+    fi
 else
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.devcontainer/features/claude-code-native/install.sh around lines 112 - 121,
The current block only injects hasCompletedOnboarding when missing; change it to
unconditionally set hasCompletedOnboarding to true in the JSON file referenced
by CLAUDE_JSON: if jq is available, use jq to assign .hasCompletedOnboarding =
true (overwrite existing value) and atomically mv the temp file back to
CLAUDE_JSON; otherwise use sed to replace any existing "hasCompletedOnboarding":
... entry with "hasCompletedOnboarding": true or append the field if missing.
Keep references to CLAUDE_JSON, jq, sed and ensure the script logs the same echo
after forcing the value.

The Anthropic devcontainer feature installed Claude Code via npm as root,
making the package directory unwritable by the vscode user. This caused
the in-session auto-updater to fail with EACCES on every update attempt.

Replaces ghcr.io/anthropics/devcontainer-features/claude-code (npm) with
a new ./features/claude-code-native feature that uses Anthropic's official
native installer (https://claude.ai/install.sh). The native binary installs
to ~/.local/bin/claude owned by the container user, so claude update works
without permission issues.
- CHANGELOG: split entries into Changed (user outcomes) and Fixed (script fixes)
- install.sh: require curl explicitly, add semver validation, quote TARGET in su command, remove dead if/else
- setup-update-claude.sh: use PIPESTATUS[0] to capture timeout exit code instead of tee
- setup.sh: pin update log to /workspaces/.tmp/claude-update.log with override support
- notify-hook, mcp-qdrant: update installsAfter from old npm feature to ./features/claude-code-native
- check-setup.sh: remove stale /usr/local/bin/claude fallback
- Replace &>/dev/null with >/dev/null 2>&1 (POSIX)
- Require curl only (not wget) — matches official installer dependency
- Use bash instead of sh for piping installer (it requires bash)
- Quote ${TARGET} in su -c to prevent word splitting
- Pre-create ~/.local/state and ~/.claude directories
- Add 99-claude-onboarding.sh post-start hook to ensure
  hasCompletedOnboarding is set when token auth is configured
@AnExiledDev AnExiledDev force-pushed the feat/native-claude-code branch from 9bd6e65 to 1eebfc5 Compare February 26, 2026 02:55
@AnExiledDev AnExiledDev merged commit 8e0f1fd into main Feb 26, 2026
1 check passed
@AnExiledDev AnExiledDev deleted the feat/native-claude-code branch February 26, 2026 02:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant