Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
3467dea
refactor(claude-code): slim to install-and-configure only
matifali Apr 22, 2026
2c69ab9
refactor(claude-code): drop ai bridge, claude_md_path, and opinionate…
matifali Apr 22, 2026
430fbdc
feat(claude-code): add `env` map for arbitrary env vars
matifali Apr 22, 2026
6a86d74
refactor(claude-code): drop auth shortcuts, use only `env` map
matifali Apr 22, 2026
802950d
docs(claude-code): add explicit AI Bridge example
matifali Apr 22, 2026
2b1d58e
fix(claude-code): handle pre-installed Claude binary cases
matifali Apr 22, 2026
79a9a17
refactor(claude-code): feed install script directly to coder-utils
matifali Apr 22, 2026
d8d5977
feat(claude-code): brand coder-utils scripts with display_name and icon
matifali Apr 22, 2026
9f0b0b0
docs(claude-code): rename AI Bridge to AI Gateway and document script…
matifali Apr 22, 2026
6bf210c
test(claude-code): assert only install script is created when pre/pos…
matifali Apr 22, 2026
49d1d30
test(claude-code): add example MCP JSON fixture for e2e tests
matifali Apr 22, 2026
b118b1d
fix(claude-code): harden install script against shell injection and l…
matifali Apr 22, 2026
1616ec4
test(claude-code): rename happy-path, assert script counts, dedupe casts
matifali Apr 22, 2026
c03be50
docs(claude-code): warn Tasks and AgentAPI users to stay on v4
matifali Apr 22, 2026
615d41a
feat(claude-code): emit scripts output for downstream composition
matifali Apr 22, 2026
2669f30
docs(claude-code): add unattended-mode example for template admins
matifali Apr 22, 2026
512f3b0
docs(claude-code): add Bedrock and Vertex examples, tighten README
matifali Apr 22, 2026
6534587
refactor(claude-code): delegate scripts output filtering to coder-utils
matifali Apr 22, 2026
1384fd6
refactor(claude-code): move module_directory to $HOME/.coder-modules/…
matifali Apr 22, 2026
dbb2c21
docs(claude-code): tighten README intro
matifali Apr 22, 2026
78ecee6
docs(claude-code): strip Terraform and Coder-internal jargon from README
matifali Apr 22, 2026
ee22f40
feat(claude-code): restore model, oauth_token, ai_gateway, auto_updat…
matifali Apr 22, 2026
b0a1612
refactor(claude-code): rename disable_auto_updater to disable_autoupd…
matifali Apr 22, 2026
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
526 changes: 254 additions & 272 deletions registry/coder/modules/claude-code/README.md

Large diffs are not rendered by default.

642 changes: 260 additions & 382 deletions registry/coder/modules/claude-code/main.test.ts

Large diffs are not rendered by default.

488 changes: 134 additions & 354 deletions registry/coder/modules/claude-code/main.tf

Large diffs are not rendered by default.

477 changes: 221 additions & 256 deletions registry/coder/modules/claude-code/main.tftest.hcl

Large diffs are not rendered by default.

297 changes: 117 additions & 180 deletions registry/coder/modules/claude-code/scripts/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,66 +2,67 @@

set -euo pipefail

BOLD='\033[0;1m'

command_exists() {
command -v "$1" > /dev/null 2>&1
}

ARG_CLAUDE_CODE_VERSION=${ARG_CLAUDE_CODE_VERSION:-}
ARG_WORKDIR=${ARG_WORKDIR:-"$HOME"}
ARG_INSTALL_CLAUDE_CODE=${ARG_INSTALL_CLAUDE_CODE:-}
# Decode every ARG_* from base64. Terraform base64-encodes all values so that
# attacker-controlled input (e.g. a workspace parameter forwarded into
# `claude_code_version`) cannot break out of the shell literal and inject
# commands. An empty input decodes to an empty string.
decode_arg() {
local raw="${1:-}"
if [ -z "$raw" ]; then
printf ''
return
fi
printf '%s' "$raw" | base64 -d
}

