diff --git a/registry/coder/modules/claude-code/README.md b/registry/coder/modules/claude-code/README.md index f4e907033..079c75d91 100644 --- a/registry/coder/modules/claude-code/README.md +++ b/registry/coder/modules/claude-code/README.md @@ -13,7 +13,7 @@ Install and configure the [Claude Code](https://docs.anthropic.com/en/docs/agent ```tf module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "5.4.0" + version = "5.4.1" agent_id = coder_agent.main.id anthropic_api_key = "xxxx-xxxxx-xxxx" } @@ -51,7 +51,7 @@ locals { module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "5.4.0" + version = "5.4.1" agent_id = coder_agent.main.id workdir = local.claude_workdir anthropic_api_key = "xxxx-xxxxx-xxxx" @@ -82,7 +82,7 @@ resource "coder_app" "claude" { ```tf module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "5.4.0" + version = "5.4.1" agent_id = coder_agent.main.id workdir = "/home/coder/project" enable_ai_gateway = true @@ -106,7 +106,7 @@ The `managed_settings` input writes a policy file to `/etc/claude-code/managed-s ```tf module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "5.4.0" + version = "5.4.1" agent_id = coder_agent.main.id workdir = "/home/coder/project" anthropic_api_key = "xxxx-xxxxx-xxxx" @@ -133,7 +133,7 @@ For production deployments we recommend `api_key_helper` over a static `anthropi ```tf module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "5.4.0" + version = "5.4.1" agent_id = coder_agent.main.id workdir = "/home/coder/project" @@ -152,7 +152,7 @@ Or, sourcing from AWS Secrets Manager: ```tf module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "5.4.0" + version = "5.4.1" agent_id = coder_agent.main.id workdir = "/home/coder/project" @@ -177,7 +177,7 @@ This example shows version pinning, a pre-installed binary path, a custom model, ```tf module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "5.4.0" + version = "5.4.1" agent_id = coder_agent.main.id workdir = "/home/coder/project" @@ -241,7 +241,7 @@ Downstream `coder_script` resources can wait for this module's install pipeline ```tf module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "5.4.0" + version = "5.4.1" agent_id = coder_agent.main.id workdir = "/home/coder/project" anthropic_api_key = "xxxx-xxxxx-xxxx" @@ -271,7 +271,7 @@ Set `use_bedrock = true` to route Claude Code through Amazon Bedrock. The module ```tf module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "5.4.0" + version = "5.4.1" agent_id = coder_agent.main.id workdir = "/home/coder/project" use_bedrock = true @@ -324,7 +324,7 @@ Set `use_vertex = true` to route Claude Code through Google Vertex AI. The modul ```tf module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "5.4.0" + version = "5.4.1" agent_id = coder_agent.main.id workdir = "/home/coder/project" use_vertex = true @@ -357,7 +357,7 @@ Set `anthropic_base_url` to point Claude Code at a self-hosted gateway or proxy ```tf module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "5.4.0" + version = "5.4.1" agent_id = coder_agent.main.id workdir = "/home/coder/project" anthropic_base_url = "https://llm-gateway.example.com/anthropic" @@ -376,7 +376,7 @@ The module automatically tags every span and metric with `coder.workspace_id`, ` ```tf module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "5.4.0" + version = "5.4.1" agent_id = coder_agent.main.id workdir = "/home/coder/project" anthropic_api_key = "xxxx-xxxxx-xxxx" diff --git a/registry/coder/modules/claude-code/main.test.ts b/registry/coder/modules/claude-code/main.test.ts index 35da1e39d..dcfd1de56 100644 --- a/registry/coder/modules/claude-code/main.test.ts +++ b/registry/coder/modules/claude-code/main.test.ts @@ -142,6 +142,50 @@ const setup = async ( return { id, coderEnvVars, scripts }; }; +const writeCurlMock = async ( + containerId: string, + installer: string, + exitCode = 0, +) => { + const installerBase64 = Buffer.from(installer).toString("base64"); + await writeExecutable({ + containerId, + filePath: "/usr/local/bin/curl", + content: `#!/bin/bash +printf '%s\n' "$*" > /tmp/claude-installer-curl-args +output="" +while [ "$#" -gt 0 ]; do + if [ "$1" = "--output" ]; then + output="$2" + shift 2 + continue + fi + shift +done +if [ ${exitCode} -ne 0 ]; then + exit ${exitCode} +fi +printf '%s' '${installerBase64}' | base64 -d > "$output" +`, + }); +}; + +const successfulInstaller = `#!/bin/bash +set -euo pipefail +if [ "$#" -ne 1 ]; then + exit 2 +fi +version="$1" +mkdir -p "$HOME/.local/bin" +cat > "$HOME/.local/bin/claude" < { claude_code_version: version, }, }); + await writeCurlMock(id, successfulInstaller); + await runScripts(id, scripts, coderEnvVars); + const installLog = await readFileContainer( + id, + "/home/coder/.coder-modules/coder/claude-code/logs/install.log", + ); + expect(installLog).toContain( + "Claude Code installed successfully: claude version 1.0.40", + ); + const curlArgs = await readFileContainer( + id, + "/tmp/claude-installer-curl-args", + ); + expect(curlArgs).toContain("--retry 2"); + expect(curlArgs).toContain("--connect-timeout 10"); + expect(curlArgs).toContain("--max-time 60"); + }); + + test.each([ + [ + "download failure", + "", + 28, + "Claude Code could not be downloaded after up to 3 attempts.", + ], + [ + "invalid installer", + "service unavailable", + 0, + "Claude Code installer download was invalid.", + ], + [ + "installer failure", + "#!/bin/bash\nexit 7\n", + 0, + "Claude Code installation failed.", + ], + [ + "missing installed binary", + "#!/bin/bash\nexit 0\n", + 0, + "Claude Code binary was not found.", + ], + ])( + "%s is propagated without reporting success", + async (_scenario, installer, exitCode, expectedError) => { + const { id, coderEnvVars, scripts } = await setup({ + skipClaudeMock: true, + moduleVariables: { install_claude_code: "true" }, + }); + await writeCurlMock(id, installer, exitCode); + + await expect(runScripts(id, scripts, coderEnvVars)).rejects.toThrow(); + + const installLog = await readFileContainer( + id, + "/home/coder/.coder-modules/coder/claude-code/logs/install.log", + ); + expect(installLog).toContain(expectedError); + expect(installLog).not.toContain("Claude Code installed successfully"); + }, + ); + + test("pre-installed-binary-is-required-when-install-is-disabled", async () => { + const { id, coderEnvVars, scripts } = await setup({ skipClaudeMock: true }); + + await expect(runScripts(id, scripts, coderEnvVars)).rejects.toThrow(); + + const installLog = await readFileContainer( + id, + "/home/coder/.coder-modules/coder/claude-code/logs/install.log", + ); + expect(installLog).toContain("Claude Code binary was not found."); + }); + + test("resolved-pre-installed-binary-is-available-to-later-steps", async () => { + const { id, coderEnvVars, scripts } = await setup({ + skipClaudeMock: true, + moduleVariables: { + claude_binary_path: "/opt/claude/bin", + mcp: JSON.stringify({ + mcpServers: { test: { command: "test-cmd", type: "stdio" } }, + }), + }, + }); + await execContainer(id, ["mkdir", "-p", "/home/coder/.local/bin"]); + await writeExecutable({ + containerId: id, + filePath: "/home/coder/.local/bin/claude", + content: await Bun.file( + path.join(import.meta.dir, "testdata", "claude-mock.sh"), + ).text(), + }); + await runScripts(id, scripts, coderEnvVars); + const installLog = await readFileContainer( id, "/home/coder/.coder-modules/coder/claude-code/logs/install.log", ); - expect(installLog).toContain(version); + expect(installLog).toContain("Claude Code validated successfully"); + expect(installLog).toContain("claude invoked with: mcp add-json"); }); test("anthropic-api-key", async () => { @@ -246,18 +386,19 @@ describe("claude-code", async () => { }, }); const { id, coderEnvVars, scripts } = await setup({ - skipClaudeMock: true, moduleVariables: { - install_claude_code: "true", mcp: mcpConfig, }, }); await runScripts(id, scripts, coderEnvVars); - const claudeConfig = await readFileContainer( + const installLog = await readFileContainer( id, - "/home/coder/.claude.json", + "/home/coder/.coder-modules/coder/claude-code/logs/install.log", ); - expect(claudeConfig).toContain("test-cmd"); + expect(installLog).toContain( + "claude invoked with: mcp add-json --scope user test", + ); + expect(installLog).toContain("test-cmd"); }); test("claude-model", async () => { @@ -314,9 +455,7 @@ describe("claude-code", async () => { "https://raw.githubusercontent.com/coder/coder/main/.mcp.json"; const { id, coderEnvVars, scripts } = await setup({ - skipClaudeMock: true, moduleVariables: { - install_claude_code: "true", mcp_config_remote_path: JSON.stringify([failingUrl, successUrl]), }, }); @@ -341,21 +480,14 @@ describe("claude-code", async () => { `Warning: Failed to fetch MCP configuration from '${successUrl}'`, ); - // Should contain the MCP server add command from the successful fetch. + // The mock mirrors invocations so the test verifies the module-to-CLI + // boundary without depending on Claude Code's on-disk config format. expect(installLog).toContain( - "Added stdio MCP server go-language-server to user config", + "claude invoked with: mcp add-json --scope user go-language-server", ); expect(installLog).toContain( - "Added stdio MCP server typescript-language-server to user config", - ); - - // Verify the MCP config was added to .claude.json. - const claudeConfig = await readFileContainer( - id, - "/home/coder/.claude.json", + "claude invoked with: mcp add-json --scope user typescript-language-server", ); - expect(claudeConfig).toContain("typescript-language-server"); - expect(claudeConfig).toContain("go-language-server"); }); test("standalone-mode-with-api-key", async () => { diff --git a/registry/coder/modules/claude-code/scripts/install.sh.tftpl b/registry/coder/modules/claude-code/scripts/install.sh.tftpl index 30ae2df65..ca17219ce 100644 --- a/registry/coder/modules/claude-code/scripts/install.sh.tftpl +++ b/registry/coder/modules/claude-code/scripts/install.sh.tftpl @@ -76,23 +76,34 @@ function add_path_to_shell_profiles() { fi } -function ensure_claude_in_path() { - local CLAUDE_BIN="" - if command -v claude > /dev/null 2>&1; then - CLAUDE_BIN=$(command -v claude) - elif [ -x "$${ARG_CLAUDE_BINARY_PATH}/claude" ]; then - CLAUDE_BIN="$${ARG_CLAUDE_BINARY_PATH}/claude" +function resolve_claude_binary() { + if [ -x "$${ARG_CLAUDE_BINARY_PATH}/claude" ]; then + printf '%s\n' "$${ARG_CLAUDE_BINARY_PATH}/claude" + elif command -v claude > /dev/null 2>&1; then + command -v claude elif [ -x "$HOME/.local/bin/claude" ]; then - CLAUDE_BIN="$HOME/.local/bin/claude" + printf '%s\n' "$HOME/.local/bin/claude" + else + echo "Claude Code binary was not found." >&2 + return 1 fi +} - if [ -z "$${CLAUDE_BIN}" ] || [ ! -x "$${CLAUDE_BIN}" ]; then - echo "Warning: Could not find claude binary" - return +function configure_claude_binary() { + local action="$1" + local CLAUDE_BIN + local CLAUDE_VERSION + + CLAUDE_BIN=$(resolve_claude_binary) + + if ! CLAUDE_VERSION=$("$${CLAUDE_BIN}" --version); then + echo "Claude Code binary could not be executed." >&2 + return 1 fi local CLAUDE_DIR CLAUDE_DIR=$(dirname "$${CLAUDE_BIN}") + export PATH="$${CLAUDE_DIR}:$PATH" if [ -n "$${CODER_SCRIPT_BIN_DIR:-}" ] && [ ! -e "$${CODER_SCRIPT_BIN_DIR}/claude" ]; then ln -s "$${CLAUDE_BIN}" "$${CODER_SCRIPT_BIN_DIR}/claude" @@ -100,26 +111,42 @@ function ensure_claude_in_path() { fi add_path_to_shell_profiles "$${CLAUDE_DIR}" + printf "Claude Code %s successfully: %s\n" "$${action}" "$${CLAUDE_VERSION}" } +function install_with_official_installer() ( + local installer_file + installer_file=$(mktemp) + trap 'rm -f "$${installer_file}"' EXIT + + if ! curl --fail --silent --show-error --location \ + --retry 2 --retry-connrefused --connect-timeout 10 --max-time 60 \ + --output "$${installer_file}" https://claude.ai/install.sh; then + echo "Claude Code could not be downloaded after up to 3 attempts." >&2 + return 1 + fi + + if ! bash -n "$${installer_file}"; then + echo "Claude Code installer download was invalid." >&2 + return 1 + fi + + if ! bash "$${installer_file}" "$${ARG_CLAUDE_CODE_VERSION}"; then + echo "Claude Code installation failed." >&2 + return 1 + fi +) + function install_claude_code_cli() { if [ "$${ARG_INSTALL_CLAUDE_CODE}" != "true" ]; then echo "Skipping Claude Code installation as per configuration." - ensure_claude_in_path + configure_claude_binary "validated" return fi 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')" - - ensure_claude_in_path + install_with_official_installer + configure_claude_binary "installed" } function setup_claude_configurations() {