diff --git a/registry/coder-labs/modules/opencode/README.md b/registry/coder-labs/modules/opencode/README.md index e462b6884..ab8385597 100644 --- a/registry/coder-labs/modules/opencode/README.md +++ b/registry/coder-labs/modules/opencode/README.md @@ -1,103 +1,164 @@ --- display_name: OpenCode icon: ../../../../.icons/opencode.svg -description: Run OpenCode AI coding assistant for AI-powered terminal assistance +description: Install and configure the OpenCode AI coding agent in your workspace. verified: false -tags: [agent, opencode, ai, tasks] +tags: [agent, opencode, ai] --- # OpenCode -Run [OpenCode](https://opencode.ai) AI coding assistant in your workspace for intelligent code generation, analysis, and development assistance. This module integrates with [AgentAPI](https://github.com/coder/agentapi) for seamless task reporting in the Coder UI. +Install and configure [OpenCode](https://opencode.ai) in your workspace. ```tf module "opencode" { source = "registry.coder.com/coder-labs/opencode/coder" - version = "0.1.3" + version = "1.0.0" agent_id = coder_agent.main.id - workdir = "/home/coder/project" } ``` -## Prerequisites - -- **Authentication credentials** - OpenCode auth.json file is required for non-interactive authentication, you can find this file on your system: `$HOME/.local/share/opencode/auth.json` +> [!WARNING] +> If upgrading from v0.x.x of this module: v1 is a major refactor that drops support for Coder Tasks and AgentAPI. The module now only installs and configures OpenCode; launch it with your own `coder_app`. `workdir` is now optional. `ai_prompt`, `continue`, and `session_id` are removed in favor of the `--prompt`, `--continue`, and `--session` CLI flags in your launcher. `config_json` is now merged into `~/.config/opencode/opencode.json` instead of replacing it, and MCP servers move from `config_json` to the new `mcp` variable. `auth_json` is merged into `auth.json` instead of replacing it. Keep using v0.x.x if you depend on Coder Tasks. ## Examples -### Basic Usage +### Standalone mode with a launcher app ```tf -module "opencode" { - source = "registry.coder.com/coder-labs/opencode/coder" - version = "0.1.3" - agent_id = coder_agent.main.id - workdir = "/home/coder/project" - - auth_json = <<-EOT -{ - "google": { - "type": "api", - "key": "gem-xxx-xxxx" - }, - "anthropic": { - "type": "api", - "key": "sk-ant-api03-xxx-xxxxxxx" - } - +locals { + opencode_workdir = "/home/coder/project" } -EOT + +module "opencode" { + source = "registry.coder.com/coder-labs/opencode/coder" + version = "1.0.0" + agent_id = coder_agent.main.id + workdir = local.opencode_workdir + auth_json = var.opencode_auth_json config_json = jsonencode({ - "$schema" = "https://opencode.ai/config.json" - mcp = { - filesystem = { - command = ["npx", "-y", "@modelcontextprotocol/server-filesystem", "/home/coder/projects"] - enabled = true - type = "local" - environment = { - SOME_VARIABLE_X = "value" - } - } - playwright = { - command = ["npx", "-y", "@playwright/mcp@latest", "--headless", "--isolated"] - enabled = true - type = "local" - } - } - model = "anthropic/claude-sonnet-4-20250514" + model = "anthropic/claude-sonnet-4-5" }) +} - pre_install_script = <<-EOT +resource "coder_app" "opencode" { + agent_id = coder_agent.main.id + slug = "opencode" + display_name = "OpenCode" + icon = "/icon/opencode.svg" + open_in = "slim-window" + command = <<-EOT #!/usr/bin/env bash - curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - - sudo apt-get install -y nodejs + set -e + cd "${local.opencode_workdir}" + exec opencode --continue EOT } ``` -### Standalone CLI Mode +When `workdir` is set, the module creates it if missing. Pass `--model`, `--continue`, `--session`, `--agent`, or any other [CLI flag](https://opencode.ai/docs/cli/) in the launcher command. -Run OpenCode as a command-line tool without web interface or task reporting: +> [!NOTE] +> The `coder_app` command re-executes on every pane reconnect. This works for the interactive `opencode` TUI, but one-shot commands like `opencode run` 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 or `opencode attach`). + +### MCP servers ```tf module "opencode" { - source = "registry.coder.com/coder-labs/opencode/coder" - version = "0.1.3" + source = "registry.coder.com/coder-labs/opencode/coder" + version = "1.0.0" + agent_id = coder_agent.main.id + + mcp = jsonencode({ + playwright = { + type = "local" + command = ["npx", "-y", "@playwright/mcp@latest", "--headless", "--isolated"] + enabled = true + } + context7 = { + type = "remote" + url = "https://mcp.context7.com/mcp" + } + }) +} +``` + +`mcp` uses the format of the [`mcp` key in `opencode.json`](https://opencode.ai/docs/mcp-servers/) and is merged into `~/.config/opencode/opencode.json`. Servers already in that file win on duplicate names, so changes made inside the workspace (for example with `opencode mcp add`) are not overwritten. + +> [!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 "opencode" { + source = "registry.coder.com/coder-labs/opencode/coder" + version = "1.0.0" + agent_id = coder_agent.main.id + + managed_settings = { + share = "disabled" + autoupdate = false + permission = { + bash = "ask" + } + } +} +``` + +`managed_settings` is written as root to `/etc/opencode/opencode.json`. OpenCode loads [managed settings](https://opencode.ai/docs/config/#managed-settings) last, so user and project config cannot override them. Writing the file requires root or passwordless `sudo`; otherwise the module logs a warning and skips it. + +### 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 "opencode" { + source = "registry.coder.com/coder-labs/opencode/coder" + version = "1.0.0" + agent_id = coder_agent.main.id +} + +resource "coder_script" "post_opencode" { agent_id = coder_agent.main.id - workdir = "/home/coder" - report_tasks = false - cli_app = true + display_name = "Run after OpenCode install" + run_on_start = true + script = <<-EOT + #!/usr/bin/env bash + set -euo pipefail + trap 'coder exp sync complete post-opencode' EXIT + coder exp sync want post-opencode ${join(" ", module.opencode.scripts)} + coder exp sync start post-opencode + + opencode --version + EOT } ``` +## Configuration + +`config_json` is deep-merged into the global `~/.config/opencode/opencode.json`: keys you set win, and everything else in the file is preserved. If the existing file is not valid JSON (for example, JSONC with comments), it is backed up to `opencode.json.bak` and replaced. See the [OpenCode config reference](https://opencode.ai/docs/config/) for available keys. + +`auth_json` uses the format of `~/.local/share/opencode/auth.json` (the file `opencode providers login` writes). Its provider entries are merged into that file with mode `0600`, and other credentials on disk are preserved. The value is exported to the workspace as `CODER_OPENCODE_AUTH_JSON` so it is never rendered into the install script. Alternatively, skip `auth_json` and set the provider's API key environment variable (for example `ANTHROPIC_API_KEY`) with a `coder_env`. See [OpenCode providers](https://opencode.ai/docs/providers/). + +The module installs OpenCode with the [official installer](https://opencode.ai/install) into `~/.opencode/bin`. Set `opencode_version` to pin a release; when a different version is already installed, the installer runs again. With `latest`, the install is skipped when `opencode` is already on `PATH`. OpenCode updates itself on startup by default; set `autoupdate = false` in `config_json` or `managed_settings` to keep a pinned version. If `install_opencode = false`, a working `opencode` must already be available on `PATH`, or workspace startup fails. + ## Troubleshooting -If you encounter any issues, check the log files in the `~/.opencode-module` directory within your workspace for detailed information. +Check the log files in `~/.coder-modules/coder-labs/opencode/logs/` for detailed information. + +```bash +cat ~/.coder-modules/coder-labs/opencode/logs/install.log +cat ~/.coder-modules/coder-labs/opencode/logs/pre_install.log +cat ~/.coder-modules/coder-labs/opencode/logs/post_install.log +``` + +Run `opencode debug config` in the workspace to see the resolved configuration. ## References -- [Opencode JSON Config](https://opencode.ai/docs/config/) -- [OpenCode Documentation](https://opencode.ai/docs) -- [AgentAPI Documentation](https://github.com/coder/agentapi) -- [Coder AI Agents Guide](https://coder.com/docs/tutorials/ai-agents) +- [OpenCode documentation](https://opencode.ai/docs) +- [OpenCode config](https://opencode.ai/docs/config/) +- [OpenCode MCP servers](https://opencode.ai/docs/mcp-servers/) +- [OpenCode CLI](https://opencode.ai/docs/cli/) diff --git a/registry/coder-labs/modules/opencode/main.test.ts b/registry/coder-labs/modules/opencode/main.test.ts index dec42a592..f2bd4f47c 100644 --- a/registry/coder-labs/modules/opencode/main.test.ts +++ b/registry/coder-labs/modules/opencode/main.test.ts @@ -6,15 +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 dedent from "dedent"; +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 = `OpenCode: ${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) => { @@ -33,35 +85,106 @@ afterEach(async () => { }); interface SetupProps { - skipAgentAPIMock?: boolean; skipOpencodeMock?: 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_opencode: props?.skipOpencodeMock ? "true" : "false", - install_agentapi: props?.skipAgentAPIMock ? "true" : "false", - workdir: projectDir, - ...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_opencode: "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?.skipOpencodeMock) { await writeExecutable({ containerId: id, filePath: "/usr/bin/opencode", - content: await loadTestFile(import.meta.dir, "opencode-mock.sh"), + content: await Bun.file( + path.join(moduleDir, "testdata", "opencode-mock.sh"), + ).text(), }); } - return { id }; + 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}`); + } + } +}; + +const moduleLogDir = "/home/coder/.coder-modules/coder-labs/opencode/logs"; +const installLog = (id: string) => + readFileContainer(id, `${moduleLogDir}/install.log`); +const configPath = "/home/coder/.config/opencode/opencode.json"; +const authPath = "/home/coder/.local/share/opencode/auth.json"; +const managedPath = "/etc/opencode/opencode.json"; + +const seedFile = async (id: string, filePath: string, content: string) => { + const encoded = Buffer.from(content).toString("base64"); + await execContainer(id, [ + "bash", + "-c", + `mkdir -p "$(dirname '${filePath}')" && echo '${encoded}' | base64 -d > '${filePath}'`, + ]); }; setDefaultTimeout(60 * 1000); @@ -71,292 +194,297 @@ describe("opencode", async () => { await runTerraformInit(import.meta.dir); }); - test("happy-path", async () => { - const { id } = await setup(); - await execModuleScript(id); - await expectAgentAPIStarted(id); + 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 OpenCode installation"); + expect(log).toContain("Validated existing OpenCode"); + expect(log).toContain("0.0.0-mock"); + expect(log).toContain("OpenCode module setup completed."); + expect(log).not.toContain("agentapi"); + // Nothing is written when no config is provided. + const cfg = await execContainer(id, ["test", "-e", configPath]); + expect(cfg.exitCode).not.toBe(0); }); - test("install-opencode-version", async () => { - const version_to_install = "0.1.0"; - const { id } = await setup({ - skipOpencodeMock: true, - moduleVariables: { - install_opencode: "true", - opencode_version: version_to_install, - pre_install_script: dedent` - #!/usr/bin/env bash - set -euo pipefail - - # Mock the opencode install for testing - mkdir -p /home/coder/.opencode/bin - echo '#!/bin/bash\necho "opencode mock version ${version_to_install}"' > /home/coder/.opencode/bin/opencode - chmod +x /home/coder/.opencode/bin/opencode - `, - }, - }); - await execModuleScript(id); - const resp = await execContainer(id, [ - "bash", - "-c", - `cat /home/coder/.opencode-module/install.log`, - ]); - expect(resp.stdout).toContain(version_to_install); + test("preinstalled-binary-required-when-install-disabled", async () => { + const { id, scripts } = await setup({ skipOpencodeMock: 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("check-latest-opencode-version-works", async () => { - const { id } = await setup({ + test("official-installer-is-used", async () => { + const { id, scripts } = await setup({ skipOpencodeMock: true, - skipAgentAPIMock: true, - moduleVariables: { - install_opencode: "true", - pre_install_script: dedent` - #!/usr/bin/env bash - set -euo pipefail - - # Mock the opencode install for testing - mkdir -p /home/coder/.opencode/bin - echo '#!/bin/bash\necho "opencode mock latest version"' > /home/coder/.opencode/bin/opencode - chmod +x /home/coder/.opencode/bin/opencode - `, - }, + moduleVariables: { install_opencode: "true", opencode_version: "v1.2.3" }, }); - await execModuleScript(id); - await expectAgentAPIStarted(id); - }); - - test("opencode-auth-json", async () => { - const authJson = JSON.stringify({ - token: "test-auth-token-123", - user: "test-user", + await writeExecutable({ + containerId: id, + filePath: "/tmp/opencode-installer-fixture.sh", + content: [ + "#!/usr/bin/env bash", + 'printf "VERSION=%s ARGS=%s\\n" "$VERSION" "$*" > /tmp/opencode-installer-env', + 'mkdir -p "$HOME/.opencode/bin"', + `cat > "$HOME/.opencode/bin/opencode" <<'EOF'`, + "#!/bin/sh", + 'if [ "$1" = "--version" ]; then echo "1.2.3"; fi', + "EOF", + 'chmod +x "$HOME/.opencode/bin/opencode"', + ].join("\n"), }); - const { id } = await setup({ - moduleVariables: { - auth_json: authJson, - }, + await writeExecutable({ + containerId: id, + filePath: "/usr/local/bin/curl", + content: [ + "#!/bin/bash", + "printf '%s\\n' \"$*\" > /tmp/opencode-curl-args", + 'while [ $# -gt 0 ]; do if [ "$1" = "--output" ]; then out="$2"; fi; shift; done', + 'cp /tmp/opencode-installer-fixture.sh "$out"', + ].join("\n"), }); - await execModuleScript(id); - - const authFile = await readFileContainer( + await runScripts(id, scripts); + const log = await installLog(id); + expect(log).toContain("Installed OpenCode"); + expect(log).toContain("OpenCode version: 1.2.3"); + const curlArgs = await readFileContainer(id, "/tmp/opencode-curl-args"); + expect(curlArgs).toContain("https://opencode.ai/install"); + expect(curlArgs).toContain("--retry 2"); + expect(curlArgs).toContain("--connect-timeout 10"); + expect(curlArgs).toContain("--max-time 300"); + const installerEnv = await readFileContainer( id, - "/home/coder/.local/share/opencode/auth.json", + "/tmp/opencode-installer-env", ); - - expect(authFile).toContain("test-auth-token-123"); - expect(authFile).toContain("test-user"); + expect(installerEnv.trim()).toBe("VERSION=v1.2.3 ARGS=--no-modify-path"); + const bashrc = await readFileContainer(id, "/home/coder/.bashrc"); + expect(bashrc).toContain("/home/coder/.opencode/bin"); }); - test("opencode-config-json", async () => { - const configJson = JSON.stringify({ - $schema: "https://opencode.ai/config.json", - mcp: { - test: { - command: ["test-cmd"], - type: "local", - }, - }, - model: "anthropic/claude-sonnet-4-20250514", + test("latest-is-passed-to-installer-as-empty-version", async () => { + const { id, scripts } = await setup({ + skipOpencodeMock: true, + moduleVariables: { install_opencode: "true" }, }); - const { id } = await setup({ - moduleVariables: { - config_json: configJson, - }, + await writeExecutable({ + containerId: id, + filePath: "/tmp/opencode-installer-fixture.sh", + content: [ + "#!/usr/bin/env bash", + 'printf "VERSION=[%s]\\n" "$VERSION" > /tmp/opencode-installer-env', + 'mkdir -p "$HOME/.opencode/bin"', + 'printf "#!/bin/sh\\necho 9.9.9\\n" > "$HOME/.opencode/bin/opencode"', + 'chmod +x "$HOME/.opencode/bin/opencode"', + ].join("\n"), }); - await execModuleScript(id); - - const configFile = await readFileContainer( + await writeExecutable({ + containerId: id, + filePath: "/usr/local/bin/curl", + content: [ + "#!/bin/bash", + 'while [ $# -gt 0 ]; do if [ "$1" = "--output" ]; then out="$2"; fi; shift; done', + 'cp /tmp/opencode-installer-fixture.sh "$out"', + ].join("\n"), + }); + await runScripts(id, scripts); + const installerEnv = await readFileContainer( id, - "/home/coder/.config/opencode/opencode.json", + "/tmp/opencode-installer-env", ); - expect(configFile).toContain("test-cmd"); - expect(configFile).toContain("anthropic/claude-sonnet-4-20250514"); + expect(installerEnv.trim()).toBe("VERSION=[]"); }); - test("opencode-ai-prompt", async () => { - const prompt = "This is a task prompt for OpenCode."; - const { id } = await setup({ - moduleVariables: { - ai_prompt: prompt, - }, + test("installer-download-failure-is-terminal", async () => { + const { id, scripts } = await setup({ + skipOpencodeMock: true, + moduleVariables: { install_opencode: "true" }, }); - await execModuleScript(id); - - const resp = await execContainer(id, [ - "bash", - "-c", - `cat /home/coder/.opencode-module/agentapi-start.log`, - ]); - expect(resp.stdout).toContain(prompt); - }); - - test("opencode-continue-flag", async () => { - const { id } = await setup({ - moduleVariables: { - continue: "true", - ai_prompt: "test prompt", - }, + await writeExecutable({ + containerId: id, + filePath: "/usr/local/bin/curl", + content: "#!/bin/bash\nexit 22\n", }); - await execModuleScript(id); - - const startLog = await execContainer(id, [ - "bash", - "-c", - "cat /home/coder/.opencode-module/agentapi-start.log", - ]); - expect(startLog.stdout).toContain("--continue"); + 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("opencode-continue-with-session-id", async () => { - const sessionId = "session-123"; - const { id } = await setup({ - moduleVariables: { - continue: "true", - session_id: sessionId, - ai_prompt: "test prompt", - }, - }); - await execModuleScript(id); - - const startLog = await execContainer(id, [ - "bash", - "-c", - "cat /home/coder/.opencode-module/agentapi-start.log", - ]); - expect(startLog.stdout).toContain("--continue"); - expect(startLog.stdout).toContain(`--session ${sessionId}`); + 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("opencode-session-id", async () => { - const sessionId = "session-123"; - const { id } = await setup({ + test("config-json-merges-and-preserves-existing-keys", async () => { + const { id, scripts } = await setup({ moduleVariables: { - session_id: sessionId, - ai_prompt: "test prompt", + config_json: JSON.stringify({ + model: "anthropic/claude-sonnet-4-5", + provider: { anthropic: { options: { timeout: 600000 } } }, + }), }, }); - await execModuleScript(id); - - const startLog = await execContainer(id, [ - "bash", - "-c", - "cat /home/coder/.opencode-module/agentapi-start.log", - ]); - expect(startLog.stdout).toContain(`--session ${sessionId}`); + await seedFile( + id, + configPath, + JSON.stringify({ + model: "openai/gpt-5", + share: "manual", + provider: { anthropic: { options: { setCacheKey: true } } }, + }), + ); + await runScripts(id, scripts); + const cfg = JSON.parse(await readFileContainer(id, configPath)); + expect(cfg.model).toBe("anthropic/claude-sonnet-4-5"); + expect(cfg.share).toBe("manual"); + expect(cfg.provider.anthropic.options.timeout).toBe(600000); + expect(cfg.provider.anthropic.options.setCacheKey).toBe(true); + expect(cfg.$schema).toBe("https://opencode.ai/config.json"); }); - test("opencode-report-tasks-enabled", async () => { - const { id } = await setup({ + test("writes-mcp-servers-without-coder-server", async () => { + const { id, scripts } = await setup({ moduleVariables: { - report_tasks: "true", - ai_prompt: "test prompt", + mcp: JSON.stringify({ + playwright: { + type: "local", + command: ["npx", "-y", "@playwright/mcp"], + }, + }), }, }); - await execModuleScript(id); - - const startLog = await execContainer(id, [ - "bash", - "-c", - "cat /home/coder/.opencode-module/agentapi-start.log", + await runScripts(id, scripts); + const cfg = JSON.parse(await readFileContainer(id, configPath)); + expect(cfg.mcp.playwright.command).toEqual([ + "npx", + "-y", + "@playwright/mcp", ]); - expect(startLog.stdout).toContain( - "report your progress using coder_report_task", - ); + expect(cfg.mcp.coder).toBeUndefined(); }); - test("opencode-report-tasks-disabled", async () => { - const { id } = await setup({ + test("merges-mcp-config-existing-servers-win", async () => { + const { id, scripts } = await setup({ moduleVariables: { - report_tasks: "false", - ai_prompt: "test prompt", + mcp: JSON.stringify({ + shared: { type: "local", command: ["module-command"] }, + extra: { type: "local", command: ["extra-command"] }, + }), }, }); - await execModuleScript(id); - - const startLog = await execContainer(id, [ - "bash", - "-c", - "cat /home/coder/.opencode-module/agentapi-start.log", - ]); - expect(startLog.stdout).not.toContain( - "report your progress using coder_report_task", + await seedFile( + id, + configPath, + JSON.stringify({ + theme: "keep-me", + mcp: { + shared: { type: "local", command: ["existing-command"] }, + seeded: { type: "remote", url: "https://seeded.example.com" }, + }, + }), ); + await runScripts(id, scripts); + const cfg = JSON.parse(await readFileContainer(id, configPath)); + expect(cfg.theme).toBe("keep-me"); + expect(cfg.mcp.shared.command).toEqual(["existing-command"]); + expect(cfg.mcp.seeded.url).toBe("https://seeded.example.com"); + expect(cfg.mcp.extra.command).toEqual(["extra-command"]); }); - test("cli-app-creation", async () => { - const { id } = await setup({ + test("invalid-existing-config-is-backed-up", async () => { + const { id, scripts } = await setup({ moduleVariables: { - cli_app: "true", - cli_app_display_name: "OpenCode Terminal", + config_json: JSON.stringify({ share: "disabled" }), }, }); - await execModuleScript(id); - // CLI app creation is handled by the agentapi module - // We just verify the setup completed successfully - await expectAgentAPIStarted(id); + await seedFile(id, configPath, '{ // jsonc\n "share": "auto" }'); + await runScripts(id, scripts); + const cfg = JSON.parse(await readFileContainer(id, configPath)); + expect(cfg.share).toBe("disabled"); + const backup = await readFileContainer(id, `${configPath}.bak`); + expect(backup).toContain("// jsonc"); }); - test("pre-post-install-scripts", async () => { - const { id } = await setup({ + test("auth-json-from-env-merges-and-is-private", async () => { + const secret = "sk-ant-test-secret-123"; + const { id, coderEnvVars, scripts } = await setup({ moduleVariables: { - pre_install_script: "#!/bin/bash\necho 'opencode-pre-install-script'", - post_install_script: "#!/bin/bash\necho 'opencode-post-install-script'", + auth_json: JSON.stringify({ + anthropic: { type: "api", key: secret }, + }), }, }); - await execModuleScript(id); - - const preInstallLog = await readFileContainer( - id, - "/home/coder/.opencode-module/pre_install.log", + expect(coderEnvVars["CODER_OPENCODE_AUTH_JSON"]).toContain(secret); + expect(scripts.install).not.toContain(secret); + expect(scripts.install).not.toContain( + Buffer.from(coderEnvVars["CODER_OPENCODE_AUTH_JSON"]).toString("base64"), ); - expect(preInstallLog).toContain("opencode-pre-install-script"); - - const postInstallLog = await readFileContainer( + await seedFile( id, - "/home/coder/.opencode-module/post_install.log", + authPath, + JSON.stringify({ + anthropic: { type: "api", key: "old-key" }, + openai: { type: "api", key: "keep-me" }, + }), ); - expect(postInstallLog).toContain("opencode-post-install-script"); + await runScripts(id, scripts, coderEnvVars); + const auth = JSON.parse(await readFileContainer(id, authPath)); + expect(auth.anthropic.key).toBe(secret); + expect(auth.openai.key).toBe("keep-me"); + const mode = await execContainer(id, ["stat", "-c", "%a", authPath]); + expect(mode.stdout.trim()).toBe("600"); + const log = await installLog(id); + expect(log).not.toContain(secret); }); - test("workdir-variable", async () => { - const workdir = "/home/coder/opencode-test-folder"; - const { id } = await setup({ - skipOpencodeMock: false, - moduleVariables: { - workdir, - }, - }); - await execModuleScript(id); - - const resp = await readFileContainer( - id, - "/home/coder/.opencode-module/agentapi-start.log", - ); - expect(resp).toContain(workdir); + test("no-auth-json-leaves-auth-file-absent", async () => { + const { id, scripts } = await setup(); + await runScripts(id, scripts); + const resp = await execContainer(id, ["test", "-e", authPath]); + expect(resp.exitCode).not.toBe(0); }); - test("subdomain-enabled", async () => { - const { id } = await setup({ + test("managed-settings-written-as-root", async () => { + const { id, scripts } = await setup({ moduleVariables: { - subdomain: "true", + managed_settings: JSON.stringify({ share: "disabled" }), }, }); - await execModuleScript(id); - // Subdomain configuration is handled by the agentapi module - // We just verify the setup completed successfully - await expectAgentAPIStarted(id); + await runScripts(id, scripts); + const managed = JSON.parse(await readFileContainer(id, managedPath)); + expect(managed.share).toBe("disabled"); + const owner = await execContainer(id, ["stat", "-c", "%U %a", managedPath]); + expect(owner.stdout.trim()).toBe("root 644"); + const log = await installLog(id); + expect(log).toContain(`Wrote OpenCode managed settings to ${managedPath}`); + }); + + test("managed-settings-absent-when-unset", async () => { + const { id, scripts } = await setup(); + await runScripts(id, scripts); + const resp = await execContainer(id, ["test", "-e", managedPath]); + expect(resp.exitCode).not.toBe(0); + const log = await installLog(id); + expect(log).not.toContain("managed settings"); }); - test("custom-display-names", async () => { - const { id } = await setup({ + test("pre-post-install-scripts", async () => { + const { id, scripts } = await setup({ moduleVariables: { - web_app_display_name: "Custom OpenCode Web", - cli_app_display_name: "Custom OpenCode CLI", - cli_app: "true", + pre_install_script: "#!/bin/bash\necho 'opencode-pre-install-script'", + post_install_script: "#!/bin/bash\necho 'opencode-post-install-script'", }, }); - await execModuleScript(id); - // Display names are handled by the agentapi module - // We just verify the setup completed successfully - await expectAgentAPIStarted(id); + await runScripts(id, scripts); + expect( + await readFileContainer(id, `${moduleLogDir}/pre_install.log`), + ).toContain("opencode-pre-install-script"); + expect( + await readFileContainer(id, `${moduleLogDir}/post_install.log`), + ).toContain("opencode-post-install-script"); }); }); diff --git a/registry/coder-labs/modules/opencode/main.tf b/registry/coder-labs/modules/opencode/main.tf index 880417669..3afff0ca6 100644 --- a/registry/coder-labs/modules/opencode/main.tf +++ b/registry/coder-labs/modules/opencode/main.tf @@ -14,55 +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/opencode.svg" } variable "workdir" { type = string - description = "The folder to run OpenCode in." -} - -variable "report_tasks" { - type = bool - description = "Whether to enable task reporting to Coder UI via AgentAPI" - default = true -} - -variable "cli_app" { - type = bool - description = "Whether to create a CLI app for OpenCode" - default = false -} - -variable "web_app_display_name" { - type = string - description = "Display name for the web app" - default = "OpenCode" -} - -variable "cli_app_display_name" { - type = string - description = "Display name for the CLI app" - default = "OpenCode CLI" + description = "Optional project directory. When set, the module pre-creates it if missing. OpenCode has no workspace trust prompt, so nothing else is written for it." + default = null } variable "pre_install_script" { @@ -77,127 +38,107 @@ variable "post_install_script" { default = null } -variable "install_agentapi" { +variable "install_opencode" { type = bool - description = "Whether to install AgentAPI." + description = "Whether to install OpenCode with the official installer (https://opencode.ai/install). When false, a working opencode must already be on PATH." default = true } -variable "agentapi_version" { +variable "opencode_version" { type = string - description = "The version of AgentAPI to install." - default = "v0.11.2" + description = "The version of OpenCode to install, for example '1.18.33'. Use 'latest' for the newest release. See https://github.com/anomalyco/opencode/releases" + default = "latest" + + validation { + condition = can(regex("^(latest|v?[0-9]+\\.[0-9]+\\.[0-9]+[0-9A-Za-z.+-]*)$", var.opencode_version)) + error_message = "opencode_version must be 'latest' or a release version such as '1.18.33'." + } } -variable "ai_prompt" { +variable "auth_json" { type = string - description = "Initial task prompt for OpenCode." + description = "OpenCode credentials in the format of $HOME/.local/share/opencode/auth.json. Merged into that file (module-provided providers win, other providers are preserved). Exported to the workspace as CODER_OPENCODE_AUTH_JSON so it is never rendered into the install script. See https://opencode.ai/docs/providers/#credentials" + sensitive = true default = "" -} - -variable "subdomain" { - type = bool - description = "Whether to use a subdomain for AgentAPI." - default = false -} -variable "install_opencode" { - type = bool - description = "Whether to install OpenCode." - default = true + validation { + condition = var.auth_json == "" || can(keys(jsondecode(var.auth_json))) + error_message = "auth_json must be a JSON object keyed by provider ID." + } } -variable "opencode_version" { +variable "config_json" { type = string - description = "The version of OpenCode to install." - default = "latest" -} + description = "OpenCode config as JSON, deep-merged into the global $HOME/.config/opencode/opencode.json. Keys set here win; other keys on disk are preserved. Configure MCP servers with the mcp variable instead. See https://opencode.ai/docs/config/" + default = "" -variable "continue" { - type = bool - description = "continue the last session. Uses the --continue flag" - default = false + validation { + condition = var.config_json == "" || can(keys(jsondecode(var.config_json))) + error_message = "config_json must be a JSON object." + } + + validation { + condition = var.config_json == "" || !can(jsondecode(var.config_json).mcp) + error_message = "config_json must not contain an mcp key; use the mcp variable for MCP servers." + } } -variable "session_id" { +variable "mcp" { type = string - description = "Session id to continue. Passed via --session" + description = "MCP servers as a JSON object keyed by server name, in the format of the mcp key of opencode.json. Merged into $HOME/.config/opencode/opencode.json; servers already on disk win on duplicate names. See https://opencode.ai/docs/mcp-servers/" default = "" + + validation { + condition = var.mcp == "" || can(keys(jsondecode(var.mcp))) + error_message = "mcp must be a JSON object keyed by MCP server name." + } } -variable "auth_json" { - type = string - description = "Your auth.json from $HOME/.local/share/opencode/auth.json, Required for non-interactive authentication" - default = "" +variable "managed_settings" { + type = any + description = "OpenCode config written as root to /etc/opencode/opencode.json. Managed config has the highest precedence and cannot be overridden by user or project config. See https://opencode.ai/docs/config/#managed-settings" + default = null + + validation { + condition = var.managed_settings == null || can(keys(var.managed_settings)) + error_message = "managed_settings must be an object." + } } -variable "config_json" { - type = string - description = "OpenCode JSON config. https://opencode.ai/docs/config/" - default = "" +resource "coder_env" "opencode_auth_json" { + count = var.auth_json != "" ? 1 : 0 + agent_id = var.agent_id + name = "CODER_OPENCODE_AUTH_JSON" + value = var.auth_json } locals { - workdir = trimsuffix(var.workdir, "/") - app_slug = "opencode" - install_script = file("${path.module}/scripts/install.sh") - start_script = file("${path.module}/scripts/start.sh") - module_dir_name = ".opencode-module" -} - -module "agentapi" { - source = "registry.coder.com/coder/agentapi/coder" - version = "2.0.0" - - agent_id = var.agent_id - web_app_slug = local.app_slug - web_app_order = var.order - web_app_group = var.group - web_app_icon = var.icon - web_app_display_name = 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 - folder = local.workdir - 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_WORKDIR='${local.workdir}' \ - ARG_AI_PROMPT='${base64encode(var.ai_prompt)}' \ - ARG_SESSION_ID='${var.session_id}' \ - ARG_REPORT_TASKS='${var.report_tasks}' \ - ARG_CONTINUE='${var.continue}' \ - /tmp/start.sh - EOT - - install_script = <<-EOT - #!/usr/bin/env bash - set -o errexit - set -o pipefail - - echo -n '${base64encode(local.install_script)}' | base64 -d > /tmp/install.sh - chmod +x /tmp/install.sh - ARG_OPENCODE_VERSION='${var.opencode_version}' \ - ARG_MCP_APP_STATUS_SLUG='${local.app_slug}' \ - ARG_INSTALL_OPENCODE='${var.install_opencode}' \ - ARG_REPORT_TASKS='${var.report_tasks}' \ - ARG_WORKDIR='${local.workdir}' \ - ARG_AUTH_JSON='${var.auth_json != null ? base64encode(replace(var.auth_json, "'", "'\\''")) : ""}' \ - ARG_OPENCODE_CONFIG='${var.config_json != null ? base64encode(replace(var.config_json, "'", "'\\''")) : ""}' \ - /tmp/install.sh - EOT -} - -output "task_app_id" { - value = module.agentapi.task_app_id + workdir = var.workdir != null ? trimsuffix(var.workdir, "/") : "" + install_script = templatefile("${path.module}/scripts/install.sh.tftpl", { + ARG_INSTALL = tostring(var.install_opencode) + ARG_OPENCODE_VERSION = var.opencode_version + ARG_WORKDIR = local.workdir != "" ? base64encode(local.workdir) : "" + ARG_CONFIG_JSON = var.config_json != "" ? base64encode(var.config_json) : "" + ARG_MCP_CONFIG = var.mcp != "" ? base64encode(var.mcp) : "" + ARG_MANAGED_SETTINGS_JSON = var.managed_settings != null ? base64encode(jsonencode(var.managed_settings)) : "" + }) + module_dir_name = ".coder-modules/coder-labs/opencode" +} + +module "coder_utils" { + source = "registry.coder.com/coder/coder-utils/coder" + version = "0.0.1" + + agent_id = var.agent_id + module_directory = "$HOME/${local.module_dir_name}" + display_name_prefix = "OpenCode" + 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-labs/modules/opencode/main.tftest.hcl b/registry/coder-labs/modules/opencode/main.tftest.hcl index e8c7954b7..810f8879a 100644 --- a/registry/coder-labs/modules/opencode/main.tftest.hcl +++ b/registry/coder-labs/modules/opencode/main.tftest.hcl @@ -3,76 +3,40 @@ run "defaults_are_correct" { variables { agent_id = "test-agent" - workdir = "/home/coder/project" } assert { condition = var.install_opencode == true - error_message = "OpenCode installation should be enabled by default" - } - - assert { - condition = var.install_agentapi == true - error_message = "AgentAPI installation should be enabled by default" - } - - assert { - condition = var.agentapi_version == "v0.11.2" - error_message = "Default AgentAPI version should be 'v0.11.2'" + error_message = "install_opencode should default to true" } assert { condition = var.opencode_version == "latest" - error_message = "Default OpenCode version should be 'latest'" - } - - assert { - condition = var.report_tasks == true - error_message = "Task reporting should be enabled by default" + error_message = "opencode_version should default to 'latest'" } assert { - condition = var.cli_app == false - error_message = "CLI app should be disabled by default" + condition = local.workdir == "" + error_message = "workdir should be empty by default" } assert { - condition = var.subdomain == false - error_message = "Subdomain should be disabled by default" + condition = local.module_dir_name == ".coder-modules/coder-labs/opencode" + error_message = "module_dir_name should be '.coder-modules/coder-labs/opencode'" } assert { - condition = var.web_app_display_name == "OpenCode" - error_message = "Default web app display name should be 'OpenCode'" + condition = length(coder_env.opencode_auth_json) == 0 + error_message = "CODER_OPENCODE_AUTH_JSON should not be created when auth_json is empty" } assert { - condition = var.cli_app_display_name == "OpenCode CLI" - error_message = "Default CLI app display name should be 'OpenCode CLI'" - } - - assert { - condition = local.app_slug == "opencode" - error_message = "App slug should be 'opencode'" - } - - assert { - condition = local.module_dir_name == ".opencode-module" - error_message = "Module dir name should be '.opencode-module'" - } - - assert { - condition = local.workdir == "/home/coder/project" - error_message = "Workdir should be trimmed of trailing slash" - } - - assert { - condition = var.continue == false - error_message = "Continue flag should be disabled by default" + condition = strcontains(local.install_script, "ARG_OPENCODE_VERSION='latest'") + error_message = "install script should receive the opencode version" } } -run "workdir_trailing_slash_trimmed" { +run "workdir_trailing_slash_is_trimmed" { command = plan variables { @@ -82,293 +46,186 @@ run "workdir_trailing_slash_trimmed" { assert { condition = local.workdir == "/home/coder/project" - error_message = "Workdir should be trimmed of trailing slash" - } -} - -run "opencode_version_configuration" { - command = plan - - variables { - agent_id = "test-agent" - workdir = "/home/coder/project" - opencode_version = "v1.0.0" + error_message = "workdir should have its trailing slash trimmed" } assert { - condition = var.opencode_version == "v1.0.0" - error_message = "OpenCode version should be set correctly" + condition = strcontains(local.install_script, base64encode("/home/coder/project")) + error_message = "install script should receive the base64-encoded workdir" } } -run "agentapi_version_configuration" { +run "auth_json_creates_env_var" { command = plan variables { - agent_id = "test-agent" - workdir = "/home/coder/project" - agentapi_version = "v0.9.0" - } - - assert { - condition = var.agentapi_version == "v0.9.0" - error_message = "AgentAPI version should be set correctly" - } -} - -run "cli_app_configuration" { - command = plan - - variables { - agent_id = "test-agent" - workdir = "/home/coder/project" - cli_app = true - cli_app_display_name = "Custom OpenCode CLI" + agent_id = "test-agent" + auth_json = "{\"anthropic\": {\"type\": \"api\", \"key\": \"sk-ant-test-secret\"}}" } assert { - condition = var.cli_app == true - error_message = "CLI app should be enabled when specified" + condition = coder_env.opencode_auth_json[0].name == "CODER_OPENCODE_AUTH_JSON" && coder_env.opencode_auth_json[0].value == var.auth_json + error_message = "CODER_OPENCODE_AUTH_JSON env var should be created with the provided auth_json" } assert { - condition = var.cli_app_display_name == "Custom OpenCode CLI" - error_message = "Custom CLI app display name should be set" + condition = !strcontains(local.install_script, "sk-ant-test-secret") && !strcontains(local.install_script, base64encode(nonsensitive(var.auth_json))) + error_message = "auth_json should not be rendered into the install script" } } -run "web_app_configuration" { +run "config_and_mcp_are_encoded" { command = plan variables { - agent_id = "test-agent" - workdir = "/home/coder/project" - web_app_display_name = "Custom OpenCode Web" - order = 5 - group = "AI Tools" - icon = "/custom/icon.svg" - } - - assert { - condition = var.web_app_display_name == "Custom OpenCode Web" - error_message = "Custom web app display name should be set" - } - - assert { - condition = var.order == 5 - error_message = "Custom order should be set" + agent_id = "test-agent" + config_json = "{\"model\": \"anthropic/claude-sonnet-4-5\"}" + mcp = "{\"playwright\": {\"type\": \"local\", \"command\": [\"npx\", \"-y\", \"@playwright/mcp\"]}}" } assert { - condition = var.group == "AI Tools" - error_message = "Custom group should be set" + condition = strcontains(local.install_script, base64encode(var.config_json)) + error_message = "install script should receive the base64-encoded config_json" } assert { - condition = var.icon == "/custom/icon.svg" - error_message = "Custom icon should be set" + condition = strcontains(local.install_script, base64encode(var.mcp)) + error_message = "install script should receive the base64-encoded mcp" } } -run "ai_configuration_variables" { +run "managed_settings_are_encoded" { command = plan variables { - agent_id = "test-agent" - workdir = "/home/coder/project" - ai_prompt = "This is a test prompt" - session_id = "session-123" - continue = true - } - - assert { - condition = var.ai_prompt == "This is a test prompt" - error_message = "AI prompt should be set correctly" - } - - assert { - condition = var.session_id == "session-123" - error_message = "Session ID should be set correctly" + agent_id = "test-agent" + managed_settings = { + share = "disabled" + } } assert { - condition = var.continue == true - error_message = "Continue flag should be set correctly" + condition = strcontains(local.install_script, base64encode(jsonencode({ share = "disabled" }))) + error_message = "install script should receive the base64-encoded managed_settings" } } -run "auth_json_configuration" { +run "invalid_auth_json_fails" { command = plan variables { agent_id = "test-agent" - workdir = "/home/coder/project" - auth_json = "{\"token\": \"test-token\", \"user\": \"test-user\"}" - } - - assert { - condition = var.auth_json != "" - error_message = "Auth JSON should be set" + auth_json = "not-json" } - assert { - condition = can(jsondecode(var.auth_json)) - error_message = "Auth JSON should be valid JSON" - } + expect_failures = [ + var.auth_json, + ] } -run "config_json_configuration" { +run "invalid_opencode_version_fails" { command = plan variables { - agent_id = "test-agent" - workdir = "/home/coder/project" - config_json = "{\"$schema\": \"https://opencode.ai/config.json\", \"mcp\": {\"test\": {\"command\": [\"test-cmd\"], \"type\": \"local\"}}, \"model\": \"anthropic/claude-sonnet-4-20250514\"}" - } - - assert { - condition = var.config_json != "" - error_message = "OpenCode JSON configuration should be set" + agent_id = "test-agent" + opencode_version = "1.0'; rm -rf ~; '" } - assert { - condition = can(jsondecode(var.config_json)) - error_message = "OpenCode JSON configuration should be valid JSON" - } + expect_failures = [ + var.opencode_version, + ] } -run "task_reporting_configuration" { +run "pinned_opencode_version_is_accepted" { command = plan variables { - agent_id = "test-agent" - workdir = "/home/coder/project" - report_tasks = false + agent_id = "test-agent" + opencode_version = "v1.18.33" } assert { - condition = var.report_tasks == false - error_message = "Task reporting should be disabled when specified" + condition = strcontains(local.install_script, "ARG_OPENCODE_VERSION='v1.18.33'") + error_message = "install script should receive the pinned opencode version" } } -run "subdomain_configuration" { +run "invalid_config_json_fails" { command = plan variables { - agent_id = "test-agent" - workdir = "/home/coder/project" - subdomain = true + agent_id = "test-agent" + config_json = "[1, 2]" } - assert { - condition = var.subdomain == true - error_message = "Subdomain should be enabled when specified" - } + expect_failures = [ + var.config_json, + ] } -run "install_flags_configuration" { +run "config_json_with_mcp_fails" { command = plan variables { - agent_id = "test-agent" - workdir = "/home/coder/project" - install_opencode = false - install_agentapi = false - } - - assert { - condition = var.install_opencode == false - error_message = "OpenCode installation should be disabled when specified" + agent_id = "test-agent" + config_json = "{\"mcp\": {\"x\": {\"type\": \"remote\", \"url\": \"https://example.com\"}}}" } - assert { - condition = var.install_agentapi == false - error_message = "AgentAPI installation should be disabled when specified" - } + expect_failures = [ + var.config_json, + ] } -run "custom_scripts_configuration" { +run "invalid_mcp_fails" { command = plan variables { - agent_id = "test-agent" - workdir = "/home/coder/project" - pre_install_script = "#!/bin/bash\necho 'pre-install'" - post_install_script = "#!/bin/bash\necho 'post-install'" - } - - assert { - condition = var.pre_install_script != null - error_message = "Pre-install script should be set" - } - - assert { - condition = var.post_install_script != null - error_message = "Post-install script should be set" - } - - assert { - condition = can(regex("pre-install", var.pre_install_script)) - error_message = "Pre-install script should contain expected content" + agent_id = "test-agent" + mcp = "[\"x\"]" } - assert { - condition = can(regex("post-install", var.post_install_script)) - error_message = "Post-install script should contain expected content" - } + expect_failures = [ + var.mcp, + ] } -run "empty_variables_handled_correctly" { +run "invalid_managed_settings_fails" { command = plan variables { - agent_id = "test-agent" - workdir = "/home/coder/project" - ai_prompt = "" - session_id = "" - auth_json = "" - config_json = "" - continue = false - } - - assert { - condition = var.ai_prompt == "" - error_message = "Empty AI prompt should be handled correctly" + agent_id = "test-agent" + managed_settings = "share=disabled" } - assert { - condition = var.session_id == "" - error_message = "Empty session ID should be handled correctly" - } + expect_failures = [ + var.managed_settings, + ] +} - assert { - condition = var.auth_json == "" - error_message = "Empty auth JSON should be handled correctly" - } +run "scripts_output_is_ordered" { + command = plan - assert { - condition = var.config_json == "" - error_message = "Empty config JSON should be handled correctly" + variables { + agent_id = "test-agent" + pre_install_script = "echo pre" + post_install_script = "echo post" } assert { - condition = var.continue == false - error_message = "Continue flag default should be handled correctly" + condition = output.scripts == ["coder-labs-opencode-pre_install_script", "coder-labs-opencode-install_script", "coder-labs-opencode-post_install_script"] + error_message = "scripts output should be ordered pre_install, install, post_install" } } -run "continue_flag_configuration" { +run "scripts_output_install_only" { command = plan variables { agent_id = "test-agent" - workdir = "/home/coder/project" - continue = true } assert { - condition = var.continue == true - error_message = "Continue flag should be enabled when specified" + condition = output.scripts == ["coder-labs-opencode-install_script"] + error_message = "scripts output should only contain install when no pre/post scripts are set" } -} \ No newline at end of file +} diff --git a/registry/coder-labs/modules/opencode/scripts/install.sh b/registry/coder-labs/modules/opencode/scripts/install.sh deleted file mode 100755 index 280d2a9aa..000000000 --- a/registry/coder-labs/modules/opencode/scripts/install.sh +++ /dev/null @@ -1,131 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -command_exists() { - command -v "$1" > /dev/null 2>&1 -} - -ARG_WORKDIR=${ARG_WORKDIR:-"$HOME"} -ARG_REPORT_TASKS=${ARG_REPORT_TASKS:-true} -ARG_MCP_APP_STATUS_SLUG=${ARG_MCP_APP_STATUS_SLUG:-} -ARG_OPENCODE_VERSION=${ARG_OPENCODE_VERSION:-latest} -ARG_INSTALL_OPENCODE=${ARG_INSTALL_OPENCODE:-true} -ARG_AUTH_JSON=$(echo -n "$ARG_AUTH_JSON" | base64 -d 2> /dev/null || echo "") -ARG_OPENCODE_CONFIG=$(echo -n "$ARG_OPENCODE_CONFIG" | base64 -d 2> /dev/null || echo "") - -# Print all received environment variables -printf "=== INSTALL CONFIG ===\n" -printf "ARG_WORKDIR: %s\n" "$ARG_WORKDIR" -printf "ARG_REPORT_TASKS: %s\n" "$ARG_REPORT_TASKS" -printf "ARG_MCP_APP_STATUS_SLUG: %s\n" "$ARG_MCP_APP_STATUS_SLUG" -printf "ARG_OPENCODE_VERSION: %s\n" "$ARG_OPENCODE_VERSION" -printf "ARG_INSTALL_OPENCODE: %s\n" "$ARG_INSTALL_OPENCODE" -if [ -n "$ARG_AUTH_JSON" ]; then - printf "ARG_AUTH_JSON: [AUTH DATA RECEIVED]\n" -else - printf "ARG_AUTH_JSON: [NOT PROVIDED]\n" -fi -if [ -n "$ARG_OPENCODE_CONFIG" ]; then - printf "ARG_OPENCODE_CONFIG: [RECEIVED]\n" -else - printf "ARG_OPENCODE_CONFIG: [NOT PROVIDED]\n" -fi -printf "==================================\n" - -install_opencode() { - if [ "$ARG_INSTALL_OPENCODE" = "true" ]; then - if ! command_exists opencode; then - echo "Installing OpenCode (version: ${ARG_OPENCODE_VERSION})..." - if [ "$ARG_OPENCODE_VERSION" = "latest" ]; then - curl -fsSL https://opencode.ai/install | bash - else - curl -fsSL https://opencode.ai/install | VERSION="${ARG_OPENCODE_VERSION}" bash - fi - export PATH=/home/coder/.opencode/bin:$PATH - printf "Opencode location: %s\n" "$(which opencode)" - if ! command_exists opencode; then - echo "ERROR: Failed to install OpenCode" - exit 1 - fi - echo "OpenCode installed successfully" - else - echo "OpenCode already installed" - fi - else - echo "OpenCode installation skipped (ARG_INSTALL_OPENCODE=false)" - fi -} - -setup_opencode_config() { - local opencode_config_file="$HOME/.config/opencode/opencode.json" - local auth_json_file="$HOME/.local/share/opencode/auth.json" - - mkdir -p "$(dirname "$auth_json_file")" - mkdir -p "$(dirname "$opencode_config_file")" - - setup_opencode_auth "$auth_json_file" - - if [ -n "$ARG_OPENCODE_CONFIG" ]; then - echo "Writing to the config file" - echo "$ARG_OPENCODE_CONFIG" > "$opencode_config_file" - fi - - if [ "$ARG_REPORT_TASKS" = "true" ]; then - setup_coder_mcp_server "$opencode_config_file" - fi - - echo "MCP configuration completed: $opencode_config_file" -} - -setup_opencode_auth() { - local auth_json_file="$1" - - if [ -n "$ARG_AUTH_JSON" ]; then - echo "$ARG_AUTH_JSON" > "$auth_json_file" - printf "added auth json to %s" "$auth_json_file" - else - printf "auth json not provided" - fi -} - -setup_coder_mcp_server() { - local opencode_config_file="$1" - - # Set environment variables based on task reporting setting - echo "Configuring OpenCode task reporting" - export CODER_MCP_APP_STATUS_SLUG="$ARG_MCP_APP_STATUS_SLUG" - export CODER_MCP_AI_AGENTAPI_URL="http://localhost:3284" - echo "Coder integration configured for task reporting" - - # Add coder MCP server configuration to the JSON file - echo "Adding Coder MCP server configuration" - - # Create the coder server configuration JSON - coder_config=$( - cat << EOF -{ - "type": "local", - "command": ["coder", "exp", "mcp", "server"], - "enabled": true, - "environment": { - "CODER_MCP_APP_STATUS_SLUG": "${CODER_MCP_APP_STATUS_SLUG:-}", - "CODER_MCP_AI_AGENTAPI_URL": "${CODER_MCP_AI_AGENTAPI_URL:-}", - "CODER_AGENT_URL": "${CODER_AGENT_URL:-}", - "CODER_AGENT_TOKEN": "${CODER_AGENT_TOKEN:-}", - "CODER_MCP_ALLOWED_TOOLS": "coder_report_task" - } -} -EOF - ) - - temp_file=$(mktemp) - jq --argjson coder_config "$coder_config" '.mcp.coder = $coder_config' "$opencode_config_file" > "$temp_file" - mv "$temp_file" "$opencode_config_file" - echo "Coder MCP server configuration added" - -} - -install_opencode -setup_opencode_config - -echo "OpenCode module setup completed." diff --git a/registry/coder-labs/modules/opencode/scripts/install.sh.tftpl b/registry/coder-labs/modules/opencode/scripts/install.sh.tftpl new file mode 100644 index 000000000..f911cf88b --- /dev/null +++ b/registry/coder-labs/modules/opencode/scripts/install.sh.tftpl @@ -0,0 +1,273 @@ +#!/usr/bin/env bash + +set -euo pipefail + +BOLD='\033[0;1m' + +command_exists() { + command -v "$1" > /dev/null 2>&1 +} + +ARG_INSTALL='${ARG_INSTALL}' +ARG_OPENCODE_VERSION='${ARG_OPENCODE_VERSION}' +ARG_WORKDIR=$(echo -n '${ARG_WORKDIR}' | base64 -d) +ARG_CONFIG_JSON=$(echo -n '${ARG_CONFIG_JSON}' | base64 -d) +ARG_MCP_CONFIG=$(echo -n '${ARG_MCP_CONFIG}' | base64 -d) +ARG_MANAGED_SETTINGS_JSON=$(echo -n '${ARG_MANAGED_SETTINGS_JSON}' | base64 -d) + +# Delivered through coder_env so credentials never appear in this script. +AUTH_JSON="$${CODER_OPENCODE_AUTH_JSON:-}" + +OPENCODE_BIN_DIR="$HOME/.opencode/bin" +export PATH="$${OPENCODE_BIN_DIR}:$PATH" + +echo "--------------------------------" +printf "install_opencode: %s\n" "$${ARG_INSTALL}" +printf "opencode_version: %s\n" "$${ARG_OPENCODE_VERSION}" +printf "workdir: %s\n" "$${ARG_WORKDIR}" +printf "auth_json: %s\n" "$([ -n "$${AUTH_JSON}" ] && echo provided || echo "not provided")" +echo "--------------------------------" + +require_jq() { + if ! command_exists jq; then + echo "ERROR: 'jq' is required to $1 but was not found." >&2 + exit 1 + fi +} + +# Prints the JSON object stored in $1, or {} when the file is missing. Invalid +# files (for example JSONC with comments) are backed up to $1.bak. +read_json_object() { + local file="$1" + if [ ! -f "$${file}" ]; then + echo '{}' + return + fi + if jq -e 'type == "object"' "$${file}" > /dev/null 2>&1; then + cat "$${file}" + return + fi + cp "$${file}" "$${file}.bak" + echo "Warning: $${file} is not a valid JSON object; backed it up to $${file}.bak" >&2 + 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_opencode_in_path() { + local OPENCODE_BIN="" + if command_exists opencode; then + OPENCODE_BIN=$(command -v opencode) + fi + + if [ -z "$${OPENCODE_BIN}" ] || [ ! -x "$${OPENCODE_BIN}" ]; then + echo "OpenCode binary (opencode) was not found or is not executable." >&2 + return 1 + fi + + local OPENCODE_VERSION + if ! OPENCODE_VERSION=$("$${OPENCODE_BIN}" --version); then + echo "OpenCode binary could not be executed." >&2 + return 1 + fi + printf "OpenCode version: %s\n" "$${OPENCODE_VERSION}" + + if [ -n "$${CODER_SCRIPT_BIN_DIR:-}" ] && [ ! -e "$${CODER_SCRIPT_BIN_DIR}/opencode" ]; then + ln -s "$${OPENCODE_BIN}" "$${CODER_SCRIPT_BIN_DIR}/opencode" + echo "Created symlink: $${CODER_SCRIPT_BIN_DIR}/opencode -> $${OPENCODE_BIN}" + fi + + add_path_to_shell_profiles "$(dirname "$${OPENCODE_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://opencode.ai/install; then + echo "OpenCode installer could not be downloaded after up to 3 attempts." >&2 + return 1 + fi + + if ! bash -n "$${installer_file}"; then + echo "OpenCode installer download was invalid." >&2 + return 1 + fi + + # The installer treats any non-empty VERSION as a release tag (a leading 'v' + # is stripped), so 'latest' must be passed as empty. It installs to + # ~/.opencode/bin; PATH is handled by this script. + local version="$${ARG_OPENCODE_VERSION}" + if [ "$${version}" = "latest" ]; then + version="" + fi + if ! VERSION="$${version}" bash "$${installer_file}" --no-modify-path; then + echo "OpenCode installation failed." >&2 + return 1 + fi +) + +install_opencode() { + if [ "$${ARG_INSTALL}" != "true" ]; then + echo "Skipping OpenCode installation as per configuration." + if ! ensure_opencode_in_path; then + echo "ERROR: install_opencode is false but a working 'opencode' was not found on PATH." >&2 + exit 1 + fi + printf "%s Validated existing OpenCode\n" "$${BOLD}" + return + fi + + if command_exists opencode; then + local installed wanted + installed=$(opencode --version 2> /dev/null || true) + wanted="$${ARG_OPENCODE_VERSION#v}" + if [ "$${ARG_OPENCODE_VERSION}" = "latest" ] || [ "$${installed}" = "$${wanted}" ]; then + echo "OpenCode already installed ($${installed})" + ensure_opencode_in_path + return + fi + echo "OpenCode $${installed} is installed; installing $${wanted}" + fi + + printf "%s Installing OpenCode\n" "$${BOLD}" + install_with_official_installer + + if ! ensure_opencode_in_path; then + echo "ERROR: Failed to install OpenCode" >&2 + exit 1 + fi + printf "%s Installed OpenCode\n" "$${BOLD}" +} + +setup_workdir() { + if [ -n "$${ARG_WORKDIR}" ] && [ ! -d "$${ARG_WORKDIR}" ]; then + echo "Creating workdir: $${ARG_WORKDIR}" + mkdir -p "$${ARG_WORKDIR}" + fi +} + +# Managed config is loaded last and cannot be overridden by user or project config. +write_managed_settings() { + if [ -z "$${ARG_MANAGED_SETTINGS_JSON}" ]; then + return + fi + + require_jq "write 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/opencode/opencode.json" + if [ "$(id -u)" -eq 0 ]; then + mkdir -p "$(dirname "$${target}")" + echo "$${ARG_MANAGED_SETTINGS_JSON}" > "$${target}" + chmod 0644 "$${target}" + elif command_exists sudo && sudo -n true 2> /dev/null; then + sudo mkdir -p "$(dirname "$${target}")" + echo "$${ARG_MANAGED_SETTINGS_JSON}" | sudo tee "$${target}" > /dev/null + sudo chmod 0644 "$${target}" + else + echo "Warning: root access is required to write $${target}; skipping managed settings" >&2 + return + fi + echo "Wrote OpenCode managed settings to $${target}" +} + +# The module owns only the keys it sets; everything else in opencode.json is +# preserved. config_json keys win, while MCP servers already on disk win on +# duplicate names so in-workspace edits are not overwritten. +setup_config() { + local config_file="$HOME/.config/opencode/opencode.json" + + if [ -z "$${ARG_CONFIG_JSON}" ] && [ -z "$${ARG_MCP_CONFIG}" ]; then + echo "No config_json or mcp configured; leaving $${config_file} unchanged." + return + fi + + require_jq "configure OpenCode" + + mkdir -p "$(dirname "$${config_file}")" + + local existing config='{}' mcp='{}' + existing=$(read_json_object "$${config_file}") + if [ -n "$${ARG_CONFIG_JSON}" ]; then + config="$${ARG_CONFIG_JSON}" + fi + if [ -n "$${ARG_MCP_CONFIG}" ]; then + mcp="$${ARG_MCP_CONFIG}" + fi + + echo "$${existing}" | jq --argjson config "$${config}" --argjson mcp "$${mcp}" ' + (. * $config) + | if ($mcp | length) > 0 then .mcp = ($mcp + (.mcp // {})) else . end + | .["$schema"] //= "https://opencode.ai/config.json" + ' > "$${config_file}.tmp" + mv "$${config_file}.tmp" "$${config_file}" + echo "OpenCode configuration written: $${config_file}" +} + +# Provider entries from auth_json replace the same provider IDs on disk; other +# credentials (for example from `opencode providers login`) are preserved. +setup_auth() { + local auth_file="$HOME/.local/share/opencode/auth.json" + + if [ -z "$${AUTH_JSON}" ]; then + echo "No auth_json provided; leaving $${auth_file} unchanged." + return + fi + + require_jq "write OpenCode credentials" + + if ! echo "$${AUTH_JSON}" | jq -e 'type == "object"' > /dev/null 2>&1; then + echo "ERROR: CODER_OPENCODE_AUTH_JSON is not a JSON object." >&2 + exit 1 + fi + + mkdir -p "$(dirname "$${auth_file}")" + + local existing + existing=$(read_json_object "$${auth_file}") + ( + umask 077 + # Read from the environment so credentials never appear in process arguments. + echo "$${existing}" | jq '. + (env.CODER_OPENCODE_AUTH_JSON | fromjson)' > "$${auth_file}.tmp" + ) + mv "$${auth_file}.tmp" "$${auth_file}" + chmod 0600 "$${auth_file}" + echo "OpenCode credentials written: $${auth_file}" +} + +install_opencode +setup_workdir +write_managed_settings +setup_config +setup_auth + +echo "OpenCode module setup completed." diff --git a/registry/coder-labs/modules/opencode/scripts/start.sh b/registry/coder-labs/modules/opencode/scripts/start.sh deleted file mode 100755 index 6691a61f4..000000000 --- a/registry/coder-labs/modules/opencode/scripts/start.sh +++ /dev/null @@ -1,71 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -export PATH=/home/coder/.opencode/bin:$PATH - -command_exists() { - command -v "$1" > /dev/null 2>&1 -} - -ARG_WORKDIR=${ARG_WORKDIR:-"$HOME"} -ARG_AI_PROMPT=$(echo -n "${ARG_AI_PROMPT:-}" | base64 -d 2> /dev/null || echo "") -ARG_REPORT_TASKS=${ARG_REPORT_TASKS:-true} -ARG_SESSION_ID=${ARG_SESSION_ID:-} -ARG_CONTINUE=${ARG_CONTINUE:-false} - -# Print all received environment variables -printf "=== START CONFIG ===\n" -printf "ARG_WORKDIR: %s\n" "$ARG_WORKDIR" -printf "ARG_REPORT_TASKS: %s\n" "$ARG_REPORT_TASKS" -printf "ARG_CONTINUE: %s\n" "$ARG_CONTINUE" -printf "ARG_SESSION_ID: %s\n" "$ARG_SESSION_ID" -if [ -n "$ARG_AI_PROMPT" ]; then - printf "ARG_AI_PROMPT: [AI PROMPT RECEIVED]\n" -else - printf "ARG_AI_PROMPT: [NOT PROVIDED]\n" -fi -printf "==================================\n" - -OPENCODE_ARGS=() -AGENTAPI_ARGS=() - -validate_opencode_installation() { - if ! command_exists opencode; then - printf "ERROR: OpenCode not installed. Set install_opencode to true\n" - exit 1 - fi -} - -build_opencode_args() { - - if [ -n "$ARG_SESSION_ID" ]; then - OPENCODE_ARGS+=(--session "$ARG_SESSION_ID") - fi - - if [ "$ARG_CONTINUE" = "true" ]; then - OPENCODE_ARGS+=(--continue) - fi - - if [ -n "$ARG_AI_PROMPT" ]; then - if [ "$ARG_REPORT_TASKS" = "true" ]; then - PROMPT="Every step of the way, report your progress using coder_report_task tool with proper summary and statuses. Your task at hand: $ARG_AI_PROMPT" - else - PROMPT="$ARG_AI_PROMPT" - fi - AGENTAPI_ARGS+=(-I "$PROMPT") - fi -} - -start_agentapi() { - printf "Starting in directory: %s\n" "$ARG_WORKDIR" - cd "$ARG_WORKDIR" - - build_opencode_args - - printf "Running OpenCode with args: %s\n" "${OPENCODE_ARGS[*]}" - echo agentapi server "${AGENTAPI_ARGS[@]}" --type opencode --term-width 67 --term-height 1190 -- opencode "${OPENCODE_ARGS[@]}" - agentapi server "${AGENTAPI_ARGS[@]}" --type opencode --term-width 67 --term-height 1190 -- opencode "${OPENCODE_ARGS[@]}" -} - -validate_opencode_installation -start_agentapi diff --git a/registry/coder-labs/modules/opencode/testdata/opencode-mock.sh b/registry/coder-labs/modules/opencode/testdata/opencode-mock.sh index dcde8756c..431c05ac0 100644 --- a/registry/coder-labs/modules/opencode/testdata/opencode-mock.sh +++ b/registry/coder-labs/modules/opencode/testdata/opencode-mock.sh @@ -1,25 +1,9 @@ -#!/bin/bash +#!/usr/bin/env bash -# Mock OpenCode CLI for testing purposes -# This script simulates the OpenCode command-line interface - -echo "OpenCode Mock CLI - Test Version" -echo "Args received: $*" - -# Simulate opencode behavior based on arguments -case "$1" in - --version | -v) - echo "opencode mock version 0.1.0-test" - ;; - --help | -h) - echo "OpenCode Mock Help" - echo "Usage: opencode [options] [command]" - echo "This is a mock version for testing" - ;; - *) - echo "Running OpenCode mock with arguments: $*" - echo "Mock execution completed successfully" - ;; -esac +if [[ "$1" == "--version" || "$1" == "-v" ]]; then + echo "0.0.0-mock" + exit 0 +fi +echo "opencode invoked with: $*" exit 0