ARG_CLAUDE_CODE_VERSION=$(decode_arg "${ARG_CLAUDE_CODE_VERSION:-}")
ARG_CLAUDE_CODE_VERSION=${ARG_CLAUDE_CODE_VERSION:-latest}
ARG_INSTALL_CLAUDE_CODE=$(decode_arg "${ARG_INSTALL_CLAUDE_CODE:-}")
ARG_INSTALL_CLAUDE_CODE=${ARG_INSTALL_CLAUDE_CODE:-true}
ARG_CLAUDE_BINARY_PATH=$(decode_arg "${ARG_CLAUDE_BINARY_PATH:-}")
ARG_CLAUDE_BINARY_PATH=${ARG_CLAUDE_BINARY_PATH:-"$HOME/.local/bin"}
ARG_CLAUDE_BINARY_PATH="${ARG_CLAUDE_BINARY_PATH/#\~/$HOME}"
ARG_CLAUDE_BINARY_PATH="${ARG_CLAUDE_BINARY_PATH//\$HOME/$HOME}"
ARG_INSTALL_VIA_NPM=${ARG_INSTALL_VIA_NPM:-false}
ARG_REPORT_TASKS=${ARG_REPORT_TASKS:-true}
ARG_MCP_APP_STATUS_SLUG=${ARG_MCP_APP_STATUS_SLUG:-}
ARG_MCP=$(echo -n "${ARG_MCP:-}" | base64 -d)
ARG_MCP_CONFIG_REMOTE_PATH=$(echo -n "${ARG_MCP_CONFIG_REMOTE_PATH:-}" | base64 -d)
ARG_ALLOWED_TOOLS=${ARG_ALLOWED_TOOLS:-}
ARG_DISALLOWED_TOOLS=${ARG_DISALLOWED_TOOLS:-}
ARG_ENABLE_AIBRIDGE=${ARG_ENABLE_AIBRIDGE:-false}
ARG_PERMISSION_MODE=${ARG_PERMISSION_MODE:-}
ARG_MCP=$(decode_arg "${ARG_MCP:-}")
ARG_MCP_CONFIG_REMOTE_PATH=$(decode_arg "${ARG_MCP_CONFIG_REMOTE_PATH:-}")

export PATH="$ARG_CLAUDE_BINARY_PATH:$PATH"

# Log only non-sensitive ARG_* values. ARG_MCP (inline JSON) and
# ARG_MCP_CONFIG_REMOTE_PATH (URL list) may contain credentials embedded in
# MCP server configs or internal URLs, so we log only presence, not content.
echo "--------------------------------"

printf "ARG_CLAUDE_CODE_VERSION: %s\n" "$ARG_CLAUDE_CODE_VERSION"
printf "ARG_WORKDIR: %s\n" "$ARG_WORKDIR"
printf "ARG_INSTALL_CLAUDE_CODE: %s\n" "$ARG_INSTALL_CLAUDE_CODE"
printf "ARG_CLAUDE_BINARY_PATH: %s\n" "$ARG_CLAUDE_BINARY_PATH"
printf "ARG_INSTALL_VIA_NPM: %s\n" "$ARG_INSTALL_VIA_NPM"
printf "ARG_REPORT_TASKS: %s\n" "$ARG_REPORT_TASKS"
printf "ARG_MCP_APP_STATUS_SLUG: %s\n" "$ARG_MCP_APP_STATUS_SLUG"
printf "ARG_MCP: %s\n" "$ARG_MCP"
printf "ARG_MCP_CONFIG_REMOTE_PATH: %s\n" "$ARG_MCP_CONFIG_REMOTE_PATH"
printf "ARG_ALLOWED_TOOLS: %s\n" "$ARG_ALLOWED_TOOLS"
printf "ARG_DISALLOWED_TOOLS: %s\n" "$ARG_DISALLOWED_TOOLS"
printf "ARG_ENABLE_AIBRIDGE: %s\n" "$ARG_ENABLE_AIBRIDGE"

if [ -n "$ARG_MCP" ]; then
printf "ARG_MCP: [set, %d bytes]\n" "${#ARG_MCP}"
else
printf "ARG_MCP: [unset]\n"
fi
if [ -n "$ARG_MCP_CONFIG_REMOTE_PATH" ] && [ "$ARG_MCP_CONFIG_REMOTE_PATH" != "[]" ]; then
local_url_count=$(echo "$ARG_MCP_CONFIG_REMOTE_PATH" | jq -r '. | length' 2> /dev/null || echo "?")
printf "ARG_MCP_CONFIG_REMOTE_PATH: [%s URL(s)]\n" "$local_url_count"
else
printf "ARG_MCP_CONFIG_REMOTE_PATH: [unset]\n"
fi
echo "--------------------------------"

