Skip to content
Open
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
24 changes: 12 additions & 12 deletions registry/coder/modules/claude-code/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand All @@ -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"
Expand All @@ -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"

Expand All @@ -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"

Expand All @@ -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"

Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand All @@ -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"
Expand Down
170 changes: 151 additions & 19 deletions registry/coder/modules/claude-code/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
#!/bin/bash
if [ "\\$1" = "--version" ]; then
echo "claude version $version"
fi
CLAUDE
chmod +x "$HOME/.local/bin/claude"
`;

// Runs the coder-utils script pipeline (pre_install, install, post_install) in
// order inside the container. Each script is written to /tmp and executed
// under bash with the test's env vars exported first.
Expand Down Expand Up @@ -208,12 +252,108 @@ describe("claude-code", async () => {
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",
"<html>service unavailable</html>",
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 () => {
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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]),
},
});
Expand All @@ -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 () => {
Expand Down
Loading