From 3d3fc04bf78729fae439dcbe4cd888d5697c112e Mon Sep 17 00:00:00 2001 From: Hugo Dutka Date: Mon, 30 Jun 2025 14:52:14 +0200 Subject: [PATCH 1/2] agentapi module --- registry/coder/modules/agentapi/README.md | 54 +++++ registry/coder/modules/agentapi/main.test.ts | 151 +++++++++++++ registry/coder/modules/agentapi/main.tf | 213 ++++++++++++++++++ .../scripts/agentapi-wait-for-start.sh | 32 +++ .../coder/modules/agentapi/scripts/main.sh | 96 ++++++++ registry/coder/modules/agentapi/test-util.ts | 130 +++++++++++ .../agentapi/testdata/agentapi-mock.js | 19 ++ .../agentapi/testdata/agentapi-start.sh | 16 ++ .../agentapi/testdata/ai-agent-mock.js | 9 + test/test.ts | 10 + 10 files changed, 730 insertions(+) create mode 100644 registry/coder/modules/agentapi/README.md create mode 100644 registry/coder/modules/agentapi/main.test.ts create mode 100644 registry/coder/modules/agentapi/main.tf create mode 100644 registry/coder/modules/agentapi/scripts/agentapi-wait-for-start.sh create mode 100644 registry/coder/modules/agentapi/scripts/main.sh create mode 100644 registry/coder/modules/agentapi/test-util.ts create mode 100644 registry/coder/modules/agentapi/testdata/agentapi-mock.js create mode 100644 registry/coder/modules/agentapi/testdata/agentapi-start.sh create mode 100644 registry/coder/modules/agentapi/testdata/ai-agent-mock.js diff --git a/registry/coder/modules/agentapi/README.md b/registry/coder/modules/agentapi/README.md new file mode 100644 index 00000000..0dc97565 --- /dev/null +++ b/registry/coder/modules/agentapi/README.md @@ -0,0 +1,54 @@ +--- +display_name: AgentAPI +description: Building block for modules that need to run an agentapi server +icon: ../../../../.icons/coder.svg +maintainer_github: coder +verified: true +tags: [internal] +--- + +# AgentAPI + +The AgentAPI module is a building block for modules that need to run an agentapi server. It is intended primarily for internal use by Coder to create modules compatible with Tasks. + +We do not recommend using this module directly. Instead, please consider using one of our [Tasks-compatible AI agent modules](https://registry.coder.com/modules?search=tag%3Atasks). + +```tf +module "agentapi" { + source = "registry.coder.com/coder/agentapi/coder" + version = "1.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" + 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 +} +``` + +## 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). diff --git a/registry/coder/modules/agentapi/main.test.ts b/registry/coder/modules/agentapi/main.test.ts new file mode 100644 index 00000000..fab16967 --- /dev/null +++ b/registry/coder/modules/agentapi/main.test.ts @@ -0,0 +1,151 @@ +import { + test, + afterEach, + expect, + describe, + setDefaultTimeout, + beforeAll, +} from "bun:test"; +import { execContainer, readFileContainer, runTerraformInit } from "~test"; +import { + loadTestFile, + writeExecutable, + setup as setupUtil, + execModuleScript, + expectAgentAPIStarted, +} from "./test-util"; + +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) { + try { + await cleanup(); + } catch (error) { + console.error("Error during cleanup:", error); + } + } +}); + +interface SetupProps { + skipAgentAPIMock?: boolean; + moduleVariables?: Record; +} + +const moduleDirName = ".agentapi-module"; + +const setup = async (props?: SetupProps): Promise<{ id: string }> => { + const projectDir = "/home/coder/project"; + const { id } = await setupUtil({ + moduleVariables: { + experiment_report_tasks: "true", + install_agentapi: props?.skipAgentAPIMock ? "true" : "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", + module_dir_name: moduleDirName, + start_script: await loadTestFile(import.meta.dir, "agentapi-start.sh"), + folder: projectDir, + ...props?.moduleVariables, + }, + registerCleanup, + projectDir, + skipAgentAPIMock: props?.skipAgentAPIMock, + moduleDir: import.meta.dir, + }); + await writeExecutable({ + containerId: id, + filePath: "/usr/bin/aiagent", + content: await loadTestFile(import.meta.dir, "ai-agent-mock.js"), + }); + return { id }; +}; + +// 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("agentapi", async () => { + beforeAll(async () => { + await runTerraformInit(import.meta.dir); + }); + + test("happy-path", async () => { + const { id } = await setup(); + + await execModuleScript(id); + + await expectAgentAPIStarted(id); + }); + + test("custom-port", async () => { + const { id } = await setup({ + moduleVariables: { + agentapi_port: "3827", + }, + }); + await execModuleScript(id); + 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 }); + + const respModuleScript = await execModuleScript(id); + expect(respModuleScript.exitCode).toBe(0); + + await expectAgentAPIStarted(id); + const respAgentAPI = await execContainer(id, [ + "bash", + "-c", + "agentapi --version", + ]); + expect(respAgentAPI.exitCode).toBe(0); + }); +}); diff --git a/registry/coder/modules/agentapi/main.tf b/registry/coder/modules/agentapi/main.tf new file mode 100644 index 00000000..2e2c8669 --- /dev/null +++ b/registry/coder/modules/agentapi/main.tf @@ -0,0 +1,213 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 2.7" + } + } +} + +variable "agent_id" { + type = string + description = "The ID of a Coder agent." +} + +data "coder_workspace" "me" {} + +data "coder_workspace_owner" "me" {} + +variable "web_app_order" { + type = number + description = "The order determines the position of app in the UI presentation. The lowest order is shown first and apps with equal order are sorted by name (ascending order)." + default = null +} + +variable "web_app_group" { + type = string + description = "The name of a group that this app belongs to." + default = null +} + +variable "web_app_icon" { + type = string + description = "The icon to use for the app." +} + +variable "web_app_display_name" { + type = string + description = "The display name of the web app." +} + +variable "web_app_slug" { + type = string + description = "The slug of the web app." +} + +variable "folder" { + type = string + description = "The folder to run AgentAPI in." + default = "/home/coder" +} + +variable "cli_app" { + type = bool + description = "Whether to create the CLI workspace app." + default = false +} + +variable "cli_app_order" { + type = number + description = "The order of the CLI workspace app." + default = null +} + +variable "cli_app_group" { + type = string + description = "The group of the CLI workspace app." + default = null +} + +variable "cli_app_icon" { + type = string + description = "The icon to use for the app." + default = "/icon/claude.svg" +} + +variable "cli_app_display_name" { + type = string + description = "The display name of the CLI workspace app." +} + +variable "cli_app_slug" { + type = string + description = "The slug of the CLI workspace app." +} + +variable "pre_install_script" { + type = string + description = "Custom script to run before installing the agent used by AgentAPI." + default = null +} + +variable "install_script" { + type = string + description = "Script to install the agent used by AgentAPI." + default = "" +} + +variable "post_install_script" { + type = string + description = "Custom script to run after installing the agent used by AgentAPI." + default = null +} + +variable "start_script" { + type = string + description = "Script that starts AgentAPI." +} + +variable "install_agentapi" { + type = bool + description = "Whether to install AgentAPI." + default = true +} + +variable "agentapi_version" { + type = string + description = "The version of AgentAPI to install." + default = "v0.2.3" +} + +variable "agentapi_port" { + type = number + description = "The port used by AgentAPI." + default = 3284 +} + +variable "module_dir_name" { + type = string + description = "Name of the subdirectory in the home directory for module files." +} + + +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) + agentapi_wait_for_start_script_b64 = base64encode(file("${path.module}/scripts/agentapi-wait-for-start.sh")) + main_script = file("${path.module}/scripts/main.sh") +} + +resource "coder_script" "agentapi" { + agent_id = var.agent_id + display_name = "Install and start AgentAPI" + icon = var.web_app_icon + script = <<-EOT + #!/bin/bash + set -o errexit + set -o pipefail + + 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}' \ + /tmp/main.sh + EOT + run_on_start = true +} + +resource "coder_app" "agentapi_web" { + slug = var.web_app_slug + display_name = var.web_app_display_name + agent_id = var.agent_id + url = "http://localhost:${var.agentapi_port}/" + icon = var.web_app_icon + order = var.web_app_order + group = var.web_app_group + subdomain = true + healthcheck { + url = "http://localhost:${var.agentapi_port}/status" + interval = 3 + threshold = 20 + } +} + +resource "coder_app" "agentapi_cli" { + count = var.cli_app ? 1 : 0 + + slug = var.cli_app_slug + display_name = var.cli_app_display_name + agent_id = var.agent_id + command = <<-EOT + #!/bin/bash + set -e + + export LANG=en_US.UTF-8 + export LC_ALL=en_US.UTF-8 + + agentapi attach + EOT + icon = var.cli_app_icon + order = var.cli_app_order + group = var.cli_app_group +} + +resource "coder_ai_task" "agentapi" { + sidebar_app { + id = coder_app.agentapi_web.id + } +} diff --git a/registry/coder/modules/agentapi/scripts/agentapi-wait-for-start.sh b/registry/coder/modules/agentapi/scripts/agentapi-wait-for-start.sh new file mode 100644 index 00000000..7430e9ec --- /dev/null +++ b/registry/coder/modules/agentapi/scripts/agentapi-wait-for-start.sh @@ -0,0 +1,32 @@ +#!/bin/bash +set -o errexit +set -o pipefail + +port=${1:-3284} + +# 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 $port..." +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:$port/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 $port after 15 seconds." + exit 1 +fi + +echo "agentapi server started on port $port." diff --git a/registry/coder/modules/agentapi/scripts/main.sh b/registry/coder/modules/agentapi/scripts/main.sh new file mode 100644 index 00000000..f7a5caab --- /dev/null +++ b/registry/coder/modules/agentapi/scripts/main.sh @@ -0,0 +1,96 @@ +#!/bin/bash +set -e +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" +set +o nounset + +command_exists() { + command -v "$1" >/dev/null 2>&1 +} + +module_path="$HOME/${MODULE_DIR_NAME}" +mkdir -p "$module_path/scripts" + +if [ ! -d "${WORKDIR}" ]; then + echo "Warning: The specified folder '${WORKDIR}' does not exist." + echo "Creating the folder..." + 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 + 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 + if [ "${AGENTAPI_VERSION}" = "latest" ]; then + # for the latest release the download URL pattern is different than for tagged releases + # https://docs.github.com/en/repositories/releasing-projects-on-github/linking-to-releases + download_url="https://github.com/coder/agentapi/releases/latest/download/$binary_name" + else + download_url="https://github.com/coder/agentapi/releases/download/${AGENTAPI_VERSION}/$binary_name" + fi + curl \ + --retry 5 \ + --retry-delay 5 \ + --fail \ + --retry-all-errors \ + -L \ + -C - \ + -o agentapi \ + "$download_url" + 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 + +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 + +cd "${WORKDIR}" +nohup "$module_path/scripts/agentapi-start.sh" true "${AGENTAPI_PORT}" &>"$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 new file mode 100644 index 00000000..66860def --- /dev/null +++ b/registry/coder/modules/agentapi/test-util.ts @@ -0,0 +1,130 @@ +import { + execContainer, + findResourceInstance, + removeContainer, + runContainer, + runTerraformApply, + writeFileContainer, +} from "~test"; +import path from "path"; +import { expect } from "bun:test"; + +export const setupContainer = async ({ + moduleDir, + image, + vars, +}: { + moduleDir: string; + image?: string; + vars?: Record; +}) => { + const state = await runTerraformApply(moduleDir, { + agent_id: "foo", + ...vars, + }); + const coderScript = findResourceInstance(state, "coder_script"); + const id = await runContainer(image ?? "codercom/enterprise-node:latest"); + return { id, coderScript, cleanup: () => removeContainer(id) }; +}; + +export const loadTestFile = async ( + moduleDir: string, + ...relativePath: [string, ...string[]] +) => { + return await Bun.file( + path.join(moduleDir, "testdata", ...relativePath), + ).text(); +}; + +export 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"], + ); +}; + +interface SetupProps { + skipAgentAPIMock?: boolean; + moduleDir: string; + moduleVariables: Record; + projectDir?: string; + registerCleanup: (cleanup: () => Promise) => void; + agentapiMockScript?: string; +} + +export const setup = async (props: SetupProps): Promise<{ id: string }> => { + const projectDir = props.projectDir ?? "/home/coder/project"; + const { id, coderScript, cleanup } = await setupContainer({ + moduleDir: props.moduleDir, + vars: props.moduleVariables, + }); + props.registerCleanup(cleanup); + await execContainer(id, ["bash", "-c", `mkdir -p '${projectDir}'`]); + if (!props?.skipAgentAPIMock) { + await writeExecutable({ + containerId: id, + filePath: "/usr/bin/agentapi", + content: + props.agentapiMockScript ?? + (await loadTestFile(import.meta.dir, "agentapi-mock.js")), + }); + } + await writeExecutable({ + containerId: id, + filePath: "/home/coder/script.sh", + content: coderScript.script, + }); + return { id }; +}; + +export const expectAgentAPIStarted = async ( + id: string, + port: number = 3284, +) => { + const resp = await execContainer(id, [ + "bash", + "-c", + `curl -fs -o /dev/null "http://localhost:${port}/status"`, + ]); + if (resp.exitCode !== 0) { + console.log("agentapi not started"); + console.log(resp.stdout); + console.log(resp.stderr); + } + expect(resp.exitCode).toBe(0); +}; + +export const execModuleScript = async ( + id: string, + env?: Record, +) => { + const envArgs = Object.entries(env ?? {}) + .map(([key, value]) => ["--env", `${key}=${value}`]) + .flat(); + 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`, + ], + envArgs, + ); + if (resp.exitCode !== 0) { + console.log(resp.stdout); + console.log(resp.stderr); + } + return resp; +}; diff --git a/registry/coder/modules/agentapi/testdata/agentapi-mock.js b/registry/coder/modules/agentapi/testdata/agentapi-mock.js new file mode 100644 index 00000000..4d2417ba --- /dev/null +++ b/registry/coder/modules/agentapi/testdata/agentapi-mock.js @@ -0,0 +1,19 @@ +#!/usr/bin/env node + +const http = require("http"); +const args = process.argv.slice(2); +const portIdx = args.findIndex((arg) => arg === "--port") + 1; +const port = portIdx ? args[portIdx] : 3284; + +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/agentapi/testdata/agentapi-start.sh b/registry/coder/modules/agentapi/testdata/agentapi-start.sh new file mode 100644 index 00000000..1564fe03 --- /dev/null +++ b/registry/coder/modules/agentapi/testdata/agentapi-start.sh @@ -0,0 +1,16 @@ +#!/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 server --port "$port" --term-width 67 --term-height 1190 -- \ + bash -c aiagent \ + >"$log_file_path" 2>&1 diff --git a/registry/coder/modules/agentapi/testdata/ai-agent-mock.js b/registry/coder/modules/agentapi/testdata/ai-agent-mock.js new file mode 100644 index 00000000..eb228a30 --- /dev/null +++ b/registry/coder/modules/agentapi/testdata/ai-agent-mock.js @@ -0,0 +1,9 @@ +#!/usr/bin/env node + +const main = async () => { + console.log("mocking an ai agent"); + // sleep for 30 minutes + await new Promise((resolve) => setTimeout(resolve, 30 * 60 * 1000)); +}; + +main(); diff --git a/test/test.ts b/test/test.ts index 0de9fb04..bb09a410 100644 --- a/test/test.ts +++ b/test/test.ts @@ -324,3 +324,13 @@ export const writeFileContainer = async ( } expect(proc.exitCode).toBe(0); }; + +export const readFileContainer = async (id: string, path: string) => { + const proc = await execContainer(id, ["cat", path], ["--user", "root"]); + if (proc.exitCode !== 0) { + console.log(proc.stderr); + console.log(proc.stdout); + } + expect(proc.exitCode).toBe(0); + return proc.stdout; +}; From 7c099ec9b060cc6bd2af73324011684bffccd92a Mon Sep 17 00:00:00 2001 From: Hugo Dutka Date: Thu, 3 Jul 2025 14:16:15 +0200 Subject: [PATCH 2/2] goose module --- bun.lockb | Bin 10569 -> 10960 bytes package.json | 1 + registry/coder/modules/goose/main.test.ts | 254 ++++++++++++++++ registry/coder/modules/goose/main.tf | 284 ++++-------------- .../coder/modules/goose/scripts/install.sh | 57 ++++ registry/coder/modules/goose/scripts/start.sh | 35 +++ .../testdata/agentapi-mock-print-args.js | 19 ++ .../modules/goose/testdata/goose-mock.sh | 8 + 8 files changed, 426 insertions(+), 232 deletions(-) create mode 100644 registry/coder/modules/goose/main.test.ts create mode 100644 registry/coder/modules/goose/scripts/install.sh create mode 100644 registry/coder/modules/goose/scripts/start.sh create mode 100644 registry/coder/modules/goose/testdata/agentapi-mock-print-args.js create mode 100644 registry/coder/modules/goose/testdata/goose-mock.sh diff --git a/bun.lockb b/bun.lockb index 7f379c9d1d11dde5144bad2cac47850647568d60..6ec83ca9d65d4b78728809bcc9b540c18a5a4aac 100755 GIT binary patch delta 1250 zcmcIkYe-a45I*PXK69;o*y`?@4`MTIukKd1TDq$sNft#>8~rGJETZYQY-^eHkgT95 z+CXR-8b%aFftzInlD{hQM?^vd5d@JMg@pwRWzF2X`===S)4+G<%*>gYGw00Q#~Yt+ z?i3fNXa*Mcf4e<;;(W*0`!m;uZ|0u$PVDTs{c3#Zg041l$D7L}vUT|~geA7F*8atH z{yKjk%$Nxc8phfal`@Yi7@LD~8_G$@qfo@}sy~hgC(D!HeTq!;B!y-f>N)x;!71cH zP*C#`h$=mTP6@@TQlU%;{M4aNJkbp#->}ENov|t=AS&fP`GQk0@ z`4BExI)WS*#CQgBa?C=+=|2_Vk`nzx1=2aNl3xp;v;Vn(NRRfi(reyw@U8dgl{(Yd zxuNl^)<<8~2D**2{VTp_4=&pJS*f|-vf8A-l|R#)&^7g}Z12lXo8p|ydE`x_rSkb` zUqvMx(5FI)zKW;7alH@R1|57r$qYpXA55Th(NM+HU=pQnqYl;?tGG4NWAq8&6-<%o z6psQPXq|?jO)7%jh;i*IsIV56&;F2!jHw8E2?9}Erq{4Y8pi1D$o?W0#+wc!*7A*N zRFR3cQ3uUM%tPoA)N$DA(mhdE%tufcQRmT}(*4upTMZVQMP(z>|8Z+&jL1$*r z62#G_|HTpa@-g0;Jlh`gbmvAaS;LAvx!`<9zPxZq)r#Tzwwmf1f0HBF)KcFNa5Puf ShIRqB+h9#jYGlg3L-+}{d+O`} delta 1080 zcmcIj&1(};5P!RNn@wzLleB3*Ha6QsNP0-MwNh=|_=Q+wMFa^g6e`+7AxddPBZ%$6 zQ=yPB9`qs}ym;`?tfgAaRq%t7KS05gND=fR7{TcLb~h){dU4>lznytA^WMCfS3m#q zTsfU8c-E5l?=)_{+j#w9vOcq(NzH5pCyvgoZcQ6)m%h}VC50u?O1@7Xj8;ky^lmn| zg;=U7HdmQ-W+R)_Q3?kf)OYFFg+Aq%bvD`xfw;)7?4Qd>+gy!IHDb z6Nf#S;=;u=8QT71qWRMn92%F8BLR?>jtdT17MrpD-pO@bqf@a2z16fcaEBX)rjxOY z(Z%2y79Iv7FaSLa3<`z}*MuQKKhg7f{IGmN+I|J?57CWSegMf^!3Y8n02|igm;{y+ zacag&W_h4tQ|TaOSgvhr1uV`M{5dg#zWEH9R}NSBh6J6bk9$?B#2-3-m}&P+gyoq+Iq3zCAkfMk;kS4?97t PNpq_IsbWqhugkvxCwP%z diff --git a/package.json b/package.json index 733230db..8ea769ca 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "devDependencies": { "@types/bun": "^1.2.9", "bun-types": "^1.1.23", + "dedent": "^1.6.0", "gray-matter": "^4.0.3", "marked": "^12.0.2", "prettier": "^3.3.3", diff --git a/registry/coder/modules/goose/main.test.ts b/registry/coder/modules/goose/main.test.ts new file mode 100644 index 00000000..bbf8b262 --- /dev/null +++ b/registry/coder/modules/goose/main.test.ts @@ -0,0 +1,254 @@ +import { + test, + afterEach, + describe, + setDefaultTimeout, + beforeAll, + expect, +} from "bun:test"; +import { execContainer, readFileContainer, runTerraformInit } from "~test"; +import { + 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) { + try { + await cleanup(); + } catch (error) { + console.error("Error during cleanup:", error); + } + } +}); + +interface SetupProps { + skipAgentAPIMock?: boolean; + skipGooseMock?: boolean; + moduleVariables?: Record; + agentapiMockScript?: string; +} + +const setup = async (props?: SetupProps): Promise<{ id: string }> => { + const projectDir = "/home/coder/project"; + const { id } = await setupUtil({ + moduleDir: import.meta.dir, + moduleVariables: { + install_goose: props?.skipGooseMock ? "true" : "false", + install_agentapi: props?.skipAgentAPIMock ? "true" : "false", + goose_provider: "test-provider", + goose_model: "test-model", + ...props?.moduleVariables, + }, + registerCleanup, + projectDir, + skipAgentAPIMock: props?.skipAgentAPIMock, + agentapiMockScript: props?.agentapiMockScript, + }); + if (!props?.skipGooseMock) { + await writeExecutable({ + containerId: id, + filePath: "/usr/bin/goose", + content: await loadTestFile(import.meta.dir, "goose-mock.sh"), + }); + } + return { id }; +}; + +// increase the default timeout to 60 seconds +setDefaultTimeout(60 * 1000); + +describe("goose", async () => { + beforeAll(async () => { + await runTerraformInit(import.meta.dir); + }); + + test("happy-path", async () => { + const { id } = await setup(); + + await execModuleScript(id); + + await expectAgentAPIStarted(id); + }); + + test("install-version", async () => { + const { id } = await setup({ + skipGooseMock: true, + moduleVariables: { + install_goose: "true", + goose_version: "v1.0.24", + }, + }); + + await execModuleScript(id); + + const resp = await execContainer(id, [ + "bash", + "-c", + `"$HOME/.local/bin/goose" --version`, + ]); + if (resp.exitCode !== 0) { + console.log(resp.stdout); + console.log(resp.stderr); + } + expect(resp.exitCode).toBe(0); + expect(resp.stdout).toContain("1.0.24"); + }); + + test("install-stable", async () => { + const { id } = await setup({ + skipGooseMock: true, + moduleVariables: { + install_goose: "true", + goose_version: "stable", + }, + }); + + await execModuleScript(id); + + const resp = await execContainer(id, [ + "bash", + "-c", + `"$HOME/.local/bin/goose" --version`, + ]); + if (resp.exitCode !== 0) { + console.log(resp.stdout); + console.log(resp.stderr); + } + expect(resp.exitCode).toBe(0); + }); + + test("config", async () => { + const expected = + dedent` + GOOSE_PROVIDER: anthropic + GOOSE_MODEL: claude-3-5-sonnet-latest + extensions: + coder: + args: + - exp + - mcp + - server + cmd: coder + description: Report ALL tasks and statuses (in progress, done, failed) you are working on. + enabled: true + envs: + CODER_MCP_APP_STATUS_SLUG: goose + CODER_MCP_AI_AGENTAPI_URL: http://localhost:3284 + name: Coder + timeout: 3000 + type: stdio + developer: + display_name: Developer + enabled: true + name: developer + timeout: 300 + type: builtin + custom-stuff: + enabled: true + name: custom-stuff + timeout: 300 + type: builtin + `.trim() + "\n"; + + const { id } = await setup({ + moduleVariables: { + goose_provider: "anthropic", + goose_model: "claude-3-5-sonnet-latest", + additional_extensions: dedent` + custom-stuff: + enabled: true + name: custom-stuff + timeout: 300 + type: builtin + `.trim(), + }, + }); + await execModuleScript(id); + const resp = await readFileContainer( + id, + "/home/coder/.config/goose/config.yaml", + ); + expect(resp).toEqual(expected); + }); + + test("pre-post-install-scripts", async () => { + const { id } = await setup({ + moduleVariables: { + pre_install_script: "#!/bin/bash\necho 'pre-install-script'", + post_install_script: "#!/bin/bash\necho 'post-install-script'", + }, + }); + + await execModuleScript(id); + + const preInstallLog = await readFileContainer( + id, + "/home/coder/.goose-module/pre_install.log", + ); + expect(preInstallLog).toContain("pre-install-script"); + + const postInstallLog = await readFileContainer( + id, + "/home/coder/.goose-module/post_install.log", + ); + expect(postInstallLog).toContain("post-install-script"); + }); + + const promptFile = "/home/coder/.goose-module/prompt.txt"; + const agentapiStartLog = "/home/coder/.goose-module/agentapi-start.log"; + + test("start-with-prompt", async () => { + const { id } = await setup({ + agentapiMockScript: await loadTestFile( + import.meta.dir, + "agentapi-mock-print-args.js", + ), + }); + await execModuleScript(id, { + GOOSE_TASK_PROMPT: "custom-test-prompt", + }); + const prompt = await readFileContainer(id, promptFile); + expect(prompt).toContain("custom-test-prompt"); + + const agentapiMockOutput = await readFileContainer(id, agentapiStartLog); + expect(agentapiMockOutput).toContain( + "'goose run --interactive --instructions /home/coder/.goose-module/prompt.txt '", + ); + }); + + test("start-without-prompt", async () => { + const { id } = await setup({ + agentapiMockScript: await loadTestFile( + import.meta.dir, + "agentapi-mock-print-args.js", + ), + }); + await execModuleScript(id); + + const agentapiMockOutput = await readFileContainer(id, agentapiStartLog); + expect(agentapiMockOutput).toContain("'goose '"); + + const prompt = await execContainer(id, ["ls", "-l", promptFile]); + expect(prompt.exitCode).not.toBe(0); + expect(prompt.stderr).toContain("No such file or directory"); + }); +}); diff --git a/registry/coder/modules/goose/main.tf b/registry/coder/modules/goose/main.tf index a159ca7b..1b1b8089 100644 --- a/registry/coder/modules/goose/main.tf +++ b/registry/coder/modules/goose/main.tf @@ -4,7 +4,7 @@ terraform { required_providers { coder = { source = "coder/coder" - version = ">= 2.5" + version = ">= 2.7" } } } @@ -54,67 +54,42 @@ variable "goose_version" { default = "stable" } -variable "experiment_use_screen" { +variable "install_agentapi" { type = bool - description = "Whether to use screen for running Goose in the background." - default = false -} - -variable "experiment_use_tmux" { - type = bool - description = "Whether to use tmux instead of screen for running Goose in the background." - default = false -} - -variable "session_name" { - type = string - description = "Name for the persistent session (screen or tmux)" - default = "goose" -} - -variable "experiment_report_tasks" { - type = bool - description = "Whether to enable task reporting." - default = false -} - -variable "experiment_auto_configure" { - type = bool - description = "Whether to automatically configure Goose." - default = false + description = "Whether to install AgentAPI." + default = true } -variable "experiment_goose_provider" { +variable "goose_provider" { type = string description = "The provider to use for Goose (e.g., anthropic)." - default = "" } -variable "experiment_goose_model" { +variable "goose_model" { type = string description = "The model to use for Goose (e.g., claude-3-5-sonnet-latest)." - default = "" } -variable "experiment_pre_install_script" { +variable "pre_install_script" { type = string description = "Custom script to run before installing Goose." default = null } -variable "experiment_post_install_script" { +variable "post_install_script" { type = string description = "Custom script to run after installing Goose." default = null } -variable "experiment_additional_extensions" { +variable "additional_extensions" { type = string description = "Additional extensions configuration in YAML format to append to the config." default = null } locals { + app_slug = "goose" base_extensions = <<-EOT coder: args: @@ -125,7 +100,8 @@ coder: description: Report ALL tasks and statuses (in progress, done, failed) you are working on. enabled: true envs: - CODER_MCP_APP_STATUS_SLUG: goose + CODER_MCP_APP_STATUS_SLUG: ${local.app_slug} + CODER_MCP_AI_AGENTAPI_URL: http://localhost:3284 name: Coder timeout: 3000 type: stdio @@ -139,204 +115,48 @@ EOT # Add two spaces to each line of extensions to match YAML structure formatted_base = " ${replace(trimspace(local.base_extensions), "\n", "\n ")}" - additional_extensions = var.experiment_additional_extensions != null ? "\n ${replace(trimspace(var.experiment_additional_extensions), "\n", "\n ")}" : "" - - combined_extensions = <<-EOT + additional_extensions = var.additional_extensions != null ? "\n ${replace(trimspace(var.additional_extensions), "\n", "\n ")}" : "" + combined_extensions = <<-EOT extensions: ${local.formatted_base}${local.additional_extensions} EOT - - 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) : "" -} - -# Install and Initialize Goose -resource "coder_script" "goose" { - agent_id = var.agent_id - display_name = "Goose" - icon = var.icon - script = <<-EOT + install_script = file("${path.module}/scripts/install.sh") + start_script = file("${path.module}/scripts/start.sh") + module_dir_name = ".goose-module" +} + +module "agentapi" { + # TODO: how to reference the agentapi module? We need version pinning. + # while the PR is in review, you can reference the module like this: + # source = "git::https://github.com/coder/registry.git//registry/coder/modules/agentapi?ref=hugodutka/agentapi-module" + source = "../agentapi" + + 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 = "${local.app_slug}-cli" + cli_app_display_name = "Goose 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 -e - - # Function to check if a command exists - command_exists() { - command -v "$1" >/dev/null 2>&1 - } - - # Run pre-install script if provided - 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 - - # Install Goose if enabled - if [ "${var.install_goose}" = "true" ]; then - if ! command_exists npm; then - echo "Error: npm is not installed. Please install Node.js and npm first." - exit 1 - fi - echo "Installing Goose..." - RELEASE_TAG=v${var.goose_version} curl -fsSL https://github.com/block/goose/releases/download/stable/download_cli.sh | CONFIGURE=false bash - fi - - # Run post-install script if provided - 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 - - # Configure Goose if auto-configure is enabled - if [ "${var.experiment_auto_configure}" = "true" ]; then - echo "Configuring Goose..." - mkdir -p "$HOME/.config/goose" - cat > "$HOME/.config/goose/config.yaml" << EOL -GOOSE_PROVIDER: ${var.experiment_goose_provider} -GOOSE_MODEL: ${var.experiment_goose_model} -${trimspace(local.combined_extensions)} -EOL - fi - - # Write system prompt to config - mkdir -p "$HOME/.config/goose" - echo "$GOOSE_SYSTEM_PROMPT" > "$HOME/.config/goose/.goosehints" - - # Handle terminal multiplexer selection (tmux or screen) - if [ "${var.experiment_use_tmux}" = "true" ] && [ "${var.experiment_use_screen}" = "true" ]; then - echo "Error: Both experiment_use_tmux and experiment_use_screen cannot be true simultaneously." - echo "Please set only one of them to true." - exit 1 - fi - - # Determine goose command - if command_exists goose; then - GOOSE_CMD=goose - elif [ -f "$HOME/.local/bin/goose" ]; then - GOOSE_CMD="$HOME/.local/bin/goose" - else - echo "Error: Goose is not installed. Please enable install_goose or install it manually." - exit 1 - fi - - # Run with tmux if enabled - if [ "${var.experiment_use_tmux}" = "true" ]; then - echo "Running Goose in the background with tmux..." - - # Check if tmux is installed - if ! command_exists tmux; then - echo "Error: tmux is not installed. Please install tmux manually." - exit 1 - fi - - touch "$HOME/.goose.log" - - export LANG=en_US.UTF-8 - export LC_ALL=en_US.UTF-8 - - # Configure tmux for shared sessions - if [ ! -f "$HOME/.tmux.conf" ]; then - echo "Creating ~/.tmux.conf with shared session settings..." - echo "set -g mouse on" > "$HOME/.tmux.conf" - fi - - if ! grep -q "^set -g mouse on$" "$HOME/.tmux.conf"; then - echo "Adding 'set -g mouse on' to ~/.tmux.conf..." - echo "set -g mouse on" >> "$HOME/.tmux.conf" - fi - - # Create a new tmux session in detached mode - tmux new-session -d -s ${var.session_name} -c ${var.folder} "\"$GOOSE_CMD\" run --text \"Review your goosehints. Every step of the way, report tasks to Coder with proper descriptions and statuses. Your task at hand: $GOOSE_TASK_PROMPT\" --interactive | tee -a \"$HOME/.goose.log\"; exec bash" - elif [ "${var.experiment_use_screen}" = "true" ]; then - echo "Running Goose in the background..." - - # Check if screen is installed - if ! command_exists screen; then - echo "Error: screen is not installed. Please install screen manually." - exit 1 - fi - - touch "$HOME/.goose.log" - - # Ensure the screenrc exists - if [ ! -f "$HOME/.screenrc" ]; then - echo "Creating ~/.screenrc and adding multiuser settings..." | tee -a "$HOME/.goose.log" - echo -e "multiuser on\nacladd $(whoami)" > "$HOME/.screenrc" - fi - - if ! grep -q "^multiuser on$" "$HOME/.screenrc"; then - echo "Adding 'multiuser on' to ~/.screenrc..." | tee -a "$HOME/.goose.log" - echo "multiuser on" >> "$HOME/.screenrc" - fi - - if ! grep -q "^acladd $(whoami)$" "$HOME/.screenrc"; then - echo "Adding 'acladd $(whoami)' to ~/.screenrc..." | tee -a "$HOME/.goose.log" - echo "acladd $(whoami)" >> "$HOME/.screenrc" - fi - export LANG=en_US.UTF-8 - export LC_ALL=en_US.UTF-8 - - screen -U -dmS ${var.session_name} bash -c " - cd ${var.folder} - \"$GOOSE_CMD\" run --text \"Review your goosehints. Every step of the way, report tasks to Coder with proper descriptions and statuses. Your task at hand: $GOOSE_TASK_PROMPT\" --interactive | tee -a \"$HOME/.goose.log\" - /bin/bash - " - fi - EOT - run_on_start = true -} - -resource "coder_app" "goose" { - slug = "goose" - display_name = "Goose" - agent_id = var.agent_id - command = <<-EOT - #!/bin/bash - set -e - - # Function to check if a command exists - command_exists() { - command -v "$1" >/dev/null 2>&1 - } - - # Determine goose command - if command_exists goose; then - GOOSE_CMD=goose - elif [ -f "$HOME/.local/bin/goose" ]; then - GOOSE_CMD="$HOME/.local/bin/goose" - else - echo "Error: Goose is not installed. Please enable install_goose or install it manually." - exit 1 - fi - - export LANG=en_US.UTF-8 - export LC_ALL=en_US.UTF-8 - - if [ "${var.experiment_use_tmux}" = "true" ]; then - if tmux has-session -t ${var.session_name} 2>/dev/null; then - echo "Attaching to existing Goose tmux session." | tee -a "$HOME/.goose.log" - tmux attach-session -t ${var.session_name} - else - echo "Starting a new Goose tmux session." | tee -a "$HOME/.goose.log" - tmux new-session -s ${var.session_name} -c ${var.folder} "\"$GOOSE_CMD\" run --text \"Review your goosehints. Every step of the way, report tasks to Coder with proper descriptions and statuses. Your task at hand: $GOOSE_TASK_PROMPT\" --interactive | tee -a \"$HOME/.goose.log\"; exec bash" - fi - elif [ "${var.experiment_use_screen}" = "true" ]; then - # Check if session exists first - if ! screen -list | grep -q "${var.session_name}"; then - echo "Error: No existing Goose session found. Please wait for the script to start it." - exit 1 - fi - # Only attach to existing session - screen -xRR ${var.session_name} - else - cd ${var.folder} - "$GOOSE_CMD" run --text "Review goosehints. Your task: $GOOSE_TASK_PROMPT" --interactive - fi - EOT - icon = var.icon - order = var.order - group = var.group + 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 } diff --git a/registry/coder/modules/goose/scripts/install.sh b/registry/coder/modules/goose/scripts/install.sh new file mode 100644 index 00000000..28fc923a --- /dev/null +++ b/registry/coder/modules/goose/scripts/install.sh @@ -0,0 +1,57 @@ +#!/bin/bash + +# Function to check if a command exists +command_exists() { + command -v "$1" >/dev/null 2>&1 +} + +set -o nounset + +echo "--------------------------------" +echo "provider: $ARG_PROVIDER" +echo "model: $ARG_MODEL" +echo "goose_config: $ARG_GOOSE_CONFIG" +echo "install: $ARG_INSTALL" +echo "goose_version: $ARG_GOOSE_VERSION" +echo "--------------------------------" + +set +o nounset + +if [ "${ARG_INSTALL}" = "true" ]; then + echo "Installing Goose..." + parsed_version="${ARG_GOOSE_VERSION}" + if [ "${ARG_GOOSE_VERSION}" = "stable" ]; then + parsed_version="" + fi + curl -fsSL https://github.com/block/goose/releases/download/stable/download_cli.sh | GOOSE_VERSION="${parsed_version}" CONFIGURE=false bash + echo "Goose installed" +else + echo "Skipping Goose installation" +fi + +if [ "${ARG_GOOSE_CONFIG}" != "" ]; then + echo "Configuring Goose..." + mkdir -p "$HOME/.config/goose" + echo "GOOSE_PROVIDER: $ARG_PROVIDER" >"$HOME/.config/goose/config.yaml" + echo "GOOSE_MODEL: $ARG_MODEL" >>"$HOME/.config/goose/config.yaml" + echo "$ARG_GOOSE_CONFIG" >>"$HOME/.config/goose/config.yaml" +else + echo "Skipping Goose configuration" +fi + +if [ "${GOOSE_SYSTEM_PROMPT}" != "" ]; then + echo "Setting Goose system prompt..." + mkdir -p "$HOME/.config/goose" + echo "$GOOSE_SYSTEM_PROMPT" >"$HOME/.config/goose/.goosehints" +else + echo "Goose system prompt not set. use the GOOSE_SYSTEM_PROMPT environment variable to set it." +fi + +if command_exists goose; then + GOOSE_CMD=goose +elif [ -f "$HOME/.local/bin/goose" ]; then + GOOSE_CMD="$HOME/.local/bin/goose" +else + echo "Error: Goose is not installed. Please enable install_goose or install it manually." + exit 1 +fi diff --git a/registry/coder/modules/goose/scripts/start.sh b/registry/coder/modules/goose/scripts/start.sh new file mode 100644 index 00000000..314a41d0 --- /dev/null +++ b/registry/coder/modules/goose/scripts/start.sh @@ -0,0 +1,35 @@ +#!/bin/bash + +set -o errexit +set -o pipefail + +command_exists() { + command -v "$1" >/dev/null 2>&1 +} + +if command_exists goose; then + GOOSE_CMD=goose +elif [ -f "$HOME/.local/bin/goose" ]; then + GOOSE_CMD="$HOME/.local/bin/goose" +else + echo "Error: Goose is not installed. Please enable install_goose or install it manually." + exit 1 +fi + +# this must be kept up to date with main.tf +MODULE_DIR="$HOME/.goose-module" +mkdir -p "$MODULE_DIR" + +if [ ! -z "$GOOSE_TASK_PROMPT" ]; then + echo "Starting with a prompt" + PROMPT="Review your goosehints. Every step of the way, report tasks to Coder with proper descriptions and statuses. Your task at hand: $GOOSE_TASK_PROMPT" + PROMPT_FILE="$MODULE_DIR/prompt.txt" + echo -n "$PROMPT" >"$PROMPT_FILE" + GOOSE_ARGS=(run --interactive --instructions "$PROMPT_FILE") +else + echo "Starting without a prompt" + GOOSE_ARGS=() +fi + +agentapi server --term-width 67 --term-height 1190 -- \ + bash -c "$(printf '%q ' "$GOOSE_CMD" "${GOOSE_ARGS[@]}")" diff --git a/registry/coder/modules/goose/testdata/agentapi-mock-print-args.js b/registry/coder/modules/goose/testdata/agentapi-mock-print-args.js new file mode 100644 index 00000000..fd859c81 --- /dev/null +++ b/registry/coder/modules/goose/testdata/agentapi-mock-print-args.js @@ -0,0 +1,19 @@ +#!/usr/bin/env node + +const http = require("http"); +const args = process.argv.slice(2); +console.log(args); +const port = 3284; + +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/goose/testdata/goose-mock.sh b/registry/coder/modules/goose/testdata/goose-mock.sh new file mode 100644 index 00000000..4d7d3931 --- /dev/null +++ b/registry/coder/modules/goose/testdata/goose-mock.sh @@ -0,0 +1,8 @@ +#!/bin/bash + +set -e + +while true; do + echo "$(date) - goose-mock" + sleep 15 +done