function add_mcp_servers() {
local mcp_json="$1"
local source_desc="$2"

while IFS= read -r server_name && IFS= read -r server_json; do
echo "------------------------"
echo "Executing: claude mcp add-json \"$server_name\" '$server_json' ($source_desc)"
claude mcp add-json "$server_name" "$server_json" || echo "Warning: Failed to add MCP server '$server_name', continuing..."
echo "------------------------"
echo ""
done < <(echo "$mcp_json" | jq -r '.mcpServers | to_entries[] | .key, (.value | @json)')
}

function add_path_to_shell_profiles() {
# Ensures $ARG_CLAUDE_BINARY_PATH is on PATH across the common shell profiles
# so interactive shells started by the user can find the installed claude
# binary.
add_path_to_shell_profiles() {
local path_dir="$1"

for profile in "$HOME/.profile" "$HOME/.bash_profile" "$HOME/.bashrc" "$HOME/.zprofile" "$HOME/.zshrc"; do
if [ -f "$profile" ]; then
if ! grep -q "$path_dir" "$profile" 2> /dev/null; then
# grep -F treats the path as a literal string so regex metacharacters
# (uncommon but valid in paths) don't cause false negatives.
if ! grep -qF "$path_dir" "$profile" 2> /dev/null; then
echo "export PATH=\"\$PATH:$path_dir\"" >> "$profile"
echo "Added $path_dir to $profile"
fi
Expand All @@ -70,14 +71,16 @@ function add_path_to_shell_profiles() {

local fish_config="$HOME/.config/fish/config.fish"
if [ -f "$fish_config" ]; then
if ! grep -q "$path_dir" "$fish_config" 2> /dev/null; then
if ! grep -qF "$path_dir" "$fish_config" 2> /dev/null; then
echo "fish_add_path $path_dir" >> "$fish_config"
echo "Added $path_dir to $fish_config"
fi
fi
}

function ensure_claude_in_path() {
# Resolves the claude binary, symlinks it into CODER_SCRIPT_BIN_DIR so the
# agent's coder_script context can call it, and updates shell profiles.
ensure_claude_in_path() {
local CLAUDE_BIN=""
if command -v claude > /dev/null 2>&1; then
CLAUDE_BIN=$(command -v claude)
Expand All @@ -103,163 +106,97 @@ function ensure_claude_in_path() {
add_path_to_shell_profiles "$CLAUDE_DIR"
}

function install_claude_code_cli() {
# Totals across all MCP sources. Populated by add_mcp_servers, inspected at
# the end of apply_mcp so the user sees whether any server actually landed.
MCP_ADDED=0
MCP_FAILED=0

# Adds each MCP server from the provided JSON at user scope. The claude CLI
# writes to ~/.claude.json; this module does not touch that file directly.
add_mcp_servers() {
local mcp_json="$1"
local source_desc="$2"

while IFS= read -r server_name && IFS= read -r server_json; do
echo "------------------------"
echo "Executing: claude mcp add-json --scope user \"$server_name\" ($source_desc)"
if claude mcp add-json --scope user "$server_name" "$server_json"; then
MCP_ADDED=$((MCP_ADDED + 1))
else
MCP_FAILED=$((MCP_FAILED + 1))
echo "Warning: Failed to add MCP server '$server_name', continuing..."
fi
echo "------------------------"
done < <(echo "$mcp_json" | jq -r '.mcpServers | to_entries[] | .key, (.value | @json)')
}

install_claude_code_cli() {
if [ "$ARG_INSTALL_CLAUDE_CODE" != "true" ]; then
echo "Skipping Claude Code installation as per configuration."
ensure_claude_in_path
return
fi

# Use npm when install_via_npm is true
if [ "$ARG_INSTALL_VIA_NPM" = "true" ]; then
echo "WARNING: npm installation method will be deprecated and removed in the next major release."
echo "Installing Claude Code via npm (version: $ARG_CLAUDE_CODE_VERSION)"
npm install -g "@anthropic-ai/claude-code@$ARG_CLAUDE_CODE_VERSION"
echo "Installed Claude Code via npm. Version: $(claude --version || echo 'unknown')"
else
echo "Installing Claude Code via official installer"
set +e
curl -fsSL claude.ai/install.sh | bash -s -- "$ARG_CLAUDE_CODE_VERSION" 2>&1
CURL_EXIT=${PIPESTATUS[0]}
set -e
if [ $CURL_EXIT -ne 0 ]; then
echo "Claude Code installer failed with exit code $CURL_EXIT"
fi
echo "Installed Claude Code successfully. Version: $(claude --version || echo 'unknown')"
echo "Installing Claude Code via official installer (version: $ARG_CLAUDE_CODE_VERSION)"
set +e
curl -fsSL https://claude.ai/install.sh | bash -s -- "$ARG_CLAUDE_CODE_VERSION" 2>&1
CURL_EXIT=${PIPESTATUS[0]}
set -e
if [ "$CURL_EXIT" -ne 0 ]; then
echo "Claude Code installer failed with exit code $CURL_EXIT"
exit "$CURL_EXIT"
fi
echo "Installed Claude Code successfully. Version: $(claude --version || echo 'unknown')"

ensure_claude_in_path
}

function setup_claude_configurations() {
if [ ! -d "$ARG_WORKDIR" ]; then
echo "Warning: The specified folder '$ARG_WORKDIR' does not exist."
echo "Creating the folder..."
mkdir -p "$ARG_WORKDIR"
echo "Folder created successfully."
fi

module_path="$HOME/.claude-module"
mkdir -p "$module_path"

if [ "$ARG_MCP" != "" ]; then
(
cd "$ARG_WORKDIR"
add_mcp_servers "$ARG_MCP" "in $ARG_WORKDIR"
)
apply_mcp() {
if [ -n "$ARG_MCP" ]; then
add_mcp_servers "$ARG_MCP" "inline"
fi

if [ -n "$ARG_MCP_CONFIG_REMOTE_PATH" ] && [ "$ARG_MCP_CONFIG_REMOTE_PATH" != "[]" ]; then
(
cd "$ARG_WORKDIR"
for url in $(echo "$ARG_MCP_CONFIG_REMOTE_PATH" | jq -r '.[]'); do
echo "Fetching MCP configuration from: $url"
mcp_json=$(curl -fsSL "$url") || {
echo "Warning: Failed to fetch MCP configuration from '$url', continuing..."
continue
}
if ! echo "$mcp_json" | jq -e '.mcpServers' > /dev/null 2>&1; then
echo "Warning: Invalid MCP configuration from '$url' (missing mcpServers), continuing..."
continue
fi
add_mcp_servers "$mcp_json" "from $url"
done
)
fi

if [ -n "$ARG_ALLOWED_TOOLS" ]; then
coder --allowedTools "$ARG_ALLOWED_TOOLS"
fi

if [ -n "$ARG_DISALLOWED_TOOLS" ]; then
coder --disallowedTools "$ARG_DISALLOWED_TOOLS"
fi

}

function configure_standalone_mode() {
echo "Configuring Claude Code for standalone mode..."

if [ -z "${CLAUDE_API_KEY:-}" ] && [ "$ARG_ENABLE_AIBRIDGE" = "false" ]; then
echo "Note: Neither claude_api_key nor enable_aibridge is set, skipping authentication setup"
return
fi

local claude_config="$HOME/.claude.json"
local workdir_normalized
workdir_normalized=$(echo "$ARG_WORKDIR" | tr '/' '-')

# Create or update .claude.json with minimal configuration for API key auth
# This skips the interactive login prompt and onboarding screens
if [ -f "$claude_config" ]; then
echo "Updating existing Claude configuration at $claude_config"

jq --arg workdir "$ARG_WORKDIR" --arg apikey "${CLAUDE_API_KEY:-}" \
'.autoUpdaterStatus = "disabled" |
.autoModeAccepted = true |
.bypassPermissionsModeAccepted = true |
.hasAcknowledgedCostThreshold = true |
.hasCompletedOnboarding = true |
.primaryApiKey = $apikey |
.projects[$workdir].hasCompletedProjectOnboarding = true |
.projects[$workdir].hasTrustDialogAccepted = true' \
"$claude_config" > "${claude_config}.tmp" && mv "${claude_config}.tmp" "$claude_config"
else
echo "Creating new Claude configuration at $claude_config"
cat > "$claude_config" << EOF
{
"autoUpdaterStatus": "disabled",
"autoModeAccepted": true,
"bypassPermissionsModeAccepted": true,
"hasAcknowledgedCostThreshold": true,
"hasCompletedOnboarding": true,
"primaryApiKey": "${CLAUDE_API_KEY:-}",
"projects": {
"$ARG_WORKDIR": {
"hasCompletedProjectOnboarding": true,
"hasTrustDialogAccepted": true
}
}
}
EOF
fi

echo "Standalone mode configured successfully"
}

function report_tasks() {
if [ "$ARG_REPORT_TASKS" = "true" ]; then
echo "Configuring Claude Code to report tasks via Coder MCP..."
export CODER_MCP_APP_STATUS_SLUG="$ARG_MCP_APP_STATUS_SLUG"
export CODER_MCP_AI_AGENTAPI_URL="http://localhost:3284"
coder exp mcp configure claude-code "$ARG_WORKDIR"
else
configure_standalone_mode
# Read one URL per line so URLs with whitespace stay intact. A plain
# `for url in $(...)` would word-split and break URLs silently.
while IFS= read -r url; do
[ -z "$url" ] && continue
echo "Fetching MCP configuration from: $url"
mcp_json=$(curl -fsSL "$url") || {
echo "Warning: Failed to fetch MCP configuration from '$url', continuing..."
continue
}
if ! echo "$mcp_json" | jq -e '.mcpServers' > /dev/null 2>&1; then
echo "Warning: Invalid MCP configuration from '$url' (missing mcpServers), continuing..."
continue
fi
add_mcp_servers "$mcp_json" "from $url"
done < <(echo "$ARG_MCP_CONFIG_REMOTE_PATH" | jq -r '.[]')
fi
}

function accept_auto_mode() {
# Pre-accept the auto mode TOS prompt so it doesn't appear interactively.
# Claude Code shows a confirmation dialog for auto mode that blocks
# non-interactive/headless usage.
# Note: bypassPermissions acceptance is already handled by
# coder exp mcp configure (task mode) and configure_standalone_mode.
local claude_config="$HOME/.claude.json"

if [ -f "$claude_config" ]; then
jq '.autoModeAccepted = true' \
"$claude_config" > "${claude_config}.tmp" && mv "${claude_config}.tmp" "$claude_config"
else
echo '{"autoModeAccepted": true}' > "$claude_config"
local attempted=$((MCP_ADDED + MCP_FAILED))
if [ "$attempted" -gt 0 ]; then
echo "MCP configuration complete: $MCP_ADDED added, $MCP_FAILED failed."
if [ "$MCP_FAILED" -gt 0 ] && [ "$MCP_ADDED" -eq 0 ]; then
echo "Error: all $MCP_FAILED MCP server(s) failed to register." >&2
exit 1
fi
fi

echo "Pre-accepted auto mode prompt"
}

install_claude_code_cli
setup_claude_configurations
report_tasks

if [ "$ARG_PERMISSION_MODE" = "auto" ]; then
accept_auto_mode
# Guard: MCP add commands require the claude binary. If Claude is absent
# (install_claude_code=false and no pre_install_script installed it), fail
# loudly instead of silently no-oping every `claude mcp add-json` call.
if ! command -v claude > /dev/null 2>&1; then
if [ -n "$ARG_MCP" ] || { [ -n "$ARG_MCP_CONFIG_REMOTE_PATH" ] && [ "$ARG_MCP_CONFIG_REMOTE_PATH" != "[]" ]; }; then
echo "Error: MCP configuration was provided but the claude binary is not on PATH." >&2
echo "Either set install_claude_code = true, install Claude via a pre_install_script, or point claude_binary_path at a pre-installed binary." >&2
exit 1
fi
echo "Note: claude binary not found on PATH. Skipping MCP configuration."
exit 0
fi

apply_mcp
Loading
Loading