From 535eaeac6fbc9d2cf5352c6aa7de974969dbe1d8 Mon Sep 17 00:00:00 2001 From: Jash Date: Fri, 25 Jul 2025 12:23:44 +0000 Subject: [PATCH] feat(aider): Add Coder Tasks and AgentAPI support --- aider-test-template/aider/main.test.ts | 107 +++++++++++ aider-test-template/aider/main.tf | 179 ++++++++++++++++++ aider-test-template/aider/scripts/install.sh | 71 +++++++ aider-test-template/aider/scripts/start.sh | 35 ++++ aider-test-template/main.tf | 81 ++++++++ registry/coder/modules/aider/main.tf | 34 ++++ .../coder/modules/aider/scripts/install.sh | 71 +++++++ registry/coder/modules/aider/scripts/start.sh | 35 ++++ 8 files changed, 613 insertions(+) create mode 100644 aider-test-template/aider/main.test.ts create mode 100644 aider-test-template/aider/main.tf create mode 100644 aider-test-template/aider/scripts/install.sh create mode 100644 aider-test-template/aider/scripts/start.sh create mode 100644 aider-test-template/main.tf create mode 100644 registry/coder/modules/aider/scripts/install.sh create mode 100644 registry/coder/modules/aider/scripts/start.sh diff --git a/aider-test-template/aider/main.test.ts b/aider-test-template/aider/main.test.ts new file mode 100644 index 00000000..c25513a5 --- /dev/null +++ b/aider-test-template/aider/main.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from "bun:test"; +import { + findResourceInstance, + runTerraformApply, + runTerraformInit, + testRequiredVariables, +} from "~test"; + +describe("aider", async () => { + await runTerraformInit(import.meta.dir); + + testRequiredVariables(import.meta.dir, { + agent_id: "foo", + }); + + it("configures task prompt correctly", async () => { + const testPrompt = "Add a hello world function"; + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + task_prompt: testPrompt, + }); + + const instance = findResourceInstance(state, "coder_script"); + expect(instance.script).toContain( + `This is your current task: ${testPrompt}`, + ); + expect(instance.script).toContain("aider --architect --yes-always"); + }); + + it("handles custom system prompt", async () => { + const customPrompt = "Report all tasks with state: working"; + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + system_prompt: customPrompt, + }); + + const instance = findResourceInstance(state, "coder_script"); + expect(instance.script).toContain(customPrompt); + }); + + it("handles pre and post install scripts", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + experiment_pre_install_script: "echo 'Pre-install script executed'", + experiment_post_install_script: "echo 'Post-install script executed'", + }); + + const instance = findResourceInstance(state, "coder_script"); + + expect(instance.script).toContain("Running pre-install script"); + expect(instance.script).toContain("Running post-install script"); + expect(instance.script).toContain("base64 -d > /tmp/pre_install.sh"); + expect(instance.script).toContain("base64 -d > /tmp/post_install.sh"); + }); + + it("validates that use_screen and use_tmux cannot both be true", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + use_screen: true, + use_tmux: true, + }); + + const instance = findResourceInstance(state, "coder_script"); + + expect(instance.script).toContain( + "Error: Both use_screen and use_tmux cannot be enabled at the same time", + ); + expect(instance.script).toContain("exit 1"); + }); + + it("configures Aider with known provider and model", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + ai_provider: "anthropic", + ai_model: "sonnet", + ai_api_key: "test-anthropic-key", + }); + + const instance = findResourceInstance(state, "coder_script"); + expect(instance.script).toContain( + 'export ANTHROPIC_API_KEY=\\"test-anthropic-key\\"', + ); + expect(instance.script).toContain("--model sonnet"); + expect(instance.script).toContain( + "Starting Aider using anthropic provider and model: sonnet", + ); + }); + + it("handles custom provider with custom env var and API key", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + ai_provider: "custom", + custom_env_var_name: "MY_CUSTOM_API_KEY", + ai_model: "custom-model", + ai_api_key: "test-custom-key", + }); + + const instance = findResourceInstance(state, "coder_script"); + expect(instance.script).toContain( + 'export MY_CUSTOM_API_KEY=\\"test-custom-key\\"', + ); + expect(instance.script).toContain("--model custom-model"); + expect(instance.script).toContain( + "Starting Aider using custom provider and model: custom-model", + ); + }); +}); diff --git a/aider-test-template/aider/main.tf b/aider-test-template/aider/main.tf new file mode 100644 index 00000000..c47a7abb --- /dev/null +++ b/aider-test-template/aider/main.tf @@ -0,0 +1,179 @@ +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 "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 "group" { + type = string + description = "The name of a group that this app belongs to." + default = null +} + +variable "icon" { + type = string + description = "The icon to use for the app." + default = "/icon/aider.svg" +} + +variable "folder" { + type = string + description = "The folder to run Aider in." + default = "/home/coder" +} + +variable "install_aider" { + type = bool + description = "Whether to install Aider." + default = true +} + +variable "aider_version" { + type = string + description = "The version of Aider to install." + default = "latest" +} + +variable "agentapi_version" { + type = string + description = "The version of AgentAPI to install." + default = "latest" +} + +variable "experiment_pre_install_script" { + type = string + description = "Custom script to run before installing Aider." + default = null +} + +variable "experiment_post_install_script" { + type = string + description = "Custom script to run after installing Aider." + default = null +} + +variable "experiment_additional_extensions" { + type = string + description = "Additional extensions configuration in YAML format to append to the config." + default = null +} + +variable "ai_provider" { + type = string + description = "AI provider to use with Aider (openai, anthropic, azure, google, etc.)" + default = "anthropic" + validation { + condition = contains(["openai", "anthropic", "azure", "google", "cohere", "mistral", "ollama", "custom"], var.ai_provider) + error_message = "ai_provider must be one of: openai, anthropic, azure, google, cohere, mistral, ollama, custom" + } +} + +variable "ai_model" { + type = string + description = "AI model to use with Aider. Can use Aider's built-in aliases like '4o' (gpt-4o), 'sonnet' (claude-3-7-sonnet), 'opus' (claude-3-opus), etc." + default = "sonnet" +} + +variable "ai_api_key" { + type = string + description = "API key for the selected AI provider. This will be set as the appropriate environment variable based on the provider." + default = "" + sensitive = true +} + +variable "custom_env_var_name" { + type = string + description = "Custom environment variable name when using custom provider" + default = "" +} + +locals { + app_slug = "aider" + base_extensions = <<-EOT +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: ${local.app_slug} + 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 +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 +extensions: +${local.formatted_base}${local.additional_extensions} +EOT + install_script = file("${path.module}/scripts/install.sh") + start_script = file("${path.module}/scripts/start.sh") + module_dir_name = ".aider-module" +} + +module "agentapi" { + source = "registry.coder.com/coder/agentapi/coder" + version = "1.0.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 = "Aider" + cli_app_slug = "${local.app_slug}-cli" + cli_app_display_name = "Aider CLI" + module_dir_name = local.module_dir_name + agentapi_version = var.agentapi_version + pre_install_script = var.experiment_pre_install_script + post_install_script = var.experiment_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.ai_provider}' \ + ARG_MODEL='${var.ai_model}' \ + ARG_AIDER_CONFIG="$(echo -n '${base64encode(local.combined_extensions)}' | base64 -d)" \ + ARG_INSTALL='${var.install_aider}' \ + ARG_AIDER_VERSION='${var.aider_version}' \ + /tmp/install.sh + EOT +} \ No newline at end of file diff --git a/aider-test-template/aider/scripts/install.sh b/aider-test-template/aider/scripts/install.sh new file mode 100644 index 00000000..d34bdd41 --- /dev/null +++ b/aider-test-template/aider/scripts/install.sh @@ -0,0 +1,71 @@ +#!/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 "aider_config: $ARG_AIDER_CONFIG" +echo "install: $ARG_INSTALL" +echo "aider_version: $ARG_AIDER_VERSION" +echo "--------------------------------" + +set +o nounset + +if [ "${ARG_INSTALL}" = "true" ]; then + echo "Installing Aider..." + if ! command_exists python3 || ! command_exists pip3; then + echo "Installing Python dependencies required for Aider..." + if command -v apt-get >/dev/null 2>&1; then + if command -v sudo >/dev/null 2>&1; then + sudo apt-get update -qq + sudo apt-get install -y -qq python3-pip python3-venv + else + apt-get update -qq || echo "Warning: Cannot update package lists without sudo privileges" + apt-get install -y -qq python3-pip python3-venv || echo "Warning: Cannot install Python packages without sudo privileges" + fi + elif command -v dnf >/dev/null 2>&1; then + if command -v sudo >/dev/null 2>&1; then + sudo dnf install -y -q python3-pip python3-virtualenv + else + dnf install -y -q python3-pip python3-virtualenv || echo "Warning: Cannot install Python packages without sudo privileges" + fi + else + echo "Warning: Unable to install Python on this system. Neither apt-get nor dnf found." + fi + else + echo "Python is already installed, skipping installation." + fi + + if ! command_exists aider; then + curl -LsSf https://aider.chat/install.sh | sh + fi + + if [ -f "$HOME/.bashrc" ]; then + if ! grep -q 'export PATH="$HOME/bin:$PATH"' "$HOME/.bashrc"; then + echo 'export PATH="$HOME/bin:$PATH"' >> "$HOME/.bashrc" + fi + fi + + if [ -f "$HOME/.zshrc" ]; then + if ! grep -q 'export PATH="$HOME/bin:$PATH"' "$HOME/.zshrc"; then + echo 'export PATH="$HOME/bin:$PATH"' >> "$HOME/.zshrc" + fi + fi +else + echo "Skipping Aider installation" +fi + +if [ "${ARG_AIDER_CONFIG}" != "" ]; then + echo "Configuring Aider..." + mkdir -p "$HOME/.config/aider" + echo "model: $ARG_MODEL" > "$HOME/.config/aider/config.yml" + echo "$ARG_AIDER_CONFIG" >> "$HOME/.config/aider/config.yml" +else + echo "Skipping Aider configuration" +fi \ No newline at end of file diff --git a/aider-test-template/aider/scripts/start.sh b/aider-test-template/aider/scripts/start.sh new file mode 100644 index 00000000..b28cd293 --- /dev/null +++ b/aider-test-template/aider/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 aider; then + AIDER_CMD=aider +elif [ -f "$HOME/.local/bin/aider" ]; then + AIDER_CMD="$HOME/.local/bin/aider" +else + echo "Error: Aider is not installed. Please enable install_aider or install it manually." + exit 1 +fi + +# this must be kept up to date with main.tf +MODULE_DIR="$HOME/.aider-module" +mkdir -p "$MODULE_DIR" + +PROMPT_FILE="$MODULE_DIR/prompt.txt" + +if [ -n "${AIDER_TASK_PROMPT}" ]; then + echo "Starting with a prompt" + echo -n "${AIDER_TASK_PROMPT}" >"$PROMPT_FILE" + AIDER_ARGS=(--message-file "$PROMPT_FILE") +else + echo "Starting without a prompt" + AIDER_ARGS=() +fi + +agentapi server --term-width 67 --term-height 1190 -- \ + bash -c "$(printf '%q ' "$AIDER_CMD" "${AIDER_ARGS[@]}")" \ No newline at end of file diff --git a/aider-test-template/main.tf b/aider-test-template/main.tf new file mode 100644 index 00000000..0531cc78 --- /dev/null +++ b/aider-test-template/main.tf @@ -0,0 +1,81 @@ +terraform { + required_providers { + coder = { + source = "coder/coder" + version = ">= 2.5" + } + docker = { + source = "kreuzwerker/docker" + version = ">= 3.0" + } + } +} + +provider "docker" {} + +data "coder_provisioner" "me" {} +data "coder_workspace" "me" {} +data "coder_workspace_owner" "me" {} + +# This variable will prompt you for your API key when you create the workspace. +variable "api_key" { + description = "API Key for your AI provider (e.g., OpenAI)." + sensitive = true + default = "" +} + +# This parameter is required by the agentapi module to accept the initial user prompt. +data "coder_parameter" "ai_prompt" { + name = "AI Prompt" + description = "Write an initial prompt for Aider to work on." + type = "string" + default = "" + mutable = true + ephemeral = true +} + + +# Create a persistent volume for the home directory. +resource "docker_volume" "home" { + name = "coder-${data.coder_workspace.me.id}-home" +} + +# Create the Docker container for the workspace. +resource "docker_container" "workspace" { + count = data.coder_workspace.me.start_count + image = "codercom/enterprise-base:ubuntu" + name = "coder-${data.coder_workspace.me.id}" + hostname = data.coder_workspace.me.name + entrypoint = ["sh", "-c", replace(coder_agent.main.init_script, "/localhost|127\\.0\\.0\\.1/", "host.docker.internal")] + env = ["CODER_AGENT_TOKEN=${coder_agent.main.token}"] + host { + host = "host.docker.internal" + ip = "host-gateway" + } + volumes { + container_path = "/home/coder" + volume_name = docker_volume.home.name + } +} + +resource "coder_agent" "main" { + os = "linux" + arch = data.coder_provisioner.me.arch + # Set the AI provider's API key as an environment variable in the agent. + # This is how the Aider module will access it. + env = { + OPENAI_API_KEY = var.api_key + AIDER_TASK_PROMPT = data.coder_parameter.ai_prompt.value + } +} + +# This is the most important part! +# It includes your local Aider module into this template. +module "aider" { + source = "./aider" # Use the local copy of the module + + agent_id = coder_agent.main.id + ai_provider = "openai" + ai_model = "4o" # Aider's alias for gpt-4o + ai_api_key = var.api_key +} \ No newline at end of file diff --git a/registry/coder/modules/aider/main.tf b/registry/coder/modules/aider/main.tf index e1f2eccd..77ae3b96 100644 --- a/registry/coder/modules/aider/main.tf +++ b/registry/coder/modules/aider/main.tf @@ -507,3 +507,37 @@ resource "coder_app" "aider_cli" { order = var.order group = var.group } + +module "agentapi" { + source = "registry.coder.com/coder/agentapi/coder" + version = "1.0.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 = "Aider" + cli_app_slug = "${local.app_slug}-cli" + cli_app_display_name = "Aider CLI" + module_dir_name = local.module_dir_name + agentapi_version = var.agentapi_version + pre_install_script = var.experiment_pre_install_script + post_install_script = var.experiment_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.ai_provider}' \ + ARG_MODEL='${var.ai_model}' \ + ARG_AIDER_CONFIG="$(echo -n '${base64encode(local.combined_extensions)}' | base64 -d)" \ + ARG_INSTALL='${var.install_aider}' \ + ARG_AIDER_VERSION='${var.aider_version}' \ + /tmp/install.sh + EOT +} diff --git a/registry/coder/modules/aider/scripts/install.sh b/registry/coder/modules/aider/scripts/install.sh new file mode 100644 index 00000000..c85a2c9f --- /dev/null +++ b/registry/coder/modules/aider/scripts/install.sh @@ -0,0 +1,71 @@ +#!/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 "aider_config: $ARG_AIDER_CONFIG" +echo "install: $ARG_INSTALL" +echo "aider_version: $ARG_AIDER_VERSION" +echo "--------------------------------" + +set +o nounset + +if [ "${ARG_INSTALL}" = "true" ]; then + echo "Installing Aider..." + if ! command_exists python3 || ! command_exists pip3; then + echo "Installing Python dependencies required for Aider..." + if command -v apt-get >/dev/null 2>&1; then + if command -v sudo >/dev/null 2>&1; then + sudo apt-get update -qq + sudo apt-get install -y -qq python3-pip python3-venv + else + apt-get update -qq || echo "Warning: Cannot update package lists without sudo privileges" + apt-get install -y -qq python3-pip python3-venv || echo "Warning: Cannot install Python packages without sudo privileges" + fi + elif command -v dnf >/dev/null 2>&1; then + if command -v sudo >/dev/null 2>&1; then + sudo dnf install -y -q python3-pip python3-virtualenv + else + dnf install -y -q python3-pip python3-virtualenv || echo "Warning: Cannot install Python packages without sudo privileges" + fi + else + echo "Warning: Unable to install Python on this system. Neither apt-get nor dnf found." + fi + else + echo "Python is already installed, skipping installation." + fi + + if ! command_exists aider; then + curl -LsSf https://aider.chat/install.sh | sh + fi + + if [ -f "$HOME/.bashrc" ]; then + if ! grep -q 'export PATH="$HOME/bin:$PATH"' "$HOME/.bashrc"; then + echo 'export PATH="$HOME/bin:$PATH"' >> "$HOME/.bashrc" + fi + fi + + if [ -f "$HOME/.zshrc" ]; then + if ! grep -q 'export PATH="$HOME/bin:$PATH"' "$HOME/.zshrc"; then + echo 'export PATH="$HOME/bin:$PATH"' >> "$HOME/.zshrc" + fi + fi +else + echo "Skipping Aider installation" +fi + +if [ "${ARG_AIDER_CONFIG}" != "" ]; then + echo "Configuring Aider..." + mkdir -p "$HOME/.config/aider" + echo "model: $ARG_MODEL" > "$HOME/.config/aider/config.yml" + echo "$ARG_AIDER_CONFIG" >> "$HOME/.config/aider/config.yml" +else + echo "Skipping Aider configuration" +fi diff --git a/registry/coder/modules/aider/scripts/start.sh b/registry/coder/modules/aider/scripts/start.sh new file mode 100644 index 00000000..ff1f1d79 --- /dev/null +++ b/registry/coder/modules/aider/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 aider; then + AIDER_CMD=aider +elif [ -f "$HOME/.local/bin/aider" ]; then + AIDER_CMD="$HOME/.local/bin/aider" +else + echo "Error: Aider is not installed. Please enable install_aider or install it manually." + exit 1 +fi + +# this must be kept up to date with main.tf +MODULE_DIR="$HOME/.aider-module" +mkdir -p "$MODULE_DIR" + +PROMPT_FILE="$MODULE_DIR/prompt.txt" + +if [ -n "${AIDER_TASK_PROMPT:-}" ]; then + echo "Starting with a prompt" + echo -n "${AIDER_TASK_PROMPT}" >"$PROMPT_FILE" + AIDER_ARGS=(--message-file "$PROMPT_FILE") +else + echo "Starting without a prompt" + AIDER_ARGS=() +fi + +agentapi server --term-width 67 --term-height 1190 -- \ + bash -c "$(printf '%q ' "$AIDER_CMD" "${AIDER_ARGS[@]}")" \ No newline at end of file