diff --git a/.icons/opencode.png b/.icons/opencode.png new file mode 100644 index 000000000..b7436235d Binary files /dev/null and b/.icons/opencode.png differ diff --git a/bun.lock b/bun.lock index 16c21d09b..b8a3e27c9 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "registry", diff --git a/registry/rothnic/.images/avatar.png b/registry/rothnic/.images/avatar.png new file mode 100644 index 000000000..be46cf071 Binary files /dev/null and b/registry/rothnic/.images/avatar.png differ diff --git a/registry/rothnic/README.md b/registry/rothnic/README.md new file mode 100644 index 000000000..a81fd1d3c --- /dev/null +++ b/registry/rothnic/README.md @@ -0,0 +1,25 @@ +--- +display_name: "Nick Roth" +bio: "Product leader and engineer focused on end-to-end product delivery and agentic AI systems. Based in Huntsville, AL." +avatar: "./.images/avatar.png" +github: "rothnic" +linkedin: "http://www.linkedin.com/in/nicholasleeroth/" +website: "https://www.nickroth.com" +status: "community" +--- + +# Nick Roth + +Product manager and engineer with 14 years of experience leading product strategy, engineering delivery, and platform initiatives. I focus on end-to-end product ownershipβ€”from discovery and design through implementation, deployment, and iterative improvement. My current work centers on building self-learning, agentic systems that automate content and operational workflows while keeping humans in the loop for quality and governance. + +## Expertise + +- **Product Leadership:** Cross-functional product strategy, roadmapping, team leadership, vendor and stakeholder management. +- **Agentic Systems & Automation:** Designing and delivering self-learning agent pipelines, human-in-the-loop workflows, and production-ready automation for content and operations. +- **Platform Delivery & Modernization:** Architecture, procurement, migrations, and shipping reliable systems from prototype to production. +- **Growth & Experimentation:** SEO-led content strategy, A/B testing, experimentation frameworks, and monetization optimization. +- **Systems Engineering:** Requirements, architecture, and integration across complex systems. + +## Modules + +- [opencode](./modules/opencode/) - Execute AI-driven coding tasks and agentic workflows directly within Coder workspaces using OpenCode. diff --git a/registry/rothnic/modules/opencode/README.md b/registry/rothnic/modules/opencode/README.md new file mode 100644 index 000000000..78bc3497e --- /dev/null +++ b/registry/rothnic/modules/opencode/README.md @@ -0,0 +1,316 @@ +--- +display_name: OpenCode +description: AI-powered terminal coding agent with support for GitHub Copilot, Anthropic, and OpenAI +icon: ../../../../.icons/opencode.png +maintainer_github: rothnic +verified: false +tags: [agent, ai, opencode, coding-assistant, copilot, terminal] +--- + +# OpenCode + +Integrate [OpenCode.ai](https://opencode.ai/) - an AI coding agent that executes tasks in your terminal and reports progress to Coder's task system via [AgentAPI](https://github.com/coder/agentapi). Supports GitHub Copilot, Anthropic Claude, OpenAI, and other providers. + +## Quick Start + +**Basic setup:** + +```tf +module "opencode" { + source = "registry.coder.com/rothnic/opencode/coder" + agent_id = coder_agent.main.id + workdir = "/home/coder" +} +``` + +**With Coder Tasks (recommended for AI workspaces):** + +```tf +data "coder_task" "me" {} + +module "opencode" { + source = "registry.coder.com/rothnic/opencode/coder" + agent_id = coder_agent.main.id + workdir = "/home/coder" + + ai_prompt = data.coder_task.me.prompt + report_tasks = true +} +``` + +**With model and MCP servers:** + +```tf +module "opencode" { + source = "registry.coder.com/rothnic/opencode/coder" + agent_id = coder_agent.main.id + workdir = "/home/coder" + + opencode_model = "claude-3.7-sonnet" + mcp_servers = jsonencode({ + filesystem = { + type = "local" + command = ["npx", "-y", "@modelcontextprotocol/server-filesystem", "/home/coder"] + } + }) +} +``` + +## Authentication + +OpenCode supports many AI providers (GitHub Copilot, Anthropic Claude, OpenAI, etc.). Authentication is managed via `~/.local/share/opencode/auth.json`, created by running `opencode auth login`. + +### GitHub Copilot + +GitHub Copilot requires OAuth device flow authentication - you cannot use standard GitHub tokens. + +**Option 1: Pre-configured auth (recommended for automation)** + +Generate auth on your local machine, then embed in your template: + +```bash +# On your local machine +npm install -g opencode-ai +opencode auth login # Select "GitHub Copilot", complete device flow +cat ~/.local/share/opencode/auth.json # Copy this content +``` + +Create `opencode-auth.json` in your template directory, then reference it: + +```tf +module "opencode" { + source = "registry.coder.com/rothnic/opencode/coder" + agent_id = coder_agent.main.id + workdir = "/workspaces" + opencode_auth_config = file("${path.module}/opencode-auth.json") +} +``` + +**Option 2: Manual login per workspace** + +Users run `opencode auth login` inside the workspace. Auth persists across restarts. + +### Other Providers (Claude, OpenAI, etc.) + +For Anthropic, OpenAI, DeepSeek, Groq, and [other providers](https://opencode.ai/docs/providers/): + +1. Users run `opencode auth login` in the workspace +2. Select their provider and enter API key +3. Credentials persist in `~/.local/share/opencode/auth.json` + +Or provide API keys via environment variables (see provider docs). + +**Note:** The `opencode_provider` variable is for documentation only - it doesn't configure OpenCode. Provider selection happens via `opencode auth login`. + +## Configuration + +### Core Variables + +| Variable | Description | Default | Required | +| ---------- | ------------------------------ | ------- | -------- | +| `agent_id` | Coder agent ID | - | Yes | +| `workdir` | Working directory for OpenCode | - | Yes | + +### Authentication & Provider + +| Variable | Description | Default | +| ---------------------- | -------------------------------------------------------------------------------------- | ----------- | +| `opencode_auth_config` | Pre-configured auth.json content (for GitHub Copilot or any provider) | `""` | +| `opencode_provider` | Intended provider (documentation only - actual provider set via `opencode auth login`) | `"copilot"` | +| `github_token` | GitHub token for git operations (not AI provider auth) | `""` | +| `external_auth_id` | Coder external auth provider ID for git operations | `"github"` | + +### Task Integration + +| Variable | Description | Default | +| ---------------- | ----------------------------------------------------- | --------------- | +| `ai_prompt` | Initial task prompt (use `data.coder_task.me.prompt`) | `""` | +| `system_prompt` | Custom system prompt for the AI | Built-in prompt | +| `report_tasks` | Enable task reporting to Coder UI | `true` | +| `resume_session` | Auto-resume latest session on restart | `true` | + +### Installation & Versioning + +| Variable | Description | Default | +| ------------------ | ----------------------------------------------- | ----------- | +| `opencode_version` | OpenCode version (`latest` or specific version) | `"latest"` | +| `install_method` | Installation method (`npm` recommended) | `"npm"` | +| `install_agentapi` | Install AgentAPI | `true` | +| `agentapi_version` | AgentAPI version | `"v0.10.0"` | + +### UI & Apps + +| Variable | Description | Default | +| ---------------------- | -------------------------------------------------------------------------- | ---------------------- | +| `web_app_display_name` | Display name in Coder UI | `"OpenCode"` | +| `order` | App position in UI | `null` | +| `group` | App group name | `null` | +| `icon` | App icon path | `"/icon/opencode.png"` | +| `subdomain` | Use subdomain for app access (requires [wildcard DNS][wildcard-dns-setup]) | `false` | +| `cli_app` | Create CLI app entry | `false` | + +[wildcard-dns-setup]: https://coder.com/docs/admin/setup#wildcard-access-url + +### Model & MCP Configuration + +| Variable | Description | Default | +| ---------------- | ------------------------------------------------------------------------------------ | ------- | +| `opencode_model` | Model to use (e.g., `claude-3.7-sonnet`, `gpt-4o`). If empty, uses provider default. | `""` | +| `mcp_servers` | MCP servers configuration as JSON string (see example below) | `""` | + +**MCP Servers Example:** + +OpenCode uses `type: "local"` for local MCP servers with `command` as an array (including all arguments): + +```tf +module "opencode" { + source = "registry.coder.com/rothnic/opencode/coder" + agent_id = coder_agent.main.id + workdir = "/workspaces" + + opencode_model = "claude-3.7-sonnet" + + mcp_servers = jsonencode({ + filesystem = { + type = "local" + command = ["npx", "-y", "@modelcontextprotocol/server-filesystem", "/workspaces"] + } + github = { + type = "local" + command = ["npx", "-y", "@modelcontextprotocol/server-github"] + environment = { + GITHUB_TOKEN = var.github_token + } + } + }) +} +``` + +For remote MCP servers, use `type: "remote"` with a `url`: + +```tf +mcp_servers = jsonencode({ + context7 = { + type = "remote" + url = "https://mcp.context7.com/mcp" + } +}) +``` + +See [OpenCode MCP documentation](https://opencode.ai/docs/mcp-servers/) for more details. + +### Advanced + +| Variable | Description | Default | +| --------------------- | ------------------------------------------------------------------- | ------- | +| `opencode_config` | Full custom OpenCode config (JSON). Overrides other config options. | `""` | +| `pre_install_script` | Script to run before install | `null` | +| `post_install_script` | Script to run after install | `null` | + +See [main.tf](./main.tf) for complete variable definitions. + +## Features + +- πŸ€– Multiple AI providers (Copilot, Claude, GPT) +- πŸ”§ MCP servers configuration support +- 🎯 Model selection per deployment +- πŸ“Š Task reporting to Coder UI +- πŸ’Ύ Session persistence +- πŸ” Flexible authentication +- πŸš€ Node.js tarball installation (no apt/nvm) +- πŸ“Œ Version pinning support via `opencode_version` +- πŸ’Ύ Shared Node.js cache across workspaces + +## Complete Template Example + +Here's a lightweight template for Coder task execution: + +```tf +terraform { + required_providers { + coder = { + source = "coder/coder" + } + docker = { + source = "kreuzwerker/docker" + } + } +} + +provider "docker" {} + +data "coder_workspace" "me" {} +data "coder_workspace_owner" "me" {} + +# Task data source - provides prompts from Coder Tasks UI +data "coder_task" "me" {} + +resource "coder_agent" "main" { + arch = "amd64" + os = "linux" + + startup_script = <<-EOT + set -e + mkdir -p /workspaces + EOT + + env = { + GIT_AUTHOR_NAME = coalesce(data.coder_workspace_owner.me.full_name, data.coder_workspace_owner.me.name) + GIT_AUTHOR_EMAIL = data.coder_workspace_owner.me.email + GIT_COMMITTER_NAME = coalesce(data.coder_workspace_owner.me.full_name, data.coder_workspace_owner.me.name) + GIT_COMMITTER_EMAIL = data.coder_workspace_owner.me.email + } +} + +# OpenCode AI coding agent +module "opencode" { + source = "registry.coder.com/rothnic/opencode/coder" + agent_id = coder_agent.main.id + workdir = "/workspaces" + + # Pre-configured authentication (from 'opencode auth login' output) + opencode_auth_config = file("${path.module}/opencode-auth.json") + + # Pass task prompt from Coder Tasks UI + ai_prompt = data.coder_task.me.prompt + + # Enable task reporting for Coder UI integration + report_tasks = true + + # Use subdomain for better app routing (requires wildcard DNS) + # subdomain = true + + # Display settings + order = 1 + web_app_display_name = "OpenCode AI" +} + +resource "docker_container" "workspace" { + count = data.coder_workspace.me.start_count + image = "codercom/enterprise-base:ubuntu" + name = "coder-${data.coder_workspace_owner.me.name}-${lower(data.coder_workspace.me.name)}" + + entrypoint = ["sh", "-c", coder_agent.main.init_script] + env = ["CODER_AGENT_TOKEN=${coder_agent.main.token}"] +} +``` + +## Prerequisites + +- None - Node.js 20 is automatically installed via tarball + +## Notes + +- **Node.js installation**: Installed via tarball (not apt/nvm) to `/workspaces/.coder-tools` for reliability and workspace portability +- **OpenCode installation**: Installed via `npm install -g opencode-ai@{version}` +- **Caching**: Node.js and npm cache shared across workspaces in `/workspaces/.coder-tools` +- **Version pinning**: Use `opencode_version` variable to lock to specific versions +- **TUI limitations**: Some interactive features (slash commands, menus) may have limitations through AgentAPI +- **Subdomain access**: For production, use `subdomain = true` with [wildcard access URL](https://coder.com/docs/admin/setup#wildcard-access-url) +- **Task reporting**: When `report_tasks = true`, the module automatically configures system prompts for granular task status updates + +## Resources + +- [OpenCode Documentation](https://opencode.ai/docs/) +- [OpenCode GitHub](https://github.com/opencode-ai/opencode) +- [AgentAPI](https://github.com/coder/agentapi) diff --git a/registry/rothnic/modules/opencode/examples/minimal-agent-task/main.tf b/registry/rothnic/modules/opencode/examples/minimal-agent-task/main.tf new file mode 100644 index 000000000..580bb98de --- /dev/null +++ b/registry/rothnic/modules/opencode/examples/minimal-agent-task/main.tf @@ -0,0 +1,202 @@ +terraform { + required_providers { + coder = { + source = "coder/coder" + } + docker = { + source = "kreuzwerker/docker" + } + } +} + +# This template requires a valid Docker socket +# You can reference Kubernetes/VM example templates and adapt: +# see: https://registry.coder.com/templates +provider "docker" {} + +data "coder_provisioner" "me" {} +data "coder_workspace" "me" {} +data "coder_workspace_owner" "me" {} + +# OpenCode module handles automatic task reporting via agentapi +# For testing with current branch: +module "opencode" { + count = data.coder_workspace.me.start_count + source = "git::https://github.com/coder/registry.git//registry/rothnic/modules/opencode?ref=claude/review-module-guidelines-014yytiyG8n6Rj4V8BbxZb2B" + + # After merge, use: + # source = "registry.coder.com/rothnic/opencode/coder" + # version = "~> 1.0" + + agent_id = coder_agent.main.id + workdir = "/home/coder/projects" + order = 999 + ai_prompt = data.coder_parameter.ai_prompt.value + subdomain = true + + # GitHub Copilot Authentication: + # 1. Run `opencode auth login` locally and select GitHub Copilot + # 2. Copy ~/.local/share/opencode/auth.json to opencode-auth.json + # 3. Uncomment the line below: + # opencode_auth_config = file("${path.module}/opencode-auth.json") + + # Configure model (optional): + # opencode_model = "claude-sonnet-4-20250514" + + # MCP Servers (optional): + # mcp_servers = jsonencode({ + # filesystem = { + # type = "local" + # command = ["npx", "-y", "@anthropic/mcp-server-filesystem", "/home/coder/projects"] + # } + # }) +} + +# Workspace presets for different use cases +# See https://coder.com/docs/admin/templates/extending-templates/parameters#workspace-presets +data "coder_workspace_preset" "default" { + name = "Default OpenCode Workspace" + default = true + parameters = { + "system_prompt" = <<-EOT + You are a helpful coding assistant running inside a Coder workspace. + Stay on track and feel free to debug, but when the original plan fails, + do not choose a different route/architecture without checking the user first. + EOT + "container_image" = "codercom/enterprise-base:ubuntu" + } +} + +# Parameters (set via preset or manually) +data "coder_parameter" "ai_prompt" { + type = "string" + name = "AI Prompt" + default = "" + description = "Write a prompt for OpenCode" + display_name = "AI Prompt" + mutable = true +} + +data "coder_parameter" "system_prompt" { + name = "system_prompt" + display_name = "System Prompt" + type = "string" + form_type = "textarea" + description = "System prompt for the agent with generalized instructions" + mutable = false + default = "" +} + +data "coder_parameter" "container_image" { + name = "container_image" + display_name = "Container Image" + type = "string" + default = "codercom/enterprise-base:ubuntu" + mutable = false +} + +resource "coder_agent" "main" { + arch = data.coder_provisioner.me.arch + os = "linux" + startup_script = <<-EOT + set -e + # Prepare user home with default files on first start + if [ ! -f ~/.init_done ]; then + cp -rT /etc/skel ~ + touch ~/.init_done + fi + # Create projects directory + mkdir -p /home/coder/projects + EOT + + # Git configuration from workspace owner + env = { + GIT_AUTHOR_NAME = coalesce(data.coder_workspace_owner.me.full_name, data.coder_workspace_owner.me.name) + GIT_AUTHOR_EMAIL = "${data.coder_workspace_owner.me.email}" + GIT_COMMITTER_NAME = coalesce(data.coder_workspace_owner.me.full_name, data.coder_workspace_owner.me.name) + GIT_COMMITTER_EMAIL = "${data.coder_workspace_owner.me.email}" + } + + metadata { + display_name = "CPU Usage" + key = "0_cpu_usage" + script = "coder stat cpu" + interval = 10 + timeout = 1 + } + + metadata { + display_name = "RAM Usage" + key = "1_ram_usage" + script = "coder stat mem" + interval = 10 + timeout = 1 + } + + metadata { + display_name = "Home Disk" + key = "3_home_disk" + script = "coder stat disk --path $${HOME}" + interval = 60 + timeout = 1 + } +} + +resource "docker_volume" "home_volume" { + name = "coder-${data.coder_workspace.me.id}-home" + lifecycle { + ignore_changes = all + } + labels { + label = "coder.owner" + value = data.coder_workspace_owner.me.name + } + labels { + label = "coder.owner_id" + value = data.coder_workspace_owner.me.id + } + labels { + label = "coder.workspace_id" + value = data.coder_workspace.me.id + } + labels { + label = "coder.workspace_name_at_creation" + value = data.coder_workspace.me.name + } +} + +resource "docker_container" "workspace" { + count = data.coder_workspace.me.start_count + image = data.coder_parameter.container_image.value + name = "coder-${data.coder_workspace_owner.me.name}-${lower(data.coder_workspace.me.name)}" + hostname = data.coder_workspace.me.name + user = "coder" + # Use the docker gateway if the access URL is 127.0.0.1 + entrypoint = ["sh", "-c", replace(coder_agent.main.init_script, "/localhost|127\\.0\\.0\\.1/", "host.docker.internal")] + env = ["CODER_AGENT_TOKEN=${coder_agent.main.token}"] + host { + host = "host.docker.internal" + ip = "host-gateway" + } + volumes { + container_path = "/home/coder" + volume_name = docker_volume.home_volume.name + read_only = false + } + labels { + label = "coder.owner" + value = data.coder_workspace_owner.me.name + } + labels { + label = "coder.owner_id" + value = data.coder_workspace_owner.me.id + } + labels { + label = "coder.workspace_id" + value = data.coder_workspace.me.id + } + labels { + label = "coder.workspace_name" + value = data.coder_workspace.me.name + } +} diff --git a/registry/rothnic/modules/opencode/main.tf b/registry/rothnic/modules/opencode/main.tf new file mode 100644 index 000000000..c87eaf037 --- /dev/null +++ b/registry/rothnic/modules/opencode/main.tf @@ -0,0 +1,264 @@ +terraform { + required_version = ">= 1.0" + required_providers { + coder = { + source = "coder/coder" + version = ">= 2.7" + } + } +} + +variable "agent_id" { + type = string + description = "The ID of a Coder agent." +} + +variable "workdir" { + type = string + description = "The folder to run OpenCode in." +} + +variable "external_auth_id" { + type = string + description = "ID of the GitHub external auth provider configured in Coder." + default = "github" +} + +variable "github_token" { + type = string + description = "GitHub OAuth token or Personal Access Token. If provided, this will be used instead of auto-detecting authentication." + default = "" + sensitive = true +} + +variable "opencode_provider" { + type = string + description = "Intended AI provider (for documentation/defaults). Actual provider is configured via opencode_auth_config or by running 'opencode auth login' in the workspace." + default = "copilot" +} + +variable "opencode_model" { + type = string + description = "The model for OpenCode to use (e.g., 'claude-3.7-sonnet', 'gpt-4o'). If empty, uses provider default." + default = "" +} + +variable "mcp_servers" { + type = string + description = "MCP servers configuration as JSON string. Will be merged into the 'mcp' section of opencode.json. OpenCode format uses type='local' with command as array. Example: '{\"filesystem\":{\"type\":\"local\",\"command\":[\"npx\",\"-y\",\"@modelcontextprotocol/server-filesystem\",\"/workspaces\"]}}'" + default = "" +} + +variable "opencode_config" { + type = string + description = "Complete custom OpenCode configuration as JSON string. If provided, this overrides default config generation. For partial config (just MCP servers), use mcp_servers variable instead." + default = "" +} + +variable "opencode_auth_config" { + type = string + description = "Pre-configured OpenCode auth.json content as JSON string. Use this to provide GitHub Copilot credentials obtained from running 'opencode auth login' locally." + default = "" + sensitive = true +} + +variable "ai_prompt" { + type = string + description = "Initial task prompt for programmatic mode." + default = "" +} + +variable "system_prompt" { + type = string + description = "The system prompt to use for OpenCode. Task reporting instructions are automatically added when report_tasks is enabled." + default = "You are a helpful coding assistant that helps developers write, debug, and understand code. Provide clear explanations, follow best practices, and help solve coding problems efficiently." +} + +variable "install_agentapi" { + type = bool + description = "Whether to install AgentAPI." + default = true +} + +variable "agentapi_version" { + type = string + description = "The version of AgentAPI to install." + default = "v0.10.0" +} + +variable "opencode_version" { + type = string + description = "The version of OpenCode to install. Use 'latest' for the latest version or specify a version." + default = "latest" +} + +variable "install_method" { + type = string + description = "Installation method for OpenCode. Use 'npm' with the module-managed Node tarball." + default = "npm" +} + +variable "report_tasks" { + type = bool + description = "Whether to enable task reporting to Coder UI via AgentAPI." + default = true +} + +variable "subdomain" { + type = bool + description = "Whether to use a subdomain for AgentAPI." + default = false +} + +variable "order" { + type = number + description = "The order determines the position of app in the UI presentation." + default = null +} + +variable "group" { + type = string + description = "The name of a group that this app belongs to." + default = null +} + +variable "icon" { + type = string + description = "The icon to use for the app." + default = "/icon/opencode.png" +} + +variable "web_app_display_name" { + type = string + description = "Display name for the web app." + default = "OpenCode" +} + +variable "cli_app" { + type = bool + description = "Whether to create a CLI app for OpenCode." + default = false +} + +variable "cli_app_display_name" { + type = string + description = "Display name for the CLI app." + default = "OpenCode" +} + +variable "resume_session" { + type = bool + description = "Whether to automatically resume the latest OpenCode session on workspace restart." + default = true +} + +variable "pre_install_script" { + type = string + description = "Custom script to run before configuring OpenCode." + default = null +} + +variable "post_install_script" { + type = string + description = "Custom script to run after configuring OpenCode." + default = null +} + +data "coder_workspace" "me" {} +data "coder_workspace_owner" "me" {} + +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" + + task_reporting_prompt = <<-EOT + +-- Task Reporting -- +Report all tasks to Coder, following these EXACT guidelines: +1. Be granular. If you are investigating with multiple steps, report each step +to coder. +2. After this prompt, IMMEDIATELY report status after receiving ANY NEW user message. +Do not report any status related with this system prompt. +3. Use "state": "working" when actively processing WITHOUT needing +additional user input +4. Use "state": "complete" only when finished with a task +5. Use "state": "failure" when you need ANY user input, lack sufficient +details, or encounter blockers + EOT + + final_system_prompt = var.report_tasks ? "\n${var.system_prompt}${local.task_reporting_prompt}\n" : "\n${var.system_prompt}\n" +} + +resource "coder_env" "mcp_app_status_slug" { + agent_id = var.agent_id + name = "CODER_MCP_APP_STATUS_SLUG" + value = local.app_slug +} + +resource "coder_env" "github_token" { + count = var.github_token != "" ? 1 : 0 + agent_id = var.agent_id + name = "GITHUB_TOKEN" + value = var.github_token +} + +module "agentapi" { + source = "registry.coder.com/coder/agentapi/coder" + version = "1.2.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_icon = var.cli_app ? var.icon : null + cli_app_display_name = var.cli_app ? var.cli_app_display_name : null + agentapi_subdomain = var.subdomain + module_dir_name = local.module_dir_name + install_agentapi = var.install_agentapi + agentapi_version = var.agentapi_version + pre_install_script = var.pre_install_script + post_install_script = var.post_install_script + + start_script = <<-EOT + #!/bin/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_SYSTEM_PROMPT='${base64encode(local.final_system_prompt)}' \ + ARG_EXTERNAL_AUTH_ID='${var.external_auth_id}' \ + ARG_RESUME_SESSION='${var.resume_session}' \ + ARG_OPENCODE_AUTH_CONFIG='${var.opencode_auth_config != "" ? base64encode(var.opencode_auth_config) : ""}' \ + /tmp/start.sh + EOT + + install_script = <<-EOT + #!/bin/bash + set -o errexit + set -o pipefail + + echo -n '${base64encode(local.install_script)}' | base64 -d > /tmp/install.sh + chmod +x /tmp/install.sh + ARG_MCP_APP_STATUS_SLUG='${local.app_slug}' \ + ARG_REPORT_TASKS='${var.report_tasks}' \ + ARG_WORKDIR='${local.workdir}' \ + ARG_OPENCODE_CONFIG='${var.opencode_config != "" ? base64encode(var.opencode_config) : ""}' \ + ARG_MCP_SERVERS='${var.mcp_servers != "" ? base64encode(var.mcp_servers) : ""}' \ + ARG_OPENCODE_MODEL='${var.opencode_model}' \ + ARG_EXTERNAL_AUTH_ID='${var.external_auth_id}' \ + ARG_OPENCODE_VERSION='${var.opencode_version}' \ + ARG_INSTALL_METHOD='${var.install_method}' \ + /tmp/install.sh + EOT +} diff --git a/registry/rothnic/modules/opencode/opencode.tftest.hcl b/registry/rothnic/modules/opencode/opencode.tftest.hcl new file mode 100644 index 000000000..5ece555a8 --- /dev/null +++ b/registry/rothnic/modules/opencode/opencode.tftest.hcl @@ -0,0 +1,286 @@ +run "defaults_are_correct" { + command = plan + + variables { + agent_id = "test-agent" + workdir = "/home/coder" + } + + assert { + condition = var.opencode_provider == "copilot" + error_message = "Default provider should be 'copilot'" + } + + assert { + condition = var.report_tasks == true + error_message = "Task reporting should be enabled by default" + } + + assert { + condition = var.resume_session == true + error_message = "Session resumption should be enabled by default" + } + + assert { + condition = var.install_method == "npm" + error_message = "Default install method should be 'npm'" + } + + assert { + condition = resource.coder_env.mcp_app_status_slug.name == "CODER_MCP_APP_STATUS_SLUG" + error_message = "Status slug env var should be created" + } + + assert { + condition = resource.coder_env.mcp_app_status_slug.value == "opencode" + error_message = "Status slug value should be 'opencode'" + } +} + +run "github_token_creates_env_var" { + command = plan + + variables { + agent_id = "test-agent" + workdir = "/home/coder" + github_token = "test_github_token_abc123" + } + + assert { + condition = length(resource.coder_env.github_token) == 1 + error_message = "github_token env var should be created when token is provided" + } + + assert { + condition = resource.coder_env.github_token[0].name == "GITHUB_TOKEN" + error_message = "github_token env var name should be 'GITHUB_TOKEN'" + } + + assert { + condition = resource.coder_env.github_token[0].value == "test_github_token_abc123" + error_message = "github_token env var value should match input" + } +} + +run "github_token_not_created_when_empty" { + command = plan + + variables { + agent_id = "test-agent" + workdir = "/home/coder" + github_token = "" + } + + assert { + condition = length(resource.coder_env.github_token) == 0 + error_message = "github_token env var should not be created when empty" + } +} + +run "install_method_validation" { + command = plan + + variables { + agent_id = "test-agent" + workdir = "/home/coder" + install_method = "curl" + } + + assert { + condition = contains(["npm", "curl"], var.install_method) + error_message = "Install method should be either 'npm' or 'curl'" + } +} + +run "workdir_trimmed_of_trailing_slash" { + command = plan + + variables { + agent_id = "test-agent" + workdir = "/home/coder/project/" + } + + assert { + condition = local.workdir == "/home/coder/project" + error_message = "workdir should be trimmed of trailing slash" + } +} + +run "app_slug_is_consistent" { + command = plan + + variables { + agent_id = "test-agent" + workdir = "/home/coder" + } + + 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'" + } +} + +run "custom_opencode_config" { + command = plan + + variables { + agent_id = "test-agent" + workdir = "/home/coder" + opencode_config = jsonencode({ + theme = "dark" + }) + } + + assert { + condition = var.opencode_config != "" + error_message = "Custom opencode config should be set" + } +} + +run "task_reporting_prompt_included" { + command = plan + + variables { + agent_id = "test-agent" + workdir = "/home/coder" + report_tasks = true + } + + assert { + condition = length(local.final_system_prompt) > 0 + error_message = "final_system_prompt should be computed" + } + + assert { + condition = can(regex("Task Reporting", local.final_system_prompt)) + error_message = "Task reporting prompt should be included when report_tasks is true" + } +} + +run "task_reporting_prompt_excluded" { + command = plan + + variables { + agent_id = "test-agent" + workdir = "/home/coder" + report_tasks = false + } + + assert { + condition = !can(regex("Task Reporting", local.final_system_prompt)) + error_message = "Task reporting prompt should not be included when report_tasks is false" + } +} + +run "version_defaults_to_latest" { + command = plan + + variables { + agent_id = "test-agent" + workdir = "/home/coder" + } + + assert { + condition = var.opencode_version == "latest" + error_message = "OpenCode version should default to 'latest'" + } +} + +run "agentapi_version_is_set" { + command = plan + + variables { + agent_id = "test-agent" + workdir = "/home/coder" + } + + assert { + condition = var.agentapi_version == "v0.10.0" + error_message = "AgentAPI version should be set to v0.10.0" + } +} + +run "mcp_servers_config" { + command = plan + + variables { + agent_id = "test-agent" + workdir = "/home/coder" + mcp_servers = jsonencode({ + filesystem = { + command = "npx" + args = ["-y", "@modelcontextprotocol/server-filesystem", "/workspaces"] + } + }) + } + + assert { + condition = var.mcp_servers != "" + error_message = "MCP servers configuration should be provided" + } +} + +run "opencode_model_config" { + command = plan + + variables { + agent_id = "test-agent" + workdir = "/home/coder" + opencode_model = "claude-3.7-sonnet" + } + + assert { + condition = var.opencode_model == "claude-3.7-sonnet" + error_message = "OpenCode model should be set to 'claude-3.7-sonnet'" + } +} + +run "mcp_and_model_combined" { + command = plan + + variables { + agent_id = "test-agent" + workdir = "/home/coder" + opencode_model = "gpt-4o" + mcp_servers = jsonencode({ + github = { + command = "npx" + args = ["-y", "@modelcontextprotocol/server-github"] + } + }) + } + + assert { + condition = var.opencode_model == "gpt-4o" + error_message = "OpenCode model should be set" + } + + assert { + condition = var.mcp_servers != "" + error_message = "MCP servers should be configured" + } +} + +run "model_defaults_to_empty" { + command = plan + + variables { + agent_id = "test-agent" + workdir = "/home/coder" + } + + assert { + condition = var.opencode_model == "" + error_message = "OpenCode model should default to empty (provider default)" + } + + assert { + condition = var.mcp_servers == "" + error_message = "MCP servers should default to empty" + } +} diff --git a/registry/rothnic/modules/opencode/scripts/install.sh b/registry/rothnic/modules/opencode/scripts/install.sh new file mode 100644 index 000000000..991daf95b --- /dev/null +++ b/registry/rothnic/modules/opencode/scripts/install.sh @@ -0,0 +1,222 @@ +#!/bin/bash +set -euo pipefail + +source "$HOME"/.bashrc 2> /dev/null || true + +command_exists() { + command -v "$1" > /dev/null 2>&1 +} + +# Configuration +ARG_WORKDIR=${ARG_WORKDIR:-"$HOME"} +ARG_REPORT_TASKS=${ARG_REPORT_TASKS:-true} +ARG_MCP_APP_STATUS_SLUG=${ARG_MCP_APP_STATUS_SLUG:-} +ARG_OPENCODE_CONFIG=$(echo -n "${ARG_OPENCODE_CONFIG:-}" | base64 -d 2> /dev/null || echo "") +ARG_MCP_SERVERS=$(echo -n "${ARG_MCP_SERVERS:-}" | base64 -d 2> /dev/null || echo "") +ARG_OPENCODE_MODEL=${ARG_OPENCODE_MODEL:-} +ARG_EXTERNAL_AUTH_ID=${ARG_EXTERNAL_AUTH_ID:-github} +ARG_OPENCODE_VERSION=${ARG_OPENCODE_VERSION:-latest} +ARG_INSTALL_METHOD=${ARG_INSTALL_METHOD:-npm} + +NODE_VERSION="${NODE_VERSION:-20.18.0}" + +install_nodejs() { + if [ "$ARG_INSTALL_METHOD" != "npm" ]; then + echo "ERROR: install_method=${ARG_INSTALL_METHOD} is not supported without the curl installer." + echo " Use install_method=\"npm\" in the module or Terraform." + exit 1 + fi + + # Shared tool root across workspaces on this host + # Use HOME as fallback since /workspaces may not exist or be writable + local dev_root="${DEV_ROOT:-$HOME}" + local tool_root="${TOOL_ROOT:-$dev_root/.coder-tools}" + local node_distro="linux-x64" + local node_tarball="node-v${NODE_VERSION}-${node_distro}.tar.xz" + local node_dir="${tool_root}/node-v${NODE_VERSION}-${node_distro}" + + mkdir -p "$tool_root" + + if [ ! -d "$node_dir" ]; then + echo "Node.js ${NODE_VERSION} not found in cache. Downloading to ${tool_root}..." + curl -fsSL "https://nodejs.org/dist/v${NODE_VERSION}/${node_tarball}" \ + -o "${tool_root}/${node_tarball}" + + echo "Extracting Node.js..." + tar -xJf "${tool_root}/${node_tarball}" -C "$tool_root" + rm -f "${tool_root}/${node_tarball}" + else + echo "βœ“ Node.js ${NODE_VERSION} already cached at ${node_dir}" + fi + + # Make Node available now + export PATH="${node_dir}/bin:$HOME/.local/bin:$PATH" + + # Shared npm cache to speed up repeated installs + local npm_cache_dir="${tool_root}/npm-cache" + mkdir -p "$npm_cache_dir" + export NPM_CONFIG_CACHE="$npm_cache_dir" + + # Persist PATH + npm cache so start.sh and future shells see it + if ! grep -q "node-v${NODE_VERSION}-${node_distro}/bin" "$HOME/.bashrc" 2> /dev/null; then + { + echo "export PATH=\"${node_dir}/bin:\$HOME/.local/bin:\$PATH\"" + echo "export NPM_CONFIG_CACHE=\"${npm_cache_dir}\"" + } >> "$HOME/.bashrc" + fi + + if ! command_exists node; then + echo "ERROR: Node.js still not on PATH after tarball install" + exit 1 + fi + + echo "βœ“ Node.js installed via tarball: $(node --version)" +} + +install_opencode() { + mkdir -p "$HOME/.local/bin" + export PATH="$HOME/.local/bin:$PATH" + + if ! command_exists opencode; then + echo "Installing OpenCode via npm (version: ${ARG_OPENCODE_VERSION})..." + + npm config set prefix "$HOME/.local" > /dev/null 2>&1 || true + + if ! grep -q 'PATH="$HOME/.local/bin:$PATH"' "$HOME/.bashrc" 2> /dev/null; then + echo 'export PATH="$HOME/.local/bin:$PATH"' >> "$HOME/.bashrc" + fi + + if [ "$ARG_OPENCODE_VERSION" = "latest" ]; then + npm install -g opencode-ai@latest + else + npm install -g "opencode-ai@${ARG_OPENCODE_VERSION}" + fi + + export PATH="$HOME/.local/bin:$PATH" + + if ! command_exists opencode; then + echo "ERROR: Failed to install OpenCode" + exit 1 + fi + + echo "βœ“ OpenCode installed successfully: $(opencode --version 2>&1 | head -1)" + else + echo "βœ“ OpenCode already installed: $(opencode --version 2>&1 | head -1)" + fi +} + +check_github_authentication() { + echo "Checking GitHub authentication..." + + if [ -n "${GITHUB_TOKEN:-}" ]; then + echo "βœ“ GITHUB_TOKEN already set via environment/module" + return 0 + fi + + if command_exists coder; then + if coder external-auth access-token "${ARG_EXTERNAL_AUTH_ID:-github}" > /dev/null 2>&1; then + local t + t=$(coder external-auth access-token "${ARG_EXTERNAL_AUTH_ID:-github}" 2> /dev/null || echo "") + if [ -n "$t" ] && [ "$t" != "null" ]; then + export GITHUB_TOKEN="$t" + export GH_TOKEN="$t" + echo "βœ“ Using Coder external auth token for GitHub" + return 0 + fi + fi + fi + + if command_exists gh && gh auth status > /dev/null 2>&1; then + echo "βœ“ GitHub CLI OAuth authentication (gh auth status) is available" + return 0 + fi + + echo "⚠ No GitHub authentication detected." + echo " This only affects Git operations / gh; OpenCode can still use other providers." + return 0 +} + +setup_opencode_configurations() { + mkdir -p "$ARG_WORKDIR" + + local module_path="$HOME/.opencode-module" + mkdir -p "$module_path" + + setup_opencode_config +} + +setup_opencode_config() { + export XDG_DATA_HOME="${XDG_DATA_HOME:-$HOME/.local/share}" + local opencode_data_dir="$XDG_DATA_HOME/opencode" + local opencode_config_dir="$HOME/.config/opencode" + + mkdir -p "$opencode_data_dir" + mkdir -p "$opencode_config_dir" + + # If full custom config provided, use it directly + if [ -n "$ARG_OPENCODE_CONFIG" ]; then + echo "Setting up OpenCode configuration (opencode.json) from custom config..." + echo "$ARG_OPENCODE_CONFIG" > "$opencode_config_dir/opencode.json" + return 0 + fi + + # Otherwise, build config from individual options + echo "Building OpenCode configuration..." + + # Start with base config + local config='{}' + + # Add model config if specified + if [ -n "$ARG_OPENCODE_MODEL" ]; then + echo " Adding model configuration: $ARG_OPENCODE_MODEL" + config=$(echo "$config" | jq --arg model "$ARG_OPENCODE_MODEL" '. + { + "agents": { + "coder": {"model": $model}, + "task": {"model": $model} + } + }') + fi + + # Add MCP servers if specified (OpenCode uses 'mcp' key, not 'mcpServers') + if [ -n "$ARG_MCP_SERVERS" ]; then + echo " Adding MCP servers configuration..." + # Merge MCP servers into config under 'mcp' key + local mcp_config + mcp_config=$(echo "$ARG_MCP_SERVERS" | jq '.') + if [ $? -eq 0 ] && [ -n "$mcp_config" ]; then + config=$(echo "$config" | jq --argjson mcp "$mcp_config" '. + {"mcp": $mcp}') + else + echo " ⚠ Warning: Invalid MCP servers JSON, skipping" + fi + fi + + # Only write config if we have something to configure + if [ "$config" != '{}' ]; then + echo "$config" | jq '.' > "$opencode_config_dir/opencode.json" + echo "βœ“ OpenCode config written to $opencode_config_dir/opencode.json" + else + echo " No custom configuration needed" + fi +} + +configure_coder_integration() { + if [ "$ARG_REPORT_TASKS" = "true" ] && [ -n "$ARG_MCP_APP_STATUS_SLUG" ]; then + 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" + else + echo "Task reporting disabled or no app status slug provided." + export CODER_MCP_APP_STATUS_SLUG="" + export CODER_MCP_AI_AGENTAPI_URL="" + fi +} + +# Main execution +install_nodejs +install_opencode +check_github_authentication +setup_opencode_configurations +configure_coder_integration + +echo "OpenCode module setup completed." diff --git a/registry/rothnic/modules/opencode/scripts/start.sh b/registry/rothnic/modules/opencode/scripts/start.sh new file mode 100644 index 000000000..9f25d4058 --- /dev/null +++ b/registry/rothnic/modules/opencode/scripts/start.sh @@ -0,0 +1,108 @@ +#!/bin/bash +set -euo pipefail + +source "$HOME"/.bashrc 2> /dev/null || true +export PATH="$HOME/.local/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_SYSTEM_PROMPT=$(echo -n "${ARG_SYSTEM_PROMPT:-}" | base64 -d 2> /dev/null || echo "") +ARG_EXTERNAL_AUTH_ID=${ARG_EXTERNAL_AUTH_ID:-github} +ARG_RESUME_SESSION=${ARG_RESUME_SESSION:-true} +ARG_OPENCODE_AUTH_CONFIG=$(echo -n "${ARG_OPENCODE_AUTH_CONFIG:-}" | base64 -d 2> /dev/null || echo "") + +validate_opencode_installation() { + if ! command_exists opencode; then + echo "ERROR: OpenCode not found on PATH. Did install.sh fail?" + exit 1 + fi + echo "βœ“ OpenCode found: $(opencode --version 2>&1 | head -1 || echo '')" +} + +build_initial_prompt() { + local initial_prompt="" + + if [ -n "$ARG_AI_PROMPT" ]; then + if [ -n "$ARG_SYSTEM_PROMPT" ]; then + initial_prompt="$ARG_SYSTEM_PROMPT + +$ARG_AI_PROMPT" + else + initial_prompt="$ARG_AI_PROMPT" + fi + fi + + echo "$initial_prompt" +} + +setup_github_authentication() { + export XDG_DATA_HOME="${XDG_DATA_HOME:-$HOME/.local/share}" + local opencode_data_dir="$XDG_DATA_HOME/opencode" + local auth_file="$opencode_data_dir/auth.json" + + echo "Setting up OpenCode / GitHub authentication..." + mkdir -p "$opencode_data_dir" + + # 1) If the module is given a full auth.json blob, use it verbatim. + if [ -n "$ARG_OPENCODE_AUTH_CONFIG" ]; then + echo "βœ“ Using pre-configured OpenCode auth.json from module variable" + echo "$ARG_OPENCODE_AUTH_CONFIG" > "$auth_file" + fi + + # 2) For general GitHub use (git, gh), try to populate GITHUB_TOKEN / GH_TOKEN. + # We do NOT derive auth.json from these tokens. + if [ -z "${GITHUB_TOKEN:-}" ]; then + if command_exists coder; then + local t + t=$(coder external-auth access-token "${ARG_EXTERNAL_AUTH_ID:-github}" 2> /dev/null || echo "") + if [ -n "$t" ] && [ "$t" != "null" ]; then + export GITHUB_TOKEN="$t" + export GH_TOKEN="$t" + echo "βœ“ Using Coder external auth token for GitHub (GITHUB_TOKEN/GH_TOKEN)" + fi + fi + else + export GH_TOKEN="$GITHUB_TOKEN" + echo "βœ“ Using GITHUB_TOKEN from module configuration" + fi + + # 3) If still no token env, fall back to gh CLI if it's logged in. + if [ -z "${GITHUB_TOKEN:-}" ] && command_exists gh && gh auth status > /dev/null 2>&1; then + echo "βœ“ GitHub CLI auth is available (gh auth status ok)" + fi + + # 4) If we still don't have an auth.json, warn, but don't fabricate one. + if [ ! -f "$auth_file" ]; then + echo "⚠ No OpenCode auth.json present." + echo " Copilot / provider credentials must be set by:" + echo " - Running 'opencode auth login' and wiring that auth.json into opencode_auth_config" + echo " - Or using another provider via env vars / config" + fi +} + +start_agentapi() { + echo "Starting in directory: $ARG_WORKDIR" + cd "$ARG_WORKDIR" + + echo "Starting OpenCode TUI with agentapi..." + local initial_prompt + initial_prompt=$(build_initial_prompt) + + # Run opencode with agentapi, backgrounded so start script returns quickly + # The agentapi module's wrapper expects the script to exit so it can run its wait loop + # Use standard terminal dimensions (120x40) for TUI stability - prevents browser auto-scroll issues + if [ -n "$initial_prompt" ]; then + echo "Using initial prompt with system context" + agentapi server -I="$initial_prompt" --type=opencode --term-width 120 --term-height 40 -- opencode "$ARG_WORKDIR" & + else + agentapi server --type=opencode --term-width 120 --term-height 40 -- opencode "$ARG_WORKDIR" & + fi +} + +setup_github_authentication +validate_opencode_installation +start_agentapi