diff --git a/registry/coder/modules/agentapi/README.md b/registry/coder/modules/agentapi/README.md index 06897d6b5..117e3a4d6 100644 --- a/registry/coder/modules/agentapi/README.md +++ b/registry/coder/modules/agentapi/README.md @@ -16,36 +16,21 @@ The AgentAPI module is a building block for modules that need to run an AgentAPI ```tf module "agentapi" { source = "registry.coder.com/coder/agentapi/coder" - version = "2.1.0" + version = "4.0.0" 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 = "Goose" - cli_app_slug = "goose-cli" - cli_app_display_name = "Goose CLI" + web_app_display_name = "ClaudeCode" + cli_app_slug = "claude-cli" + cli_app_display_name = "Claude CLI" module_dir_name = local.module_dir_name install_agentapi = var.install_agentapi - pre_install_script = var.pre_install_script - post_install_script = var.post_install_script - start_script = local.start_script - 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_PROVIDER='${var.goose_provider}' \ - ARG_MODEL='${var.goose_model}' \ - ARG_GOOSE_CONFIG="$(echo -n '${base64encode(local.combined_extensions)}' | base64 -d)" \ - ARG_INSTALL='${var.install_goose}' \ - ARG_GOOSE_VERSION='${var.goose_version}' \ - /tmp/install.sh - EOT + agentapi_server_type = "claude" + agentapi_term_width = 67 + agentapi_term_height = 1190 } ``` @@ -65,3 +50,21 @@ module "agentapi" { ## For module developers For a complete example of how to use this module, see the [Goose module](https://github.com/coder/registry/blob/main/registry/coder/modules/goose/main.tf). + +### agent-command.sh + +The calling module must create an executable script at `$HOME/{module_dir_name}/agent-command.sh` before this module's script runs. This script should contain the command to start your AI agent. + +Example: + +```bash +#!/bin/bash +module_path="$HOME/.my-module" + +cat > "$module_path/agent-command.sh" << EOF +#!/bin/bash +my-agent-command --my-agent-flags +EOF +``` + +The AgentAPI module will run this script with the agentapi server. diff --git a/registry/coder/modules/agentapi/main.test.ts b/registry/coder/modules/agentapi/main.test.ts index 20b47b1a0..e1efbbfb7 100644 --- a/registry/coder/modules/agentapi/main.test.ts +++ b/registry/coder/modules/agentapi/main.test.ts @@ -6,7 +6,12 @@ import { setDefaultTimeout, beforeAll, } from "bun:test"; -import { execContainer, readFileContainer, runTerraformInit } from "~test"; +import { + execContainer, + readFileContainer, + runTerraformInit, + runTerraformApply, +} from "~test"; import { loadTestFile, writeExecutable, @@ -58,9 +63,13 @@ const setup = async (props?: SetupProps): Promise<{ id: string }> => { cli_app_display_name: "AgentAPI CLI", cli_app_slug: "agentapi-cli", agentapi_version: "latest", + agent_name: "claude", module_dir_name: moduleDirName, - start_script: await loadTestFile(import.meta.dir, "agentapi-start.sh"), folder: projectDir, + pre_install_script: "echo 'Pre-install'", + install_script: "echo 'Install'", + post_install_script: "echo 'Post-install'", + start_script: "echo 'Start'", ...props?.moduleVariables, }, registerCleanup, @@ -68,11 +77,23 @@ const setup = async (props?: SetupProps): Promise<{ id: string }> => { skipAgentAPIMock: props?.skipAgentAPIMock, moduleDir: import.meta.dir, }); + // Create the ai agent mock binary await writeExecutable({ containerId: id, filePath: "/usr/bin/aiagent", content: await loadTestFile(import.meta.dir, "ai-agent-mock.js"), }); + // Create the agent-command.sh script that the module expects + await execContainer(id, [ + "bash", + "-c", + `mkdir -p /home/coder/${moduleDirName}`, + ]); + await writeExecutable({ + containerId: id, + filePath: `/home/coder/${moduleDirName}/agent-command.sh`, + content: "#!/bin/bash\nexec aiagent", + }); return { id }; }; @@ -104,36 +125,6 @@ describe("agentapi", async () => { await expectAgentAPIStarted(id, 3827); }); - test("pre-post-install-scripts", async () => { - const { id } = await setup({ - moduleVariables: { - pre_install_script: `#!/bin/bash\necho "pre-install"`, - install_script: `#!/bin/bash\necho "install"`, - post_install_script: `#!/bin/bash\necho "post-install"`, - }, - }); - - await execModuleScript(id); - await expectAgentAPIStarted(id); - - const preInstallLog = await readFileContainer( - id, - `/home/coder/${moduleDirName}/pre_install.log`, - ); - const installLog = await readFileContainer( - id, - `/home/coder/${moduleDirName}/install.log`, - ); - const postInstallLog = await readFileContainer( - id, - `/home/coder/${moduleDirName}/post_install.log`, - ); - - expect(preInstallLog).toContain("pre-install"); - expect(installLog).toContain("install"); - expect(postInstallLog).toContain("post-install"); - }); - test("install-agentapi", async () => { const { id } = await setup({ skipAgentAPIMock: true }); @@ -160,12 +151,12 @@ describe("agentapi", async () => { expect(respModuleScript.exitCode).toBe(0); await expectAgentAPIStarted(id); - const agentApiStartLog = await readFileContainer( + const agentApiMockLog = await readFileContainer( id, - "/home/coder/test-agentapi-start.log", + "/home/coder/agentapi-mock.log", ); - expect(agentApiStartLog).toContain( - "Using AGENTAPI_CHAT_BASE_PATH: /@default/default.foo/apps/agentapi-web/chat", + expect(agentApiMockLog).toContain( + "AGENTAPI_CHAT_BASE_PATH: /@default/default.foo/apps/agentapi-web/chat", ); }); @@ -258,6 +249,64 @@ describe("agentapi", async () => { expect(agentApiStartLog).toContain("AGENTAPI_ALLOWED_HOSTS: *"); }); + test("enable-agentapi-false", async () => { + // Test that when enable_agentapi is false: + // 1. AgentAPI web app is not created + // 2. AgentAPI is not started + // 3. CLI app still works and uses agent-command.sh + const { id } = await setup({ + moduleVariables: { + enable_agentapi: "false", + cli_app: "true", + }, + }); + + const respModuleScript = await execModuleScript(id); + expect(respModuleScript.exitCode).toBe(0); + + // Verify agentapi is not running on the default port + const respCheck = await execContainer(id, [ + "bash", + "-c", + "curl -fs -o /dev/null http://localhost:3284/status || echo 'not running'", + ]); + expect(respCheck.stdout).toContain("not running"); + + // Verify agent-command.sh script exists and is executable + const respAgentCommand = await execContainer(id, [ + "bash", + "-c", + `test -x /home/coder/${moduleDirName}/agent-command.sh && echo 'exists'`, + ]); + expect(respAgentCommand.stdout).toContain("exists"); + }); + + test("task-app-id-output", async () => { + // Test that task_app_id output is null when enable_agentapi is false + const projectDir = "/home/coder/project"; + const state = await runTerraformApply(import.meta.dir, { + agent_id: "test-agent", + experiment_report_tasks: "true", + install_agentapi: "false", + web_app_display_name: "AgentAPI Web", + web_app_slug: "agentapi-web", + web_app_icon: "/icon/coder.svg", + cli_app_display_name: "AgentAPI CLI", + cli_app_slug: "agentapi-cli", + agentapi_version: "latest", + agent_name: "claude", + module_dir_name: moduleDirName, + folder: projectDir, + pre_install_script: "echo 'Pre-install'", + install_script: "echo 'Install'", + post_install_script: "echo 'Post-install'", + start_script: "echo 'Start'", + enable_agentapi: "false", + }); + + expect(state.outputs.task_app_id.value).toBeNull(); + }); + describe("shutdown script", async () => { const setupMocks = async ( containerId: string, diff --git a/registry/coder/modules/agentapi/main.tf b/registry/coder/modules/agentapi/main.tf index 6914be779..e587a581c 100644 --- a/registry/coder/modules/agentapi/main.tf +++ b/registry/coder/modules/agentapi/main.tf @@ -110,6 +110,12 @@ variable "start_script" { description = "Script that starts AgentAPI." } +variable "enable_agentapi" { + type = bool + description = "Whether to enable AgentAPI. If false, AgentAPI will not be installed or started, and the web app will not be created." + default = true +} + variable "install_agentapi" { type = bool description = "Whether to install AgentAPI." @@ -128,6 +134,29 @@ variable "agentapi_port" { default = 3284 } +variable "agent_name" { + type = string + description = "The agent's name. This is used as server type for AgentAPI, passed using --agent flag." +} + +variable "agentapi_term_width" { + type = number + description = "The terminal width for AgentAPI." + default = 67 +} + +variable "agentapi_term_height" { + type = number + description = "The terminal height for AgentAPI." + default = 1190 +} + +variable "agentapi_initial_prompt" { + type = string + description = "Initial prompt for the agent. Recommended only if the agent doesn't support initial prompt in interaction mode." + default = null +} + variable "task_log_snapshot" { type = bool description = "Capture last 10 messages when workspace stops for offline viewing while task is paused." @@ -168,10 +197,7 @@ variable "module_dir_name" { locals { # we always trim the slash for consistency workdir = trimsuffix(var.folder, "/") - encoded_pre_install_script = var.pre_install_script != null ? base64encode(var.pre_install_script) : "" - encoded_install_script = var.install_script != null ? base64encode(var.install_script) : "" - encoded_post_install_script = var.post_install_script != null ? base64encode(var.post_install_script) : "" - agentapi_start_script_b64 = base64encode(var.start_script) + encoded_initial_prompt = var.agentapi_initial_prompt != null ? base64encode(var.agentapi_initial_prompt) : "" agentapi_wait_for_start_script_b64 = base64encode(file("${path.module}/scripts/agentapi-wait-for-start.sh")) // Chat base path is only set if not using a subdomain. // NOTE: @@ -182,30 +208,51 @@ locals { agentapi_chat_base_path = var.agentapi_subdomain ? "" : "/@${data.coder_workspace_owner.me.name}/${data.coder_workspace.me.name}.${var.agent_id}/apps/${var.web_app_slug}/chat" main_script = file("${path.module}/scripts/main.sh") shutdown_script = file("${path.module}/scripts/agentapi-shutdown.sh") + + start_script_name = "${var.agent_name}-start_script" + agentapi_main_script_name = "${var.agent_name}-main_script" + + module_dir_path = "$HOME/${var.module_dir_name}" +} + +module "agent-helper" { + source = "git::https://github.com/coder/registry.git//registry/coder/modules/agent-helper?ref=35C4n0r/feat-agent-helper-module" + agent_id = var.agent_id + agent_name = var.agent_name + module_dir_name = var.module_dir_name + pre_install_script = var.pre_install_script + install_script = var.install_script + post_install_script = var.post_install_script + start_script = var.start_script } resource "coder_script" "agentapi" { + count = var.enable_agentapi ? 1 : 0 agent_id = var.agent_id - display_name = "Install and start AgentAPI" + display_name = "Start AgentAPI" icon = var.web_app_icon script = <<-EOT #!/bin/bash set -o errexit set -o pipefail + trap 'coder exp sync complete ${local.agentapi_main_script_name}' EXIT + coder exp sync want ${local.agentapi_main_script_name} ${local.start_script_name} + coder exp sync start ${local.agentapi_main_script_name} + echo -n '${base64encode(local.main_script)}' | base64 -d > /tmp/main.sh chmod +x /tmp/main.sh ARG_MODULE_DIR_NAME='${var.module_dir_name}' \ ARG_WORKDIR="$(echo -n '${base64encode(local.workdir)}' | base64 -d)" \ - ARG_PRE_INSTALL_SCRIPT="$(echo -n '${local.encoded_pre_install_script}' | base64 -d)" \ - ARG_INSTALL_SCRIPT="$(echo -n '${local.encoded_install_script}' | base64 -d)" \ ARG_INSTALL_AGENTAPI='${var.install_agentapi}' \ ARG_AGENTAPI_VERSION='${var.agentapi_version}' \ - ARG_START_SCRIPT="$(echo -n '${local.agentapi_start_script_b64}' | base64 -d)" \ ARG_WAIT_FOR_START_SCRIPT="$(echo -n '${local.agentapi_wait_for_start_script_b64}' | base64 -d)" \ - ARG_POST_INSTALL_SCRIPT="$(echo -n '${local.encoded_post_install_script}' | base64 -d)" \ ARG_AGENTAPI_PORT='${var.agentapi_port}' \ + ARG_AGENTAPI_SERVER_TYPE='${var.agent_name}' \ + ARG_AGENTAPI_TERM_WIDTH='${var.agentapi_term_width}' \ + ARG_AGENTAPI_TERM_HEIGHT='${var.agentapi_term_height}' \ + ARG_AGENTAPI_INITIAL_PROMPT="$(echo -n '${local.encoded_initial_prompt}' | base64 -d)" \ ARG_AGENTAPI_CHAT_BASE_PATH='${local.agentapi_chat_base_path}' \ ARG_TASK_ID='${try(data.coder_task.me.id, "")}' \ ARG_TASK_LOG_SNAPSHOT='${var.task_log_snapshot}' \ @@ -215,6 +262,7 @@ resource "coder_script" "agentapi" { } resource "coder_script" "agentapi_shutdown" { + count = var.enable_agentapi ? 1 : 0 agent_id = var.agent_id display_name = "AgentAPI Shutdown" icon = var.web_app_icon @@ -234,6 +282,7 @@ resource "coder_script" "agentapi_shutdown" { } resource "coder_app" "agentapi_web" { + count = var.enable_agentapi ? 1 : 0 slug = var.web_app_slug display_name = var.web_app_display_name agent_id = var.agent_id @@ -249,7 +298,7 @@ resource "coder_app" "agentapi_web" { } } -resource "coder_app" "agentapi_cli" { +resource "coder_app" "agent_cli" { count = var.cli_app ? 1 : 0 slug = var.cli_app_slug @@ -262,7 +311,11 @@ resource "coder_app" "agentapi_cli" { export LANG=en_US.UTF-8 export LC_ALL=en_US.UTF-8 + %{if var.enable_agentapi~} agentapi attach + %{else} + ${local.module_dir_path}/agent-command.sh + %{endif} EOT icon = var.cli_app_icon order = var.cli_app_order @@ -270,5 +323,5 @@ resource "coder_app" "agentapi_cli" { } output "task_app_id" { - value = coder_app.agentapi_web.id + value = var.enable_agentapi ? coder_app.agentapi_web[0].id : null } diff --git a/registry/coder/modules/agentapi/scripts/main.sh b/registry/coder/modules/agentapi/scripts/main.sh index 63e013eb9..e4c1abf7e 100644 --- a/registry/coder/modules/agentapi/scripts/main.sh +++ b/registry/coder/modules/agentapi/scripts/main.sh @@ -5,14 +5,14 @@ set -x set -o nounset MODULE_DIR_NAME="$ARG_MODULE_DIR_NAME" WORKDIR="$ARG_WORKDIR" -PRE_INSTALL_SCRIPT="$ARG_PRE_INSTALL_SCRIPT" -INSTALL_SCRIPT="$ARG_INSTALL_SCRIPT" INSTALL_AGENTAPI="$ARG_INSTALL_AGENTAPI" AGENTAPI_VERSION="$ARG_AGENTAPI_VERSION" -START_SCRIPT="$ARG_START_SCRIPT" WAIT_FOR_START_SCRIPT="$ARG_WAIT_FOR_START_SCRIPT" -POST_INSTALL_SCRIPT="$ARG_POST_INSTALL_SCRIPT" AGENTAPI_PORT="$ARG_AGENTAPI_PORT" +AGENTAPI_SERVER_TYPE="$ARG_AGENTAPI_SERVER_TYPE" +AGENTAPI_TERM_WIDTH="$ARG_AGENTAPI_TERM_WIDTH" +AGENTAPI_TERM_HEIGHT="$ARG_AGENTAPI_TERM_HEIGHT" +AGENTAPI_INITIAL_PROMPT="${ARG_AGENTAPI_INITIAL_PROMPT:-}" AGENTAPI_CHAT_BASE_PATH="${ARG_AGENTAPI_CHAT_BASE_PATH:-}" TASK_ID="${ARG_TASK_ID:-}" TASK_LOG_SNAPSHOT="${ARG_TASK_LOG_SNAPSHOT:-true}" @@ -38,17 +38,6 @@ if [ ! -d "${WORKDIR}" ]; then mkdir -p "${WORKDIR}" echo "Folder created successfully." fi -if [ -n "${PRE_INSTALL_SCRIPT}" ]; then - echo "Running pre-install script..." - echo -n "${PRE_INSTALL_SCRIPT}" > "$module_path/pre_install.sh" - chmod +x "$module_path/pre_install.sh" - "$module_path/pre_install.sh" 2>&1 | tee "$module_path/pre_install.log" -fi - -echo "Running install script..." -echo -n "${INSTALL_SCRIPT}" > "$module_path/install.sh" -chmod +x "$module_path/install.sh" -"$module_path/install.sh" 2>&1 | tee "$module_path/install.log" # Install AgentAPI if enabled if [ "${INSTALL_AGENTAPI}" = "true" ]; then @@ -86,18 +75,9 @@ if ! command_exists agentapi; then exit 1 fi -echo -n "${START_SCRIPT}" > "$module_path/scripts/agentapi-start.sh" echo -n "${WAIT_FOR_START_SCRIPT}" > "$module_path/scripts/agentapi-wait-for-start.sh" -chmod +x "$module_path/scripts/agentapi-start.sh" chmod +x "$module_path/scripts/agentapi-wait-for-start.sh" -if [ -n "${POST_INSTALL_SCRIPT}" ]; then - echo "Running post-install script..." - echo -n "${POST_INSTALL_SCRIPT}" > "$module_path/post_install.sh" - chmod +x "$module_path/post_install.sh" - "$module_path/post_install.sh" 2>&1 | tee "$module_path/post_install.log" -fi - export LANG=en_US.UTF-8 export LC_ALL=en_US.UTF-8 @@ -106,5 +86,20 @@ cd "${WORKDIR}" export AGENTAPI_CHAT_BASE_PATH="${AGENTAPI_CHAT_BASE_PATH:-}" # Disable host header check since AgentAPI is proxied by Coder (which does its own validation) export AGENTAPI_ALLOWED_HOSTS="*" -nohup "$module_path/scripts/agentapi-start.sh" true "${AGENTAPI_PORT}" &> "$module_path/agentapi-start.log" & + +# Build agentapi server command arguments +ARGS=( + "server" + "--type" "${AGENTAPI_SERVER_TYPE}" + "--port" "${AGENTAPI_PORT}" + "--term-width" "${AGENTAPI_TERM_WIDTH}" + "--term-height" "${AGENTAPI_TERM_HEIGHT}" +) +if [ -n "${AGENTAPI_INITIAL_PROMPT}" ]; then + ARGS+=("--initial-prompt" "${AGENTAPI_INITIAL_PROMPT}") +fi + +# Start agentapi server with the agent-command.sh script +nohup agentapi "${ARGS[@]}" -- "$module_path/agent-command.sh" &> "$module_path/agentapi-start.log" & + "$module_path/scripts/agentapi-wait-for-start.sh" "${AGENTAPI_PORT}" diff --git a/registry/coder/modules/agentapi/test-util.ts b/registry/coder/modules/agentapi/test-util.ts index 85d1bddd8..d9afb3367 100644 --- a/registry/coder/modules/agentapi/test-util.ts +++ b/registry/coder/modules/agentapi/test-util.ts @@ -115,6 +115,13 @@ export const setup = async ( }); props.registerCleanup(cleanup); await execContainer(id, ["bash", "-c", `mkdir -p '${projectDir}'`]); + // Add a mock coder CLI so that `coder exp sync` commands in the + // startup script succeed inside the test container. + await writeExecutable({ + containerId: id, + filePath: "/usr/bin/coder", + content: "#!/bin/bash\nexit 0", + }); if (!props?.skipAgentAPIMock) { await writeExecutable({ containerId: id, diff --git a/registry/coder/modules/agentapi/testdata/agentapi-mock.js b/registry/coder/modules/agentapi/testdata/agentapi-mock.js index 72db716a3..f81d33177 100644 --- a/registry/coder/modules/agentapi/testdata/agentapi-mock.js +++ b/registry/coder/modules/agentapi/testdata/agentapi-mock.js @@ -9,7 +9,7 @@ const port = portIdx ? args[portIdx] : 3284; console.log(`starting server on port ${port}`); fs.writeFileSync( "/home/coder/agentapi-mock.log", - `AGENTAPI_ALLOWED_HOSTS: ${process.env.AGENTAPI_ALLOWED_HOSTS}`, + `AGENTAPI_ALLOWED_HOSTS: ${process.env.AGENTAPI_ALLOWED_HOSTS}\nAGENTAPI_CHAT_BASE_PATH: ${process.env.AGENTAPI_CHAT_BASE_PATH || "not set"}`, ); http diff --git a/registry/coder/modules/agentapi/testdata/agentapi-start.sh b/registry/coder/modules/agentapi/testdata/agentapi-start.sh deleted file mode 100644 index 259eb0c9f..000000000 --- a/registry/coder/modules/agentapi/testdata/agentapi-start.sh +++ /dev/null @@ -1,22 +0,0 @@ -#!/bin/bash -set -o errexit -set -o pipefail - -use_prompt=${1:-false} -port=${2:-3284} - -module_path="$HOME/.agentapi-module" -log_file_path="$module_path/agentapi.log" - -echo "using prompt: $use_prompt" >> /home/coder/test-agentapi-start.log -echo "using port: $port" >> /home/coder/test-agentapi-start.log - -AGENTAPI_CHAT_BASE_PATH="${AGENTAPI_CHAT_BASE_PATH:-}" -if [ -n "$AGENTAPI_CHAT_BASE_PATH" ]; then - echo "Using AGENTAPI_CHAT_BASE_PATH: $AGENTAPI_CHAT_BASE_PATH" >> /home/coder/test-agentapi-start.log - export AGENTAPI_CHAT_BASE_PATH -fi - -agentapi server --port "$port" --term-width 67 --term-height 1190 -- \ - bash -c aiagent \ - > "$log_file_path" 2>&1