diff --git a/registry/coder/modules/claude-code/README.md b/registry/coder/modules/claude-code/README.md index e5223e4d5..04d8f8c85 100644 --- a/registry/coder/modules/claude-code/README.md +++ b/registry/coder/modules/claude-code/README.md @@ -1,120 +1,142 @@ --- display_name: Claude Code -description: Run Claude Code in your workspace +description: Run the Claude Code agent in your workspace. icon: ../../../../.icons/claude.svg verified: true -tags: [agent, claude-code, ai, tasks] +tags: [agent, claude-code, ai, tasks, anthropic] --- # Claude Code -Run the [Claude Code](https://docs.anthropic.com/en/docs/agents-and-tools/claude-code/overview) agent in your workspace to generate code and perform tasks. +Run the [Claude Code](https://docs.anthropic.com/en/docs/agents-and-tools/claude-code/overview) agent in your workspace to generate code and perform tasks. This module integrates with [AgentAPI](https://github.com/coder/agentapi) for task reporting in the Coder UI. ```tf module "claude-code" { - source = "registry.coder.com/coder/claude-code/coder" - version = "2.2.1" - agent_id = coder_agent.example.id - folder = "/home/coder" - install_claude_code = true - claude_code_version = "latest" + source = "registry.coder.com/coder/claude-code/coder" + version = "3.0.0" + agent_id = coder_agent.example.id + workdir = "/home/coder/project" + claude_api_key = "xxxx-xxxxx-xxxx" } ``` -> **Security Notice**: This module uses the [`--dangerously-skip-permissions`](https://docs.anthropic.com/en/docs/claude-code/cli-usage#cli-flags) flag when running Claude Code. This flag -> bypasses standard permission checks and allows Claude Code broader access to your system than normally permitted. While -> this enables more functionality, it also means Claude Code can potentially execute commands with the same privileges as -> the user running it. Use this module _only_ in trusted environments and be aware of the security implications. +> [!WARNING] +> **Security Notice**: This module uses the `--dangerously-skip-permissions` flag when running Claude Code tasks. This flag bypasses standard permission checks and allows Claude Code broader access to your system than normally permitted. While this enables more functionality, it also means Claude Code can potentially execute commands with the same privileges as the user running it. Use this module _only_ in trusted environments and be aware of the security implications. > [!NOTE] > By default, this module is configured to run the embedded chat interface as a path-based application. In production, we recommend that you configure a [wildcard access URL](https://coder.com/docs/admin/setup#wildcard-access-url) and set `subdomain = true`. See [here](https://coder.com/docs/tutorials/best-practices/security-best-practices#disable-path-based-apps) for more details. ## Prerequisites -- You must add the [Coder Login](https://registry.coder.com/modules/coder-login) module to your template - -The `codercom/oss-dogfood:latest` container image can be used for testing on container-based workspaces. +- An **Anthropic API key** or a _Claude Session Token_ is required for tasks. + - You can get the API key from the [Anthropic Console](https://console.anthropic.com/dashboard). + - You can get the Session Token using the `claude setup-token` command. This is a long-lived authentication token (requires Claude subscription) ## Examples -### Run in the background and report tasks (Experimental) +### Usage with Tasks and Advanced Configuration -> This functionality is in early access as of Coder v2.21 and is still evolving. -> For now, we recommend testing it in a demo or staging environment, -> rather than deploying to production -> -> Learn more in [the Coder documentation](https://coder.com/docs/tutorials/ai-agents) -> -> Join our [Discord channel](https://discord.gg/coder) or -> [contact us](https://coder.com/contact) to get help or share feedback. +This example shows how to configure the Claude Code module with an AI prompt, API key shared by all users of the template, and other custom settings. ```tf -variable "anthropic_api_key" { - type = string - description = "The Anthropic API key" - sensitive = true -} - -module "coder-login" { - count = data.coder_workspace.me.start_count - source = "registry.coder.com/coder/coder-login/coder" - version = "1.0.15" - agent_id = coder_agent.example.id -} - data "coder_parameter" "ai_prompt" { type = "string" name = "AI Prompt" default = "" - description = "Write a prompt for Claude Code" + description = "Initial task prompt for Claude Code." mutable = true } -# Set the prompt and system prompt for Claude Code via environment variables -resource "coder_agent" "main" { - # ... - env = { - CODER_MCP_CLAUDE_API_KEY = var.anthropic_api_key # or use a coder_parameter - CODER_MCP_CLAUDE_TASK_PROMPT = data.coder_parameter.ai_prompt.value - CODER_MCP_APP_STATUS_SLUG = "claude-code" - CODER_MCP_CLAUDE_SYSTEM_PROMPT = <<-EOT - You are a helpful assistant that can help with code. - EOT - } -} - module "claude-code" { - count = data.coder_workspace.me.start_count - source = "registry.coder.com/coder/claude-code/coder" - version = "2.2.1" - agent_id = coder_agent.example.id - folder = "/home/coder" - install_claude_code = true - claude_code_version = "1.0.40" + source = "registry.coder.com/coder/claude-code/coder" + version = "3.0.0" + agent_id = coder_agent.example.id + workdir = "/home/coder/project" + + claude_api_key = "xxxx-xxxxx-xxxx" + # OR + claude_code_oauth_token = "xxxxx-xxxx-xxxx" + + claude_code_version = "1.0.82" # Pin to a specific version + agentapi_version = "v0.6.1" - # Enable experimental features - experiment_report_tasks = true + ai_prompt = data.coder_parameter.ai_prompt.value + model = "sonnet" + + permission_mode = "plan" + + mcp = <<-EOF + { + "mcpServers": { + "my-custom-tool": { + "command": "my-tool-server" + "args": ["--port", "8080"] + } + } + } + EOF } ``` -## Run standalone +### Standalone Mode -Run Claude Code as a standalone app in your workspace. This will install Claude Code and run it without any task reporting to the Coder UI. +Run and configure Claude Code as a standalone CLI in your workspace. ```tf module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "2.2.1" + version = "3.0.0" agent_id = coder_agent.example.id - folder = "/home/coder" + workdir = "/home/coder" install_claude_code = true claude_code_version = "latest" + report_tasks = false + cli_app = true +} +``` + +### Usage with Claude Code Subscription - # Icon is not available in Coder v2.20 and below, so we'll use a custom icon URL - icon = "https://registry.npmmirror.com/@lobehub/icons-static-png/1.24.0/files/dark/claude-color.png" +```tf + +variable "claude_code_oauth_token" { + type = string + description = "Generate one using `claude setup-token` command" + sensitive = true + value = "xxxx-xxx-xxxx" +} + +module "claude-code" { + source = "registry.coder.com/coder/claude-code/coder" + version = "3.0.0" + agent_id = coder_agent.example.id + workdir = "/home/coder/project" + claude_code_oauth_token = var.claude_code_oauth_token } ``` ## Troubleshooting -The module will create log files in the workspace's `~/.claude-module` directory. If you run into any issues, look at them for more information. +If you encounter any issues, check the log files in the `~/.claude-module` directory within your workspace for detailed information. + +```bash +# Installation logs +cat ~/.claude-module/install.log + +# Startup logs +cat ~/.claude-module/agentapi-start.log + +# Pre/post install script logs +cat ~/.claude-module/pre_install.log +cat ~/.claude-module/post_install.log +``` + +> [!NOTE] +> To use tasks with Claude Code, you must provide an `anthropic_api_key` or `claude_code_oauth_token`. +> The `workdir` variable is required and specifies the directory where Claude Code will run. + +## References + +- [Claude Code Documentation](https://docs.anthropic.com/en/docs/agents-and-tools/claude-code/overview) +- [AgentAPI Documentation](https://github.com/coder/agentapi) +- [Coder AI Agents Guide](https://coder.com/docs/tutorials/ai-agents) diff --git a/registry/coder/modules/claude-code/main.test.ts b/registry/coder/modules/claude-code/main.test.ts index e1647022e..9c132f1ab 100644 --- a/registry/coder/modules/claude-code/main.test.ts +++ b/registry/coder/modules/claude-code/main.test.ts @@ -1,38 +1,26 @@ import { test, afterEach, - expect, describe, - it, setDefaultTimeout, beforeAll, + expect, } from "bun:test"; -import path from "path"; +import { execContainer, readFileContainer, runTerraformInit } from "~test"; import { - execContainer, - findResourceInstance, - readFileContainer, - removeContainer, - runContainer, - runTerraformApply, - runTerraformInit, - writeCoder, - writeFileContainer, -} from "~test"; + loadTestFile, + writeExecutable, + setup as setupUtil, + execModuleScript, + expectAgentAPIStarted, +} from "../agentapi/test-util"; +import dedent from "dedent"; let cleanupFunctions: (() => Promise)[] = []; - const registerCleanup = (cleanup: () => Promise) => { cleanupFunctions.push(cleanup); }; - -// Cleanup logic depends on the fact that bun's built-in test runner -// runs tests sequentially. -// https://bun.sh/docs/test/discovery#execution-order -// Weird things would happen if tried to run tests in parallel. -// One test could clean up resources that another test was still using. afterEach(async () => { - // reverse the cleanup functions so that they are run in the correct order const cleanupFnsCopy = cleanupFunctions.slice().reverse(); cleanupFunctions = []; for (const cleanup of cleanupFnsCopy) { @@ -44,330 +32,281 @@ afterEach(async () => { } }); -const setupContainer = async ({ - image, - vars, -}: { - image?: string; - vars?: Record; -} = {}) => { - const state = await runTerraformApply(import.meta.dir, { - agent_id: "foo", - ...vars, - }); - const coderScript = findResourceInstance(state, "coder_script"); - const id = await runContainer(image ?? "codercom/enterprise-node:latest"); - registerCleanup(() => removeContainer(id)); - return { id, coderScript }; -}; - -const loadTestFile = async (...relativePath: string[]) => { - return await Bun.file( - path.join(import.meta.dir, "testdata", ...relativePath), - ).text(); -}; - -const writeExecutable = async ({ - containerId, - filePath, - content, -}: { - containerId: string; - filePath: string; - content: string; -}) => { - await writeFileContainer(containerId, filePath, content, { - user: "root", - }); - await execContainer( - containerId, - ["bash", "-c", `chmod 755 ${filePath}`], - ["--user", "root"], - ); -}; - -const writeAgentAPIMockControl = async ({ - containerId, - content, -}: { - containerId: string; - content: string; -}) => { - await writeFileContainer(containerId, "/tmp/agentapi-mock.control", content, { - user: "coder", - }); -}; - interface SetupProps { skipAgentAPIMock?: boolean; skipClaudeMock?: boolean; - extraVars?: Record; + moduleVariables?: Record; + agentapiMockScript?: string; } -const projectDir = "/home/coder/project"; - const setup = async (props?: SetupProps): Promise<{ id: string }> => { - const { id, coderScript } = await setupContainer({ - vars: { - experiment_report_tasks: "true", + const projectDir = "/home/coder/project"; + const { id } = await setupUtil({ + moduleDir: import.meta.dir, + moduleVariables: { + install_claude_code: props?.skipClaudeMock ? "true" : "false", install_agentapi: props?.skipAgentAPIMock ? "true" : "false", - install_claude_code: "false", - agentapi_version: "preview", - folder: projectDir, - ...props?.extraVars, + workdir: projectDir, + ...props?.moduleVariables, }, + registerCleanup, + projectDir, + skipAgentAPIMock: props?.skipAgentAPIMock, + agentapiMockScript: props?.agentapiMockScript, }); - await execContainer(id, ["bash", "-c", `mkdir -p '${projectDir}'`]); - // the module script assumes that there is a coder executable in the PATH - await writeCoder(id, await loadTestFile("coder-mock.js")); - if (!props?.skipAgentAPIMock) { - await writeExecutable({ - containerId: id, - filePath: "/usr/bin/agentapi", - content: await loadTestFile("agentapi-mock.js"), - }); - } if (!props?.skipClaudeMock) { await writeExecutable({ containerId: id, filePath: "/usr/bin/claude", - content: await loadTestFile("claude-mock.js"), + content: await loadTestFile(import.meta.dir, "claude-mock.sh"), }); } - await writeExecutable({ - containerId: id, - filePath: "/home/coder/script.sh", - content: coderScript.script, - }); return { id }; }; -const expectAgentAPIStarted = async (id: string) => { - const resp = await execContainer(id, [ - "bash", - "-c", - `curl -fs -o /dev/null "http://localhost:3284/status"`, - ]); - if (resp.exitCode !== 0) { - console.log("agentapi not started"); - console.log(resp.stdout); - console.log(resp.stderr); - } - expect(resp.exitCode).toBe(0); -}; - -const execModuleScript = async (id: string) => { - const resp = await execContainer(id, [ - "bash", - "-c", - `set -o errexit; set -o pipefail; cd /home/coder && ./script.sh 2>&1 | tee /home/coder/script.log`, - ]); - if (resp.exitCode !== 0) { - console.log(resp.stdout); - console.log(resp.stderr); - } - return resp; -}; - -// increase the default timeout to 60 seconds setDefaultTimeout(60 * 1000); -// we don't run these tests in CI because they take too long and make network -// calls. they are dedicated for local development. describe("claude-code", async () => { beforeAll(async () => { await runTerraformInit(import.meta.dir); }); - // test that the script runs successfully if claude starts without any errors test("happy-path", async () => { const { id } = await setup(); + await execModuleScript(id); + await expectAgentAPIStarted(id); + }); + test("install-claude-code-version", async () => { + const version_to_install = "1.0.40"; + const { id } = await setup({ + skipClaudeMock: true, + moduleVariables: { + install_claude_code: "true", + claude_code_version: version_to_install, + }, + }); + await execModuleScript(id); const resp = await execContainer(id, [ "bash", "-c", - "sudo /home/coder/script.sh", + "cat /home/coder/.claude-module/install.log", ]); - expect(resp.exitCode).toBe(0); + expect(resp.stdout).toContain(version_to_install); + }); + test("check-latest-claude-code-version-works", async () => { + const { id } = await setup({ + skipClaudeMock: true, + skipAgentAPIMock: true, + moduleVariables: { + install_claude_code: "true", + }, + }); + await execModuleScript(id); await expectAgentAPIStarted(id); }); - // test that the script removes lastSessionId from the .claude.json file - test("last-session-id-removed", async () => { - const { id } = await setup(); - - await writeFileContainer( - id, - "/home/coder/.claude.json", - JSON.stringify({ - projects: { - [projectDir]: { - lastSessionId: "123", - }, - }, - }), - ); - - const catResp = await execContainer(id, [ - "bash", - "-c", - "cat /home/coder/.claude.json", - ]); - expect(catResp.exitCode).toBe(0); - expect(catResp.stdout).toContain("lastSessionId"); - - const respModuleScript = await execModuleScript(id); - expect(respModuleScript.exitCode).toBe(0); - - await expectAgentAPIStarted(id); + test("claude-api-key", async () => { + const apiKey = "test-api-key-123"; + const { id } = await setup({ + moduleVariables: { + claude_api_key: apiKey, + }, + }); + await execModuleScript(id); - const catResp2 = await execContainer(id, [ + const envCheck = await execContainer(id, [ "bash", "-c", - "cat /home/coder/.claude.json", + 'env | grep CLAUDE_API_KEY || echo "CLAUDE_API_KEY not found"', ]); - expect(catResp2.exitCode).toBe(0); - expect(catResp2.stdout).not.toContain("lastSessionId"); + expect(envCheck.stdout).toContain("CLAUDE_API_KEY"); }); - // test that the script handles a .claude.json file that doesn't contain - // a lastSessionId field - test("last-session-id-not-found", async () => { - const { id } = await setup(); - - await writeFileContainer( - id, - "/home/coder/.claude.json", - JSON.stringify({ - projects: { - "/home/coder": {}, + test("claude-mcp-config", async () => { + const mcpConfig = JSON.stringify({ + mcpServers: { + test: { + command: "test-cmd", + type: "stdio", }, - }), - ); + }, + }); + const { id } = await setup({ + skipClaudeMock: true, + moduleVariables: { + mcp: mcpConfig, + }, + }); + await execModuleScript(id); - const respModuleScript = await execModuleScript(id); - expect(respModuleScript.exitCode).toBe(0); + const resp = await readFileContainer(id, "/home/coder/.claude.json"); + expect(resp).toContain("test-cmd"); + }); - await expectAgentAPIStarted(id); + test("claude-task-prompt", async () => { + const prompt = "This is a task prompt for Claude."; + const { id } = await setup({ + moduleVariables: { + ai_prompt: prompt, + }, + }); + await execModuleScript(id); - const catResp = await execContainer(id, [ + const resp = await execContainer(id, [ "bash", "-c", "cat /home/coder/.claude-module/agentapi-start.log", ]); - expect(catResp.exitCode).toBe(0); - expect(catResp.stdout).toContain( - "No lastSessionId found in .claude.json - nothing to do", - ); + expect(resp.stdout).toContain(prompt); }); - // test that if claude fails to run with the --continue flag and returns a - // no conversation found error, then the module script retries without the flag - test("no-conversation-found", async () => { - const { id } = await setup(); - await writeAgentAPIMockControl({ - containerId: id, - content: "no-conversation-found", + test("claude-permission-mode", async () => { + const mode = "plan"; + const { id } = await setup({ + moduleVariables: { + permission_mode: mode, + task_prompt: "test prompt", + }, }); - // check that mocking works - const respAgentAPI = await execContainer(id, [ + await execModuleScript(id); + + const startLog = await execContainer(id, [ "bash", "-c", - "agentapi --continue", + "cat /home/coder/.claude-module/agentapi-start.log", ]); - expect(respAgentAPI.exitCode).toBe(1); - expect(respAgentAPI.stderr).toContain("No conversation found to continue"); - - const respModuleScript = await execModuleScript(id); - expect(respModuleScript.exitCode).toBe(0); - - await expectAgentAPIStarted(id); + expect(startLog.stdout).toContain(`--permission-mode ${mode}`); }); - test("install-agentapi", async () => { - const { id } = await setup({ skipAgentAPIMock: true }); - - const respModuleScript = await execModuleScript(id); - expect(respModuleScript.exitCode).toBe(0); + test("claude-model", async () => { + const model = "opus"; + const { id } = await setup({ + moduleVariables: { + model: model, + task_prompt: "test prompt", + }, + }); + await execModuleScript(id); - await expectAgentAPIStarted(id); - const respAgentAPI = await execContainer(id, [ + const startLog = await execContainer(id, [ "bash", "-c", - "agentapi --version", + "cat /home/coder/.claude-module/agentapi-start.log", ]); - expect(respAgentAPI.exitCode).toBe(0); + expect(startLog.stdout).toContain(`--model ${model}`); }); - // the coder binary should be executed with specific env vars - // that are set by the module script - test("coder-env-vars", async () => { - const { id } = await setup(); - - const respModuleScript = await execModuleScript(id); - expect(respModuleScript.exitCode).toBe(0); + test("claude-continue-previous-conversation", async () => { + const { id } = await setup({ + moduleVariables: { + continue: "true", + task_prompt: "test prompt", + }, + }); + await execModuleScript(id); - const respCoderMock = await execContainer(id, [ + const startLog = await execContainer(id, [ "bash", "-c", - "cat /home/coder/coder-mock-output.json", + "cat /home/coder/.claude-module/agentapi-start.log", ]); - if (respCoderMock.exitCode !== 0) { - console.log(respCoderMock.stdout); - console.log(respCoderMock.stderr); - } - expect(respCoderMock.exitCode).toBe(0); - expect(JSON.parse(respCoderMock.stdout)).toEqual({ - statusSlug: "ccw", - agentApiUrl: "http://localhost:3284", + expect(startLog.stdout).toContain("--continue"); + }); + + test("pre-post-install-scripts", async () => { + const { id } = await setup({ + moduleVariables: { + pre_install_script: "#!/bin/bash\necho 'claude-pre-install-script'", + post_install_script: "#!/bin/bash\necho 'claude-post-install-script'", + }, }); + await execModuleScript(id); + + const preInstallLog = await readFileContainer( + id, + "/home/coder/.claude-module/pre_install.log", + ); + expect(preInstallLog).toContain("claude-pre-install-script"); + + const postInstallLog = await readFileContainer( + id, + "/home/coder/.claude-module/post_install.log", + ); + expect(postInstallLog).toContain("claude-post-install-script"); }); - // verify that the agentapi binary has access to the AGENTAPI_ALLOWED_HOSTS environment variable - // set in main.tf - test("agentapi-allowed-hosts", async () => { - const { id } = await setup(); + test("workdir-variable", async () => { + const workdir = "/home/coder/claude-test-folder"; + const { id } = await setup({ + skipClaudeMock: false, + moduleVariables: { + workdir, + }, + }); + await execModuleScript(id); - const respModuleScript = await execModuleScript(id); - expect(respModuleScript.exitCode).toBe(0); + const resp = await readFileContainer( + id, + "/home/coder/.claude-module/agentapi-start.log", + ); + expect(resp).toContain(workdir); + }); - await expectAgentAPIStarted(id); + test("coder-mcp-config-created", async () => { + const { id } = await setup({ + moduleVariables: { + install_claude_code: "false", + }, + }); + await execModuleScript(id); - const agentApiStartLog = await readFileContainer( + const installLog = await readFileContainer( id, - "/home/coder/agentapi-mock.log", + "/home/coder/.claude-module/install.log", + ); + expect(installLog).toContain( + "Configuring Claude Code to report tasks via Coder MCP", ); - expect(agentApiStartLog).toContain("AGENTAPI_ALLOWED_HOSTS=*"); }); - describe("subdomain", async () => { - it("sets AGENTAPI_CHAT_BASE_PATH when false", async () => { - const { id } = await setup(); - const respModuleScript = await execModuleScript(id); - expect(respModuleScript.exitCode).toBe(0); - await expectAgentAPIStarted(id); - const agentApiStartLog = await readFileContainer( - id, - "/home/coder/agentapi-mock.log", - ); - expect(agentApiStartLog).toContain( - "AGENTAPI_CHAT_BASE_PATH=/@default/default.foo/apps/ccw/chat", - ); + test("dangerously-skip-permissions", async () => { + const { id } = await setup({ + moduleVariables: { + dangerously_skip_permissions: "true", + }, }); + await execModuleScript(id); + + const startLog = await execContainer(id, [ + "bash", + "-c", + "cat /home/coder/.claude-module/agentapi-start.log", + ]); + expect(startLog.stdout).toContain(`--dangerously-skip-permissions`); + }); - it("does not set AGENTAPI_CHAT_BASE_PATH when true", async () => { - const { id } = await setup({ - extraVars: { subdomain: "true" }, - }); - const respModuleScript = await execModuleScript(id); - expect(respModuleScript.exitCode).toBe(0); - await expectAgentAPIStarted(id); - const agentApiStartLog = await readFileContainer( - id, - "/home/coder/agentapi-mock.log", - ); - expect(agentApiStartLog).toMatch(/AGENTAPI_CHAT_BASE_PATH=$/m); + test("subdomain-false", async () => { + const { id } = await setup({ + skipAgentAPIMock: true, + moduleVariables: { + subdomain: "false", + post_install_script: dedent` + #!/bin/bash + env | grep AGENTAPI_CHAT_BASE_PATH || echo "AGENTAPI_CHAT_BASE_PATH not found" + `, + }, }); + + await execModuleScript(id); + const startLog = await execContainer(id, [ + "bash", + "-c", + "cat /home/coder/.claude-module/post_install.log", + ]); + expect(startLog.stdout).toContain( + "ARG_AGENTAPI_CHAT_BASE_PATH=/@default/default.foo/apps/ccw/chat", + ); }); }); diff --git a/registry/coder/modules/claude-code/main.tf b/registry/coder/modules/claude-code/main.tf index 52fd32951..d391e479e 100644 --- a/registry/coder/modules/claude-code/main.tf +++ b/registry/coder/modules/claude-code/main.tf @@ -36,61 +36,47 @@ variable "icon" { default = "/icon/claude.svg" } -variable "folder" { +variable "workdir" { type = string description = "The folder to run Claude Code in." - default = "/home/coder" } -variable "install_claude_code" { +variable "report_tasks" { type = bool - description = "Whether to install Claude Code." + description = "Whether to enable task reporting to Coder UI via AgentAPI" default = true } -variable "claude_code_version" { - type = string - description = "The version of Claude Code to install." - default = "latest" -} - -variable "experiment_cli_app" { +variable "cli_app" { type = bool - description = "Whether to create the CLI workspace app." + description = "Whether to create a CLI app for Claude Code" default = false } -variable "experiment_cli_app_order" { - type = number - description = "The order of the CLI workspace app." - default = null -} - -variable "experiment_cli_app_group" { +variable "web_app_display_name" { type = string - description = "The group of the CLI workspace app." - default = null + description = "Display name for the web app" + default = "Claude Code" } -variable "experiment_report_tasks" { - type = bool - description = "Whether to enable task reporting." - default = false +variable "cli_app_display_name" { + type = string + description = "Display name for the CLI app" + default = "Claude Code CLI" } -variable "experiment_pre_install_script" { +variable "pre_install_script" { type = string description = "Custom script to run before installing Claude Code." default = null } -variable "experiment_post_install_script" { +variable "post_install_script" { type = string description = "Custom script to run after installing Claude Code." default = null } - variable "install_agentapi" { type = bool description = "Whether to install AgentAPI." @@ -100,199 +86,207 @@ variable "install_agentapi" { variable "agentapi_version" { type = string description = "The version of AgentAPI to install." - default = "v0.3.3" + default = "v0.7.1" +} + +variable "ai_prompt" { + type = string + description = "Initial task prompt for Claude Code." + default = "" } variable "subdomain" { type = bool - description = "Whether to use a subdomain for the Claude Code app." + description = "Whether to use a subdomain for AgentAPI." default = false } -locals { - # we have to trim the slash because otherwise coder exp mcp will - # set up an invalid claude config - workdir = trimsuffix(var.folder, "/") - encoded_pre_install_script = var.experiment_pre_install_script != null ? base64encode(var.experiment_pre_install_script) : "" - encoded_post_install_script = var.experiment_post_install_script != null ? base64encode(var.experiment_post_install_script) : "" - agentapi_start_script_b64 = base64encode(file("${path.module}/scripts/agentapi-start.sh")) - agentapi_wait_for_start_script_b64 = base64encode(file("${path.module}/scripts/agentapi-wait-for-start.sh")) - remove_last_session_id_script_b64 = base64encode(file("${path.module}/scripts/remove-last-session-id.sh")) - claude_code_app_slug = "ccw" - // Chat base path is only set if not using a subdomain. - // NOTE: - // - Initial support for --chat-base-path was added in v0.3.1 but configuration - // via environment variable AGENTAPI_CHAT_BASE_PATH was added in v0.3.3. - // - As CODER_WORKSPACE_AGENT_NAME is a recent addition we use agent ID - // for backward compatibility. - agentapi_chat_base_path = var.subdomain ? "" : "/@${data.coder_workspace_owner.me.name}/${data.coder_workspace.me.name}.${var.agent_id}/apps/${local.claude_code_app_slug}/chat" - server_base_path = var.subdomain ? "" : "/@${data.coder_workspace_owner.me.name}/${data.coder_workspace.me.name}.${var.agent_id}/apps/${local.claude_code_app_slug}" - healthcheck_url = "http://localhost:3284${local.server_base_path}/status" -} - -# Install and Initialize Claude Code -resource "coder_script" "claude_code" { - agent_id = var.agent_id - display_name = "Claude Code" - icon = var.icon - script = <<-EOT - #!/bin/bash - set -e - set -x - command_exists() { - command -v "$1" >/dev/null 2>&1 - } +variable "install_claude_code" { + type = bool + description = "Whether to install Claude Code." + default = true +} - function install_claude_code_cli() { - echo "Installing Claude Code via official installer" - set +e - curl -fsSL claude.ai/install.sh | bash -s -- "${var.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 - - # Ensure binaries are discoverable. - export PATH="~/.local/bin:$PATH" - echo "Installed Claude Code successfully. Version: $(claude --version || echo 'unknown')" - } +variable "claude_code_version" { + type = string + description = "The version of Claude Code to install." + default = "latest" +} - if [ ! -d "${local.workdir}" ]; then - echo "Warning: The specified folder '${local.workdir}' does not exist." - echo "Creating the folder..." - mkdir -p "${local.workdir}" - echo "Folder created successfully." - fi - if [ -n "${local.encoded_pre_install_script}" ]; then - echo "Running pre-install script..." - echo "${local.encoded_pre_install_script}" | base64 -d > /tmp/pre_install.sh - chmod +x /tmp/pre_install.sh - /tmp/pre_install.sh - fi - - if [ "${var.install_claude_code}" = "true" ]; then - install_claude_code_cli - fi - - # Install AgentAPI if enabled - if [ "${var.install_agentapi}" = "true" ]; then - echo "Installing AgentAPI..." - arch=$(uname -m) - if [ "$arch" = "x86_64" ]; then - binary_name="agentapi-linux-amd64" - elif [ "$arch" = "aarch64" ]; then - binary_name="agentapi-linux-arm64" - else - echo "Error: Unsupported architecture: $arch" - exit 1 - fi - curl \ - --retry 5 \ - --retry-delay 5 \ - --fail \ - --retry-all-errors \ - -L \ - -C - \ - -o agentapi \ - "https://github.com/coder/agentapi/releases/download/${var.agentapi_version}/$binary_name" - chmod +x agentapi - sudo mv agentapi /usr/local/bin/agentapi - fi - if ! command_exists agentapi; then - echo "Error: AgentAPI is not installed. Please enable install_agentapi or install it manually." - exit 1 - fi - - # this must be kept in sync with the agentapi-start.sh script - module_path="$HOME/.claude-module" - mkdir -p "$module_path/scripts" - - # save the prompt for the agentapi start command - echo -n "$CODER_MCP_CLAUDE_TASK_PROMPT" > "$module_path/prompt.txt" - - echo -n "${local.agentapi_start_script_b64}" | base64 -d > "$module_path/scripts/agentapi-start.sh" - echo -n "${local.agentapi_wait_for_start_script_b64}" | base64 -d > "$module_path/scripts/agentapi-wait-for-start.sh" - echo -n "${local.remove_last_session_id_script_b64}" | base64 -d > "$module_path/scripts/remove-last-session-id.sh" - chmod +x "$module_path/scripts/agentapi-start.sh" - chmod +x "$module_path/scripts/agentapi-wait-for-start.sh" - - if [ "${var.experiment_report_tasks}" = "true" ]; then - echo "Configuring Claude Code to report tasks via Coder MCP..." - export CODER_MCP_APP_STATUS_SLUG="${local.claude_code_app_slug}" - export CODER_MCP_AI_AGENTAPI_URL="http://localhost:3284" - coder exp mcp configure claude-code "${local.workdir}" - fi - - if [ -n "${local.encoded_post_install_script}" ]; then - echo "Running post-install script..." - echo "${local.encoded_post_install_script}" | base64 -d > /tmp/post_install.sh - chmod +x /tmp/post_install.sh - /tmp/post_install.sh - fi - - if ! command_exists claude; then - echo "Error: Claude Code is not installed. Please enable install_claude_code or install it manually." - exit 1 - fi - - export LANG=en_US.UTF-8 - export LC_ALL=en_US.UTF-8 - - cd "${local.workdir}" - - # Disable host header check since AgentAPI is proxied by Coder (which does its own validation) - export AGENTAPI_ALLOWED_HOSTS="*" - - # Set chat base path for non-subdomain routing (only set if not using subdomain) - export AGENTAPI_CHAT_BASE_PATH="${local.agentapi_chat_base_path}" - - nohup "$module_path/scripts/agentapi-start.sh" use_prompt &> "$module_path/agentapi-start.log" & - "$module_path/scripts/agentapi-wait-for-start.sh" - EOT - run_on_start = true -} - -resource "coder_app" "claude_code_web" { - # use a short slug to mitigate https://github.com/coder/coder/issues/15178 - slug = local.claude_code_app_slug - display_name = "Claude Code Web" - agent_id = var.agent_id - url = "http://localhost:3284/" - icon = var.icon - order = var.order - group = var.group - subdomain = var.subdomain - healthcheck { - url = local.healthcheck_url - interval = 3 - threshold = 20 - } +variable "claude_api_key" { + type = string + description = "The API key to use for the Claude Code server." + default = "" } -resource "coder_app" "claude_code" { - count = var.experiment_cli_app ? 1 : 0 +variable "model" { + type = string + description = "Sets the model for the current session with an alias for the latest model (sonnet or opus) or a model’s full name." + default = "" +} - slug = "claude-code" - display_name = "Claude Code CLI" - agent_id = var.agent_id - command = <<-EOT - #!/bin/bash - set -e +variable "resume_session_id" { + type = string + description = "Resume a specific session by ID." + default = "" +} - export LANG=en_US.UTF-8 - export LC_ALL=en_US.UTF-8 +variable "continue" { + type = bool + description = "Load the most recent conversation in the current directory. Task will fail in a new workspace with no conversation/session to continue" + default = false +} - agentapi attach - EOT - icon = var.icon - order = var.experiment_cli_app_order - group = var.experiment_cli_app_group +variable "dangerously_skip_permissions" { + type = bool + description = "Skip the permission prompts. Use with caution. This will be set to true if using Coder Tasks" + default = false } -resource "coder_ai_task" "claude_code" { - sidebar_app { - id = coder_app.claude_code_web.id +variable "permission_mode" { + type = string + description = "Permission mode for the cli, check https://docs.anthropic.com/en/docs/claude-code/iam#permission-modes" + default = "" + validation { + condition = contains(["", "default", "acceptEdits", "plan", "bypassPermissions"], var.permission_mode) + error_message = "interaction_mode must be one of: default, acceptEdits, plan, bypassPermissions." } } + +variable "mcp" { + type = string + description = "MCP JSON to be added to the claude code local scope" + default = "" +} + +variable "allowed_tools" { + type = string + description = "A list of tools that should be allowed without prompting the user for permission, in addition to settings.json files." + default = "" +} + +variable "disallowed_tools" { + type = string + description = "A list of tools that should be disallowed without prompting the user for permission, in addition to settings.json files." + default = "" + +} + +variable "claude_code_oauth_token" { + type = string + description = "Set up a long-lived authentication token (requires Claude subscription). Generated using `claude setup-token` command" + sensitive = true + default = "" +} + +variable "system_prompt" { + type = string + description = "The system prompt to use for the Claude Code server." + default = "Send a task status update to notify the user that you are ready for input, and then wait for user input." +} + +variable "claude_md_path" { + type = string + description = "The path to CLAUDE.md." + default = "$HOME/.claude/CLAUDE.md" +} + +resource "coder_env" "claude_code_md_path" { + count = var.claude_md_path == "" ? 0 : 1 + + agent_id = var.agent_id + name = "CODER_MCP_CLAUDE_MD_PATH" + value = var.claude_md_path +} + +resource "coder_env" "claude_code_system_prompt" { + count = var.system_prompt == "" ? 0 : 1 + + agent_id = var.agent_id + name = "CODER_MCP_CLAUDE_SYSTEM_PROMPT" + value = var.system_prompt +} + +resource "coder_env" "claude_code_oauth_token" { + agent_id = var.agent_id + name = "CLAUDE_CODE_OAUTH_TOKEN" + value = var.claude_code_oauth_token +} + +resource "coder_env" "claude_api_key" { + count = length(var.claude_api_key) > 0 ? 1 : 0 + + agent_id = var.agent_id + name = "CLAUDE_API_KEY" + value = var.claude_api_key +} + +locals { + # we have to trim the slash because otherwise coder exp mcp will + # set up an invalid claude config + workdir = trimsuffix(var.workdir, "/") + app_slug = "ccw" + install_script = file("${path.module}/scripts/install.sh") + start_script = file("${path.module}/scripts/start.sh") + module_dir_name = ".claude-module" + remove_last_session_id_script_b64 = base64encode(file("${path.module}/scripts/remove-last-session-id.sh")) +} + +module "agentapi" { + + source = "registry.coder.com/coder/agentapi/coder" + version = "1.1.1" + + agent_id = var.agent_id + web_app_slug = local.app_slug + web_app_order = var.order + web_app_group = var.group + web_app_icon = var.icon + web_app_display_name = var.web_app_display_name + cli_app = var.cli_app + cli_app_slug = var.cli_app ? "${local.app_slug}-cli" : null + cli_app_display_name = var.cli_app ? var.cli_app_display_name : null + agentapi_subdomain = var.subdomain + module_dir_name = local.module_dir_name + install_agentapi = var.install_agentapi + agentapi_version = var.agentapi_version + pre_install_script = var.pre_install_script + post_install_script = var.post_install_script + start_script = <<-EOT + #!/bin/bash + set -o errexit + set -o pipefail + echo -n '${base64encode(local.start_script)}' | base64 -d > /tmp/start.sh + echo -n "${local.remove_last_session_id_script_b64}" | base64 -d > "/tmp/remove-last-session-id.sh" + chmod +x /tmp/start.sh + chmod +x /tmp/remove-last-session-id.sh + + ARG_MODEL='${var.model}' \ + ARG_RESUME_SESSION_ID='${var.resume_session_id}' \ + ARG_CONTINUE='${var.continue}' \ + ARG_DANGEROUSLY_SKIP_PERMISSIONS='${var.dangerously_skip_permissions}' \ + ARG_PERMISSION_MODE='${var.permission_mode}' \ + ARG_WORKDIR='${local.workdir}' \ + ARG_AI_PROMPT='${base64encode(var.ai_prompt)}' \ + /tmp/start.sh + EOT + + install_script = <<-EOT + #!/bin/bash + set -o errexit + set -o pipefail + + echo -n '${base64encode(local.install_script)}' | base64 -d > /tmp/install.sh + chmod +x /tmp/install.sh + ARG_CLAUDE_CODE_VERSION='${var.claude_code_version}' \ + ARG_MCP_APP_STATUS_SLUG='${local.app_slug}' \ + ARG_INSTALL_CLAUDE_CODE='${var.install_claude_code}' \ + ARG_REPORT_TASKS='${var.report_tasks}' \ + ARG_WORKDIR='${local.workdir}' \ + ARG_ALLOWED_TOOLS='${var.allowed_tools}' \ + ARG_DISALLOWED_TOOLS='${var.disallowed_tools}' \ + ARG_MCP='${var.mcp != null ? base64encode(replace(var.mcp, "'", "'\\''")) : ""}' \ + /tmp/install.sh + EOT +} diff --git a/registry/coder/modules/claude-code/main.tftest.hcl b/registry/coder/modules/claude-code/main.tftest.hcl new file mode 100644 index 000000000..7931cca81 --- /dev/null +++ b/registry/coder/modules/claude-code/main.tftest.hcl @@ -0,0 +1,189 @@ +run "test_claude_code_basic" { + command = plan + + variables { + agent_id = "test-agent-123" + workdir = "/home/coder/projects" + } + + assert { + condition = var.workdir == "/home/coder/projects" + error_message = "Workdir variable should be set correctly" + } + + assert { + condition = var.agent_id == "test-agent-123" + error_message = "Agent ID variable should be set correctly" + } + + assert { + condition = var.install_claude_code == true + error_message = "Install claude_code should default to true" + } + + assert { + condition = var.install_agentapi == true + error_message = "Install agentapi should default to true" + } + + assert { + condition = var.report_tasks == true + error_message = "report_tasks should default to true" + } +} + +run "test_claude_code_with_api_key" { + command = plan + + variables { + agent_id = "test-agent-456" + workdir = "/home/coder/workspace" + claude_api_key = "test-api-key-123" + } + + assert { + condition = coder_env.claude_api_key.value == "test-api-key-123" + error_message = "Claude API key value should match the input" + } +} + +run "test_claude_code_with_custom_options" { + command = plan + + variables { + agent_id = "test-agent-789" + workdir = "/home/coder/custom" + order = 5 + group = "development" + icon = "/icon/custom.svg" + model = "opus" + task_prompt = "Help me write better code" + permission_mode = "plan" + continue = true + install_claude_code = false + install_agentapi = false + claude_code_version = "1.0.0" + agentapi_version = "v0.6.0" + dangerously_skip_permissions = true + } + + assert { + condition = var.order == 5 + error_message = "Order variable should be set to 5" + } + + assert { + condition = var.group == "development" + error_message = "Group variable should be set to 'development'" + } + + assert { + condition = var.icon == "/icon/custom.svg" + error_message = "Icon variable should be set to custom icon" + } + + assert { + condition = var.model == "opus" + error_message = "Claude model variable should be set to 'opus'" + } + + assert { + condition = var.task_prompt == "Help me write better code" + error_message = "Task prompt variable should be set correctly" + } + + assert { + condition = var.permission_mode == "plan" + error_message = "Permission mode should be set to 'plan'" + } + + assert { + condition = var.continue == true + error_message = "Continue should be set to true" + } + + assert { + condition = var.claude_code_version == "1.0.0" + error_message = "Claude Code version should be set to '1.0.0'" + } + + assert { + condition = var.agentapi_version == "v0.6.0" + error_message = "AgentAPI version should be set to 'v0.6.0'" + } + + assert { + condition = var.dangerously_skip_permissions == true + error_message = "dangerously_skip_permissions should be set to true" + } +} + +run "test_claude_code_with_mcp_and_tools" { + command = plan + + variables { + agent_id = "test-agent-mcp" + workdir = "/home/coder/mcp-test" + mcp = jsonencode({ + mcpServers = { + test = { + command = "test-server" + args = ["--config", "test.json"] + } + } + }) + allowed_tools = "bash,python" + disallowed_tools = "rm" + } + + assert { + condition = var.mcp != "" + error_message = "MCP configuration should be provided" + } + + assert { + condition = var.allowed_tools == "bash,python" + error_message = "Allowed tools should be set" + } + + assert { + condition = var.disallowed_tools == "rm" + error_message = "Disallowed tools should be set" + } +} + +run "test_claude_code_with_scripts" { + command = plan + + variables { + agent_id = "test-agent-scripts" + workdir = "/home/coder/scripts" + pre_install_script = "echo 'Pre-install script'" + post_install_script = "echo 'Post-install script'" + } + + assert { + condition = var.pre_install_script == "echo 'Pre-install script'" + error_message = "Pre-install script should be set correctly" + } + + assert { + condition = var.post_install_script == "echo 'Post-install script'" + error_message = "Post-install script should be set correctly" + } +} + +run "test_claude_code_permission_mode_validation" { + command = plan + + variables { + agent_id = "test-agent-validation" + workdir = "/home/coder/test" + permission_mode = "acceptEdits" + } + + assert { + condition = contains(["", "default", "acceptEdits", "plan", "bypassPermissions"], var.permission_mode) + error_message = "Permission mode should be one of the valid options" + } +} diff --git a/registry/coder/modules/claude-code/scripts/agentapi-start.sh b/registry/coder/modules/claude-code/scripts/agentapi-start.sh deleted file mode 100644 index eb7e8f235..000000000 --- a/registry/coder/modules/claude-code/scripts/agentapi-start.sh +++ /dev/null @@ -1,63 +0,0 @@ -#!/bin/bash -set -o errexit -set -o pipefail - -# this must be kept in sync with the main.tf file -module_path="$HOME/.claude-module" -scripts_dir="$module_path/scripts" -log_file_path="$module_path/agentapi.log" - -# if the first argument is not empty, start claude with the prompt -if [ -n "$1" ]; then - cp "$module_path/prompt.txt" /tmp/claude-code-prompt -else - rm -f /tmp/claude-code-prompt -fi - -# if the log file already exists, archive it -if [ -f "$log_file_path" ]; then - mv "$log_file_path" "$log_file_path"".$(date +%s)" -fi - -# see the remove-last-session-id.sh script for details -# about why we need it -# avoid exiting if the script fails -bash "$scripts_dir/remove-last-session-id.sh" "$(pwd)" 2> /dev/null || true - -# we'll be manually handling errors from this point on -set +o errexit - -function start_agentapi() { - local continue_flag="$1" - local prompt_subshell='"$(cat /tmp/claude-code-prompt)"' - - # use low width to fit in the tasks UI sidebar. height is adjusted so that width x height ~= 80x1000 characters - # visible in the terminal screen by default. - agentapi server --term-width 67 --term-height 1190 -- \ - bash -c "claude $continue_flag --dangerously-skip-permissions $prompt_subshell" \ - > "$log_file_path" 2>&1 -} - -echo "Starting AgentAPI..." - -# attempt to start claude with the --continue flag -start_agentapi --continue -exit_code=$? - -echo "First AgentAPI exit code: $exit_code" - -if [ $exit_code -eq 0 ]; then - exit 0 -fi - -# if there was no conversation to continue, claude exited with an error. -# start claude without the --continue flag. -if grep -q "No conversation found to continue" "$log_file_path"; then - echo "AgentAPI with --continue flag failed, starting claude without it." - start_agentapi - exit_code=$? -fi - -echo "Second AgentAPI exit code: $exit_code" - -exit $exit_code diff --git a/registry/coder/modules/claude-code/scripts/agentapi-wait-for-start.sh b/registry/coder/modules/claude-code/scripts/agentapi-wait-for-start.sh deleted file mode 100644 index b9e76d362..000000000 --- a/registry/coder/modules/claude-code/scripts/agentapi-wait-for-start.sh +++ /dev/null @@ -1,30 +0,0 @@ -#!/bin/bash -set -o errexit -set -o pipefail - -# This script waits for the agentapi server to start on port 3284. -# It considers the server started after 3 consecutive successful responses. - -agentapi_started=false - -echo "Waiting for agentapi server to start on port 3284..." -for i in $(seq 1 150); do - for j in $(seq 1 3); do - sleep 0.1 - if curl -fs -o /dev/null "http://localhost:3284/status"; then - echo "agentapi response received ($j/3)" - else - echo "agentapi server not responding ($i/15)" - continue 2 - fi - done - agentapi_started=true - break -done - -if [ "$agentapi_started" != "true" ]; then - echo "Error: agentapi server did not start on port 3284 after 15 seconds." - exit 1 -fi - -echo "agentapi server started on port 3284." diff --git a/registry/coder/modules/claude-code/scripts/install.sh b/registry/coder/modules/claude-code/scripts/install.sh new file mode 100644 index 000000000..c3dcc22ff --- /dev/null +++ b/registry/coder/modules/claude-code/scripts/install.sh @@ -0,0 +1,102 @@ +#!/bin/bash +set -euo pipefail + +source "$HOME"/.bashrc + +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:-} +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_ALLOWED_TOOLS=${ARG_ALLOWED_TOOLS:-} +ARG_DISALLOWED_TOOLS=${ARG_DISALLOWED_TOOLS:-} + +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_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_ALLOWED_TOOLS: %s\n" "$ARG_ALLOWED_TOOLS" +printf "ARG_DISALLOWED_TOOLS: %s\n" "$ARG_DISALLOWED_TOOLS" + +echo "--------------------------------" + +function install_claude_code_cli() { + if [ "$ARG_INSTALL_CLAUDE_CODE" = "true" ]; then + 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 + + # Ensure binaries are discoverable. + echo "Creating a symlink for claude" + sudo ln -s /home/coder/.local/bin/claude /usr/local/bin/claude + + echo "Installed Claude Code successfully. Version: $(claude --version || echo 'unknown')" + else + echo "Skipping Claude Code installation as per configuration." + fi +} + +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 + while IFS= read -r server_name && IFS= read -r server_json; do + echo "------------------------" + echo "Executing: claude mcp add \"$server_name\" '$server_json'" + claude mcp add "$server_name" "$server_json" + echo "------------------------" + echo "" + done < <(echo "$ARG_MCP" | jq -r '.mcpServers | to_entries[] | .key, (.value | @json)') + 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 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 + export CODER_MCP_APP_STATUS_SLUG="" + export CODER_MCP_AI_AGENTAPI_URL="" + echo "Configuring Claude Code with Coder MCP..." + coder exp mcp configure claude-code "$ARG_WORKDIR" + fi +} + +install_claude_code_cli +setup_claude_configurations +report_tasks diff --git a/registry/coder/modules/claude-code/scripts/start.sh b/registry/coder/modules/claude-code/scripts/start.sh new file mode 100644 index 000000000..b5fca7a5a --- /dev/null +++ b/registry/coder/modules/claude-code/scripts/start.sh @@ -0,0 +1,82 @@ +#!/bin/bash +set -euo pipefail + +source "$HOME"/.bashrc +export PATH="$HOME/.local/bin:$PATH" + +command_exists() { + command -v "$1" > /dev/null 2>&1 +} + +ARG_MODEL=${ARG_MODEL:-} +ARG_RESUME_SESSION_ID=${ARG_RESUME_SESSION_ID:-} +ARG_CONTINUE=${ARG_CONTINUE:-false} +ARG_DANGEROUSLY_SKIP_PERMISSIONS=${ARG_DANGEROUSLY_SKIP_PERMISSIONS:-} +ARG_PERMISSION_MODE=${ARG_PERMISSION_MODE:-} +ARG_WORKDIR=${ARG_WORKDIR:-"$HOME"} +ARG_AI_PROMPT=$(echo -n "${ARG_AI_PROMPT:-}" | base64 -d) + +echo "--------------------------------" + +printf "ARG_MODEL: %s\n" "$ARG_MODEL" +printf "ARG_RESUME: %s\n" "$ARG_RESUME_SESSION_ID" +printf "ARG_CONTINUE: %s\n" "$ARG_CONTINUE" +printf "ARG_DANGEROUSLY_SKIP_PERMISSIONS: %s\n" "$ARG_DANGEROUSLY_SKIP_PERMISSIONS" +printf "ARG_PERMISSION_MODE: %s\n" "$ARG_PERMISSION_MODE" +printf "ARG_AI_PROMPT: %s\n" "$ARG_AI_PROMPT" +printf "ARG_WORKDIR: %s\n" "$ARG_WORKDIR" + +echo "--------------------------------" + +# see the remove-last-session-id.sh script for details +# about why we need it +# avoid exiting if the script fails +bash "/tmp/remove-last-session-id.sh" "$(pwd)" 2> /dev/null || true + +function validate_claude_installation() { + if command_exists claude; then + printf "Claude Code is installed\n" + else + printf "Error: Claude Code is not installed. Please enable install_claude_code or install it manually\n" + exit 1 + fi +} + +ARGS=() + +function build_claude_args() { + if [ -n "$ARG_MODEL" ]; then + ARGS+=(--model "$ARG_MODEL") + fi + + if [ -n "$ARG_RESUME_SESSION_ID" ]; then + ARGS+=(--resume "$ARG_RESUME_SESSION_ID") + fi + + if [ "$ARG_CONTINUE" = "true" ]; then + ARGS+=(--continue) + fi + + if [ -n "$ARG_PERMISSION_MODE" ]; then + ARGS+=(--permission-mode "$ARG_PERMISSION_MODE") + fi + +} + +function start_agentapi() { + mkdir -p "$ARG_WORKDIR" + cd "$ARG_WORKDIR" + if [ -n "$ARG_AI_PROMPT" ]; then + ARGS+=(--dangerously-skip-permissions "$ARG_AI_PROMPT") + else + if [ -n "$ARG_DANGEROUSLY_SKIP_PERMISSIONS" ]; then + ARGS+=(--dangerously-skip-permissions) + fi + fi + printf "Running claude code with args: %s\n" "$(printf '%q ' "${ARGS[@]}")" + agentapi server --type claude --term-width 67 --term-height 1190 -- claude "${ARGS[@]}" +} + +validate_claude_installation +build_claude_args +start_agentapi diff --git a/registry/coder/modules/claude-code/testdata/agentapi-mock.js b/registry/coder/modules/claude-code/testdata/agentapi-mock.js deleted file mode 100644 index e74f3c680..000000000 --- a/registry/coder/modules/claude-code/testdata/agentapi-mock.js +++ /dev/null @@ -1,40 +0,0 @@ -#!/usr/bin/env node - -const http = require("http"); -const fs = require("fs"); -const args = process.argv.slice(2); -const port = 3284; - -const controlFile = "/tmp/agentapi-mock.control"; -let control = ""; -if (fs.existsSync(controlFile)) { - control = fs.readFileSync(controlFile, "utf8"); -} - -if ( - control === "no-conversation-found" && - args.join(" ").includes("--continue") -) { - // this must match the error message in the agentapi-start.sh script - console.error("No conversation found to continue"); - process.exit(1); -} - -fs.writeFileSync( - "/home/coder/agentapi-mock.log", - `AGENTAPI_ALLOWED_HOSTS=${process.env.AGENTAPI_ALLOWED_HOSTS} - AGENTAPI_CHAT_BASE_PATH=${process.env.AGENTAPI_CHAT_BASE_PATH}`, -); - -console.log(`starting server on port ${port}`); - -http - .createServer(function (_request, response) { - response.writeHead(200); - response.end( - JSON.stringify({ - status: "stable", - }), - ); - }) - .listen(port); diff --git a/registry/coder/modules/claude-code/testdata/claude-mock.js b/registry/coder/modules/claude-code/testdata/claude-mock.js deleted file mode 100644 index ea9f9aa91..000000000 --- a/registry/coder/modules/claude-code/testdata/claude-mock.js +++ /dev/null @@ -1,9 +0,0 @@ -#!/usr/bin/env node - -const main = async () => { - console.log("mocking claude"); - // sleep for 30 minutes - await new Promise((resolve) => setTimeout(resolve, 30 * 60 * 1000)); -}; - -main(); diff --git a/registry/coder/modules/claude-code/testdata/claude-mock.sh b/registry/coder/modules/claude-code/testdata/claude-mock.sh new file mode 100644 index 000000000..b437b4d30 --- /dev/null +++ b/registry/coder/modules/claude-code/testdata/claude-mock.sh @@ -0,0 +1,13 @@ +#!/bin/bash + +if [[ "$1" == "--version" ]]; then + echo "claude version v1.0.0" + exit 0 +fi + +set -e + +while true; do + echo "$(date) - claude-mock" + sleep 15 +done diff --git a/registry/coder/modules/claude-code/testdata/coder-mock.js b/registry/coder/modules/claude-code/testdata/coder-mock.js deleted file mode 100644 index cc479f43c..000000000 --- a/registry/coder/modules/claude-code/testdata/coder-mock.js +++ /dev/null @@ -1,14 +0,0 @@ -#!/usr/bin/env node - -const fs = require("fs"); - -const statusSlugEnvVar = "CODER_MCP_APP_STATUS_SLUG"; -const agentApiUrlEnvVar = "CODER_MCP_AI_AGENTAPI_URL"; - -fs.writeFileSync( - "/home/coder/coder-mock-output.json", - JSON.stringify({ - statusSlug: process.env[statusSlugEnvVar] ?? "env var not set", - agentApiUrl: process.env[agentApiUrlEnvVar] ?? "env var not set", - }), -);