Skip to content

Commit 324a5a3

Browse files
committed
Replace npm Claude Code with native binary installation
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.
1 parent 104da7f commit 324a5a3

9 files changed

Lines changed: 195 additions & 40 deletions

File tree

.devcontainer/CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,13 @@
1212

1313
### Changed
1414

15+
#### Claude Code Installation
16+
- **Replaced npm installation with native binary** — swapped `ghcr.io/anthropics/devcontainer-features/claude-code:1.0.5` (npm-based) for new `./features/claude-code-native` feature that installs via Anthropic's official native installer (`https://claude.ai/install.sh`)
17+
- **In-session auto-updater now works** — native binary installs to `~/.local/bin/claude` owned by the container user, so `claude update` can write freely without root permission issues
18+
- **setup-update-claude.sh** — stripped all npm fallback and `claude install` bootstrap code; now native-binary-only with 60s timeout and transitional npm cleanup
19+
- **setup-aliases.sh** — simplified `_CLAUDE_BIN` resolution to native binary path only (removed npm and `/usr/local/bin` fallbacks)
20+
- **setup.sh** — fixed background update script invocation to capture all output to log file instead of discarding via `&>/dev/null`
21+
1522
#### System Prompt
1623
- **`<git_worktrees>` section** — Updated to document Claude Code native worktree convention (`<repo>/.claude/worktrees/`) as the recommended approach alongside the legacy `.worktrees/` convention. Added `EnterWorktree` tool guidance, `.worktreeinclude` file documentation, and path convention comparison table.
1724

.devcontainer/CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,4 +173,4 @@ Labels are `custom-text` widgets with `merge: "no-padding"` so they fuse visuall
173173

174174
## Features
175175

