Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 8 additions & 21 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ Flutter and Dart binaries are added to `PATH` automatically.

### Claude Code

Installs [Claude Code](https://docs.anthropic.com/en/docs/claude-code), Anthropic's official CLI for Claude. Supports latest and pinned npm versions.
Installs [Claude Code](https://docs.anthropic.com/en/docs/claude-code), Anthropic's official CLI for Claude via the official installer. Supports latest and pinned versions.

```jsonc
// devcontainer.json
Expand All @@ -88,7 +88,7 @@ Installs [Claude Code](https://docs.anthropic.com/en/docs/claude-code), Anthropi

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `version` | string | `latest` | npm version: `latest` or a specific version (e.g. `1.0.3`) |
| `version` | string | `latest` | Version to install: `latest` or a specific version (e.g. `1.0.3`) |

#### Examples

Expand Down Expand Up @@ -118,28 +118,15 @@ The feature does not set `ANTHROPIC_API_KEY` automatically. Pass it from your ho

This injects the key at runtime without baking it into the Docker image layer.

#### Persisting Configuration
#### Persistent Configuration

To persist your Claude config across container rebuilds, add bind mounts to your `devcontainer.json`:
Configuration persists automatically via a Docker named volume mounted at `/claude-config`. On each container start, a symlink `~/.claude -> /claude-config` is created for the logged-in user.

```jsonc
{
"mounts": [
{
"source": "${localEnv:HOME}/.claude",
"target": "/home/vscode/.claude",
"type": "bind"
},
{
"source": "${localEnv:HOME}/.claude.json",
"target": "/home/vscode/.claude.json",
"type": "bind"
}
]
}
```
- No configuration required — it works out of the box
- Authentication and settings survive container rebuilds
- Each devcontainer gets its own isolated volume (`claude-code-config-<devcontainerId>`)

Requires the [Node.js feature](https://github.com/devcontainers/features/tree/main/src/node) (`ghcr.io/devcontainers/features/node`).
No additional features required — Claude Code is installed as a standalone binary.

---

Expand Down
17 changes: 11 additions & 6 deletions src/claude-code/devcontainer-feature.json
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
{
"id": "claude-code",
"version": "1.0.0",
"version": "1.1.0",
"name": "Claude Code",
"description": "Installs Claude Code, Anthropic's official CLI for Claude. Supports latest and pinned npm versions. To pass your API key, add '\"remoteEnv\": { \"ANTHROPIC_API_KEY\": \"${localEnv:ANTHROPIC_API_KEY}\" }' in your devcontainer.json. To persist config, bind mount ~/.claude and ~/.claude.json.",
"description": "Installs Claude Code, Anthropic's official CLI for Claude. Supports latest and pinned versions. Configuration persists automatically via a Docker volume.",
"documentationURL": "https://docs.anthropic.com/en/docs/claude-code",
"licenseURL": "https://github.com/anthropics/claude-code/blob/main/LICENSE",
"options": {
"version": {
"type": "string",
"default": "latest",
"description": "npm version to install: 'latest' or a specific version (e.g. '1.0.3')"
"description": "Version to install: 'latest' or a specific version (e.g. '1.0.3')"
}
},
"customizations": {
Expand All @@ -24,7 +24,12 @@
]
}
},
"installsAfter": [
"ghcr.io/devcontainers/features/node"
]
"mounts": [
{
"source": "claude-code-config-${devcontainerId}",
"target": "/claude-config",
"type": "volume"
}
],
"postStartCommand": "/usr/local/bin/link-claude-config"
}
50 changes: 26 additions & 24 deletions src/claude-code/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -6,39 +6,41 @@ VERSION="${VERSION:-latest}"

echo "==> Claude Code feature: version=${VERSION}"

# Node.js and npm are required — install if missing
if ! command -v npm &>/dev/null; then
echo "==> npm not found, installing Node.js..."
# Ensure curl is available (minimal images may not have it)
if ! command -v curl &>/dev/null; then
echo "==> curl not found, installing..."
apt-get update
apt-get install -y curl ca-certificates gnupg
mkdir -p /etc/apt/keyrings
curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key \
| gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg
echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_22.x nodistro main" \
> /etc/apt/sources.list.d/nodesource.list
apt-get update
apt-get install -y nodejs
apt-get install -y curl ca-certificates
fi

echo "==> npm version: $(npm --version)"
echo "==> node version: $(node --version)"

# Build the npm package reference: latest or pinned
# Install Claude Code via official installer
if [ "$VERSION" = "latest" ]; then
NPM_PACKAGE="@anthropic-ai/claude-code"
curl -fsSL https://claude.ai/install.sh | bash
else
NPM_PACKAGE="@anthropic-ai/claude-code@${VERSION}"
curl -fsSL https://claude.ai/install.sh | bash -s -- "$VERSION"
fi

echo "==> Installing ${NPM_PACKAGE}..."
npm install -g "$NPM_PACKAGE"

# Verify the binary is accessible
if ! command -v claude &>/dev/null; then
echo "ERROR: 'claude' binary not found on PATH after install."
echo "npm global bin: $(npm bin -g 2>/dev/null || echo 'unknown')"
# The installer places the binary in ~/.local/bin (under the build user's $HOME).
# Copy it to /usr/local/bin so all container users can access it regardless of
# home directory permissions (e.g. /root is 700).
CLAUDE_BIN="$HOME/.local/bin/claude"
if [ ! -f "$CLAUDE_BIN" ]; then
echo "ERROR: expected binary at $CLAUDE_BIN not found after install."
exit 1
fi
install -m 0755 "$CLAUDE_BIN" /usr/local/bin/claude

echo "==> Claude Code $(claude --version) installed at $(command -v claude)"

# Pre-create the volume mount point with correct ownership so that Docker
# named volumes inherit the ownership on first use (no sudo needed later).
if [ -n "${_REMOTE_USER:-}" ]; then
mkdir -p /claude-config
chown "$_REMOTE_USER:$_REMOTE_USER" /claude-config
fi

# Install the post-start script for persistent storage symlink
install -d /usr/local/bin
install -m 0755 ./link-claude-config.sh /usr/local/bin/link-claude-config

echo "==> Claude Code feature install complete"
32 changes: 32 additions & 0 deletions src/claude-code/link-claude-config.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
#!/bin/sh
set -eu

# Auto-detect the current user
username="$(whoami 2>/dev/null || true)"

# No username detected? Exit gracefully (volume still mounted at /claude-config)
[ -n "$username" ] || exit 0

# Find home directory for that user
home_dir="$(getent passwd "$username" | cut -d: -f6 || true)"
if [ -z "$home_dir" ]; then
echo "claude-config: user '$username' not found; skipping" >&2
exit 0
fi

mkdir -p "$home_dir"

# Create/replace symlink: ~/.claude -> /claude-config
echo "claude-config: $home_dir/.claude -> /claude-config" >&2
ln -snf /claude-config "$home_dir/.claude"

# Persist ~/.claude.json inside the volume and symlink it
config_file="/claude-config/claude.json"
target_file="$home_dir/.claude.json"
if [ -f "$target_file" ] && [ ! -L "$target_file" ]; then
# First run: move existing file into the volume
mv "$target_file" "$config_file"
fi
touch "$config_file"
echo "claude-config: $target_file -> $config_file" >&2
ln -snf "$config_file" "$target_file"
4 changes: 2 additions & 2 deletions test/claude-code/install_claude_code_specific_version.sh
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,10 @@ set -e
source "$(dirname "$0")/test.sh"

# Additional check: verify the exact version was installed
INSTALLED=$(npm list -g @anthropic-ai/claude-code --depth=0 2>/dev/null | grep @anthropic-ai/claude-code | grep -oP '\d+\.\d+\.\d+')
INSTALLED=$(claude --version 2>&1 | grep -oP '\d+\.\d+\.\d+')
EXPECTED="2.1.30"
if [ "$INSTALLED" != "$EXPECTED" ]; then
echo "FAIL: Expected @anthropic-ai/claude-code@${EXPECTED}, got ${INSTALLED}"
echo "FAIL: Expected version ${EXPECTED}, got ${INSTALLED}"
exit 1
fi
echo "PASS: Correct version ${EXPECTED} installed"
7 changes: 0 additions & 7 deletions test/claude-code/test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,4 @@ if [ -z "$CLAUDE_VERSION" ]; then
fi
echo "PASS: claude --version => ${CLAUDE_VERSION}"

# npm package must be registered globally
if ! npm list -g @anthropic-ai/claude-code --depth=0 &>/dev/null; then
echo "FAIL: @anthropic-ai/claude-code not found in npm global packages"
exit 1
fi
echo "PASS: @anthropic-ai/claude-code present in npm global packages"

echo "==> All Claude Code feature tests passed"
Loading