diff --git a/registry/coder/modules/aider/README.md b/registry/coder/modules/aider/README.md index ec561e765..1d51e1015 100644 --- a/registry/coder/modules/aider/README.md +++ b/registry/coder/modules/aider/README.md @@ -1,6 +1,6 @@ --- display_name: Aider -description: Run Aider AI pair programming in your workspace +description: Install and configure Aider AI pair programming in your workspace icon: ../../../../.icons/aider.svg verified: true tags: [agent, ai, aider] @@ -8,108 +8,133 @@ tags: [agent, ai, aider] # Aider -Run [Aider](https://aider.chat) AI pair programming in your workspace. This module installs Aider with AgentAPI for seamless Coder Tasks Support. +Install and configure [Aider](https://aider.chat) AI pair programming in your workspace. ```tf -variable "api_key" { - type = string - description = "API key" - sensitive = true -} - module "aider" { source = "registry.coder.com/coder/aider/coder" - version = "2.0.2" + version = "3.0.0" agent_id = coder_agent.main.id - api_key = var.api_key + api_key = var.gemini_api_key ai_provider = "google" model = "gemini" } ``` -## Prerequisites +> [!WARNING] +> If upgrading from v2.x.x of this module: v3 is a major refactor that drops support for Coder Tasks and AgentAPI. The module now only installs and configures Aider; launch it with your own `coder_app`. `order`, `group`, `report_tasks`, `subdomain`, `cli_app`, `web_app_display_name`, `cli_app_display_name`, `install_agentapi`, `agentapi_version`, `ai_prompt`, `system_prompt`, and `experiment_additional_extensions` are removed. `workdir` is now optional, `base_aider_config` is written to `~/.aider.conf.yml`, and `ai_provider = "ollama"` exports `api_key` as `OLLAMA_API_KEY`. Keep using v2.x.x if you depend on Coder Tasks. -- pipx is automatically installed if not already available +## Examples -## Usage Example +### Standalone mode with a launcher app ```tf -data "coder_parameter" "ai_prompt" { - name = "AI Prompt" - description = "Write an initial prompt for Aider to work on." - type = "string" - default = "" - mutable = true +locals { + aider_workdir = "/home/coder/project" } -variable "gemini_api_key" { - type = string - description = "Gemini API key" - sensitive = true +module "aider" { + source = "registry.coder.com/coder/aider/coder" + version = "3.0.0" + agent_id = coder_agent.main.id + workdir = local.aider_workdir + ai_provider = "anthropic" + model = "sonnet" + api_key = var.anthropic_api_key } -module "aider" { - source = "registry.coder.com/coder/aider/coder" - version = "2.0.2" - agent_id = coder_agent.main.id - api_key = var.gemini_api_key - install_aider = true - workdir = "/home/coder" - ai_provider = "google" - model = "gemini" - install_agentapi = true - ai_prompt = data.coder_parameter.ai_prompt.value - system_prompt = "..." +resource "coder_app" "aider" { + agent_id = coder_agent.main.id + slug = "aider" + display_name = "Aider" + icon = "/icon/aider.svg" + open_in = "slim-window" + command = <<-EOT + #!/usr/bin/env bash + set -e + cd "${local.aider_workdir}" + exec aider + EOT } ``` -### Using a custom provider +### Custom provider ```tf -variable "custom_api_key" { - type = string - description = "Custom provider API key" - sensitive = true -} - module "aider" { - count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/aider/coder" - version = "2.0.2" + version = "3.0.0" agent_id = coder_agent.main.id - workdir = "/home/coder" ai_provider = "custom" - custom_env_var_name = "MY_CUSTOM_API_KEY" - model = "custom-model" - api_key = var.custom_api_key + custom_env_var_name = "DEEPSEEK_API_KEY" + model = "deepseek" + api_key = var.deepseek_api_key } ``` -### Available AI Providers and Models +`api_key` is exported as the provider's environment variable (`OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `AZURE_OPENAI_API_KEY`, `GEMINI_API_KEY`, `COHERE_API_KEY`, `MISTRAL_API_KEY`, `OLLAMA_API_KEY`, or `custom_env_var_name`) and `model` as `AIDER_MODEL`. See [Aider API keys](https://aider.chat/docs/config/api-keys.html) and [connecting to LLMs](https://aider.chat/docs/llms.html). -Aider supports various providers and models, and this module integrates directly with Aider's built-in model aliases: +### Configuration file -| Provider | Example Models/Aliases | Default Model | -| ------------- | --------------------------------------------- | ---------------------- | -| **anthropic** | "sonnet" (Claude 3.7 Sonnet), "opus", "haiku" | "sonnet" | -| **openai** | "4o" (GPT-4o), "4" (GPT-4), "3.5-turbo" | "4o" | -| **azure** | Azure OpenAI models | "gpt-4" | -| **google** | "gemini" (Gemini Pro), "gemini-2.5-pro" | "gemini-2.5-pro" | -| **cohere** | "command-r-plus", etc. | "command-r-plus" | -| **mistral** | "mistral-large-latest" | "mistral-large-latest" | -| **ollama** | "llama3", etc. | "llama3" | -| **custom** | Any model name with custom ENV variable | - | +```tf +module "aider" { + source = "registry.coder.com/coder/aider/coder" + version = "3.0.0" + agent_id = coder_agent.main.id + model = "gemini" + api_key = var.gemini_api_key + + base_aider_config = <<-EOT + read: + - CONVENTIONS.md + auto-commits: false + EOT +} +``` -For a complete and up-to-date list of supported aliases and models, please refer to the [Aider LLM documentation](https://aider.chat/docs/llms.html) and the [Aider LLM Leaderboards](https://aider.chat/docs/leaderboards.html) which show performance comparisons across different models. +`base_aider_config` is written to `~/.aider.conf.yml`. See the [YAML config file](https://aider.chat/docs/config/aider_conf.html) reference. + +### Serialize a downstream `coder_script` after the install pipeline + +The module exposes the `scripts` output: an ordered list of `coder exp sync` names for the scripts this module creates (pre_install, install, post_install). Scripts that were not configured are absent. + +```tf +module "aider" { + source = "registry.coder.com/coder/aider/coder" + version = "3.0.0" + agent_id = coder_agent.main.id + model = "gemini" +} + +resource "coder_script" "post_aider" { + agent_id = coder_agent.main.id + display_name = "Run after Aider install" + run_on_start = true + script = <<-EOT + #!/usr/bin/env bash + set -euo pipefail + trap 'coder exp sync complete post-aider' EXIT + coder exp sync want post-aider ${join(" ", module.aider.scripts)} + coder exp sync start post-aider + + aider --version + EOT +} +``` ## Troubleshooting -- If `aider` is not found, ensure `install_aider = true` and your API key is valid -- Logs are written under `/home/coder/.aider-module/` (`install.log`, `agentapi-start.log`) for debugging -- If AgentAPI fails to start, verify that your container has network access and executable permissions for the scripts +Check the log files in `~/.coder-modules/coder/aider/logs/` for detailed information. + +```bash +cat ~/.coder-modules/coder/aider/logs/install.log +cat ~/.coder-modules/coder/aider/logs/pre_install.log +cat ~/.coder-modules/coder/aider/logs/post_install.log +``` ## References -- [Aider Documentation](https://aider.chat/docs) -- [AgentAPI Documentation](https://github.com/coder/agentapi) -- [Coder AI Agents Guide](https://coder.com/docs/tutorials/ai-agents) +- [Aider documentation](https://aider.chat/docs/) +- [Aider installation](https://aider.chat/docs/install.html) +- [Aider configuration](https://aider.chat/docs/config.html) +- [Aider API keys](https://aider.chat/docs/config/api-keys.html) diff --git a/registry/coder/modules/aider/main.test.ts b/registry/coder/modules/aider/main.test.ts index c0aa51df1..3a448ac79 100644 --- a/registry/coder/modules/aider/main.test.ts +++ b/registry/coder/modules/aider/main.test.ts @@ -6,14 +6,50 @@ import { beforeAll, expect, } from "bun:test"; -import { execContainer, readFileContainer, runTerraformInit } from "~test"; import { - loadTestFile, + execContainer, + readFileContainer, + removeContainer, + runContainer, + runTerraformApply, + runTerraformInit, + TerraformState, +} from "~test"; +import { + extractCoderEnvVars, writeExecutable, - setup as setupUtil, - execModuleScript, - expectAgentAPIStarted, } from "../../../coder/modules/agentapi/test-util"; +import path from "path"; + +interface ModuleScripts { + pre_install?: string; + install: string; + post_install?: string; +} + +const collectScripts = (state: TerraformState): ModuleScripts => { + const byDisplayName: Record = {}; + for (const resource of state.resources) { + if (resource.type !== "coder_script") continue; + for (const instance of resource.instances) { + const attrs = instance.attributes as Record; + const displayName = attrs.display_name as string | undefined; + const script = attrs.script as string | undefined; + if (displayName && script) { + byDisplayName[displayName] = script; + } + } + } + const install = byDisplayName["Aider: Install Script"]; + if (!install) { + throw new Error("install script not found in terraform state"); + } + return { + pre_install: byDisplayName["Aider: Pre-Install Script"], + install, + post_install: byDisplayName["Aider: Post-Install Script"], + }; +}; let cleanupFunctions: (() => Promise)[] = []; const registerCleanup = (cleanup: () => Promise) => { @@ -32,107 +68,264 @@ afterEach(async () => { }); interface SetupProps { - skipAgentAPIMock?: boolean; skipAiderMock?: 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_aider: props?.skipAiderMock ? "true" : "false", - install_agentapi: props?.skipAgentAPIMock ? "true" : "false", - aider_model: "test-model", - ...props?.moduleVariables, - }, - registerCleanup, - projectDir, - skipAgentAPIMock: props?.skipAgentAPIMock, - agentapiMockScript: props?.agentapiMockScript, - }); - - // Place the Aider mock CLI binary inside the container +const projectDir = "/home/coder/project"; + +const setup = async ( + props?: SetupProps, +): Promise<{ + id: string; + coderEnvVars: Record; + scripts: ModuleScripts; +}> => { + const moduleDir = path.resolve(import.meta.dir); + const state = await runTerraformApply(moduleDir, { + agent_id: "foo", + model: "gemini", + workdir: projectDir, + install_aider: "false", + ...props?.moduleVariables, + }); + const scripts = collectScripts(state); + const coderEnvVars = extractCoderEnvVars(state); + + const id = await runContainer("codercom/enterprise-node:latest"); + registerCleanup(async () => { + if (process.env["DEBUG"] === "true" || process.env["DEBUG"] === "1") { + console.log(`Not removing container ${id} in debug mode`); + return; + } + await removeContainer(id); + }); + + await writeExecutable({ + containerId: id, + filePath: "/usr/bin/coder", + content: "#!/bin/bash\nexit 0\n", + }); if (!props?.skipAiderMock) { await writeExecutable({ containerId: id, filePath: "/usr/bin/aider", - content: await loadTestFile(`${import.meta.dir}`, "aider-mock.sh"), + content: await Bun.file( + path.join(moduleDir, "testdata", "aider-mock.sh"), + ).text(), }); } + return { id, coderEnvVars, scripts }; +}; + +// coder_env values reach coder_script processes through the agent environment. +const envArgs = (env: Record) => + Object.entries(env).flatMap(([key, value]) => ["--env", `${key}=${value}`]); + +const runScript = async ( + id: string, + name: string, + script: string, + env: Record = {}, +) => { + const target = `/tmp/coder-utils-${name}.sh`; + await writeExecutable({ containerId: id, filePath: target, content: script }); + return execContainer(id, ["bash", "-c", target], envArgs(env)); +}; + +const runScripts = async ( + id: string, + scripts: ModuleScripts, + env: Record = {}, +) => { + const ordered: [string, string | undefined][] = [ + ["pre_install", scripts.pre_install], + ["install", scripts.install], + ["post_install", scripts.post_install], + ]; + for (const [name, script] of ordered) { + if (!script) continue; + const resp = await runScript(id, name, script, env); + if (resp.exitCode !== 0) { + console.log(`script ${name} failed:`); + console.log(resp.stdout); + console.log(resp.stderr); + throw new Error(`coder-utils ${name} script exited ${resp.exitCode}`); + } + } +}; - return { id }; +const moduleLogDir = "/home/coder/.coder-modules/coder/aider/logs"; +const installLog = (id: string) => + readFileContainer(id, `${moduleLogDir}/install.log`); +const configPath = "/home/coder/.aider.conf.yml"; + +// Replaces curl with a stub that serves `installer` as the downloaded script. +const mockInstallerDownload = async (id: string, installer: string[]) => { + await writeExecutable({ + containerId: id, + filePath: "/tmp/aider-installer-fixture.sh", + content: ["#!/bin/sh", ...installer].join("\n"), + }); + await writeExecutable({ + containerId: id, + filePath: "/usr/local/bin/curl", + content: [ + "#!/bin/bash", + "printf '%s\\n' \"$*\" > /tmp/aider-curl-args", + 'while [ $# -gt 0 ]; do if [ "$1" = "--output" ]; then out="$2"; fi; shift; done', + 'cp /tmp/aider-installer-fixture.sh "$out"', + ].join("\n"), + }); }; setDefaultTimeout(60 * 1000); -describe("Aider", async () => { +describe("aider", async () => { beforeAll(async () => { await runTerraformInit(import.meta.dir); }); - test("happy-path", async () => { - const { id } = await setup({ - moduleVariables: { - model: "gemini", - }, - }); - await execModuleScript(id); - await expectAgentAPIStarted(id); + test("happy-path-validates-existing-binary", async () => { + const { id, coderEnvVars, scripts } = await setup(); + await runScripts(id, scripts); + const log = await installLog(id); + expect(log).toContain("Skipping Aider installation"); + expect(log).toContain("Validated existing Aider"); + expect(log).toContain("aider 0.86.0-mock"); + expect(log).toContain("Aider module setup completed."); + expect(log).not.toContain("agentapi"); + expect(coderEnvVars["AIDER_MODEL"]).toBe("gemini"); + const cfg = await execContainer(id, ["test", "-e", configPath]); + expect(cfg.exitCode).not.toBe(0); }); - test("api-key", async () => { - const apiKey = "test-api-key-123"; - const { id } = await setup({ - moduleVariables: { - api_key: apiKey, - model: "gemini", - }, + test("preinstalled-binary-required-when-install-disabled", async () => { + const { id, scripts } = await setup({ skipAiderMock: true }); + const resp = await runScript(id, "install", scripts.install); + expect(resp.exitCode).not.toBe(0); + const log = await installLog(id); + expect(log).toContain("was not found or is not executable"); + }); + + test("official-installer-is-used", async () => { + const { id, scripts } = await setup({ + skipAiderMock: true, + moduleVariables: { install_aider: "true" }, }); - await execModuleScript(id); - const resp = await readFileContainer( + await mockInstallerDownload(id, [ + 'printf "UV_NO_MODIFY_PATH=%s\\n" "$UV_NO_MODIFY_PATH" > /tmp/aider-installer-env', + 'mkdir -p "$HOME/.local/bin"', + `printf '#!/bin/sh\\necho "aider 0.86.2"\\n' > "$HOME/.local/bin/aider"`, + 'chmod +x "$HOME/.local/bin/aider"', + ]); + await runScripts(id, scripts); + const log = await installLog(id); + expect(log).toContain("Installed Aider"); + expect(log).toContain("Aider version: aider 0.86.2"); + const curlArgs = await readFileContainer(id, "/tmp/aider-curl-args"); + expect(curlArgs).toContain("https://aider.chat/install.sh"); + expect(curlArgs).toContain("--retry 2"); + expect(curlArgs).toContain("--connect-timeout 10"); + expect(curlArgs).toContain("--max-time 300"); + const installerEnv = await readFileContainer( id, - "/home/coder/.aider-module/agentapi-start.log", + "/tmp/aider-installer-env", ); - expect(resp).toContain("API key provided!"); + expect(installerEnv.trim()).toBe("UV_NO_MODIFY_PATH=1"); + const bashrc = await readFileContainer(id, "/home/coder/.bashrc"); + expect(bashrc).toContain("/home/coder/.local/bin"); + }); + + test("installer-download-failure-is-terminal", async () => { + const { id, scripts } = await setup({ + skipAiderMock: true, + moduleVariables: { install_aider: "true" }, + }); + await writeExecutable({ + containerId: id, + filePath: "/usr/local/bin/curl", + content: "#!/bin/bash\nexit 22\n", + }); + const resp = await runScript(id, "install", scripts.install); + expect(resp.exitCode).not.toBe(0); + const log = await installLog(id); + expect(log).toContain("could not be downloaded"); }); - test("custom-folder", async () => { - const workdir = "/tmp/aider-test"; - const { id } = await setup({ + test("symlinks-into-coder-script-bin-dir", async () => { + const { id, scripts } = await setup(); + await execContainer(id, ["mkdir", "-p", "/tmp/coder-bin"]); + await runScripts(id, scripts, { CODER_SCRIPT_BIN_DIR: "/tmp/coder-bin" }); + const link = await execContainer(id, ["readlink", "/tmp/coder-bin/aider"]); + expect(link.stdout.trim()).toBe("/usr/bin/aider"); + }); + + test("workdir-is-created", async () => { + const { id, scripts } = await setup(); + await runScripts(id, scripts); + const dir = await execContainer(id, ["test", "-d", projectDir]); + expect(dir.exitCode).toBe(0); + }); + + test("base-aider-config-is-written-verbatim", async () => { + const config = "model: sonnet\nread:\n - CONVENTIONS.md\ndark-mode: true"; + const { id, scripts } = await setup({ + moduleVariables: { base_aider_config: config }, + }); + await runScripts(id, scripts); + expect(await readFileContainer(id, configPath)).toBe(`${config}\n`); + expect(await installLog(id)).toContain( + `Aider config written: ${configPath}`, + ); + }); + + test("existing-config-is-kept-without-base-aider-config", async () => { + const { id, scripts } = await setup(); + await execContainer(id, [ + "bash", + "-c", + `echo 'dark-mode: true' > ${configPath}`, + ]); + await runScripts(id, scripts); + expect((await readFileContainer(id, configPath)).trim()).toBe( + "dark-mode: true", + ); + expect(await installLog(id)).toContain("No base_aider_config provided"); + }); + + test("api-key-is-only-in-coder-env", async () => { + const secret = "sk-ant-test-secret-123"; + const { id, coderEnvVars, scripts } = await setup({ moduleVariables: { - workdir, - model: "gemini", + ai_provider: "anthropic", + model: "sonnet", + api_key: secret, }, }); - await execModuleScript(id); - const resp = await readFileContainer( - id, - "/home/coder/.aider-module/install.log", + expect(coderEnvVars["ANTHROPIC_API_KEY"]).toBe(secret); + expect(coderEnvVars["AIDER_MODEL"]).toBe("sonnet"); + expect(scripts.install).not.toContain(secret); + expect(scripts.install).not.toContain( + Buffer.from(secret).toString("base64"), ); - expect(resp).toContain(workdir); + await runScripts(id, scripts, coderEnvVars); + expect(await installLog(id)).not.toContain(secret); }); test("pre-post-install-scripts", async () => { - const { id } = await setup({ + const { id, scripts } = await setup({ moduleVariables: { - pre_install_script: "#!/bin/bash\necho 'pre-install-script'", - post_install_script: "#!/bin/bash\necho 'post-install-script'", - model: "gemini", + pre_install_script: "#!/bin/bash\necho 'aider-pre-install-script'", + post_install_script: "#!/bin/bash\necho 'aider-post-install-script'", }, }); - await execModuleScript(id); - const preLog = await readFileContainer( - id, - "/home/coder/.aider-module/pre_install.log", - ); - expect(preLog).toContain("pre-install-script"); - const postLog = await readFileContainer( - id, - "/home/coder/.aider-module/post_install.log", - ); - expect(postLog).toContain("post-install-script"); + await runScripts(id, scripts); + expect( + await readFileContainer(id, `${moduleLogDir}/pre_install.log`), + ).toContain("aider-pre-install-script"); + expect( + await readFileContainer(id, `${moduleLogDir}/post_install.log`), + ).toContain("aider-post-install-script"); }); }); diff --git a/registry/coder/modules/aider/main.tf b/registry/coder/modules/aider/main.tf index 90001ea1b..9aba7a165 100644 --- a/registry/coder/modules/aider/main.tf +++ b/registry/coder/modules/aider/main.tf @@ -4,7 +4,7 @@ terraform { required_providers { coder = { source = "coder/coder" - version = ">= 2.5" + version = ">= 2.12" } } } @@ -14,62 +14,16 @@ variable "agent_id" { 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." + description = "The icon to use for the install scripts." default = "/icon/aider.svg" } variable "workdir" { type = string - description = "The folder to run Aider in." - default = "/home/coder" -} - -variable "report_tasks" { - type = bool - description = "Whether to enable task reporting to Coder UI via AgentAPI" - default = false -} - -variable "subdomain" { - type = bool - description = "Whether to use a subdomain for AgentAPI." - default = false -} - -variable "cli_app" { - type = bool - description = "Whether to create a CLI app for Aider" - default = false -} - -variable "web_app_display_name" { - type = string - description = "Display name for the web app" - default = "Aider" -} - -variable "cli_app_display_name" { - type = string - description = "Display name for the CLI app" - default = "Aider CLI" + description = "Optional project directory. When set, the module creates it if missing." + default = null } variable "pre_install_script" { @@ -84,44 +38,12 @@ variable "post_install_script" { default = null } -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.10.0" -} - -variable "ai_prompt" { - type = string - description = "Initial task prompt for Aider." - default = "" -} - -# --------------------------------------------- - variable "install_aider" { type = bool - description = "Whether to install Aider." + description = "Whether to install Aider with the official installer (https://aider.chat/install.sh). When false, a working aider must already be on PATH." default = true } -variable "system_prompt" { - type = string - description = "System prompt for instructing Aider on task reporting and behavior" - default = "You are a helpful coding assistant that helps developers write, debug, and understand code. Provide clear explanations, follow best practices, and help solve coding problems efficiently." -} - -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.)" @@ -134,7 +56,7 @@ variable "ai_provider" { variable "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." + description = "AI model to use with Aider, exported as AIDER_MODEL. Can use Aider's built-in aliases like '4o' (gpt-4o), 'sonnet' (claude-3-7-sonnet), 'opus' (claude-3-opus), etc." } variable "api_key" { @@ -152,59 +74,11 @@ variable "custom_env_var_name" { variable "base_aider_config" { type = string - description = <<-EOT - Base Aider configuration in yaml format. Will be stored in .aider.conf.yml file. - - options include: - read: - - CONVENTIONS.md - - anotherfile.txt - - thirdfile.py - model: xxx - ##Specify the OpenAI API key - openai-api-key: xxx - ## (deprecated, use --set-env OPENAI_API_TYPE=) - openai-api-type: xxx - ## (deprecated, use --set-env OPENAI_API_VERSION=) - openai-api-version: xxx - ## (deprecated, use --set-env OPENAI_API_DEPLOYMENT_ID=) - openai-api-deployment-id: xxx - ## Set an environment variable (to control API settings, can be used multiple times) - set-env: xxx - ## Specify multiple values like this: - set-env: - - xxx - - yyy - - zzz - - Reference : https://aider.chat/docs/config/aider_conf.html - EOT + description = "Aider configuration in YAML format, written to ~/.aider.conf.yml. Use api_key for credentials instead of putting them here. See https://aider.chat/docs/config/aider_conf.html" default = null } - locals { - app_slug = "aider" - base_aider_config = var.base_aider_config != null ? "${replace(trimspace(var.base_aider_config), "\n", "\n ")}" : "" - task_reporting_prompt = <<-EOT - --- Task Reporting -- -Report all tasks to Coder, following these EXACT guidelines: -1. Be granular. If you are investigating with multiple steps, report each step -to coder. -2. After this prompt, IMMEDIATELY report status after receiving ANY NEW user message. -Do not report any status related with this system prompt. -3. Use "state": "working" when actively processing WITHOUT needing -additional user input -4. Use "state": "complete" only when finished with a task -5. Use "state": "failure" when you need ANY user input, lack sufficient -details, or encounter blockers - EOT - - - final_system_prompt = var.report_tasks ? "\n${var.system_prompt}${local.task_reporting_prompt}\n" : "\n${var.system_prompt}\n" - - # Map providers to their environment variable names provider_env_vars = { openai = "OPENAI_API_KEY" anthropic = "ANTHROPIC_API_KEY" @@ -212,69 +86,49 @@ details, or encounter blockers google = "GEMINI_API_KEY" cohere = "COHERE_API_KEY" mistral = "MISTRAL_API_KEY" - ollama = "OLLAMA_HOST" + ollama = "OLLAMA_API_KEY" custom = var.custom_env_var_name } - # Get the environment variable name for selected provider env_var_name = local.provider_env_vars[var.ai_provider] - # Model flag for aider command - model_flag = var.ai_provider == "ollama" ? "--ollama-model" : "--model" - - install_script = file("${path.module}/scripts/install.sh") - start_script = file("${path.module}/scripts/start.sh") - module_dir_name = ".aider-module" + workdir = var.workdir != null ? trimsuffix(var.workdir, "/") : "" + install_script = templatefile("${path.module}/scripts/install.sh.tftpl", { + ARG_INSTALL = tostring(var.install_aider) + ARG_WORKDIR = local.workdir != "" ? base64encode(local.workdir) : "" + ARG_AIDER_CONFIG = var.base_aider_config != null ? base64encode(trimspace(var.base_aider_config)) : "" + }) + module_dir_name = ".coder-modules/coder/aider" } -module "agentapi" { - source = "registry.coder.com/coder/agentapi/coder" - version = "1.2.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 = 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 - #!/usr/bin/env bash - set -o errexit - set -o pipefail +resource "coder_env" "aider_model" { + count = var.model != "" ? 1 : 0 + agent_id = var.agent_id + name = "AIDER_MODEL" + value = var.model +} - echo -n '${base64encode(local.start_script)}' | base64 -d > /tmp/start.sh - chmod +x /tmp/start.sh - ARG_WORKDIR='${var.workdir}' \ - ARG_API_KEY='${base64encode(var.api_key)}' \ - ARG_MODEL='${var.model}' \ - ARG_PROVIDER='${var.ai_provider}' \ - ARG_ENV_API_NAME_HOLDER='${local.env_var_name}' \ - ARG_SYSTEM_PROMPT='${base64encode(local.final_system_prompt)}' \ - ARG_AI_PROMPT='${base64encode(var.ai_prompt)}' \ - /tmp/start.sh - EOT +resource "coder_env" "api_key" { + count = var.api_key != "" && local.env_var_name != "" ? 1 : 0 + agent_id = var.agent_id + name = local.env_var_name + value = var.api_key +} - install_script = <<-EOT - #!/usr/bin/env bash - set -o errexit - set -o pipefail +module "coder_utils" { + source = "registry.coder.com/coder/coder-utils/coder" + version = "0.0.1" - echo -n '${base64encode(local.install_script)}' | base64 -d > /tmp/install.sh - chmod +x /tmp/install.sh - ARG_WORKDIR='${var.workdir}' \ - ARG_INSTALL_AIDER='${var.install_aider}' \ - ARG_REPORT_TASKS='${var.report_tasks}' \ - ARG_AIDER_CONFIG="$(echo -n '${base64encode(local.base_aider_config)}' | base64 -d)" \ - /tmp/install.sh - EOT + agent_id = var.agent_id + module_directory = "$HOME/${local.module_dir_name}" + display_name_prefix = "Aider" + icon = var.icon + pre_install_script = var.pre_install_script + post_install_script = var.post_install_script + install_script = local.install_script } +output "scripts" { + description = "Ordered list of coder exp sync names for the coder_script resources this module creates, in run order (pre_install, install, post_install). Scripts that were not configured are absent from the list." + value = module.coder_utils.scripts +} diff --git a/registry/coder/modules/aider/main.tftest.hcl b/registry/coder/modules/aider/main.tftest.hcl index 281bde866..0a4ff4e6b 100644 --- a/registry/coder/modules/aider/main.tftest.hcl +++ b/registry/coder/modules/aider/main.tftest.hcl @@ -1,149 +1,180 @@ -run "test_aider_basic" { +run "defaults_are_correct" { command = plan variables { - agent_id = "test-agent-123" - workdir = "/home/coder" + agent_id = "test-agent" model = "gemini" } assert { - condition = var.workdir == "/home/coder" - error_message = "Workdir variable should default to /home/coder" + condition = var.install_aider == true + error_message = "install_aider should default to true" } assert { - condition = var.agent_id == "test-agent-123" - error_message = "Agent ID variable should be set correctly" + condition = local.workdir == "" + error_message = "workdir should be empty by default" } assert { - condition = var.install_aider == true - error_message = "install_aider should default to true" + condition = local.module_dir_name == ".coder-modules/coder/aider" + error_message = "module_dir_name should be '.coder-modules/coder/aider'" } assert { - condition = var.install_agentapi == true - error_message = "install_agentapi should default to true" + condition = coder_env.aider_model[0].name == "AIDER_MODEL" && coder_env.aider_model[0].value == "gemini" + error_message = "AIDER_MODEL env var should be set from model" } assert { - condition = var.report_tasks == false - error_message = "report_tasks should default to false" + condition = length(coder_env.api_key) == 0 + error_message = "No API key env var should be created when api_key is empty" } } -run "test_with_api_key" { +run "workdir_trailing_slash_is_trimmed" { command = plan variables { - agent_id = "test-agent-456" - workdir = "/home/coder/workspace" - api_key = "test-api-key-123" + agent_id = "test-agent" model = "gemini" + workdir = "/home/coder/project/" + } + + assert { + condition = local.workdir == "/home/coder/project" + error_message = "workdir should have its trailing slash trimmed" } assert { - condition = var.api_key == "test-api-key-123" - error_message = "API key value should match the input" + condition = strcontains(local.install_script, base64encode("/home/coder/project")) + error_message = "install script should receive the base64-encoded workdir" } } -run "test_custom_options" { +run "api_key_uses_provider_env_var" { command = plan variables { - agent_id = "test-agent-789" - workdir = "/home/coder/custom" - order = 5 - group = "development" - icon = "/icon/custom.svg" - model = "4o" - ai_prompt = "Help me write better code" - install_aider = false - install_agentapi = false - agentapi_version = "v0.10.0" - api_key = "" - base_aider_config = "read:\n - CONVENTIONS.md" + agent_id = "test-agent" + model = "sonnet" + ai_provider = "anthropic" + api_key = "sk-ant-test-secret" } assert { - condition = var.order == 5 - error_message = "Order variable should be set to 5" + condition = coder_env.api_key[0].name == "ANTHROPIC_API_KEY" && coder_env.api_key[0].value == "sk-ant-test-secret" + error_message = "ANTHROPIC_API_KEY env var should be created with the provided key" } assert { - condition = var.group == "development" - error_message = "Group variable should be set to 'development'" + condition = !strcontains(local.install_script, nonsensitive(var.api_key)) && !strcontains(local.install_script, base64encode(nonsensitive(var.api_key))) + error_message = "API key should not be rendered into the install script" } +} - assert { - condition = var.icon == "/icon/custom.svg" - error_message = "Icon variable should be set to custom icon" +run "ollama_api_key_env_var" { + command = plan + + variables { + agent_id = "test-agent" + model = "ollama_chat/llama3" + ai_provider = "ollama" + api_key = "ollama-test-key" } assert { - condition = var.model == "4o" - error_message = "Model variable should be set to '4o'" + condition = coder_env.api_key[0].name == "OLLAMA_API_KEY" + error_message = "ollama should use OLLAMA_API_KEY" } +} - assert { - condition = var.ai_prompt == "Help me write better code" - error_message = "AI prompt variable should be set correctly" +run "custom_provider_env_var" { + command = plan + + variables { + agent_id = "test-agent" + model = "custom-model" + ai_provider = "custom" + custom_env_var_name = "MY_CUSTOM_API_KEY" + api_key = "custom-test-key" } assert { - condition = var.install_aider == false - error_message = "install_aider should be set to false" + condition = coder_env.api_key[0].name == "MY_CUSTOM_API_KEY" && coder_env.api_key[0].value == "custom-test-key" + error_message = "custom provider should use custom_env_var_name" } +} - assert { - condition = var.install_agentapi == false - error_message = "install_agentapi should be set to false" +run "custom_provider_without_env_var_name" { + command = plan + + variables { + agent_id = "test-agent" + model = "custom-model" + ai_provider = "custom" + api_key = "custom-test-key" } assert { - condition = var.agentapi_version == "v0.10.0" - error_message = "AgentAPI version should be set to 'v0.10.0'" + condition = length(coder_env.api_key) == 0 + error_message = "No API key env var should be created without custom_env_var_name" } } -run "test_with_scripts" { +run "invalid_provider_is_rejected" { command = plan variables { - agent_id = "test-agent-scripts" - workdir = "/home/coder/scripts" - model = "gemini" - pre_install_script = "echo 'Pre-install script'" - post_install_script = "echo 'Post-install script'" + agent_id = "test-agent" + model = "gemini" + ai_provider = "unknown" } - assert { - condition = var.pre_install_script == "echo 'Pre-install script'" - error_message = "Pre-install script should be set correctly" + expect_failures = [var.ai_provider] +} + +run "base_aider_config_is_encoded" { + command = plan + + variables { + agent_id = "test-agent" + model = "gemini" + base_aider_config = "read:\n - CONVENTIONS.md\ndark-mode: true\n" } assert { - condition = var.post_install_script == "echo 'Post-install script'" - error_message = "Post-install script should be set correctly" + condition = strcontains(local.install_script, base64encode("read:\n - CONVENTIONS.md\ndark-mode: true")) + error_message = "install script should receive the base64-encoded config without re-indentation" } } -run "test_ai_provider_env_mapping" { +run "scripts_output_is_ordered" { command = plan variables { - agent_id = "test-agent-provider" - workdir = "/home/coder/test" - ai_provider = "google" + agent_id = "test-agent" model = "gemini" - custom_env_var_name = "" + pre_install_script = "echo pre" + post_install_script = "echo post" + } + + assert { + condition = output.scripts == ["coder-aider-pre_install_script", "coder-aider-install_script", "coder-aider-post_install_script"] + error_message = "scripts output should be ordered pre_install, install, post_install" + } +} + +run "scripts_output_install_only" { + command = plan + + variables { + agent_id = "test-agent" + model = "gemini" } - # Ensure provider -> env var mapping works as expected (based on locals.provider_env_vars) assert { - condition = var.ai_provider == "google" - error_message = "AI provider should be set to 'google' for this test" + condition = output.scripts == ["coder-aider-install_script"] + error_message = "scripts output should only contain install when no pre/post scripts are set" } } diff --git a/registry/coder/modules/aider/scripts/install.sh b/registry/coder/modules/aider/scripts/install.sh deleted file mode 100644 index c6833dc27..000000000 --- a/registry/coder/modules/aider/scripts/install.sh +++ /dev/null @@ -1,49 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# Function to check if a command exists -command_exists() { - command -v "$1" > /dev/null 2>&1 -} - -# Inputs -ARG_WORKDIR=${ARG_WORKDIR:-/home/coder} -ARG_INSTALL_AIDER=${ARG_INSTALL_AIDER:-true} -ARG_AIDER_CONFIG=${ARG_AIDER_CONFIG:-} - -echo "--------------------------------" -echo "Install flag: $ARG_INSTALL_AIDER" -echo "Workspace: $ARG_WORKDIR" -echo "--------------------------------" - -function install_aider() { - echo "pipx installing..." - sudo apt-get install -y pipx - echo "pipx installed!" - pipx ensurepath - mkdir -p "$ARG_WORKDIR/.local/bin" - export PATH="$HOME/.local/bin:$ARG_WORKDIR/.local/bin:$PATH" - - if ! command_exists aider; then - echo "Installing Aider via pipx..." - pipx install --force aider-install - aider-install - fi - echo "Aider installed: $(aider --version || echo 'Aider installation check failed')" -} - -function configure_aider_settings() { - if [ -n "${ARG_AIDER_CONFIG}" ]; then - echo "Configuring Aider environment variables and model" - - mkdir -p "$HOME/.config/aider" - - echo "$ARG_AIDER_CONFIG" > "$HOME/.config/aider/.aider.conf.yml" - echo "Aider config created at $HOME/.config/aider/.aider.conf.yml" - else - printf "No Aider environment variables or model configured\n" - fi -} - -install_aider -configure_aider_settings diff --git a/registry/coder/modules/aider/scripts/install.sh.tftpl b/registry/coder/modules/aider/scripts/install.sh.tftpl new file mode 100644 index 000000000..a2f51ad16 --- /dev/null +++ b/registry/coder/modules/aider/scripts/install.sh.tftpl @@ -0,0 +1,137 @@ +#!/usr/bin/env bash + +set -euo pipefail + +command_exists() { + command -v "$1" > /dev/null 2>&1 +} + +ARG_INSTALL='${ARG_INSTALL}' +ARG_WORKDIR=$(echo -n '${ARG_WORKDIR}' | base64 -d) +ARG_AIDER_CONFIG=$(echo -n '${ARG_AIDER_CONFIG}' | base64 -d) + +export PATH="$HOME/.local/bin:$PATH" + +echo "--------------------------------" +printf "install_aider: %s\n" "$${ARG_INSTALL}" +printf "workdir: %s\n" "$${ARG_WORKDIR}" +echo "--------------------------------" + +function add_path_to_shell_profiles() { + local path_dir="$1" + + for profile in "$HOME/.profile" "$HOME/.bash_profile" "$HOME/.bashrc" "$HOME/.zprofile" "$HOME/.zshrc"; do + if [ -f "$${profile}" ]; then + if ! grep -q "$${path_dir}" "$${profile}" 2> /dev/null; then + echo "export PATH=\"\$PATH:$${path_dir}\"" >> "$${profile}" + echo "Added $${path_dir} to $${profile}" + fi + fi + done + + local fish_config="$HOME/.config/fish/config.fish" + if [ -f "$${fish_config}" ]; then + if ! grep -q "$${path_dir}" "$${fish_config}" 2> /dev/null; then + echo "fish_add_path $${path_dir}" >> "$${fish_config}" + echo "Added $${path_dir} to $${fish_config}" + fi + fi +} + +function ensure_aider_in_path() { + local AIDER_BIN="" + if command_exists aider; then + AIDER_BIN=$(command -v aider) + fi + + if [ -z "$${AIDER_BIN}" ] || [ ! -x "$${AIDER_BIN}" ]; then + echo "Aider binary (aider) was not found or is not executable." >&2 + return 1 + fi + + local AIDER_VERSION + if ! AIDER_VERSION=$("$${AIDER_BIN}" --version); then + echo "Aider binary could not be executed." >&2 + return 1 + fi + printf "Aider version: %s\n" "$${AIDER_VERSION}" + + if [ -n "$${CODER_SCRIPT_BIN_DIR:-}" ] && [ ! -e "$${CODER_SCRIPT_BIN_DIR}/aider" ]; then + ln -s "$${AIDER_BIN}" "$${CODER_SCRIPT_BIN_DIR}/aider" + echo "Created symlink: $${CODER_SCRIPT_BIN_DIR}/aider -> $${AIDER_BIN}" + fi + + add_path_to_shell_profiles "$(dirname "$${AIDER_BIN}")" +} + +# Subshell scopes the temp-file cleanup trap to this function. +install_with_official_installer() ( + local installer_file + installer_file=$(mktemp) + trap 'rm -f "$${installer_file}"' EXIT + + if ! curl --fail --silent --show-error --location \ + --retry 2 --retry-delay 1 --retry-all-errors \ + --connect-timeout 10 --max-time 300 \ + --output "$${installer_file}" https://aider.chat/install.sh; then + echo "Aider installer could not be downloaded after up to 3 attempts." >&2 + return 1 + fi + + if ! bash -n "$${installer_file}"; then + echo "Aider installer download was invalid." >&2 + return 1 + fi + + # Installs uv and the latest aider-chat into ~/.local/bin; PATH is handled by + # this script. + if ! UV_NO_MODIFY_PATH=1 sh "$${installer_file}"; then + echo "Aider installation failed." >&2 + return 1 + fi +) + +install_aider() { + if [ "$${ARG_INSTALL}" != "true" ]; then + echo "Skipping Aider installation as per configuration." + if ! ensure_aider_in_path; then + echo "ERROR: install_aider is false but a working 'aider' was not found on PATH." >&2 + exit 1 + fi + echo "Validated existing Aider" + return + fi + + echo "Installing Aider" + install_with_official_installer + + if ! ensure_aider_in_path; then + echo "ERROR: Failed to install Aider" >&2 + exit 1 + fi + echo "Installed Aider" +} + +setup_workdir() { + if [ -n "$${ARG_WORKDIR}" ] && [ ! -d "$${ARG_WORKDIR}" ]; then + echo "Creating workdir: $${ARG_WORKDIR}" + mkdir -p "$${ARG_WORKDIR}" + fi +} + +configure_aider_settings() { + local config_file="$HOME/.aider.conf.yml" + if [ -z "$${ARG_AIDER_CONFIG}" ]; then + echo "No base_aider_config provided; leaving $${config_file} unchanged." + return + fi + + printf '%s\n' "$${ARG_AIDER_CONFIG}" > "$${config_file}" + echo "Aider config written: $${config_file}" +} + +install_aider +setup_workdir +configure_aider_settings + +echo "Aider module setup completed." diff --git a/registry/coder/modules/aider/scripts/start.sh b/registry/coder/modules/aider/scripts/start.sh deleted file mode 100644 index 8bd95957d..000000000 --- a/registry/coder/modules/aider/scripts/start.sh +++ /dev/null @@ -1,55 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# Ensure pipx-installed apps are in PATH -export PATH="$HOME/.local/bin:$PATH" - -ARG_WORKDIR=${ARG_WORKDIR:-/home/coder} -ARG_API_KEY=$(echo -n "${ARG_API_KEY:-}" | base64 -d) -ARG_SYSTEM_PROMPT=$(echo -n "${ARG_SYSTEM_PROMPT:-}" | base64 -d 2> /dev/null || echo "") -ARG_AI_PROMPT=$(echo -n "${ARG_AI_PROMPT:-}" | base64 -d 2> /dev/null || echo "") -ARG_MODEL=${ARG_MODEL:-} -ARG_PROVIDER=${ARG_PROVIDER:-} -ARG_ENV_API_NAME_HOLDER=${ARG_ENV_API_NAME_HOLDER:-} - -echo "--------------------------------" -echo "Provider: $ARG_PROVIDER" -echo "Model: $ARG_MODEL" -echo "--------------------------------" - -if [ -n "$ARG_API_KEY" ]; then - printf "API key provided!\n" - export $ARG_ENV_API_NAME_HOLDER=$ARG_API_KEY -else - printf "API key not provided.\n" -fi - -build_initial_prompt() { - local initial_prompt="" - if [ -n "$ARG_AI_PROMPT" ]; then - if [ -n "$ARG_SYSTEM_PROMPT" ]; then - initial_prompt="$ARG_SYSTEM_PROMPT $ARG_AI_PROMPT" - else - initial_prompt="$ARG_AI_PROMPT" - fi - fi - echo "$initial_prompt" -} - -start_agentapi() { - echo "Starting in directory: $ARG_WORKDIR" - cd "$ARG_WORKDIR" - - local initial_prompt - initial_prompt=$(build_initial_prompt) - if [ -n "$initial_prompt" ]; then - echo "Starting agentapi with initial prompt" - agentapi server -I="$initial_prompt" --type aider --term-width=67 --term-height=1190 -- aider --model $ARG_MODEL --yes-always - else - agentapi server --term-width=67 --term-height=1190 -- aider --model $ARG_MODEL --yes-always - fi -} - -# TODO: Implement MCP server for coder when Aider support MCP servers. - -start_agentapi diff --git a/registry/coder/modules/aider/testdata/aider-mock.sh b/registry/coder/modules/aider/testdata/aider-mock.sh index e021b2d2d..bbd2a5ce0 100644 --- a/registry/coder/modules/aider/testdata/aider-mock.sh +++ b/registry/coder/modules/aider/testdata/aider-mock.sh @@ -1,14 +1,8 @@ -#!/bin/bash +#!/usr/bin/env bash if [[ "$1" == "--version" ]]; then - echo "HELLO: $(bash -c env)" - echo "aider version v0.86.0" + echo "aider 0.86.0-mock" exit 0 fi -set -e - -while true; do - echo "$(date) - aider-agent-mock" - sleep 15 -done +echo "aider invoked with: $*"