176-
Custom features in `./features/` follow the [devcontainer feature spec](https://containers.dev/implementors/features/). Every local feature supports `"version": "none"` to skip installation. Claude Code is installed via `ghcr.io/anthropics/devcontainer-features/claude-code:1`.
176+
Custom features in `./features/` follow the [devcontainer feature spec](https://containers.dev/implementors/features/). Every local feature supports `"version": "none"` to skip installation. Claude Code is installed as a native binary via `./features/claude-code-native` (uses Anthropic's official installer at `https://claude.ai/install.sh`).

.devcontainer/devcontainer.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@
4545
},
4646

4747
// Feature install order: external runtimes first (Node, uv, Rust, Bun),
48-
// then Claude Code (needs Node), then custom features.
48+
// then Claude Code native binary (no Node dependency), then custom features.
4949
// npm-dependent features (agent-browser, ccusage, ccburn, claude-session-dashboard,
5050
// biome, lsp-servers) must come after Node. uv-dependent features (ruff, claude-monitor) must
5151
// come after uv. cargo-dependent features (ccms) must come after Rust.
@@ -57,7 +57,7 @@
5757
"ghcr.io/devcontainers-extra/features/uv",
5858
"ghcr.io/rails/devcontainer/features/bun",
5959
"ghcr.io/devcontainers/features/rust",
60-
"ghcr.io/anthropics/devcontainer-features/claude-code",
60+
"./features/claude-code-native",
6161
"./features/tmux",
6262
"./features/agent-browser",
6363
"./features/claude-monitor",
@@ -96,7 +96,7 @@
9696
},
9797
// Uncomment to add Go runtime (not installed by default):
9898
// "ghcr.io/devcontainers/features/go:1": {},
99-
"ghcr.io/anthropics/devcontainer-features/claude-code:1.0.5": {},
99+
"./features/claude-code-native": {},
100100
"./features/tmux": {},
101101
"./features/ccusage": {
102102
"version": "latest",
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# Claude Code CLI (Native Binary)
2+
3+
Installs [Claude Code](https://docs.anthropic.com/en/docs/claude-code) as a native binary using Anthropic's official installer.
4+
5+
Unlike the npm-based installation (`ghcr.io/anthropics/devcontainer-features/claude-code`), this feature installs the native binary directly to `~/.local/bin/claude`. The binary is owned by the container user, so the in-session auto-updater works without permission issues.
6+
7+
## Options
8+
9+
| Option | Default | Description |
10+
|--------|---------|-------------|
11+
| `version` | `latest` | `latest`, `stable`, or a specific semver (e.g., `2.1.52`). Set to `none` to skip. |
12+
| `username` | `automatic` | Container user to install for. `automatic` detects from `$_REMOTE_USER`. |
13+
14+
## How it works
15+
16+
1. Downloads the official installer from `https://claude.ai/install.sh`
17+
2. Runs it as the target user (not root)
18+
3. The installer handles platform detection, checksum verification, and binary placement
19+
4. Binary is installed to `~/.local/bin/claude` with versions stored in `~/.local/share/claude/versions/`
20+
21+
## Usage
22+
23+
```json
24+
{
25+
"features": {
26+
"./features/claude-code-native": {}
27+
}
28+
}
29+
```
30+
31+
With version pinning:
32+
33+
```json
34+
{
35+
"features": {
36+
"./features/claude-code-native": {
37+
"version": "2.1.52"
38+
}
39+
}
40+
}
41+
```
42+
43+
## Why native over npm?
44+
45+
The npm installation (`npm install -g @anthropic-ai/claude-code`) runs as root during the Docker build, creating a package owned by `root`. When the container user tries to auto-update Claude Code in-session, it fails with `EACCES` because it can't write to the root-owned package directory.
46+
47+
The native binary installs to `~/.local/` under the container user's ownership, so `claude update` works without elevated permissions.
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
{
2+
"id": "claude-code-native",
3+
"version": "1.0.0",
4+
"name": "Claude Code CLI (Native Binary)",
5+
"description": "Installs Claude Code CLI as a native binary via the official Anthropic installer",
6+
"documentationURL": "https://docs.anthropic.com/en/docs/claude-code",
7+
"options": {
8+
"version": {
9+
"type": "string",
10+
"description": "Version to install: 'latest', 'stable', or a specific semver. Use 'none' to skip.",
11+
"default": "latest"
12+
},
13+
"username": {
14+
"type": "string",
15+
"description": "Container user to install for",
16+
"default": "automatic"
17+
}
18+
},
19+
"customizations": {
20+
"vscode": {
21+
"extensions": [
22+
"anthropic.claude-code"
23+
]
24+
}
25+
},
26+
"installsAfter": [
27+
"ghcr.io/devcontainers/features/common-utils:2"
28+
]
29+
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
#!/bin/bash
2+
set -euo pipefail
3+
4+
VERSION="${VERSION:-latest}"
5+
USERNAME="${USERNAME:-automatic}"
6+
7+
# Skip installation if version is "none"
8+
if [ "${VERSION}" = "none" ]; then
9+
echo "[claude-code-native] Skipping installation (version=none)"
10+
exit 0
11+
fi
12+
13+
echo "[claude-code-native] Starting installation..."
14+
echo "[claude-code-native] Version: ${VERSION}"
15+
16+
# === VALIDATE DEPENDENCIES ===
17+
if ! command -v curl &>/dev/null && ! command -v wget &>/dev/null; then
18+
echo "[claude-code-native] ERROR: curl or wget is required"
19+
echo " Ensure common-utils feature is installed first"
20+
exit 1
21+
fi
22+
23+
# === DETECT USER ===
24+
if [ "${USERNAME}" = "auto" ] || [ "${USERNAME}" = "automatic" ]; then
25+
if [ -n "${_REMOTE_USER:-}" ]; then
26+
USERNAME="${_REMOTE_USER}"
27+
elif getent passwd vscode >/dev/null 2>&1; then
28+
USERNAME="vscode"
29+
elif getent passwd node >/dev/null 2>&1; then
30+
USERNAME="node"
31+
elif getent passwd codespace >/dev/null 2>&1; then
32+
USERNAME="codespace"
33+
else
34+
USERNAME="root"
35+
fi
36+
fi
37+
38+
USER_HOME=$(getent passwd "${USERNAME}" | cut -d: -f6)
39+
if [ -z "${USER_HOME}" ]; then
40+
echo "[claude-code-native] ERROR: Could not determine home directory for ${USERNAME}"
41+
exit 1
42+
fi
43+
44+
echo "[claude-code-native] Installing for user: ${USERNAME} (home: ${USER_HOME})"
45+
46+
# === PREPARE DIRECTORIES ===
47+
mkdir -p "${USER_HOME}/.local/bin"
48+
mkdir -p "${USER_HOME}/.local/share/claude"
49+
chown -R "${USERNAME}:" "${USER_HOME}/.local/bin" "${USER_HOME}/.local/share/claude"
50+
51+
# === DETERMINE TARGET ===
52+
# The official installer accepts: stable, latest, or a specific semver
53+
TARGET=""
54+
if [ "${VERSION}" != "latest" ] && [ "${VERSION}" != "stable" ]; then
55+
TARGET="${VERSION}"
56+
else
57+
TARGET="${VERSION}"
58+
fi
59+
60+
# === INSTALL ===
61+
# The official Anthropic installer handles:
62+
# - Platform detection (linux/darwin, x64/arm64, glibc/musl)
63+
# - Manifest download and checksum verification
64+
# - Binary download to ~/.local/bin/claude (symlink to ~/.local/share/claude/versions/*)
65+
echo "[claude-code-native] Downloading official installer..."
66+
67+
if [ "${USERNAME}" = "root" ]; then
68+
curl -fsSL https://claude.ai/install.sh | sh -s -- "${TARGET}"
69+
else
70+
su - "${USERNAME}" -c "curl -fsSL https://claude.ai/install.sh | sh -s -- ${TARGET}"
71+
fi
72+
73+
# === VERIFICATION ===
74+
CLAUDE_BIN="${USER_HOME}/.local/bin/claude"
75+
76+
if [ -x "${CLAUDE_BIN}" ]; then
77+
INSTALLED_VERSION=$(su - "${USERNAME}" -c "${CLAUDE_BIN} --version 2>/dev/null" || echo "unknown")
78+
echo "[claude-code-native] ✓ Claude Code installed: ${INSTALLED_VERSION}"
79+
echo "[claude-code-native] Binary: ${CLAUDE_BIN}"
80+
else
81+
echo "[claude-code-native] ERROR: Installation failed — ${CLAUDE_BIN} not found or not executable"
82+
echo "[claude-code-native] Expected binary at: ${CLAUDE_BIN}"
83+
ls -la "${USER_HOME}/.local/bin/" 2>/dev/null || true
84+
exit 1
85+
fi
86+
87+
echo "[claude-code-native] Installation complete"

.devcontainer/scripts/setup-aliases.sh

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -80,14 +80,8 @@ if [ "\$TERM" = "xterm" ] || [ -z "\$TERM" ]; then
8080
fi
8181
export COLORTERM="\${COLORTERM:-truecolor}"
8282
83-
# Prefer native binary over npm-installed version
84-
if [ -x "\$HOME/.local/bin/claude" ]; then
85-
_CLAUDE_BIN="\$HOME/.local/bin/claude"
86-
elif [ -x /usr/local/bin/claude ]; then
87-
_CLAUDE_BIN=/usr/local/bin/claude
88-
else
89-
_CLAUDE_BIN=claude
90-
fi
83+
# Native binary (installed by claude-code-native feature)
84+
_CLAUDE_BIN="\$HOME/.local/bin/claude"
9185
9286
# ChromaTerm wrapper (if ct is installed, wrap claude through it)
9387
if command -v ct >/dev/null 2>&1; then

.devcontainer/scripts/setup-update-claude.sh

Lines changed: 18 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -25,52 +25,43 @@ fi
2525

2626
# === CLEANUP TRAP ===
2727
cleanup() {
28-
rm -f "${_TMPDIR}/claude-update" 2>/dev/null || true
29-
rm -f "${_TMPDIR}/claude-update-manifest.json" 2>/dev/null || true
3028
rm -rf "$LOCK_FILE" 2>/dev/null || true
3129
}
3230
trap cleanup EXIT
3331

34-
# === VERIFY CLAUDE IS INSTALLED ===
35-
if ! command -v claude &>/dev/null; then
36-
log "Claude Code not found, skipping update"
37-
exit 0
38-
fi
32+
# === NATIVE BINARY ===
33+
NATIVE_BIN="$HOME/.local/bin/claude"
3934

40-
# === ENSURE NATIVE BINARY EXISTS ===
41-
# 'claude install' puts the binary at ~/.local/bin/claude (symlink to ~/.local/share/claude/versions/*)
42-
# Legacy manual installs used /usr/local/bin/claude — check both, prefer ~/.local
43-
if [ -x "$HOME/.local/bin/claude" ]; then
44-
NATIVE_BIN="$HOME/.local/bin/claude"
45-
elif [ -x "/usr/local/bin/claude" ]; then
46-
NATIVE_BIN="/usr/local/bin/claude"
47-
else
48-
NATIVE_BIN=""
35+
if [ ! -x "$NATIVE_BIN" ]; then
36+
log "ERROR: Native binary not found at ${NATIVE_BIN}"
37+
log " The claude-code-native feature should install this during container build."
38+
log " Try rebuilding the container or running: curl -fsSL https://claude.ai/install.sh | sh"
39+
exit 1
4940
fi
50-
if [ -z "$NATIVE_BIN" ]; then
51-
log "Native binary not found, installing..."
52-
if claude install 2>&1 | tee -a "$LOG_FILE"; then
53-
log "Native binary installed successfully"
41+
42+
# === TRANSITIONAL: Remove leftover npm installation ===
43+
NPM_CLAUDE="$(npm config get prefix 2>/dev/null)/lib/node_modules/@anthropic-ai/claude-code"
44+
if [ -d "$NPM_CLAUDE" ]; then
45+
log "Removing leftover npm installation at ${NPM_CLAUDE}..."
46+
if sudo npm uninstall -g @anthropic-ai/claude-code 2>/dev/null; then
47+
log "Removed leftover npm installation"
5448
else
55-
log "WARNING: 'claude install' failed, skipping"
56-
exit 0
49+
log "WARNING: Could not remove npm installation (non-blocking)"
5750
fi
58-
# Skip update check on first install — next start will handle it
59-
exit 0
6051
fi
6152

6253
# === CHECK FOR UPDATES ===
6354
CURRENT_VERSION=$("$NATIVE_BIN" --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || echo "unknown")
6455
log "Current version: ${CURRENT_VERSION}"
6556

66-
# Use the official update command (handles download, verification, and versioned install)
67-
if "$NATIVE_BIN" update 2>&1 | tee -a "$LOG_FILE"; then
57+
# Use the official update command with timeout (handles download, verification, and versioned install)
58+
if timeout 60 "$NATIVE_BIN" update 2>&1 | tee -a "$LOG_FILE"; then
6859
UPDATED_VERSION=$("$NATIVE_BIN" --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || echo "unknown")
6960
if [ "$CURRENT_VERSION" != "$UPDATED_VERSION" ]; then
7061
log "Updated Claude Code: ${CURRENT_VERSION}${UPDATED_VERSION}"
7162
else
7263
log "Already up to date (${CURRENT_VERSION})"
7364
fi
7465
else
75-
log "WARNING: 'claude update' failed, skipping"
66+
log "WARNING: 'claude update' failed or timed out"
7667
fi

.devcontainer/scripts/setup.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ run_script "$SCRIPT_DIR/setup-terminal.sh" "$SETUP_TERMINAL"
118118

119119
# Background the update to avoid blocking container start
120120
if [ "$SETUP_UPDATE_CLAUDE" = "true" ] && [ -f "$SCRIPT_DIR/setup-update-claude.sh" ]; then
121-
bash "$SCRIPT_DIR/setup-update-claude.sh" &>/dev/null &
121+
bash "$SCRIPT_DIR/setup-update-claude.sh" >>"${TMPDIR:-/tmp}/claude-update.log" 2>&1 &
122122
disown
123123
SETUP_RESULTS+=("setup-update-claude:background")
124124
else

0 commit comments

Comments
 (0)