diff --git a/registry/coder-labs/modules/sourcegraph-amp/README.md b/registry/coder-labs/modules/sourcegraph-amp/README.md index 5703faec8..d48bc1902 100644 --- a/registry/coder-labs/modules/sourcegraph-amp/README.md +++ b/registry/coder-labs/modules/sourcegraph-amp/README.md @@ -1,95 +1,169 @@ --- display_name: Amp icon: ../../../../.icons/sourcegraph-amp.svg -description: Sourcegraph's AI coding agent with deep codebase understanding and intelligent code search capabilities +description: Install and configure the Amp CLI in your workspace. verified: true -tags: [agent, sourcegraph, amp, ai, tasks] +tags: [agent, sourcegraph, amp, ai] --- -# Sourcegraph Amp CLI +# Amp -Run [Amp CLI](https://ampcode.com/) in your workspace to access Sourcegraph's AI-powered code search and analysis tools, with AgentAPI integration for seamless Coder Tasks support. +Install and configure the [Amp CLI](https://ampcode.com/docs/cli) in your workspace. ```tf -module "amp-cli" { - source = "registry.coder.com/coder-labs/sourcegraph-amp/coder" - version = "3.0.1" - agent_id = coder_agent.example.id - amp_api_key = var.amp_api_key - install_amp = true - agentapi_version = "latest" +module "amp" { + source = "registry.coder.com/coder-labs/sourcegraph-amp/coder" + version = "4.0.0" + agent_id = coder_agent.main.id + amp_api_key = var.amp_api_key } ``` -## Prerequisites +> [!WARNING] +> If upgrading from v3.x.x of this module: v4 is a major refactor that drops support for Coder Tasks and AgentAPI. The module now only installs and configures Amp; launch it with your own `coder_app`. `workdir` is now optional. `ai_prompt`, `mode`, `report_tasks`, `install_via_npm`, and the web/CLI app inputs are removed; pass `--mode` in your launcher instead. `base_amp_config` is replaced by `amp_settings`, which merges keys into `~/.config/amp/settings.json` instead of overwriting the file and no longer writes default keys. `mcp` servers are merged without overriding servers already on disk, and the Coder task-reporting MCP server is no longer added. `instruction_prompt` is now written to `~/.config/amp/AGENTS.md`. Keep using v3.x.x if you depend on Coder Tasks. -- **Default (official installer)**: No prerequisites - the official installer includes its own runtime (Bun) -- **npm installation (`install_via_npm = true`)**: Requires Node.js and npm to be installed before Amp installation - - Required for Alpine Linux or other musl-based systems - - Ensure Node.js and npm are available in your workspace image or via earlier provisioning steps +## Examples -## Usage Example +### Standalone mode with a launcher app ```tf -data "coder_parameter" "ai_prompt" { - name = "AI Prompt" - description = "Write an initial prompt for Amp to work on." - type = "string" - default = "" - mutable = true +locals { + amp_workdir = "/home/coder/project" } -variable "amp_api_key" { - type = string - description = "Sourcegraph Amp API key. Get one at https://ampcode.com/settings" - sensitive = true +module "amp" { + source = "registry.coder.com/coder-labs/sourcegraph-amp/coder" + version = "4.0.0" + agent_id = coder_agent.main.id + workdir = local.amp_workdir + amp_api_key = var.amp_api_key } -module "amp-cli" { - count = data.coder_workspace.me.start_count - source = "registry.coder.com/coder-labs/sourcegraph-amp/coder" - amp_version = "3.0.0" - agent_id = coder_agent.example.id - amp_api_key = var.amp_api_key # recommended for tasks usage - workdir = "/home/coder/project" - instruction_prompt = <<-EOT - # Instructions - - Start every response with `amp > ` -EOT - ai_prompt = data.coder_parameter.ai_prompt.value - base_amp_config = jsonencode({ - "amp.anthropic.thinking.enabled" = true - "amp.todos.enabled" = true - "amp.tools.stopTimeout" = 600 - "amp.git.commit.ampThread.enabled" = true - "amp.git.commit.coauthor.enabled" = true - "amp.terminal.commands.nodeSpawn.loadProfile" = "daily" +resource "coder_app" "amp" { + agent_id = coder_agent.main.id + slug = "amp" + display_name = "Amp" + icon = "/icon/sourcegraph-amp.svg" + open_in = "slim-window" + command = <<-EOT + #!/usr/bin/env bash + set -e + cd "${local.amp_workdir}" + exec amp --mode medium + EOT +} +``` + +When `workdir` is set, the module creates it if missing. Pass `--mode` (`low`, `medium`, `high`, `ultra`) or any other flag from `amp --help` in the launcher command. See [The Dial](https://ampcode.com/docs/the-dial) for how modes work. + +> [!NOTE] +> The `coder_app` command re-executes on every pane reconnect. This works for interactive `amp`, but one-shot commands like `amp -x` will re-run each time. For one-shot prompts, use a `coder_script` (runs once at startup) and a `coder_app` that attaches to the existing session (for example, with tmux). + +### Settings, MCP servers, and guidance + +```tf +module "amp" { + source = "registry.coder.com/coder-labs/sourcegraph-amp/coder" + version = "4.0.0" + agent_id = coder_agent.main.id + workdir = "/home/coder/project" + amp_api_key = var.amp_api_key + + amp_settings = jsonencode({ + "amp.git.commit.coauthor.enabled" = true "amp.permissions" = [ - { "tool" : "mcp__coder__*", "action" : "allow" }, - { "tool" : "Bash", "action" : "allow", "context" : "thread" }, - { "tool" : "Bash", "matches" : { "cmd" : ["rm -rf /*", "rm -rf ~/*"] }, "action" : "reject", "context" : "subagent" }, - { "tool" : "edit_file", "action" : "allow" }, - { "tool" : "write_file", "action" : "allow" }, - { "tool" : "read_file", "action" : "allow" }, - { "tool" : "Grep", "action" : "allow" } + { tool = "Bash", action = "ask", matches = { cmd = ["git push*"] } } ] }) + + mcp = jsonencode({ + playwright = { + command = "npx" + args = ["-y", "@playwright/mcp@latest", "--headless", "--isolated", "--no-sandbox"] + } + }) + + instruction_prompt = <<-EOT + # Instructions + - Run the test suite before committing. + EOT +} +``` + +`amp_settings` and `mcp` are merged into the user-level `~/.config/amp/settings.json` (or `$AMP_SETTINGS_FILE` when set). Keys in `amp_settings` are rewritten on every start; all other keys in the file are preserved. Servers already under `amp.mcpServers` win on duplicate names, matching `amp mcp add`, so edits made inside the workspace are never overwritten. If the file contains comments or trailing commas, the module leaves it unchanged and logs a warning. See [Amp configuration](https://ampcode.com/docs/cli/settings) and [MCP](https://ampcode.com/docs/customize/mcp). + +`instruction_prompt` is written to `~/.config/amp/AGENTS.md`, which Amp includes in every session. See [AGENTS.md](https://ampcode.com/docs/customize/agents-md). + +> [!NOTE] +> The official installer ships a self-contained binary and does not install Node.js. MCP servers whose `command` is `npx` or `uvx` need that runtime available in the workspace image, or installed with `pre_install_script`. + +### Managed settings + +```tf +module "amp" { + source = "registry.coder.com/coder-labs/sourcegraph-amp/coder" + version = "4.0.0" + agent_id = coder_agent.main.id + amp_version = "0.0.1790769659-g954f35" + + managed_settings = { + "amp.updates.mode" = "disabled" + "amp.mcpPermissions" = [ + { matches = { url = "*" }, action = "reject" } + ] + } } ``` +`managed_settings` is written as root to `/etc/ampcode/managed-settings.json`. Amp merges it over user and workspace settings: scalar values from this file win, lists are combined, and objects are merged key by key. See [Enterprise managed settings](https://ampcode.com/docs/cli/settings#enterprise-managed-settings). + +Amp updates itself in the background by default, so pin `amp_version` together with `"amp.updates.mode" = "disabled"` (or the `AMP_SKIP_UPDATE_CHECK=1` environment variable) to stay on that version. + +### 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 "amp" { + source = "registry.coder.com/coder-labs/sourcegraph-amp/coder" + version = "4.0.0" + agent_id = coder_agent.main.id +} + +resource "coder_script" "post_amp" { + agent_id = coder_agent.main.id + display_name = "Run after Amp install" + run_on_start = true + script = <<-EOT + #!/usr/bin/env bash + set -euo pipefail + trap 'coder exp sync complete post-amp' EXIT + coder exp sync want post-amp ${join(" ", module.amp.scripts)} + coder exp sync start post-amp + + amp --version + EOT +} +``` + +## Configuration + +When `amp_api_key` is set, it is exported as `AMP_API_KEY` and is not rendered into the install script. Create an access token (it starts with `sgamp_`) in [Amp settings](https://ampcode.com/settings/security#access-token). Without a key, run `amp login` in the workspace. + +The module installs Amp with the [official installer](https://ampcode.com/install.sh) into `~/.amp/bin` and links it into `~/.local/bin`. The install is skipped when `amp` is already on `PATH` and matches `amp_version` (or `amp_version` is empty). If `install_amp = false`, a working `amp` must already be available on `PATH`, or workspace startup fails. + ## Troubleshooting -- If `amp` is not found, ensure `install_amp = true` and your API key is valid -- Logs are written under `/home/coder/.amp-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-labs/sourcegraph-amp/logs/` for detailed information. -> [!IMPORTANT] -> To use tasks with Amp CLI, create a `coder_parameter` named `"AI Prompt"` and pass its value to the amp-cli module's `ai_prompt` variable. The `folder` variable is required for the module to function correctly. -> For using **Coder Tasks** with Amp CLI, make sure to set `amp_api_key`. -> This ensures task reporting and status updates work seamlessly. +```bash +cat ~/.coder-modules/coder-labs/sourcegraph-amp/logs/install.log +cat ~/.coder-modules/coder-labs/sourcegraph-amp/logs/pre_install.log +cat ~/.coder-modules/coder-labs/sourcegraph-amp/logs/post_install.log +``` ## References -- [Amp CLI Documentation](https://ampcode.com/manual) -- [AgentAPI Documentation](https://github.com/coder/agentapi) -- [Coder AI Agents Guide](https://coder.com/docs/tutorials/ai-agents) +- [Amp CLI documentation](https://ampcode.com/docs/cli) +- [Amp configuration](https://ampcode.com/docs/cli/settings) +- [Amp MCP](https://ampcode.com/docs/customize/mcp) diff --git a/registry/coder-labs/modules/sourcegraph-amp/main.test.ts b/registry/coder-labs/modules/sourcegraph-amp/main.test.ts index 105f73868..cd4a84d1b 100644 --- a/registry/coder-labs/modules/sourcegraph-amp/main.test.ts +++ b/registry/coder-labs/modules/sourcegraph-amp/main.test.ts @@ -6,14 +6,67 @@ 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 SCRIPT_SUFFIXES = [ + "Pre-Install Script", + "Install Script", + "Post-Install Script", +] as const; + +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 scripts: Partial = {}; + for (const suffix of SCRIPT_SUFFIXES) { + const key = `Amp: ${suffix}`; + if (!(key in byDisplayName)) continue; + switch (suffix) { + case "Pre-Install Script": + scripts.pre_install = byDisplayName[key]; + break; + case "Install Script": + scripts.install = byDisplayName[key]; + break; + case "Post-Install Script": + scripts.post_install = byDisplayName[key]; + break; + } + } + if (!scripts.install) { + throw new Error("install script not found in terraform state"); + } + return scripts as ModuleScripts; +}; let cleanupFunctions: (() => Promise)[] = []; const registerCleanup = (cleanup: () => Promise) => { @@ -32,241 +85,383 @@ afterEach(async () => { }); interface SetupProps { - skipAgentAPIMock?: boolean; skipAmpMock?: 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: { - workdir: "/home/coder", - install_amp: props?.skipAmpMock ? "true" : "false", - install_agentapi: props?.skipAgentAPIMock ? "true" : "false", - ...props?.moduleVariables, - }, - registerCleanup, - projectDir, - skipAgentAPIMock: props?.skipAgentAPIMock, - agentapiMockScript: props?.agentapiMockScript, +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", + workdir: projectDir, + install_amp: "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); }); - // Place the AMP mock CLI binary inside the container + await writeExecutable({ + containerId: id, + filePath: "/usr/bin/coder", + content: "#!/bin/bash\nexit 0\n", + }); if (!props?.skipAmpMock) { await writeExecutable({ containerId: id, filePath: "/usr/bin/amp", - content: await loadTestFile(`${import.meta.dir}`, "amp-mock.sh"), + content: await Bun.file( + path.join(moduleDir, "testdata", "amp-mock.sh"), + ).text(), }); } + return { id, coderEnvVars, scripts }; +}; + +const runScript = async (id: string, name: string, script: string) => { + const target = `/tmp/coder-utils-${name}.sh`; + await writeExecutable({ containerId: id, filePath: target, content: script }); + return execContainer(id, ["bash", "-c", target]); +}; + +const runScripts = async (id: string, scripts: ModuleScripts) => { + 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); + 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}`); + } + } +}; + +const moduleLogDir = + "/home/coder/.coder-modules/coder-labs/sourcegraph-amp/logs"; +const installLog = (id: string) => + readFileContainer(id, `${moduleLogDir}/install.log`); +const settingsPath = "/home/coder/.config/amp/settings.json"; +const managedSettingsPath = "/etc/ampcode/managed-settings.json"; + +const seedSettings = (id: string, content: string) => + execContainer(id, [ + "bash", + "-c", + `mkdir -p /home/coder/.config/amp && cat > ${settingsPath} <<'EOF'\n${content}\nEOF`, + ]); + +// Installer fixture that drops a fake amp binary where the real installer does. +const installerFixture = [ + "#!/usr/bin/env bash", + 'printf "%s" "${AMP_VERSION:-}" > /tmp/amp-installer-version', + 'mkdir -p "$HOME/.amp/bin" "$HOME/.local/bin"', + `cat > "$HOME/.amp/bin/amp" <<'EOF'`, + "#!/bin/sh", + 'if [ "$1" = "--version" ]; then echo "0.0.1790769659-g954f35 (released 2026-09-30T12:00:59.000Z, 1m ago)"; fi', + "EOF", + 'chmod +x "$HOME/.amp/bin/amp"', + 'ln -sf "$HOME/.amp/bin/amp" "$HOME/.local/bin/amp"', +].join("\n"); - return { id }; +const mockCurl = async (id: string, exitCode = 0) => { + await writeExecutable({ + containerId: id, + filePath: "/tmp/amp-installer-fixture.sh", + content: installerFixture, + }); + await writeExecutable({ + containerId: id, + filePath: "/usr/local/bin/curl", + content: + exitCode === 0 + ? [ + "#!/bin/bash", + "printf '%s\\n' \"$*\" > /tmp/amp-curl-args", + 'while [ $# -gt 0 ]; do if [ "$1" = "--output" ]; then out="$2"; fi; shift; done', + 'cp /tmp/amp-installer-fixture.sh "$out"', + ].join("\n") + : `#!/bin/bash\nexit ${exitCode}\n`, + }); }; setDefaultTimeout(60 * 1000); -describe("amp", async () => { +describe("sourcegraph-amp", async () => { beforeAll(async () => { await runTerraformInit(import.meta.dir); }); - // test("happy-path", async () => { - // const { id } = await setup(); - // await execModuleScript(id); - // await expectAgentAPIStarted(id); - // }); - // - // test("api-key", async () => { - // const apiKey = "test-api-key-123"; - // const { id } = await setup({ - // moduleVariables: { - // amp_api_key: apiKey, - // }, - // }); - // await execModuleScript(id); - // const resp = await readFileContainer( - // id, - // "/home/coder/.amp-module/agentapi-start.log", - // ); - // expect(resp).toContain("amp_api_key provided !"); - // }); - // - test("install-latest-version", async () => { - const { id } = await setup({ + test("happy-path-validates-existing-binary", async () => { + const { id, scripts } = await setup(); + await runScripts(id, scripts); + const log = await installLog(id); + expect(log).toContain("Skipping Amp CLI installation"); + expect(log).toContain("Validated existing Amp CLI"); + expect(log).toContain("0.0.1700000000-gmock00"); + expect(log).toContain("Amp module setup completed."); + expect(log).not.toContain("agentapi"); + // No settings are written when nothing is configured. + const settings = await execContainer(id, ["test", "-e", settingsPath]); + expect(settings.exitCode).not.toBe(0); + }); + + test("preinstalled-binary-required-when-install-disabled", async () => { + const { id, scripts } = await setup({ skipAmpMock: 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({ skipAmpMock: true, - skipAgentAPIMock: true, moduleVariables: { - amp_version: "", + install_amp: "true", + amp_version: "0.0.1790769659-g954f35", }, }); - await execModuleScript(id); - await expectAgentAPIStarted(id); + await mockCurl(id); + await runScripts(id, scripts); + const log = await installLog(id); + expect(log).toContain("Installed Amp CLI"); + expect(log).toContain("0.0.1790769659-g954f35"); + const curlArgs = await readFileContainer(id, "/tmp/amp-curl-args"); + expect(curlArgs).toContain("https://ampcode.com/install.sh"); + expect(curlArgs).toContain("--retry 2"); + expect(curlArgs).toContain("--connect-timeout 10"); + expect(curlArgs).toContain("--max-time 300"); + const version = await readFileContainer(id, "/tmp/amp-installer-version"); + expect(version).toBe("0.0.1790769659-g954f35"); }); - test("install-specific-version", async () => { - const { id } = await setup({ - skipAmpMock: true, + test("existing-binary-skips-installer", async () => { + const { id, scripts } = await setup({ + moduleVariables: { install_amp: "true" }, + }); + await mockCurl(id, 22); + await runScripts(id, scripts); + const log = await installLog(id); + expect(log).toContain("Amp CLI already installed"); + }); + + test("pinned-version-mismatch-reinstalls", async () => { + const { id, scripts } = await setup({ moduleVariables: { - install_via_npm: "true", - amp_version: "0.0.1755964909-g31e083", + install_amp: "true", + amp_version: "0.0.1790769659-g954f35", }, }); - await execModuleScript(id); - const resp = await readFileContainer( - id, - "/home/coder/.amp-module/agentapi-start.log", + await mockCurl(id); + // The fixture links ~/.local/bin/amp, which the script puts ahead of /usr/bin. + await runScripts(id, scripts); + const log = await installLog(id); + expect(log).toContain( + "Amp CLI 0.0.1700000000-gmock00 is installed; installing requested 0.0.1790769659-g954f35", ); - expect(resp).toContain("0.0.1755964909-g31e08"); + expect(log).toContain("Installed Amp CLI"); }); - test("install-via-npm", async () => { - const { id } = await setup({ + test("installer-download-failure-is-terminal", async () => { + const { id, scripts } = await setup({ skipAmpMock: true, - moduleVariables: { - install_via_npm: "true", - }, + moduleVariables: { install_amp: "true" }, }); - await execModuleScript(id); - - const installLog = await readFileContainer( - id, - "/home/coder/.amp-module/install.log", - ); - expect(installLog).toContain("Installing Amp via npm"); + await mockCurl(id, 22); + 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"); + }); - const startLog = await readFileContainer( - id, - "/home/coder/.amp-module/agentapi-start.log", - ); - expect(startLog).toContain("AMP version:"); + test("workdir-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("custom-workdir", async () => { - const workdir = "/tmp/amp-test"; - const { id } = await setup({ - moduleVariables: { - workdir, - }, + test("instruction-prompt", async () => { + const prompt = "Start every response with `amp > `"; + const { id, scripts } = await setup({ + moduleVariables: { instruction_prompt: prompt }, }); - await execModuleScript(id); - const resp = await readFileContainer( + await runScripts(id, scripts); + const agents = await readFileContainer( id, - "/home/coder/.amp-module/agentapi-start.log", + "/home/coder/.config/amp/AGENTS.md", ); - expect(resp).toContain(workdir); + expect(agents.trim()).toBe(prompt); }); - test("pre-post-install-scripts", async () => { - const { id } = await setup({ + test("amp-settings-merge-preserves-unrelated-keys", async () => { + const { id, scripts } = await setup({ moduleVariables: { - pre_install_script: "#!/bin/bash\necho 'pre-install-script'", - post_install_script: "#!/bin/bash\necho 'post-install-script'", + amp_settings: JSON.stringify({ + "amp.dangerouslyAllowAll": true, + "amp.showCosts": false, + }), }, }); - await execModuleScript(id); - const preLog = await readFileContainer( - id, - "/home/coder/.amp-module/pre_install.log", - ); - expect(preLog).toContain("pre-install-script"); - const postLog = await readFileContainer( + await seedSettings( id, - "/home/coder/.amp-module/post_install.log", + JSON.stringify({ + "amp.showCosts": true, + "amp.notifications.enabled": false, + "amp.mcpServers": { seeded: { command: "seeded-command" } }, + }), ); - expect(postLog).toContain("post-install-script"); + await runScripts(id, scripts); + const settings = JSON.parse(await readFileContainer(id, settingsPath)); + expect(settings["amp.dangerouslyAllowAll"]).toBe(true); + expect(settings["amp.showCosts"]).toBe(false); + expect(settings["amp.notifications.enabled"]).toBe(false); + expect(settings["amp.mcpServers"].seeded.command).toBe("seeded-command"); }); - test("instruction-prompt", async () => { - const prompt = "this is a instruction prompt for AMP"; - const { id } = await setup({ + test("writes-mcp-servers-without-coder-server", async () => { + const { id, scripts } = await setup({ moduleVariables: { - instruction_prompt: prompt, + mcp: JSON.stringify({ + playwright: { command: "npx", args: ["-y", "@playwright/mcp"] }, + }), }, }); - await execModuleScript(id); - const resp = await readFileContainer(id, "/home/coder/.config/AGENTS.md"); - expect(resp).toContain(prompt); + await runScripts(id, scripts); + const settings = JSON.parse(await readFileContainer(id, settingsPath)); + expect(settings["amp.mcpServers"].playwright.command).toBe("npx"); + expect(settings["amp.mcpServers"].coder).toBeUndefined(); + const mode = await execContainer(id, ["stat", "-c", "%a", settingsPath]); + expect(mode.stdout.trim()).toBe("600"); }); - test("ai-prompt", async () => { - const prompt = "this is a task prompt for AMP"; - const { id } = await setup({ + test("merges-mcp-config-existing-servers-win", async () => { + const { id, scripts } = await setup({ moduleVariables: { - ai_prompt: prompt, + mcp: JSON.stringify({ + shared: { command: "module-command" }, + extra: { command: "extra-command" }, + }), }, }); - await execModuleScript(id); - const resp = await readFileContainer( + await seedSettings( id, - "/home/coder/.amp-module/agentapi-start.log", + JSON.stringify({ + "amp.showCosts": false, + "amp.mcpServers": { + shared: { command: "existing-command" }, + seeded: { command: "seeded-command" }, + }, + }), ); - expect(resp).toContain(`amp task prompt provided : ${prompt}`); + await runScripts(id, scripts); + const settings = JSON.parse(await readFileContainer(id, settingsPath)); + const servers = settings["amp.mcpServers"]; + expect(servers.shared.command).toBe("existing-command"); + expect(servers.seeded.command).toBe("seeded-command"); + expect(servers.extra.command).toBe("extra-command"); + expect(settings["amp.showCosts"]).toBe(false); }); - test("custom-base-config", async () => { - const customConfig = JSON.stringify({ - "amp.anthropic.thinking.enabled": false, - "amp.todos.enabled": false, - "amp.tools.stopTimeout": 900, - "amp.git.commit.ampThread.enabled": true, - }); - const customMcp = JSON.stringify({ - "test-server": { - command: "/usr/bin/test-mcp", - args: ["--test-arg"], - type: "stdio", + test("jsonc-settings-are-left-untouched", async () => { + const { id, scripts } = await setup({ + moduleVariables: { + mcp: JSON.stringify({ extra: { command: "x" } }), }, }); - const { id } = await setup({ + const jsonc = '{\n // user comment\n "amp.showCosts": false,\n}'; + await seedSettings(id, jsonc); + await runScripts(id, scripts); + const settings = await readFileContainer(id, settingsPath); + expect(settings.trim()).toBe(jsonc); + const log = await installLog(id); + expect(log).toContain("is not a strict JSON object"); + }); + + test("managed-settings-written-as-root", async () => { + const { id, scripts } = await setup({ moduleVariables: { - base_amp_config: customConfig, - mcp: customMcp, + managed_settings: JSON.stringify({ + "amp.updates.mode": "disabled", + "amp.mcpPermissions": [ + { matches: { command: "*" }, action: "reject" }, + ], + }), }, }); - await execModuleScript(id, { - CODER_AGENT_TOKEN: "test-token", - CODER_AGENT_URL: "http://test-url:3000", - }); - const settingsContent = await readFileContainer( - id, - "/home/coder/.config/amp/settings.json", + await runScripts(id, scripts); + const managed = JSON.parse( + await readFileContainer(id, managedSettingsPath), ); - const settings = JSON.parse(settingsContent); - - expect(settings["amp.anthropic.thinking.enabled"]).toBe(false); - expect(settings["amp.todos.enabled"]).toBe(false); - expect(settings["amp.tools.stopTimeout"]).toBe(900); - expect(settings["amp.git.commit.ampThread.enabled"]).toBe(true); - expect(settings["amp.mcpServers"]).toBeDefined(); - expect(settings["amp.mcpServers"].coder).toBeDefined(); - expect(settings["amp.mcpServers"]["test-server"]).toBeDefined(); - expect(settings["amp.mcpServers"]["test-server"].command).toBe( - "/usr/bin/test-mcp", - ); - expect(settings["amp.mcpServers"]["test-server"].args).toEqual([ - "--test-arg", + expect(managed["amp.updates.mode"]).toBe("disabled"); + expect(managed["amp.mcpPermissions"][0].action).toBe("reject"); + const stat = await execContainer(id, [ + "stat", + "-c", + "%U %a", + managedSettingsPath, ]); + expect(stat.stdout.trim()).toBe("root 644"); + const log = await installLog(id); + expect(log).toContain( + `Wrote Amp managed settings to ${managedSettingsPath}`, + ); }); - test("default-base-config", async () => { - const { id } = await setup(); - await execModuleScript(id, { - CODER_AGENT_TOKEN: "test-token", - CODER_AGENT_URL: "http://test-url:3000", + test("managed-settings-absent-when-unset", async () => { + const { id, scripts } = await setup(); + await runScripts(id, scripts); + const resp = await execContainer(id, ["test", "-e", managedSettingsPath]); + expect(resp.exitCode).not.toBe(0); + }); + + test("api-key-env-var-not-in-script", async () => { + const apiKey = "sgamp_test-api-key-123"; + const { coderEnvVars, scripts } = await setup({ + moduleVariables: { amp_api_key: apiKey }, }); - const settingsContent = await readFileContainer( - id, - "/home/coder/.config/amp/settings.json", + expect(coderEnvVars["AMP_API_KEY"]).toBe(apiKey); + expect(scripts.install).not.toContain(apiKey); + expect(scripts.install).not.toContain( + Buffer.from(apiKey).toString("base64"), ); - const settings = JSON.parse(settingsContent); + }); - expect(settings["amp.anthropic.thinking.enabled"]).toBe(true); - expect(settings["amp.todos.enabled"]).toBe(true); - expect(settings["amp.mcpServers"]).toBeDefined(); - expect(settings["amp.mcpServers"].coder).toBeDefined(); - expect(settings["amp.mcpServers"].coder.command).toBe("coder"); + test("pre-post-install-scripts", async () => { + const { id, scripts } = await setup({ + moduleVariables: { + pre_install_script: "#!/bin/bash\necho 'amp-pre-install-script'", + post_install_script: "#!/bin/bash\necho 'amp-post-install-script'", + }, + }); + await runScripts(id, scripts); + expect( + await readFileContainer(id, `${moduleLogDir}/pre_install.log`), + ).toContain("amp-pre-install-script"); + expect( + await readFileContainer(id, `${moduleLogDir}/post_install.log`), + ).toContain("amp-post-install-script"); }); }); diff --git a/registry/coder-labs/modules/sourcegraph-amp/main.tf b/registry/coder-labs/modules/sourcegraph-amp/main.tf index 907b4316b..d97cb8a65 100644 --- a/registry/coder-labs/modules/sourcegraph-amp/main.tf +++ b/registry/coder-labs/modules/sourcegraph-amp/main.tf @@ -1,17 +1,12 @@ terraform { - required_version = ">= 1.0" + required_version = ">= 1.9" required_providers { coder = { source = "coder/coder" version = ">= 2.12" } - external = { - source = "hashicorp/external" - version = "2.3.5" - } } - } variable "agent_id" { @@ -19,255 +14,128 @@ 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/sourcegraph-amp.svg" } variable "workdir" { type = string - description = "The folder to run AMP CLI in." -} - -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.11.1" -} - -variable "cli_app" { - type = bool - description = "Whether to create a CLI app for Claude Code" - default = false -} - -variable "web_app_display_name" { - type = string - description = "Display name for the web app" - default = "Amp" -} - -variable "cli_app_display_name" { - type = string - description = "Display name for the CLI app" - default = "Amp CLI" + description = "Optional project directory. When set, the module pre-creates it if missing. Amp has no folder trust prompt, so nothing else is written for it." + default = null } variable "pre_install_script" { type = string - description = "Custom script to run before installing amp cli" + description = "Custom script to run before installing Amp." default = null } variable "post_install_script" { type = string - description = "Custom script to run after installing amp cli." + description = "Custom script to run after installing Amp." default = null } -variable "report_tasks" { - type = bool - description = "Whether to enable task reporting to Coder UI" - default = true -} - variable "install_amp" { type = bool - description = "Whether to install amp cli." + description = "Whether to install Amp with the official installer. When false, a working amp binary must already be on PATH." default = true } -variable "install_via_npm" { - type = bool - description = "Install Amp via npm instead of the official installer." - default = false -} - -variable "amp_api_key" { - type = string - description = "amp cli API Key" - default = "" -} - variable "amp_version" { type = string - description = "The version of amp cli to install." + description = "Amp CLI version to install (for example 0.0.1790769659-g954f35), passed to the official installer as AMP_VERSION. Empty installs the latest release. Amp auto-updates in the background unless amp.updates.mode is \"disabled\". See https://ampcode.com/docs/cli/settings" default = "" + + validation { + condition = can(regex("^[A-Za-z0-9._-]*$", var.amp_version)) + error_message = "amp_version must be empty or a release version such as 0.0.1790769659-g954f35." + } } -variable "ai_prompt" { +variable "amp_api_key" { type = string - description = "Task prompt for the Amp CLI" + description = "Amp access token, exported as AMP_API_KEY. See https://ampcode.com/docs/cli/execute-mode#non-interactive-environments" + sensitive = true default = "" } variable "instruction_prompt" { type = string - description = "Instruction prompt for the Amp CLI. https://ampcode.com/manual#AGENTS.md" + description = "Personal guidance written to ~/.config/amp/AGENTS.md, which Amp includes in every session. See https://ampcode.com/docs/customize/agents-md" default = "" } -resource "coder_env" "amp_api_key" { - agent_id = var.agent_id - name = "AMP_API_KEY" - value = var.amp_api_key -} - -variable "base_amp_config" { +variable "amp_settings" { type = string - description = <<-EOT - Base AMP configuration in JSON format. Can be overridden to customize AMP settings. + description = "Amp user settings as a JSON object of amp.* keys. Merged into ~/.config/amp/settings.json: these keys are overwritten on every start and all other keys are preserved. Use mcp for amp.mcpServers. See https://ampcode.com/docs/cli/settings" + default = "" - If empty, defaults enable thinking and todos for autonomous operation. Additional options include: - - "amp.permissions": [] (tool permissions) - - "amp.tools.stopTimeout": 600 (extend timeout for long operations) - - "amp.terminal.commands.nodeSpawn.loadProfile": "daily" (environment loading) - - "amp.tools.disable": ["builtin:open"] (disable tools for containers) - - "amp.git.commit.ampThread.enabled": true (link commits to threads) - - "amp.git.commit.coauthor.enabled": true (add Amp as co-author) + validation { + condition = var.amp_settings == "" || can(keys(jsondecode(var.amp_settings))) + error_message = "amp_settings must be a JSON object." + } - Reference: https://ampcode.com/manual - EOT - default = "" + validation { + condition = var.amp_settings == "" || !can(jsondecode(var.amp_settings)["amp.mcpServers"]) + error_message = "amp_settings must not contain amp.mcpServers; use the mcp variable instead." + } } variable "mcp" { type = string - description = "Additional MCP servers configuration in JSON format to append to amp.mcpServers." - default = null -} + description = "MCP servers as a JSON object keyed by server name, in the amp.mcpServers format. Merged into ~/.config/amp/settings.json; servers already on disk win on duplicate names, matching amp mcp add. See https://ampcode.com/docs/customize/mcp" + default = "" -variable "mode" { - type = string - description = "Set the agent mode (free, rush, smart) — controls the model, system prompt, and tool selection. Default: smart" - default = "smart" validation { - condition = contains(["", "free", "rush", "smart"], var.mode) - error_message = "Invalid mode. Select one from (free, rush, smart)" + condition = var.mcp == "" || can(keys(jsondecode(var.mcp))) + error_message = "mcp must be a JSON object keyed by server name." } } -data "external" "env" { - program = ["sh", "-c", "echo '{\"CODER_AGENT_TOKEN\":\"'$CODER_AGENT_TOKEN'\",\"CODER_AGENT_URL\":\"'$CODER_AGENT_URL'\"}'"] +variable "managed_settings" { + type = any + description = "Enterprise managed settings written to /etc/ampcode/managed-settings.json. Takes precedence over user and workspace settings. See https://ampcode.com/docs/cli/settings#enterprise-managed-settings" + default = null } -locals { - app_slug = "amp" - - default_base_config = jsonencode({ - "amp.anthropic.thinking.enabled" = true - "amp.todos.enabled" = true - "amp.terminal.animation" = false - }) - - user_config = jsondecode(var.base_amp_config != "" ? var.base_amp_config : local.default_base_config) - base_amp_settings = { for k, v in local.user_config : k => v if k != "amp.mcpServers" } - - coder_mcp = { - "coder" = { - "command" = "coder" - "args" = ["exp", "mcp", "server"] - "env" = { - "CODER_MCP_APP_STATUS_SLUG" = var.report_tasks == true ? local.app_slug : "" - "CODER_MCP_AI_AGENTAPI_URL" = var.report_tasks == true ? "http://localhost:3284" : "" - "CODER_AGENT_TOKEN" = data.external.env.result.CODER_AGENT_TOKEN - "CODER_AGENT_URL" = data.external.env.result.CODER_AGENT_URL - } - "type" = "stdio" - } - } - - additional_mcp = var.mcp != null ? jsondecode(var.mcp) : {} - - merged_mcp_servers = merge( - lookup(local.user_config, "amp.mcpServers", {}), - local.coder_mcp, - local.additional_mcp - ) +resource "coder_env" "amp_api_key" { + count = var.amp_api_key != "" ? 1 : 0 + agent_id = var.agent_id + name = "AMP_API_KEY" + value = var.amp_api_key +} - final_config = merge(local.base_amp_settings, { - "amp.mcpServers" = local.merged_mcp_servers +locals { + workdir = var.workdir != null ? trimsuffix(var.workdir, "/") : "" + install_script = templatefile("${path.module}/scripts/install.sh.tftpl", { + ARG_INSTALL = tostring(var.install_amp) + ARG_AMP_VERSION = var.amp_version + ARG_WORKDIR = local.workdir != "" ? base64encode(local.workdir) : "" + ARG_INSTRUCTION_PROMPT = var.instruction_prompt != "" ? base64encode(var.instruction_prompt) : "" + ARG_AMP_SETTINGS = var.amp_settings != "" ? base64encode(var.amp_settings) : "" + ARG_MCP_CONFIG = var.mcp != "" ? base64encode(var.mcp) : "" + ARG_MANAGED_SETTINGS_JSON = var.managed_settings != null ? base64encode(jsonencode(var.managed_settings)) : "" }) - - install_script = file("${path.module}/scripts/install.sh") - start_script = file("${path.module}/scripts/start.sh") - module_dir_name = ".amp-module" - workdir = trimsuffix(var.workdir, "/") + module_dir_name = ".coder-modules/coder-labs/sourcegraph-amp" } -module "agentapi" { - source = "registry.coder.com/coder/agentapi/coder" - version = "2.0.0" - - agent_id = var.agent_id - folder = local.workdir - 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 - 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 - - echo -n '${base64encode(local.start_script)}' | base64 -d > /tmp/start.sh - chmod +x /tmp/start.sh - ARG_AMP_API_KEY='${var.amp_api_key}' \ - ARG_AMP_START_DIRECTORY='${var.workdir}' \ - ARG_AMP_TASK_PROMPT='${base64encode(var.ai_prompt)}' \ - ARG_REPORT_TASKS='${var.report_tasks}' \ - ARG_MODE='${var.mode}' \ - /tmp/start.sh - EOT - - 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_INSTALL_AMP='${var.install_amp}' \ - ARG_INSTALL_VIA_NPM='${var.install_via_npm}' \ - ARG_AMP_CONFIG="${base64encode(jsonencode(local.final_config))}" \ - ARG_AMP_VERSION='${var.amp_version}' \ - ARG_AMP_INSTRUCTION_PROMPT='${base64encode(var.instruction_prompt)}' \ - /tmp/install.sh - EOT + agent_id = var.agent_id + module_directory = "$HOME/${local.module_dir_name}" + display_name_prefix = "Amp" + icon = var.icon + pre_install_script = var.pre_install_script + post_install_script = var.post_install_script + install_script = local.install_script } -output "task_app_id" { - value = module.agentapi.task_app_id +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-labs/modules/sourcegraph-amp/main.tftest.hcl b/registry/coder-labs/modules/sourcegraph-amp/main.tftest.hcl new file mode 100644 index 000000000..a4eb14ae1 --- /dev/null +++ b/registry/coder-labs/modules/sourcegraph-amp/main.tftest.hcl @@ -0,0 +1,180 @@ +run "defaults_are_correct" { + command = plan + + variables { + agent_id = "test-agent" + } + + assert { + condition = var.install_amp == true + error_message = "install_amp should default to true" + } + + assert { + condition = var.amp_version == "" + error_message = "amp_version should default to empty (latest)" + } + + assert { + condition = local.workdir == "" + error_message = "workdir should be empty by default" + } + + assert { + condition = local.module_dir_name == ".coder-modules/coder-labs/sourcegraph-amp" + error_message = "module_dir_name should be '.coder-modules/coder-labs/sourcegraph-amp'" + } + + assert { + condition = length(coder_env.amp_api_key) == 0 + error_message = "AMP_API_KEY should not be created when amp_api_key is empty" + } + + assert { + condition = strcontains(local.install_script, "ARG_MANAGED_SETTINGS_JSON=$(echo -n '' | base64 -d)") + error_message = "managed settings should be empty by default" + } +} + +run "workdir_trailing_slash_is_trimmed" { + command = plan + + variables { + agent_id = "test-agent" + workdir = "/home/coder/project/" + } + + assert { + condition = local.workdir == "/home/coder/project" + error_message = "workdir should have its trailing slash trimmed" + } +} + +run "api_key_creates_env_var" { + command = plan + + variables { + agent_id = "test-agent" + amp_api_key = "sgamp_test-key" + } + + assert { + condition = coder_env.amp_api_key[0].name == "AMP_API_KEY" && coder_env.amp_api_key[0].value == "sgamp_test-key" + error_message = "AMP_API_KEY env var should be created with the provided key" + } + + assert { + condition = !strcontains(local.install_script, nonsensitive(var.amp_api_key)) && !strcontains(local.install_script, base64encode(nonsensitive(var.amp_api_key))) + error_message = "API key should not be rendered into the install script" + } +} + +run "amp_version_is_passed_through" { + command = plan + + variables { + agent_id = "test-agent" + amp_version = "0.0.1790769659-g954f35" + } + + assert { + condition = strcontains(local.install_script, "ARG_AMP_VERSION='0.0.1790769659-g954f35'") + error_message = "amp_version should be rendered into the install script" + } +} + +run "invalid_amp_version_fails" { + command = plan + + variables { + agent_id = "test-agent" + amp_version = "1.0'; rm -rf /" + } + + expect_failures = [ + var.amp_version, + ] +} + +run "invalid_mcp_fails" { + command = plan + + variables { + agent_id = "test-agent" + mcp = "[\"not-an-object\"]" + } + + expect_failures = [ + var.mcp, + ] +} + +run "invalid_amp_settings_fails" { + command = plan + + variables { + agent_id = "test-agent" + amp_settings = "not json" + } + + expect_failures = [ + var.amp_settings, + ] +} + +run "amp_settings_rejects_mcp_servers" { + command = plan + + variables { + agent_id = "test-agent" + amp_settings = "{\"amp.mcpServers\": {}}" + } + + expect_failures = [ + var.amp_settings, + ] +} + +run "managed_settings_are_encoded" { + command = plan + + variables { + agent_id = "test-agent" + managed_settings = { + "amp.updates.mode" = "disabled" + } + } + + assert { + condition = strcontains(local.install_script, base64encode(jsonencode({ "amp.updates.mode" = "disabled" }))) + error_message = "managed_settings should be rendered base64-encoded into the install script" + } +} + +run "scripts_output_is_ordered" { + command = plan + + variables { + agent_id = "test-agent" + pre_install_script = "echo pre" + post_install_script = "echo post" + } + + assert { + condition = output.scripts == ["coder-labs-sourcegraph-amp-pre_install_script", "coder-labs-sourcegraph-amp-install_script", "coder-labs-sourcegraph-amp-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" + } + + assert { + condition = output.scripts == ["coder-labs-sourcegraph-amp-install_script"] + error_message = "scripts output should only contain install when no pre/post scripts are set" + } +} diff --git a/registry/coder-labs/modules/sourcegraph-amp/scripts/install.sh b/registry/coder-labs/modules/sourcegraph-amp/scripts/install.sh deleted file mode 100644 index fd42ed7ca..000000000 --- a/registry/coder-labs/modules/sourcegraph-amp/scripts/install.sh +++ /dev/null @@ -1,142 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# ANSI colors -BOLD='\033[1m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -NC='\033[0m' - -ARG_INSTALL_AMP=${ARG_INSTALL_AMP:-true} -ARG_INSTALL_VIA_NPM=${ARG_INSTALL_VIA_NPM:-false} -ARG_AMP_VERSION=${ARG_AMP_VERSION:-} -ARG_AMP_INSTRUCTION_PROMPT=$(echo -n "${ARG_AMP_INSTRUCTION_PROMPT:-}" | base64 -d) -ARG_AMP_CONFIG=$(echo -n "${ARG_AMP_CONFIG:-}" | base64 -d) - -echo "--------------------------------" -printf "Install flag: %s\n" "$ARG_INSTALL_AMP" -printf "Install via npm: %s\n" "$ARG_INSTALL_VIA_NPM" -printf "Amp Version: %s\n" "$ARG_AMP_VERSION" -printf "AMP Config: %s\n" "$ARG_AMP_CONFIG" -printf "Instruction Prompt: %s\n" "$ARG_AMP_INSTRUCTION_PROMPT" -echo "--------------------------------" - -command_exists() { - command -v "$1" > /dev/null 2>&1 -} - -install_amp_npm() { - printf "%s${YELLOW}Installing Amp via npm${NC}\n" "${BOLD}" - - # Load nvm if available - # shellcheck source=/dev/null - if [ -f "$HOME/.nvm/nvm.sh" ]; then - source "$HOME/.nvm/nvm.sh" - fi - - if ! command_exists node || ! command_exists npm; then - printf "${YELLOW}Warning: Node.js/npm not found. Skipping Amp installation.${NC}\n" - printf "To install Amp via npm, please install Node.js and npm first.\n" - return 1 - fi - - printf "Node.js version: %s\n" "$(node --version)" - printf "npm version: %s\n" "$(npm --version)" - - NPM_GLOBAL_PREFIX="${HOME}/.npm-global" - if [ ! -d "$NPM_GLOBAL_PREFIX" ]; then - mkdir -p "$NPM_GLOBAL_PREFIX" - fi - - npm config set prefix "$NPM_GLOBAL_PREFIX" - export PATH="$NPM_GLOBAL_PREFIX/bin:$PATH" - - if [ -n "$ARG_AMP_VERSION" ]; then - npm install -g "@sourcegraph/amp@$ARG_AMP_VERSION" - else - npm install -g "@sourcegraph/amp" - fi - - if ! grep -q 'export PATH="$HOME/.npm-global/bin:$PATH"' "$HOME/.bashrc"; then - echo 'export PATH="$HOME/.npm-global/bin:$PATH"' >> "$HOME/.bashrc" - fi -} - -install_amp_official() { - printf "%s Installing Amp using official installer\n" "${BOLD}" - - if [ -n "$ARG_AMP_VERSION" ]; then - export AMP_VERSION="$ARG_AMP_VERSION" - printf "Installing Amp version: %s\n" "$AMP_VERSION" - fi - - if curl -fsSL https://ampcode.com/install.sh | bash; then - export PATH="$HOME/.local/bin:$HOME/.amp/bin:$PATH" - - if ! grep -q 'export PATH="$HOME/.local/bin:$PATH"' "$HOME/.bashrc"; then - echo 'export PATH="$HOME/.local/bin:$PATH"' >> "$HOME/.bashrc" - fi - else - printf "${YELLOW}Warning: Official installer failed. Installation skipped.${NC}\n" - return 1 - fi -} - -function install_amp() { - if [ "${ARG_INSTALL_AMP}" = "true" ]; then - if [ "${ARG_INSTALL_VIA_NPM}" = "true" ]; then - install_amp_npm || { - printf "${YELLOW}Amp installation via npm failed.${NC}\n" - return 0 - } - else - install_amp_official || { - printf "${YELLOW}Amp installation via official installer failed.${NC}\n" - return 0 - } - fi - - if command_exists amp; then - printf "%s${GREEN}Successfully installed Sourcegraph Amp CLI. Version: %s${NC}\n" "${BOLD}" "$(amp --version)" - fi - else - printf "Skipping Sourcegraph Amp CLI installation (install_amp=false)\n" - fi -} - -function setup_instruction_prompt() { - if [ -n "${ARG_AMP_INSTRUCTION_PROMPT:-}" ]; then - echo "Setting AMP instruction prompt..." - mkdir -p "$HOME/.config" - echo "$ARG_AMP_INSTRUCTION_PROMPT" > "$HOME/.config/AGENTS.md" - echo "Instruction prompt saved to $HOME/.config/AGENTS.md" - else - echo "No instruction prompt provided for Sourcegraph AMP." - fi -} - -function configure_amp_settings() { - echo "Configuring AMP settings..." - SETTINGS_PATH="$HOME/.config/amp/settings.json" - mkdir -p "$(dirname "$SETTINGS_PATH")" - - if [ -z "${ARG_AMP_CONFIG:-}" ]; then - echo "No AMP config provided, skipping configuration" - return - fi - - echo "Writing AMP configuration to $SETTINGS_PATH" - UPDATED_CONFIG=$(echo "$ARG_AMP_CONFIG" | jq --arg token "$CODER_AGENT_TOKEN" --arg url "$CODER_AGENT_URL" \ - ".[\"amp.mcpServers\"].coder.env += { - \"CODER_AGENT_TOKEN\": \"$CODER_AGENT_TOKEN\", - \"CODER_AGENT_URL\": \"$CODER_AGENT_URL\" - }") - printf "UPDATED_CONFIG: %s\n" "$UPDATED_CONFIG" - printf '%s\n' "$UPDATED_CONFIG" > "$SETTINGS_PATH" - - echo "AMP configuration complete" -} - -install_amp -setup_instruction_prompt -configure_amp_settings diff --git a/registry/coder-labs/modules/sourcegraph-amp/scripts/install.sh.tftpl b/registry/coder-labs/modules/sourcegraph-amp/scripts/install.sh.tftpl new file mode 100644 index 000000000..3e05a2a5b --- /dev/null +++ b/registry/coder-labs/modules/sourcegraph-amp/scripts/install.sh.tftpl @@ -0,0 +1,252 @@ +#!/usr/bin/env bash + +set -euo pipefail + +command_exists() { + command -v "$1" > /dev/null 2>&1 +} + +ARG_INSTALL='${ARG_INSTALL}' +ARG_AMP_VERSION='${ARG_AMP_VERSION}' +ARG_WORKDIR=$(echo -n '${ARG_WORKDIR}' | base64 -d) +ARG_INSTRUCTION_PROMPT=$(echo -n '${ARG_INSTRUCTION_PROMPT}' | base64 -d) +ARG_AMP_SETTINGS=$(echo -n '${ARG_AMP_SETTINGS}' | base64 -d) +ARG_MCP_CONFIG=$(echo -n '${ARG_MCP_CONFIG}' | base64 -d) +ARG_MANAGED_SETTINGS_JSON=$(echo -n '${ARG_MANAGED_SETTINGS_JSON}' | base64 -d) + +export PATH="$HOME/.local/bin:$HOME/.amp/bin:$PATH" + +SETTINGS_FILE="$${AMP_SETTINGS_FILE:-$HOME/.config/amp/settings.json}" + +echo "--------------------------------" +printf "install_amp: %s\n" "$${ARG_INSTALL}" +printf "amp_version: %s\n" "$${ARG_AMP_VERSION:-latest}" +printf "workdir: %s\n" "$${ARG_WORKDIR}" +echo "--------------------------------" + +require_jq() { + if ! command_exists jq; then + echo "ERROR: 'jq' is required to $1 but was not found." >&2 + exit 1 + fi +} + +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 +} + +installed_amp_version() { + amp --version 2> /dev/null | awk '{print $1}' +} + +function ensure_amp_in_path() { + local AMP_BIN="" + if command_exists amp; then + AMP_BIN=$(command -v amp) + fi + + if [ -z "$${AMP_BIN}" ] || [ ! -x "$${AMP_BIN}" ]; then + echo "Amp CLI binary (amp) was not found or is not executable." >&2 + return 1 + fi + + local version + if ! version=$(installed_amp_version) || [ -z "$${version}" ]; then + echo "Amp CLI binary could not be executed." >&2 + return 1 + fi + printf "Amp CLI version: %s\n" "$${version}" + + if [ -n "$${CODER_SCRIPT_BIN_DIR:-}" ] && [ ! -e "$${CODER_SCRIPT_BIN_DIR}/amp" ]; then + ln -s "$${AMP_BIN}" "$${CODER_SCRIPT_BIN_DIR}/amp" + echo "Created symlink: $${CODER_SCRIPT_BIN_DIR}/amp -> $${AMP_BIN}" + fi + + add_path_to_shell_profiles "$(dirname "$${AMP_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://ampcode.com/install.sh; then + echo "Amp CLI installer could not be downloaded after up to 3 attempts." >&2 + return 1 + fi + + if ! bash -n "$${installer_file}"; then + echo "Amp CLI installer download was invalid." >&2 + return 1 + fi + + # Installs to ~/.amp/bin and links ~/.local/bin/amp; empty AMP_VERSION means latest. + if ! AMP_VERSION="$${ARG_AMP_VERSION}" bash "$${installer_file}"; then + echo "Amp CLI installation failed." >&2 + return 1 + fi +) + +install_amp() { + if [ "$${ARG_INSTALL}" != "true" ]; then + echo "Skipping Amp CLI installation as per configuration." + if ! ensure_amp_in_path; then + echo "ERROR: install_amp is false but a working 'amp' was not found on PATH." >&2 + exit 1 + fi + echo "Validated existing Amp CLI" + return + fi + + if command_exists amp; then + local current + current=$(installed_amp_version || true) + if [ -z "$${ARG_AMP_VERSION}" ] || [ "$${current}" = "$${ARG_AMP_VERSION}" ]; then + echo "Amp CLI already installed" + ensure_amp_in_path + return + fi + echo "Amp CLI $${current} is installed; installing requested $${ARG_AMP_VERSION}" + fi + + echo "Installing Amp CLI" + install_with_official_installer + + if ! ensure_amp_in_path; then + echo "ERROR: Failed to install Amp CLI" >&2 + exit 1 + fi + echo "Installed Amp CLI" +} + +setup_workdir() { + if [ -n "$${ARG_WORKDIR}" ] && [ ! -d "$${ARG_WORKDIR}" ]; then + echo "Creating workdir: $${ARG_WORKDIR}" + mkdir -p "$${ARG_WORKDIR}" + fi +} + +write_instruction_prompt() { + if [ -z "$${ARG_INSTRUCTION_PROMPT}" ]; then + return + fi + + local agents_file="$HOME/.config/amp/AGENTS.md" + mkdir -p "$(dirname "$${agents_file}")" + printf '%s\n' "$${ARG_INSTRUCTION_PROMPT}" > "$${agents_file}" + echo "Wrote instruction prompt to $${agents_file}" +} + +# Prints the current user settings as JSON, or {} when the file is absent. +# Returns non-zero when the file exists but is not a strict JSON object (Amp +# also accepts JSONC), so callers can leave it untouched. +read_user_settings() { + if [ ! -f "$${SETTINGS_FILE}" ]; then + echo '{}' + return + fi + if ! jq -e 'type == "object"' "$${SETTINGS_FILE}" > /dev/null 2>&1; then + return 1 + fi + cat "$${SETTINGS_FILE}" +} + +write_user_settings() { + local content="$1" + mkdir -p "$(dirname "$${SETTINGS_FILE}")" + ( + umask 077 + printf '%s\n' "$${content}" > "$${SETTINGS_FILE}" + ) +} + +# Module-provided amp.* keys overwrite on-disk values; MCP servers already on +# disk win on duplicate names, matching `amp mcp add`. Every other key is kept. +configure_user_settings() { + if [ -z "$${ARG_AMP_SETTINGS}" ] && [ -z "$${ARG_MCP_CONFIG}" ]; then + echo "No Amp settings or MCP servers configured; leaving $${SETTINGS_FILE} unchanged." + return + fi + + require_jq "configure Amp settings" + + local existing + if ! existing=$(read_user_settings); then + echo "Warning: $${SETTINGS_FILE} is not a strict JSON object (comments or trailing commas); leaving it unchanged. Remove them to let the module manage amp_settings and mcp." >&2 + return + fi + + local settings='{}' custom='{}' + if [ -n "$${ARG_AMP_SETTINGS}" ]; then + settings="$${ARG_AMP_SETTINGS}" + fi + if [ -n "$${ARG_MCP_CONFIG}" ]; then + custom="$${ARG_MCP_CONFIG}" + fi + + local updated + updated=$(echo "$${existing}" | jq --argjson settings "$${settings}" --argjson custom "$${custom}" ' + (. + $settings) + | if ($custom | length) > 0 + then .["amp.mcpServers"] = ($custom + (.["amp.mcpServers"] // {})) + else . + end + ') + write_user_settings "$${updated}" + echo "Updated Amp settings: $${SETTINGS_FILE}" +} + +# Amp merges this file over user and workspace settings. +write_managed_settings() { + if [ -z "$${ARG_MANAGED_SETTINGS_JSON}" ]; then + return + fi + + require_jq "validate managed settings" + + if ! echo "$${ARG_MANAGED_SETTINGS_JSON}" | jq -e 'type == "object"' > /dev/null 2>&1; then + echo "Warning: managed_settings is not a JSON object, skipping managed settings write" >&2 + return + fi + + local target="/etc/ampcode/managed-settings.json" + if command_exists sudo; then + sudo mkdir -p "$(dirname "$${target}")" + echo "$${ARG_MANAGED_SETTINGS_JSON}" | sudo tee "$${target}" > /dev/null + sudo chmod 0644 "$${target}" + else + mkdir -p "$(dirname "$${target}")" + echo "$${ARG_MANAGED_SETTINGS_JSON}" > "$${target}" + chmod 0644 "$${target}" + fi + echo "Wrote Amp managed settings to $${target}" +} + +install_amp +setup_workdir +write_instruction_prompt +configure_user_settings +write_managed_settings + +echo "Amp module setup completed." diff --git a/registry/coder-labs/modules/sourcegraph-amp/scripts/start.sh b/registry/coder-labs/modules/sourcegraph-amp/scripts/start.sh deleted file mode 100644 index a36561b84..000000000 --- a/registry/coder-labs/modules/sourcegraph-amp/scripts/start.sh +++ /dev/null @@ -1,73 +0,0 @@ -#!/usr/bin/env bash - -# Load user environment -if [ -f "$HOME/.bashrc" ]; then - source "$HOME/.bashrc" -fi - -if [ -f "$HOME/.nvm/nvm.sh" ]; then - source "$HOME/.nvm/nvm.sh" -fi - -set -euo pipefail - -export PATH="$HOME/.local/bin:$HOME/.amp/bin:$HOME/.npm-global/bin:$PATH" - -function ensure_command() { - command -v "$1" &> /dev/null || { - echo "Error: '$1' not found." >&2 - exit 1 - } -} - -ARG_AMP_START_DIRECTORY=${ARG_AMP_START_DIRECTORY:-"$HOME"} -ARG_AMP_API_KEY=${ARG_AMP_API_KEY:-} -ARG_AMP_TASK_PROMPT=$(echo -n "${ARG_AMP_TASK_PROMPT:-}" | base64 -d) -ARG_REPORT_TASKS=${ARG_REPORT_TASKS:-true} - -echo "--------------------------------" -printf "Workspace: %s\n" "$ARG_AMP_START_DIRECTORY" -printf "Task Prompt: %s\n" "$ARG_AMP_TASK_PROMPT" -printf "ARG_REPORT_TASKS: %s\n" "$ARG_REPORT_TASKS" -printf "ARG_MODE: %s\n" "$ARG_MODE" -echo "--------------------------------" - -ensure_command amp -echo "AMP version: $(amp --version)" - -dir="$ARG_AMP_START_DIRECTORY" -if [[ -d "$dir" ]]; then - echo "Using existing directory: $dir" -else - echo "Creating directory: $dir" - mkdir -p "$dir" -fi -cd "$dir" - -if [ -n "$ARG_AMP_API_KEY" ]; then - printf "amp_api_key provided !\n" - export AMP_API_KEY=$ARG_AMP_API_KEY -else - printf "amp_api_key not provided\n" -fi - -ARGS=() - -if [ -n "$ARG_MODE" ]; then - printf "Running agent in: %s mode" "$ARG_MODE" - ARGS+=(--mode "$ARG_MODE") -fi - -if [ -n "$ARG_AMP_TASK_PROMPT" ]; then - if [ "$ARG_REPORT_TASKS" == "true" ]; then - printf "amp task prompt provided : %s" "$ARG_AMP_TASK_PROMPT\n" - PROMPT="Every step of the way, report your progress using coder_report_task tool with proper summary and statuses. Your task at hand: $ARG_AMP_TASK_PROMPT" - else - PROMPT="$ARG_AMP_TASK_PROMPT" - fi - # Pipe the prompt into amp, which will be run inside agentapi - agentapi server --type amp --term-width=67 --term-height=1190 -- bash -c "echo \"$PROMPT\" | amp" "${ARGS[@]}" -else - printf "No task prompt given.\n" - agentapi server --type amp --term-width=67 --term-height=1190 -- amp "${ARGS[@]}" -fi diff --git a/registry/coder-labs/modules/sourcegraph-amp/testdata/amp-mock.sh b/registry/coder-labs/modules/sourcegraph-amp/testdata/amp-mock.sh index 259db57ad..2285fbb6f 100644 --- a/registry/coder-labs/modules/sourcegraph-amp/testdata/amp-mock.sh +++ b/registry/coder-labs/modules/sourcegraph-amp/testdata/amp-mock.sh @@ -1,14 +1,9 @@ -#!/bin/bash +#!/usr/bin/env bash -# Mock behavior of the AMP CLI if [[ "$1" == "--version" ]]; then - echo "AMP CLI mock version v1.0.0" + echo "0.0.1700000000-gmock00 (released 2026-01-01T00:00:00.000Z, 1d ago)" exit 0 fi -# Simulate AMP running in a loop for AgentAPI to connect -set -e -while true; do - echo "$(date) - AMP mock is running..." - sleep 15 -done +echo "amp invoked with: $*" +exit 0