diff --git a/.icons/openai.svg b/.icons/openai.svg new file mode 100644 index 000000000..ba36fc2aa --- /dev/null +++ b/.icons/openai.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/.icons/proxmox.svg b/.icons/proxmox.svg new file mode 100755 index 000000000..c18256e23 --- /dev/null +++ b/.icons/proxmox.svg @@ -0,0 +1,137 @@ + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.icons/sourcegraph-amp.svg b/.icons/sourcegraph-amp.svg new file mode 100644 index 000000000..83777bd2d --- /dev/null +++ b/.icons/sourcegraph-amp.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/registry/coder-labs/README.md b/registry/coder-labs/README.md index c9a7d8ef7..58c707088 100644 --- a/registry/coder-labs/README.md +++ b/registry/coder-labs/README.md @@ -5,7 +5,7 @@ github: coder avatar: ./.images/avatar.svg linkedin: https://www.linkedin.com/company/coderhq website: https://discord.gg/coder -status: community +status: official --- å diff --git a/registry/coder-labs/modules/codex/README.md b/registry/coder-labs/modules/codex/README.md new file mode 100644 index 000000000..62c63a328 --- /dev/null +++ b/registry/coder-labs/modules/codex/README.md @@ -0,0 +1,146 @@ +--- +display_name: Codex CLI +icon: ../../../../.icons/openai.svg +description: Run Codex CLI in your workspace with AgentAPI integration +verified: true +tags: [agent, codex, ai, openai, tasks] +--- + +# Codex CLI + +Run Codex CLI in your workspace to access OpenAI's models through the Codex interface, with custom pre/post install scripts. This module integrates with [AgentAPI](https://github.com/coder/agentapi) for Coder Tasks compatibility. + +```tf +module "codex" { + source = "registry.coder.com/coder-labs/codex/coder" + version = "2.0.0" + agent_id = coder_agent.example.id + openai_api_key = var.openai_api_key + folder = "/home/coder/project" +} +``` + +## Prerequisites + +- You must add the [Coder Login](https://registry.coder.com/modules/coder/coder-login) module to your template +- OpenAI API key for Codex access + +## Examples + +### Run standalone + +```tf +module "codex" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder-labs/codex/coder" + version = "2.0.0" + agent_id = coder_agent.example.id + openai_api_key = "..." + folder = "/home/coder/project" +} +``` + +### Tasks integration + +```tf +data "coder_parameter" "ai_prompt" { + type = "string" + name = "AI Prompt" + default = "" + description = "Initial prompt for the Codex CLI" + mutable = true +} + +module "coder-login" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/coder-login/coder" + version = "1.0.31" + agent_id = coder_agent.example.id +} + +module "codex" { + source = "registry.coder.com/coder-labs/codex/coder" + version = "2.0.0" + agent_id = coder_agent.example.id + openai_api_key = "..." + ai_prompt = data.coder_parameter.ai_prompt.value + folder = "/home/coder/project" + + # Custom configuration for full auto mode + base_config_toml = <<-EOT + approval_policy = "never" + preferred_auth_method = "apikey" + EOT +} +``` + +> [!WARNING] +> This module configures Codex with a `workspace-write` sandbox that allows AI tasks to read/write files in the specified folder. While the sandbox provides security boundaries, Codex can still modify files within the workspace. Use this module _only_ in trusted environments and be aware of the security implications. + +## How it Works + +- **Install**: The module installs Codex CLI and sets up the environment +- **System Prompt**: If `codex_system_prompt` is set, writes the prompt to `AGENTS.md` in the `~/.codex/` directory +- **Start**: Launches Codex CLI in the specified directory, wrapped by AgentAPI +- **Configuration**: Sets `OPENAI_API_KEY` environment variable and passes `--model` flag to Codex CLI (if variables provided) + +## Configuration + +### Default Configuration + +When no custom `base_config_toml` is provided, the module uses these secure defaults: + +```toml +sandbox_mode = "workspace-write" +approval_policy = "never" +preferred_auth_method = "apikey" + +[sandbox_workspace_write] +network_access = true +``` + +### Custom Configuration + +For custom Codex configuration, use `base_config_toml` and/or `additional_mcp_servers`: + +```tf +module "codex" { + source = "registry.coder.com/coder-labs/codex/coder" + version = "2.0.0" + # ... other variables ... + + # Override default configuration + base_config_toml = <<-EOT + sandbox_mode = "danger-full-access" + approval_policy = "never" + preferred_auth_method = "apikey" + EOT + + # Add extra MCP servers + additional_mcp_servers = <<-EOT + [mcp_servers.GitHub] + command = "npx" + args = ["-y", "@modelcontextprotocol/server-github"] + type = "stdio" + EOT +} +``` + +> [!NOTE] +> If no custom configuration is provided, the module uses secure defaults. The Coder MCP server is always included automatically. For containerized workspaces (Docker/Kubernetes), you may need `sandbox_mode = "danger-full-access"` to avoid permission issues. For advanced options, see [Codex config docs](https://github.com/openai/codex/blob/main/codex-rs/config.md). + +## Troubleshooting + +- Check installation and startup logs in `~/.codex-module/` +- Ensure your OpenAI API key has access to the specified model + +> [!IMPORTANT] +> To use tasks with Codex CLI, ensure you have the `openai_api_key` variable set, and **you create a `coder_parameter` named `"AI Prompt"` and pass its value to the codex module's `ai_prompt` variable**. [Tasks Template Example](https://registry.coder.com/templates/coder-labs/tasks-docker). +> The module automatically configures Codex with your API key and model preferences. +> folder is a required variable for the module to function correctly. + +## References + +- [Codex CLI Documentation](https://github.com/openai/codex) +- [AgentAPI Documentation](https://github.com/coder/agentapi) +- [Coder AI Agents Guide](https://coder.com/docs/tutorials/ai-agents) diff --git a/registry/coder-labs/modules/codex/main.test.ts b/registry/coder-labs/modules/codex/main.test.ts new file mode 100644 index 000000000..55d0b879e --- /dev/null +++ b/registry/coder-labs/modules/codex/main.test.ts @@ -0,0 +1,368 @@ +import { + test, + afterEach, + describe, + setDefaultTimeout, + beforeAll, + expect, +} from "bun:test"; +import { execContainer, readFileContainer, runTerraformInit } from "~test"; +import { + loadTestFile, + writeExecutable, + setup as setupUtil, + execModuleScript, + expectAgentAPIStarted, +} from "../../../coder/modules/agentapi/test-util"; +import dedent from "dedent"; + +let cleanupFunctions: (() => Promise)[] = []; +const registerCleanup = (cleanup: () => Promise) => { + cleanupFunctions.push(cleanup); +}; +afterEach(async () => { + const cleanupFnsCopy = cleanupFunctions.slice().reverse(); + cleanupFunctions = []; + for (const cleanup of cleanupFnsCopy) { + try { + await cleanup(); + } catch (error) { + console.error("Error during cleanup:", error); + } + } +}); + +interface SetupProps { + skipAgentAPIMock?: boolean; + skipCodexMock?: 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_codex: props?.skipCodexMock ? "true" : "false", + install_agentapi: props?.skipAgentAPIMock ? "true" : "false", + codex_model: "gpt-4-turbo", + folder: "/home/coder", + ...props?.moduleVariables, + }, + registerCleanup, + projectDir, + skipAgentAPIMock: props?.skipAgentAPIMock, + agentapiMockScript: props?.agentapiMockScript, + }); + if (!props?.skipCodexMock) { + await writeExecutable({ + containerId: id, + filePath: "/usr/bin/codex", + content: await loadTestFile(import.meta.dir, "codex-mock.sh"), + }); + } + return { id }; +}; + +setDefaultTimeout(60 * 1000); + +describe("codex", async () => { + beforeAll(async () => { + await runTerraformInit(import.meta.dir); + }); + + test("happy-path", async () => { + const { id } = await setup(); + await execModuleScript(id); + await expectAgentAPIStarted(id); + }); + + test("install-codex-version", async () => { + const version_to_install = "0.10.0"; + const { id } = await setup({ + skipCodexMock: true, + moduleVariables: { + install_codex: "true", + codex_version: version_to_install, + }, + }); + await execModuleScript(id); + const resp = await execContainer(id, [ + "bash", + "-c", + `cat /home/coder/.codex-module/install.log`, + ]); + expect(resp.stdout).toContain(version_to_install); + }); + + test("check-latest-codex-version-works", async () => { + const { id } = await setup({ + skipCodexMock: true, + skipAgentAPIMock: true, + moduleVariables: { + install_codex: "true", + }, + }); + await execModuleScript(id); + await expectAgentAPIStarted(id); + }); + + test("base-config-toml", async () => { + const baseConfig = dedent` + sandbox_mode = "danger-full-access" + approval_policy = "never" + preferred_auth_method = "apikey" + + [custom_section] + new_feature = true + `.trim(); + const { id } = await setup({ + moduleVariables: { + base_config_toml: baseConfig, + }, + }); + await execModuleScript(id); + const resp = await readFileContainer(id, "/home/coder/.codex/config.toml"); + expect(resp).toContain("sandbox_mode = \"danger-full-access\""); + expect(resp).toContain("preferred_auth_method = \"apikey\""); + expect(resp).toContain("[custom_section]"); + expect(resp).toContain("[mcp_servers.Coder]"); + }); + + test("codex-api-key", async () => { + const apiKey = "test-api-key-123"; + const { id } = await setup({ + moduleVariables: { + openai_api_key: apiKey, + }, + }); + await execModuleScript(id); + + const resp = await readFileContainer( + id, + "/home/coder/.codex-module/agentapi-start.log", + ); + expect(resp).toContain("OpenAI API Key: Provided"); + }); + + test("pre-post-install-scripts", async () => { + const { id } = await setup({ + moduleVariables: { + pre_install_script: "#!/bin/bash\necho 'pre-install-script'", + post_install_script: "#!/bin/bash\necho 'post-install-script'", + }, + }); + await execModuleScript(id); + const preInstallLog = await readFileContainer( + id, + "/home/coder/.codex-module/pre_install.log", + ); + expect(preInstallLog).toContain("pre-install-script"); + const postInstallLog = await readFileContainer( + id, + "/home/coder/.codex-module/post_install.log", + ); + expect(postInstallLog).toContain("post-install-script"); + }); + + test("folder-variable", async () => { + const folder = "/tmp/codex-test-folder"; + const { id } = await setup({ + skipCodexMock: false, + moduleVariables: { + folder, + }, + }); + await execModuleScript(id); + const resp = await readFileContainer( + id, + "/home/coder/.codex-module/install.log", + ); + expect(resp).toContain(folder); + }); + + test("additional-mcp-servers", async () => { + const additional = dedent` + [mcp_servers.GitHub] + command = "npx" + args = ["-y", "@modelcontextprotocol/server-github"] + type = "stdio" + description = "GitHub integration" + + [mcp_servers.FileSystem] + command = "npx" + args = ["-y", "@modelcontextprotocol/server-filesystem", "/workspace"] + type = "stdio" + description = "File system access" + `.trim(); + const { id } = await setup({ + moduleVariables: { + additional_mcp_servers: additional, + }, + }); + await execModuleScript(id); + const resp = await readFileContainer(id, "/home/coder/.codex/config.toml"); + expect(resp).toContain("[mcp_servers.GitHub]"); + expect(resp).toContain("[mcp_servers.FileSystem]"); + expect(resp).toContain("[mcp_servers.Coder]"); + expect(resp).toContain("GitHub integration"); + }); + + test("full-custom-config", async () => { + const baseConfig = dedent` + sandbox_mode = "read-only" + approval_policy = "untrusted" + preferred_auth_method = "chatgpt" + custom_setting = "test-value" + + [advanced_settings] + timeout = 30000 + debug = true + logging_level = "verbose" + `.trim(); + + const additionalMCP = dedent` + [mcp_servers.CustomTool] + command = "/usr/local/bin/custom-tool" + args = ["--serve", "--port", "8080"] + type = "stdio" + description = "Custom development tool" + + [mcp_servers.DatabaseMCP] + command = "python" + args = ["-m", "database_mcp_server"] + type = "stdio" + description = "Database query interface" + `.trim(); + + const { id } = await setup({ + moduleVariables: { + base_config_toml: baseConfig, + additional_mcp_servers: additionalMCP, + }, + }); + await execModuleScript(id); + const resp = await readFileContainer(id, "/home/coder/.codex/config.toml"); + + // Check base config + expect(resp).toContain("sandbox_mode = \"read-only\""); + expect(resp).toContain("preferred_auth_method = \"chatgpt\""); + expect(resp).toContain("custom_setting = \"test-value\""); + expect(resp).toContain("[advanced_settings]"); + expect(resp).toContain("logging_level = \"verbose\""); + + // Check MCP servers + expect(resp).toContain("[mcp_servers.Coder]"); + expect(resp).toContain("[mcp_servers.CustomTool]"); + expect(resp).toContain("[mcp_servers.DatabaseMCP]"); + expect(resp).toContain("Custom development tool"); + expect(resp).toContain("Database query interface"); + }); + + test("minimal-default-config", async () => { + const { id } = await setup({ + moduleVariables: { + // No base_config_toml or additional_mcp_servers - should use defaults + }, + }); + await execModuleScript(id); + const resp = await readFileContainer(id, "/home/coder/.codex/config.toml"); + + // Check default base config + expect(resp).toContain("sandbox_mode = \"workspace-write\""); + expect(resp).toContain("approval_policy = \"never\""); + expect(resp).toContain("[sandbox_workspace_write]"); + expect(resp).toContain("network_access = true"); + + // Check only Coder MCP server is present + expect(resp).toContain("[mcp_servers.Coder]"); + expect(resp).toContain("Report ALL tasks and statuses"); + + // Ensure no additional MCP servers + const mcpServerCount = (resp.match(/\[mcp_servers\./g) || []).length; + expect(mcpServerCount).toBe(1); + }); + + test("codex-system-prompt", async () => { + const prompt = "This is a system prompt for Codex."; + const { id } = await setup({ + moduleVariables: { + codex_system_prompt: prompt, + }, + }); + await execModuleScript(id); + const resp = await readFileContainer(id, "/home/coder/.codex/AGENTS.md"); + expect(resp).toContain(prompt); + }); + + test("codex-system-prompt-skip-append-if-exists", async () => { + const prompt_1 = "This is a system prompt for Codex."; + const prompt_2 = "This is a system prompt for Goose."; + const prompt_3 = dedent` + This is a system prompt for Codex. + This is a system prompt for Gemini. + `.trim(); + const pre_install_script = dedent` + #!/bin/bash + mkdir -p /home/coder/.codex + echo -e "${prompt_3}" >> /home/coder/.codex/AGENTS.md + `.trim(); + + const { id } = await setup({ + moduleVariables: { + pre_install_script, + codex_system_prompt: prompt_2, + }, + }); + await execModuleScript(id); + const resp = await readFileContainer(id, "/home/coder/.codex/AGENTS.md"); + expect(resp).toContain(prompt_1); + expect(resp).toContain(prompt_2); + + // Re-run with a prompt that already exists, it should not append again + const { id: id_2 } = await setup({ + moduleVariables: { + pre_install_script, + codex_system_prompt: prompt_1, + }, + }); + await execModuleScript(id_2); + const resp_2 = await readFileContainer(id_2, "/home/coder/.codex/AGENTS.md"); + expect(resp_2).toContain(prompt_1); + const count = (resp_2.match(new RegExp(prompt_1, "g")) || []).length; + expect(count).toBe(1); + }); + + test("codex-ai-task-prompt", async () => { + const prompt = "This is a system prompt for Codex."; + const { id } = await setup({ + moduleVariables: { + ai_prompt: prompt, + }, + }); + await execModuleScript(id); + const resp = await execContainer(id, [ + "bash", + "-c", + `cat /home/coder/.codex-module/agentapi-start.log`, + ]); + expect(resp.stdout).toContain(prompt); + }); + + test("start-without-prompt", async () => { + const { id } = await setup({ + moduleVariables: { + codex_system_prompt: "", // Explicitly disable system prompt + }, + }); + await execModuleScript(id); + const prompt = await execContainer(id, [ + "ls", + "-l", + "/home/coder/.codex/AGENTS.md", + ]); + expect(prompt.exitCode).not.toBe(0); + expect(prompt.stderr).toContain("No such file or directory"); + }); +}); diff --git a/registry/coder-labs/modules/codex/main.tf b/registry/coder-labs/modules/codex/main.tf new file mode 100644 index 000000000..9fdec4011 --- /dev/null +++ b/registry/coder-labs/modules/codex/main.tf @@ -0,0 +1,176 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 2.7" + } + } +} + +variable "agent_id" { + type = string + description = "The ID of a Coder agent." +} + +data "coder_workspace" "me" {} + +data "coder_workspace_owner" "me" {} + +variable "order" { + type = number + description = "The order determines the position of app in the UI presentation. The lowest order is shown first and apps with equal order are sorted by name (ascending order)." + default = null +} + +variable "group" { + type = string + description = "The name of a group that this app belongs to." + default = null +} + +variable "icon" { + type = string + description = "The icon to use for the app." + default = "/icon/openai.svg" +} + +variable "folder" { + type = string + description = "The folder to run Codex in." +} + +variable "install_codex" { + type = bool + description = "Whether to install Codex." + default = true +} + +variable "codex_version" { + type = string + description = "The version of Codex to install." + default = "" # empty string means the latest available version +} + +variable "base_config_toml" { + type = string + description = "Complete base TOML configuration for Codex (without mcp_servers section). If empty, uses minimal default configuration with workspace-write sandbox mode and never approval policy. For advanced options, see https://github.com/openai/codex/blob/main/codex-rs/config.md" + default = "" +} + +variable "additional_mcp_servers" { + type = string + description = "Additional MCP servers configuration in TOML format. These will be merged with the required Coder MCP server in the [mcp_servers] section." + default = "" +} + +variable "openai_api_key" { + type = string + description = "OpenAI API key for Codex CLI" + default = "" +} + +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.5.0" +} + +variable "codex_model" { + type = string + description = "The model for Codex to use. Defaults to gpt-5." + default = "" +} + +variable "pre_install_script" { + type = string + description = "Custom script to run before installing Codex." + default = null +} + +variable "post_install_script" { + type = string + description = "Custom script to run after installing Codex." + default = null +} + +variable "ai_prompt" { + type = string + description = "Initial task prompt for Codex CLI when launched via Tasks" + default = "" +} + +variable "codex_system_prompt" { + type = string + description = "System instructions written to AGENTS.md in the ~/.codex directory" + default = "You are a helpful coding assistant. Start every response with `Codex says:`" +} + +resource "coder_env" "openai_api_key" { + agent_id = var.agent_id + name = "OPENAI_API_KEY" + value = var.openai_api_key +} + +locals { + app_slug = "codex" + install_script = file("${path.module}/scripts/install.sh") + start_script = file("${path.module}/scripts/start.sh") + module_dir_name = ".codex-module" +} + +module "agentapi" { + source = "registry.coder.com/coder/agentapi/coder" + version = "1.1.1" + + agent_id = var.agent_id + web_app_slug = local.app_slug + web_app_order = var.order + web_app_group = var.group + web_app_icon = var.icon + web_app_display_name = "Codex" + cli_app_slug = "${local.app_slug}-cli" + cli_app_display_name = "Codex CLI" + 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_OPENAI_API_KEY='${var.openai_api_key}' \ + ARG_CODEX_MODEL='${var.codex_model}' \ + ARG_CODEX_START_DIRECTORY='${var.folder}' \ + ARG_CODEX_TASK_PROMPT='${base64encode(var.ai_prompt)}' \ + /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_INSTALL='${var.install_codex}' \ + ARG_CODEX_VERSION='${var.codex_version}' \ + ARG_BASE_CONFIG_TOML='${base64encode(var.base_config_toml)}' \ + ARG_ADDITIONAL_MCP_SERVERS='${base64encode(var.additional_mcp_servers)}' \ + ARG_CODER_MCP_APP_STATUS_SLUG='${local.app_slug}' \ + ARG_CODEX_START_DIRECTORY='${var.folder}' \ + ARG_CODEX_INSTRUCTION_PROMPT='${base64encode(var.codex_system_prompt)}' \ + /tmp/install.sh + EOT +} \ No newline at end of file diff --git a/registry/coder-labs/modules/codex/scripts/install.sh b/registry/coder-labs/modules/codex/scripts/install.sh new file mode 100644 index 000000000..d725f6108 --- /dev/null +++ b/registry/coder-labs/modules/codex/scripts/install.sh @@ -0,0 +1,165 @@ +#!/bin/bash +source "$HOME"/.bashrc + +BOLD='\033[0;1m' + +command_exists() { + command -v "$1" > /dev/null 2>&1 +} +set -o errexit +set -o pipefail +set -o nounset + +ARG_BASE_CONFIG_TOML=$(echo -n "$ARG_BASE_CONFIG_TOML" | base64 -d) +ARG_ADDITIONAL_MCP_SERVERS=$(echo -n "$ARG_ADDITIONAL_MCP_SERVERS" | base64 -d) +ARG_CODEX_INSTRUCTION_PROMPT=$(echo -n "$ARG_CODEX_INSTRUCTION_PROMPT" | base64 -d) + +echo "=== Codex Module Configuration ===" +printf "Install Codex: %s\n" "$ARG_INSTALL" +printf "Codex Version: %s\n" "$ARG_CODEX_VERSION" +printf "App Slug: %s\n" "$ARG_CODER_MCP_APP_STATUS_SLUG" +printf "Start Directory: %s\n" "$ARG_CODEX_START_DIRECTORY" +printf "Has Base Config: %s\n" "$([ -n "$ARG_BASE_CONFIG_TOML" ] && echo "Yes" || echo "No")" +printf "Has Additional MCP: %s\n" "$([ -n "$ARG_ADDITIONAL_MCP_SERVERS" ] && echo "Yes" || echo "No")" +printf "Has System Prompt: %s\n" "$([ -n "$ARG_CODEX_INSTRUCTION_PROMPT" ] && echo "Yes" || echo "No")" +echo "======================================" + +set +o nounset + +function install_node() { + if ! command_exists npm; then + printf "npm not found, checking for Node.js installation...\n" + if ! command_exists node; then + printf "Node.js not found, installing Node.js via NVM...\n" + export NVM_DIR="$HOME/.nvm" + if [ ! -d "$NVM_DIR" ]; then + mkdir -p "$NVM_DIR" + curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash + [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" + else + [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" + fi + + nvm install --lts + nvm use --lts + nvm alias default node + + printf "Node.js installed: %s\n" "$(node --version)" + printf "npm installed: %s\n" "$(npm --version)" + else + printf "Node.js is installed but npm is not available. Please install npm manually.\n" + exit 1 + fi + fi +} + +function install_codex() { + if [ "${ARG_INSTALL}" = "true" ]; then + install_node + + if ! command_exists nvm; then + printf "which node: %s\n" "$(which node)" + printf "which npm: %s\n" "$(which npm)" + + mkdir -p "$HOME"/.npm-global + + npm config set prefix "$HOME/.npm-global" + + export PATH="$HOME/.npm-global/bin:$PATH" + + if ! grep -q "export PATH=$HOME/.npm-global/bin:\$PATH" ~/.bashrc; then + echo "export PATH=$HOME/.npm-global/bin:\$PATH" >> ~/.bashrc + fi + fi + + printf "%s Installing Codex CLI\n" "${BOLD}" + + if [ -n "$ARG_CODEX_VERSION" ]; then + npm install -g "@openai/codex@$ARG_CODEX_VERSION" + else + npm install -g "@openai/codex" + fi + printf "%s Successfully installed Codex CLI. Version: %s\n" "${BOLD}" "$(codex --version)" + fi +} + +write_minimal_default_config() { + local config_path="$1" + cat << EOF > "$config_path" +# Minimal Default Codex Configuration +sandbox_mode = "workspace-write" +approval_policy = "never" +preferred_auth_method = "apikey" + +[sandbox_workspace_write] +network_access = true + +EOF +} + +append_mcp_servers_section() { + local config_path="$1" + + cat << EOF >> "$config_path" + +# MCP Servers Configuration +[mcp_servers.Coder] +command = "coder" +args = ["exp", "mcp", "server"] +env = { "CODER_MCP_APP_STATUS_SLUG" = "${ARG_CODER_MCP_APP_STATUS_SLUG}", "CODER_MCP_AI_AGENTAPI_URL" = "http://localhost:3284", "CODER_AGENT_URL" = "${CODER_AGENT_URL}", "CODER_AGENT_TOKEN" = "${CODER_AGENT_TOKEN}" } +description = "Report ALL tasks and statuses (in progress, done, failed) you are working on." +type = "stdio" + +EOF + + if [ -n "$ARG_ADDITIONAL_MCP_SERVERS" ]; then + printf "Adding additional MCP servers\n" + echo "$ARG_ADDITIONAL_MCP_SERVERS" >> "$config_path" + fi +} + +function populate_config_toml() { + CONFIG_PATH="$HOME/.codex/config.toml" + mkdir -p "$(dirname "$CONFIG_PATH")" + + if [ -n "$ARG_BASE_CONFIG_TOML" ]; then + printf "Using provided base configuration\n" + echo "$ARG_BASE_CONFIG_TOML" > "$CONFIG_PATH" + else + printf "Using minimal default configuration\n" + write_minimal_default_config "$CONFIG_PATH" + fi + + append_mcp_servers_section "$CONFIG_PATH" +} + +function add_instruction_prompt_if_exists() { + if [ -n "${ARG_CODEX_INSTRUCTION_PROMPT:-}" ]; then + AGENTS_PATH="$HOME/.codex/AGENTS.md" + printf "Creating AGENTS.md in .codex directory: %s\\n" "${AGENTS_PATH}" + + mkdir -p "$HOME/.codex" + + if [ -f "${AGENTS_PATH}" ] && grep -Fq "${ARG_CODEX_INSTRUCTION_PROMPT}" "${AGENTS_PATH}"; then + printf "AGENTS.md already contains the instruction prompt. Skipping append.\n" + else + printf "Appending instruction prompt to AGENTS.md in .codex directory\n" + echo -e "\n${ARG_CODEX_INSTRUCTION_PROMPT}" >> "${AGENTS_PATH}" + fi + + if [ ! -d "${ARG_CODEX_START_DIRECTORY}" ]; then + printf "Creating start directory '%s'\\n" "${ARG_CODEX_START_DIRECTORY}" + mkdir -p "${ARG_CODEX_START_DIRECTORY}" || { + printf "Error: Could not create directory '%s'.\\n" "${ARG_CODEX_START_DIRECTORY}" + exit 1 + } + fi + else + printf "AGENTS.md instruction prompt is not set.\n" + fi +} + +install_codex +codex --version +populate_config_toml +add_instruction_prompt_if_exists diff --git a/registry/coder-labs/modules/codex/scripts/start.sh b/registry/coder-labs/modules/codex/scripts/start.sh new file mode 100644 index 000000000..29a8d741c --- /dev/null +++ b/registry/coder-labs/modules/codex/scripts/start.sh @@ -0,0 +1,73 @@ +#!/bin/bash + +source "$HOME"/.bashrc +set -o errexit +set -o pipefail +command_exists() { + command -v "$1" > /dev/null 2>&1 +} + +if [ -f "$HOME/.nvm/nvm.sh" ]; then + source "$HOME"/.nvm/nvm.sh +else + export PATH="$HOME/.npm-global/bin:$PATH" +fi + +printf "Version: %s\n" "$(codex --version)" +set -o nounset +ARG_CODEX_TASK_PROMPT=$(echo -n "$ARG_CODEX_TASK_PROMPT" | base64 -d) + +echo "=== Codex Launch Configuration ===" +printf "OpenAI API Key: %s\n" "$([ -n "$ARG_OPENAI_API_KEY" ] && echo "Provided" || echo "Not provided")" +printf "Codex Model: %s\n" "${ARG_CODEX_MODEL:-"Default"}" +printf "Start Directory: %s\n" "$ARG_CODEX_START_DIRECTORY" +printf "Has Task Prompt: %s\n" "$([ -n "$ARG_CODEX_TASK_PROMPT" ] && echo "Yes" || echo "No")" +echo "======================================" +set +o nounset +CODEX_ARGS=() + +if command_exists codex; then + printf "Codex is installed\n" +else + printf "Error: Codex is not installed. Please enable install_codex or install it manually\n" + exit 1 +fi + +if [ -d "${ARG_CODEX_START_DIRECTORY}" ]; then + printf "Directory '%s' exists. Changing to it.\\n" "${ARG_CODEX_START_DIRECTORY}" + cd "${ARG_CODEX_START_DIRECTORY}" || { + printf "Error: Could not change to directory '%s'.\\n" "${ARG_CODEX_START_DIRECTORY}" + exit 1 + } +else + printf "Directory '%s' does not exist. Creating and changing to it.\\n" "${ARG_CODEX_START_DIRECTORY}" + mkdir -p "${ARG_CODEX_START_DIRECTORY}" || { + printf "Error: Could not create directory '%s'.\\n" "${ARG_CODEX_START_DIRECTORY}" + exit 1 + } + cd "${ARG_CODEX_START_DIRECTORY}" || { + printf "Error: Could not change to directory '%s'.\\n" "${ARG_CODEX_START_DIRECTORY}" + exit 1 + } +fi + +if [ -n "$ARG_CODEX_MODEL" ]; then + CODEX_ARGS+=("--model" "$ARG_CODEX_MODEL") +fi + + + +if [ -n "$ARG_CODEX_TASK_PROMPT" ]; then + printf "Running the task prompt %s\n" "$ARG_CODEX_TASK_PROMPT" + PROMPT="Complete the task at hand in one go. Every step of the way, report your progress using coder_report_task tool with proper summary and statuses. Your task at hand: $ARG_CODEX_TASK_PROMPT" + CODEX_ARGS+=("$PROMPT") +else + printf "No task prompt given.\n" +fi + + +# Terminal dimensions optimized for Coder Tasks UI sidebar: +# - Width 67: fits comfortably in sidebar +# - Height 1190: adjusted due to Codex terminal height bug +printf "Starting Codex with arguments: %s\n" "${CODEX_ARGS[*]}" +agentapi server --term-width 67 --term-height 1190 -- codex "${CODEX_ARGS[@]}" diff --git a/registry/coder-labs/modules/codex/testdata/codex-mock.sh b/registry/coder-labs/modules/codex/testdata/codex-mock.sh new file mode 100644 index 000000000..8c1c7366d --- /dev/null +++ b/registry/coder-labs/modules/codex/testdata/codex-mock.sh @@ -0,0 +1,14 @@ +#!/bin/bash + +if [[ "$1" == "--version" ]]; then + echo "HELLO: $(bash -c env)" + echo "codex version v1.0.0" + exit 0 +fi + +set -e + +while true; do + echo "$(date) - codex-mock" + sleep 15 +done diff --git a/registry/coder-labs/modules/cursor-cli/README.md b/registry/coder-labs/modules/cursor-cli/README.md new file mode 100644 index 000000000..0d3cd7536 --- /dev/null +++ b/registry/coder-labs/modules/cursor-cli/README.md @@ -0,0 +1,135 @@ +--- +display_name: Cursor CLI +icon: ../../../../.icons/cursor.svg +description: Run Cursor Agent CLI in your workspace for AI pair programming +verified: true +tags: [agent, cursor, ai, tasks] +--- + +# Cursor CLI + +Run the Cursor Agent CLI in your workspace for interactive coding assistance and automated task execution. + +```tf +module "cursor_cli" { + source = "registry.coder.com/coder-labs/cursor-cli/coder" + version = "0.1.1" + agent_id = coder_agent.example.id + folder = "/home/coder/project" +} +``` + +## Basic setup + +A full example with MCP, rules, and pre/post install scripts: + +```tf + +data "coder_parameter" "ai_prompt" { + type = "string" + name = "AI Prompt" + default = "" + description = "Build a Minesweeper in Python." + mutable = true +} + +module "coder-login" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/coder-login/coder" + version = "1.0.31" + agent_id = coder_agent.main.id +} + +module "cursor_cli" { + source = "registry.coder.com/coder-labs/cursor-cli/coder" + version = "0.1.1" + agent_id = coder_agent.example.id + folder = "/home/coder/project" + + # Optional + install_cursor_cli = true + force = true + model = "gpt-5" + ai_prompt = data.coder_parameter.ai_prompt.value + api_key = "xxxx-xxxx-xxxx" # Required while using tasks, see note below + + # Minimal MCP server (writes `folder/.cursor/mcp.json`): + mcp = jsonencode({ + mcpServers = { + playwright = { + command = "npx" + args = ["-y", "@playwright/mcp@latest", "--headless", "--isolated", "--no-sandbox"] + } + desktop-commander = { + command = "npx" + args = ["-y", "@wonderwhy-er/desktop-commander"] + } + } + }) + + # Use a pre_install_script to install the CLI + pre_install_script = <<-EOT + #!/usr/bin/env bash + set -euo pipefail + curl -fsSL https://deb.nodesource.com/setup_20.x | bash - + apt-get install -y nodejs + EOT + + # Use post_install_script to wait for the repo to be ready + post_install_script = <<-EOT + #!/usr/bin/env bash + set -euo pipefail + TARGET="$${FOLDER}/.git/config" + echo "[cursor-cli] waiting for $${TARGET}..." + for i in $(seq 1 600); do + [ -f "$TARGET" ] && { echo "ready"; exit 0; } + sleep 1 + done + echo "timeout waiting for $${TARGET}" >&2 + EOT + + # Provide a map of file name to content; files are written to `folder/.cursor/rules/`. + rules_files = { + "python.mdc" = <<-EOT + --- + description: RPC Service boilerplate + globs: + alwaysApply: false + --- + + - Use our internal RPC pattern when defining services + - Always use snake_case for service names. + + @service-template.ts + EOT + + "frontend.mdc" = <<-EOT + --- + description: RPC Service boilerplate + globs: + alwaysApply: false + --- + + - Use our internal RPC pattern when defining services + - Always use snake_case for service names. + + @service-template.ts + EOT + } +} +``` + +> [!NOTE] +> A `.cursor` directory will be created in the specified `folder`, containing the MCP configuration, rules. +> To use this module with tasks, please pass the API Key obtained from Cursor to the `api_key` variable. To obtain the api key follow the instructions [here](https://docs.cursor.com/en/cli/reference/authentication#step-1%3A-generate-an-api-key) + +## References + +- See Cursor CLI docs: `https://docs.cursor.com/en/cli/overview` +- For MCP project config, see `https://docs.cursor.com/en/context/mcp#using-mcp-json`. This module writes your `mcp_json` into `folder/.cursor/mcp.json`. +- For Rules, see `https://docs.cursor.com/en/context/rules#project-rules`. Provide `rules_files` (map of file name to content) to populate `folder/.cursor/rules/`. + +## Troubleshooting + +- Ensure the CLI is installed (enable `install_cursor_cli = true` or preinstall it in your image) +- Logs are written to `~/.cursor-cli-module/` diff --git a/registry/coder-labs/modules/cursor-cli/cursor-cli.tftest.hcl b/registry/coder-labs/modules/cursor-cli/cursor-cli.tftest.hcl new file mode 100644 index 000000000..f8e917a1f --- /dev/null +++ b/registry/coder-labs/modules/cursor-cli/cursor-cli.tftest.hcl @@ -0,0 +1,152 @@ +run "test_cursor_cli_basic" { + command = plan + + variables { + agent_id = "test-agent-123" + folder = "/home/coder/projects" + } + + assert { + condition = coder_env.status_slug.name == "CODER_MCP_APP_STATUS_SLUG" + error_message = "Status slug environment variable should be set correctly" + } + + assert { + condition = coder_env.status_slug.value == "cursorcli" + error_message = "Status slug value should be 'cursorcli'" + } + + assert { + condition = var.folder == "/home/coder/projects" + error_message = "Folder variable should be set correctly" + } + + assert { + condition = var.agent_id == "test-agent-123" + error_message = "Agent ID variable should be set correctly" + } +} + +run "test_cursor_cli_with_api_key" { + command = plan + + variables { + agent_id = "test-agent-456" + folder = "/home/coder/workspace" + api_key = "test-api-key-123" + } + + assert { + condition = coder_env.cursor_api_key[0].name == "CURSOR_API_KEY" + error_message = "Cursor API key environment variable should be set correctly" + } + + assert { + condition = coder_env.cursor_api_key[0].value == "test-api-key-123" + error_message = "Cursor API key value should match the input" + } +} + +run "test_cursor_cli_with_custom_options" { + command = plan + + variables { + agent_id = "test-agent-789" + folder = "/home/coder/custom" + order = 5 + group = "development" + icon = "/icon/custom.svg" + model = "sonnet-4" + ai_prompt = "Help me write better code" + force = false + install_cursor_cli = false + install_agentapi = false + } + + assert { + condition = var.order == 5 + error_message = "Order variable should be set to 5" + } + + assert { + condition = var.group == "development" + error_message = "Group variable should be set to 'development'" + } + + assert { + condition = var.icon == "/icon/custom.svg" + error_message = "Icon variable should be set to custom icon" + } + + assert { + condition = var.model == "sonnet-4" + error_message = "Model variable should be set to 'sonnet-4'" + } + + assert { + condition = var.ai_prompt == "Help me write better code" + error_message = "AI prompt variable should be set correctly" + } + + assert { + condition = var.force == false + error_message = "Force variable should be set to false" + } +} + +run "test_cursor_cli_with_mcp_and_rules" { + command = plan + + variables { + agent_id = "test-agent-mcp" + folder = "/home/coder/mcp-test" + mcp = jsonencode({ + mcpServers = { + test = { + command = "test-server" + args = ["--config", "test.json"] + } + } + }) + rules_files = { + "general.md" = "# General coding rules\n- Write clean code\n- Add comments" + "security.md" = "# Security rules\n- Never commit secrets\n- Validate inputs" + } + } + + assert { + condition = var.mcp != null + error_message = "MCP configuration should be provided" + } + + assert { + condition = var.rules_files != null + error_message = "Rules files should be provided" + } + + assert { + condition = length(var.rules_files) == 2 + error_message = "Should have 2 rules files" + } +} + +run "test_cursor_cli_with_scripts" { + command = plan + + variables { + agent_id = "test-agent-scripts" + folder = "/home/coder/scripts" + pre_install_script = "echo 'Pre-install script'" + post_install_script = "echo 'Post-install script'" + } + + assert { + condition = var.pre_install_script == "echo 'Pre-install script'" + error_message = "Pre-install script should be set correctly" + } + + assert { + condition = var.post_install_script == "echo 'Post-install script'" + error_message = "Post-install script should be set correctly" + } +} diff --git a/registry/coder-labs/modules/cursor-cli/main.test.ts b/registry/coder-labs/modules/cursor-cli/main.test.ts new file mode 100644 index 000000000..52bc993f0 --- /dev/null +++ b/registry/coder-labs/modules/cursor-cli/main.test.ts @@ -0,0 +1,212 @@ +import { afterEach, beforeAll, describe, expect, setDefaultTimeout, test } from "bun:test"; +import { execContainer, runTerraformInit, writeFileContainer } from "~test"; +import { + execModuleScript, + expectAgentAPIStarted, + loadTestFile, + setup as setupUtil +} from "../../../coder/modules/agentapi/test-util"; +import { setupContainer, writeExecutable } from "../../../coder/modules/agentapi/test-util"; + +let cleanupFns: (() => Promise)[] = []; +const registerCleanup = (fn: () => Promise) => cleanupFns.push(fn); + +afterEach(async () => { + const fns = cleanupFns.slice().reverse(); + cleanupFns = []; + for (const fn of fns) { + try { + await fn(); + } catch (err) { + console.error(err); + } + } +}); + +interface SetupProps { + skipAgentAPIMock?: boolean; + skipCursorCliMock?: 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: { + enable_agentapi: "true", + install_cursor_cli: props?.skipCursorCliMock ? "true" : "false", + install_agentapi: props?.skipAgentAPIMock ? "true" : "false", + folder: projectDir, + ...props?.moduleVariables, + }, + registerCleanup, + projectDir, + skipAgentAPIMock: props?.skipAgentAPIMock, + agentapiMockScript: props?.agentapiMockScript, + }); + if (!props?.skipCursorCliMock) { + await writeExecutable({ + containerId: id, + filePath: "/usr/bin/cursor-agent", + content: await loadTestFile(import.meta.dir, "cursor-cli-mock.sh"), + }); + } + return { id }; +}; + +setDefaultTimeout(180 * 1000); + +describe("cursor-cli", async () => { + beforeAll(async () => { + await runTerraformInit(import.meta.dir); + }); + + test("agentapi-happy-path", async () => { + const { id } = await setup({}); + const resp = await execModuleScript(id); + expect(resp.exitCode).toBe(0); + + await expectAgentAPIStarted(id); + }); + + test("agentapi-mcp-json", async () => { + const mcpJson = '{"mcpServers": {"test": {"command": "test-cmd", "type": "stdio"}}}'; + const { id } = await setup({ + moduleVariables: { + mcp: mcpJson, + } + }); + const resp = await execModuleScript(id); + expect(resp.exitCode).toBe(0); + + const mcpContent = await execContainer(id, [ + "bash", + "-c", + `cat '/home/coder/project/.cursor/mcp.json'`, + ]); + expect(mcpContent.exitCode).toBe(0); + expect(mcpContent.stdout).toContain("mcpServers"); + expect(mcpContent.stdout).toContain("test"); + expect(mcpContent.stdout).toContain("test-cmd"); + expect(mcpContent.stdout).toContain("/tmp/mcp-hack.sh"); + expect(mcpContent.stdout).toContain("coder"); + }); + + test("agentapi-rules-files", async () => { + const rulesContent = "Always use TypeScript"; + const { id } = await setup({ + moduleVariables: { + rules_files: JSON.stringify({ "typescript.md": rulesContent }), + } + }); + const resp = await execModuleScript(id); + expect(resp.exitCode).toBe(0); + + const rulesFile = await execContainer(id, [ + "bash", + "-c", + `cat '/home/coder/project/.cursor/rules/typescript.md'`, + ]); + expect(rulesFile.exitCode).toBe(0); + expect(rulesFile.stdout).toContain(rulesContent); + }); + + test("agentapi-api-key", async () => { + const apiKey = "test-cursor-api-key-123"; + const { id } = await setup({ + moduleVariables: { + api_key: apiKey, + } + }); + const resp = await execModuleScript(id); + expect(resp.exitCode).toBe(0); + + const envCheck = await execContainer(id, [ + "bash", + "-c", + `env | grep CURSOR_API_KEY || echo "CURSOR_API_KEY not found"`, + ]); + expect(envCheck.stdout).toContain("CURSOR_API_KEY"); + }); + + test("agentapi-model-and-force-flags", async () => { + const model = "sonnet-4"; + const { id } = await setup({ + moduleVariables: { + model: model, + force: "true", + ai_prompt: "test prompt", + } + }); + const resp = await execModuleScript(id); + expect(resp.exitCode).toBe(0); + + const startLog = await execContainer(id, [ + "bash", + "-c", + "cat /home/coder/.cursor-cli-module/agentapi-start.log || cat /home/coder/.cursor-cli-module/start.log || true", + ]); + expect(startLog.stdout).toContain(`-m ${model}`); + expect(startLog.stdout).toContain("-f"); + expect(startLog.stdout).toContain("test prompt"); + }); + + test("agentapi-pre-post-install-scripts", async () => { + const { id } = await setup({ + moduleVariables: { + pre_install_script: "#!/bin/bash\necho 'cursor-pre-install-script'", + post_install_script: "#!/bin/bash\necho 'cursor-post-install-script'", + } + }); + const resp = await execModuleScript(id); + expect(resp.exitCode).toBe(0); + + const preInstallLog = await execContainer(id, [ + "bash", + "-c", + "cat /home/coder/.cursor-cli-module/pre_install.log || true", + ]); + expect(preInstallLog.stdout).toContain("cursor-pre-install-script"); + + const postInstallLog = await execContainer(id, [ + "bash", + "-c", + "cat /home/coder/.cursor-cli-module/post_install.log || true", + ]); + expect(postInstallLog.stdout).toContain("cursor-post-install-script"); + }); + + test("agentapi-folder-variable", async () => { + const folder = "/tmp/cursor-test-folder"; + const { id } = await setup({ + moduleVariables: { + folder: folder, + } + }); + const resp = await execModuleScript(id); + expect(resp.exitCode).toBe(0); + + const installLog = await execContainer(id, [ + "bash", + "-c", + "cat /home/coder/.cursor-cli-module/install.log || true", + ]); + expect(installLog.stdout).toContain(folder); + }); + + test("install-test-cursor-cli-latest", async () => { + const { id } = await setup({ + skipCursorCliMock: true, + skipAgentAPIMock: true, + }); + const resp = await execModuleScript(id); + expect(resp.exitCode).toBe(0); + + await expectAgentAPIStarted(id); + }) + +}); + + diff --git a/registry/coder-labs/modules/cursor-cli/main.tf b/registry/coder-labs/modules/cursor-cli/main.tf new file mode 100644 index 000000000..482103718 --- /dev/null +++ b/registry/coder-labs/modules/cursor-cli/main.tf @@ -0,0 +1,179 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 2.7" + } + } +} + +variable "agent_id" { + type = string + description = "The ID of a Coder agent." +} + +data "coder_workspace" "me" {} + +data "coder_workspace_owner" "me" {} + +variable "order" { + type = number + description = "The order determines the position of app in the UI presentation. The lowest order is shown first and apps with equal order are sorted by name (ascending order)." + default = null +} + +variable "group" { + type = string + description = "The name of a group that this app belongs to." + default = null +} + +variable "icon" { + type = string + description = "The icon to use for the app." + default = "/icon/cursor.svg" +} + +variable "folder" { + type = string + description = "The folder to run Cursor CLI in." +} + +variable "install_cursor_cli" { + type = bool + description = "Whether to install Cursor CLI." + default = true +} + +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.5.0" +} + +variable "force" { + type = bool + description = "Force allow commands unless explicitly denied" + default = true +} + +variable "model" { + type = string + description = "Model to use (e.g., sonnet-4, sonnet-4-thinking, gpt-5)" + default = "" +} + +variable "ai_prompt" { + type = string + description = "AI prompt/task passed to cursor-agent." + default = "" +} + +variable "api_key" { + type = string + description = "API key for Cursor CLI." + default = "" + sensitive = true +} + +variable "mcp" { + type = string + description = "Workspace-specific MCP JSON to write to folder/.cursor/mcp.json. See https://docs.cursor.com/en/context/mcp#using-mcp-json" + default = null +} + +variable "rules_files" { + type = map(string) + description = "Optional map of rule file name to content. Files will be written to folder/.cursor/rules/. See https://docs.cursor.com/en/context/rules#project-rules" + default = null +} + +variable "pre_install_script" { + type = string + description = "Optional script to run before installing Cursor CLI." + default = null +} + +variable "post_install_script" { + type = string + description = "Optional script to run after installing Cursor CLI." + default = null +} + +locals { + app_slug = "cursorcli" + install_script = file("${path.module}/scripts/install.sh") + start_script = file("${path.module}/scripts/start.sh") + module_dir_name = ".cursor-cli-module" +} + +# Expose status slug and API key to the agent environment +resource "coder_env" "status_slug" { + agent_id = var.agent_id + name = "CODER_MCP_APP_STATUS_SLUG" + value = local.app_slug +} + +resource "coder_env" "cursor_api_key" { + count = var.api_key != "" ? 1 : 0 + agent_id = var.agent_id + name = "CURSOR_API_KEY" + value = var.api_key +} + +module "agentapi" { + source = "registry.coder.com/coder/agentapi/coder" + version = "1.1.1" + + agent_id = var.agent_id + web_app_slug = local.app_slug + web_app_order = var.order + web_app_group = var.group + web_app_icon = var.icon + web_app_display_name = "Cursor CLI" + cli_app_slug = local.app_slug + cli_app_display_name = "Cursor CLI" + 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_FORCE='${var.force}' \ + ARG_MODEL='${var.model}' \ + ARG_AI_PROMPT='${base64encode(var.ai_prompt)}' \ + ARG_MODULE_DIR_NAME='${local.module_dir_name}' \ + ARG_FOLDER='${var.folder}' \ + /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_INSTALL='${var.install_cursor_cli}' \ + ARG_WORKSPACE_MCP_JSON='${var.mcp != null ? base64encode(replace(var.mcp, "'", "'\\''")) : ""}' \ + ARG_WORKSPACE_RULES_JSON='${var.rules_files != null ? base64encode(jsonencode(var.rules_files)) : ""}' \ + ARG_MODULE_DIR_NAME='${local.module_dir_name}' \ + ARG_FOLDER='${var.folder}' \ + ARG_CODER_MCP_APP_STATUS_SLUG='${local.app_slug}' \ + /tmp/install.sh + EOT +} diff --git a/registry/coder-labs/modules/cursor-cli/scripts/install.sh b/registry/coder-labs/modules/cursor-cli/scripts/install.sh new file mode 100644 index 000000000..5477f07dd --- /dev/null +++ b/registry/coder-labs/modules/cursor-cli/scripts/install.sh @@ -0,0 +1,122 @@ +#!/bin/bash + +set -o errexit +set -o pipefail + +command_exists() { + command -v "$1" > /dev/null 2>&1 +} + +# Inputs +ARG_INSTALL=${ARG_INSTALL:-true} +ARG_MODULE_DIR_NAME=${ARG_MODULE_DIR_NAME:-.cursor-cli-module} +ARG_FOLDER=${ARG_FOLDER:-$HOME} +ARG_CODER_MCP_APP_STATUS_SLUG=${ARG_CODER_MCP_APP_STATUS_SLUG:-} + +mkdir -p "$HOME/$ARG_MODULE_DIR_NAME" + +ARG_WORKSPACE_MCP_JSON=$(echo -n "$ARG_WORKSPACE_MCP_JSON" | base64 -d) +ARG_WORKSPACE_RULES_JSON=$(echo -n "$ARG_WORKSPACE_RULES_JSON" | base64 -d) + +echo "--------------------------------" +echo "install: $ARG_INSTALL" +echo "folder: $ARG_FOLDER" +echo "coder_mcp_app_status_slug: $ARG_CODER_MCP_APP_STATUS_SLUG" +echo "module_dir_name: $ARG_MODULE_DIR_NAME" +echo "--------------------------------" + +# Install Cursor via official installer if requested +function install_cursor_cli() { + if [ "$ARG_INSTALL" = "true" ]; then + echo "Installing Cursor via official installer..." + set +e + curl https://cursor.com/install -fsS | bash 2>&1 + CURL_EXIT=${PIPESTATUS[0]} + set -e + if [ $CURL_EXIT -ne 0 ]; then + echo "Cursor installer failed with exit code $CURL_EXIT" + fi + + # Ensure binaries are discoverable; create stable symlink to cursor-agent + CANDIDATES=( + "$(command -v cursor-agent || true)" + "$HOME/.cursor/bin/cursor-agent" + ) + FOUND_BIN="" + for c in "${CANDIDATES[@]}"; do + if [ -n "$c" ] && [ -x "$c" ]; then + FOUND_BIN="$c" + break + fi + done + mkdir -p "$HOME/.local/bin" + if [ -n "$FOUND_BIN" ]; then + ln -sf "$FOUND_BIN" "$HOME/.local/bin/cursor-agent" + fi + echo "Installed cursor-agent at: $(command -v cursor-agent || true) (resolved: $FOUND_BIN)" + fi +} + +# Write MCP config to user's home if provided (ARG_FOLDER/.cursor/mcp.json) +function write_mcp_config() { + TARGET_DIR="$ARG_FOLDER/.cursor" + TARGET_FILE="$TARGET_DIR/mcp.json" + mkdir -p "$TARGET_DIR" + + CURSOR_MCP_HACK_SCRIPT=$( + cat << EOF +#!/usr/bin/env bash +set -e + +# --- Set environment variables --- +export CODER_MCP_APP_STATUS_SLUG="${ARG_CODER_MCP_APP_STATUS_SLUG}" +export CODER_MCP_AI_AGENTAPI_URL="http://localhost:3284" +export CODER_AGENT_URL="${CODER_AGENT_URL}" +export CODER_AGENT_TOKEN="${CODER_AGENT_TOKEN}" + +# --- Launch the MCP server --- +exec coder exp mcp server +EOF + ) + echo "$CURSOR_MCP_HACK_SCRIPT" > "/tmp/mcp-hack.sh" + chmod +x /tmp/mcp-hack.sh + + CODER_MCP=$( + cat << EOF +{ + "coder": { + "args": [], + "command": "/tmp/mcp-hack.sh", + "description": "Report ALL tasks and statuses (in progress, done, failed) you are working on.", + "name": "Coder", + "timeout": 3000, + "type": "stdio", + "trust": true + } +} +EOF + ) + + echo "${ARG_WORKSPACE_MCP_JSON:-{}}" | jq --argjson base "$CODER_MCP" \ + '.mcpServers = ((.mcpServers // {}) + $base)' > "$TARGET_FILE" + echo "Wrote workspace MCP to $TARGET_FILE" +} + +# Write rules files to user's home (FOLDER/.cursor/rules) +function write_rules_file() { + if [ -n "$ARG_WORKSPACE_RULES_JSON" ]; then + RULES_DIR="$ARG_FOLDER/.cursor/rules" + mkdir -p "$RULES_DIR" + echo "$ARG_WORKSPACE_RULES_JSON" | jq -r 'to_entries[] | @base64' | while read -r entry; do + _jq() { echo "${entry}" | base64 -d | jq -r ${1}; } + NAME=$(_jq '.key') + CONTENT=$(_jq '.value') + echo "$CONTENT" > "$RULES_DIR/$NAME" + echo "Wrote rule: $RULES_DIR/$NAME" + done + fi +} + +install_cursor_cli +write_mcp_config +write_rules_file diff --git a/registry/coder-labs/modules/cursor-cli/scripts/start.sh b/registry/coder-labs/modules/cursor-cli/scripts/start.sh new file mode 100644 index 000000000..1bbc493bb --- /dev/null +++ b/registry/coder-labs/modules/cursor-cli/scripts/start.sh @@ -0,0 +1,67 @@ +#!/bin/bash + +set -o errexit +set -o pipefail + +command_exists() { + command -v "$1" > /dev/null 2>&1 +} + +ARG_AI_PROMPT=$(echo -n "${ARG_AI_PROMPT:-}" | base64 -d) +ARG_FORCE=${ARG_FORCE:-false} +ARG_MODEL=${ARG_MODEL:-} +ARG_OUTPUT_FORMAT=${ARG_OUTPUT_FORMAT:-json} +ARG_MODULE_DIR_NAME=${ARG_MODULE_DIR_NAME:-.cursor-cli-module} +ARG_FOLDER=${ARG_FOLDER:-$HOME} + +echo "--------------------------------" +echo "install: $ARG_INSTALL" +echo "version: $ARG_VERSION" +echo "folder: $ARG_FOLDER" +echo "ai_prompt: $ARG_AI_PROMPT" +echo "force: $ARG_FORCE" +echo "model: $ARG_MODEL" +echo "output_format: $ARG_OUTPUT_FORMAT" +echo "module_dir_name: $ARG_MODULE_DIR_NAME" +echo "folder: $ARG_FOLDER" +echo "--------------------------------" + +mkdir -p "$HOME/$ARG_MODULE_DIR_NAME" + +# Find cursor agent cli +if command_exists cursor-agent; then + CURSOR_CMD=cursor-agent +elif [ -x "$HOME/.local/bin/cursor-agent" ]; then + CURSOR_CMD="$HOME/.local/bin/cursor-agent" +else + echo "Error: cursor-agent not found. Install it or set install_cursor_cli=true." + exit 1 +fi + +# Ensure working directory exists +if [ -d "$ARG_FOLDER" ]; then + cd "$ARG_FOLDER" +else + mkdir -p "$ARG_FOLDER" + cd "$ARG_FOLDER" +fi + +ARGS=() + +# global flags +if [ -n "$ARG_MODEL" ]; then + ARGS+=("-m" "$ARG_MODEL") +fi +if [ "$ARG_FORCE" = "true" ]; then + ARGS+=("-f") +fi + +if [ -n "$ARG_AI_PROMPT" ]; then + printf "AI prompt provided\n" + ARGS+=("Complete the task at hand in one go. 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") +fi + +# Log and run in background, redirecting all output to the log file +printf "Running: %q %s\n" "$CURSOR_CMD" "$(printf '%q ' "${ARGS[@]}")" + +agentapi server --type cursor --term-width 67 --term-height 1190 -- "$CURSOR_CMD" "${ARGS[@]}" diff --git a/registry/coder-labs/modules/cursor-cli/testdata/cursor-cli-mock.sh b/registry/coder-labs/modules/cursor-cli/testdata/cursor-cli-mock.sh new file mode 100644 index 000000000..acf637d06 --- /dev/null +++ b/registry/coder-labs/modules/cursor-cli/testdata/cursor-cli-mock.sh @@ -0,0 +1,14 @@ +#!/bin/bash + +if [[ "$1" == "--version" ]]; then + echo "HELLO: $(bash -c env)" + echo "cursor-agent version v2.5.0" + exit 0 +fi + +set -e + +while true; do + echo "$(date) - cursor-agent-mock" + sleep 15 +done \ No newline at end of file diff --git a/registry/coder-labs/modules/sourcegraph_amp/README.md b/registry/coder-labs/modules/sourcegraph_amp/README.md new file mode 100644 index 000000000..45d175d24 --- /dev/null +++ b/registry/coder-labs/modules/sourcegraph_amp/README.md @@ -0,0 +1,90 @@ +--- +display_name: Sourcegraph AMP +icon: ../../../../.icons/sourcegraph-amp.svg +description: Run Sourcegraph AMP CLI in your workspace with AgentAPI integration +verified: false +tags: [agent, sourcegraph, amp, ai, tasks] +--- + +# Sourcegraph AMP CLI + +Run [Sourcegraph AMP CLI](https://sourcegraph.com/amp) in your workspace to access Sourcegraph's AI-powered code search and analysis tools, with AgentAPI integration for seamless Coder Tasks support. + +```tf +module "sourcegraph_amp" { + source = "registry.coder.com/coder-labs/sourcegraph_amp/coder" + version = "1.0.0" + agent_id = coder_agent.example.id + sourcegraph_amp_api_key = var.sourcegraph_amp_api_key + install_sourcegraph_amp = true + agentapi_version = "latest" +} +``` + +## Prerequisites + +- Include the [Coder Login](https://registry.coder.com/modules/coder-login/coder) module in your template +- Node.js and npm are automatically installed (via NVM) if not already available + +## Usage Example + +```tf +data "coder_parameter" "ai_prompt" { + name = "AI Prompt" + description = "Write an initial prompt for AMP to work on." + type = "string" + default = "" + mutable = true + +} + +# Set system prompt for Sourcegraph Amp via environment variables +resource "coder_agent" "main" { + # ... + env = { + SOURCEGRAPH_AMP_SYSTEM_PROMPT = <<-EOT + You are an AMP assistant that helps developers debug and write code efficiently. + + Always log task status to Coder. + EOT + SOURCEGRAPH_AMP_TASK_PROMPT = data.coder_parameter.ai_prompt.value + } +} + +variable "sourcegraph_amp_api_key" { + type = string + description = "Sourcegraph AMP API key" + sensitive = true +} + +module "sourcegraph_amp" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder-labs/sourcegraph_amp/coder" + version = "1.0.0" + agent_id = coder_agent.example.id + sourcegraph_amp_api_key = var.sourcegraph_amp_api_key # recommended for authenticated usage + install_sourcegraph_amp = true +} +``` + +## How it Works + +- **Install**: Installs Sourcegraph AMP CLI using npm (installs Node.js via NVM if required) +- **Start**: Launches AMP CLI in the specified directory, wrapped with AgentAPI to enable tasks and AI interactions +- **Environment Variables**: Sets `SOURCEGRAPH_AMP_API_KEY` and `SOURCEGRAPH_AMP_START_DIRECTORY` for the CLI execution + +## Troubleshooting + +- If `amp` is not found, ensure `install_sourcegraph_amp = true` and your API key is valid +- Logs are written under `/home/coder/.sourcegraph-amp-module/` (`install.log`, `agentapi-start.log`) for debugging +- If AgentAPI fails to start, verify that your container has network access and executable permissions for the scripts + +> [!IMPORTANT] +> For using **Coder Tasks** with Sourcegraph AMP, make sure to pass the `AI Prompt` parameter and set `sourcegraph_amp_api_key`. +> This ensures task reporting and status updates work seamlessly. + +## References + +- [Sourcegraph AMP Documentation](https://ampcode.com/manual) +- [AgentAPI Documentation](https://github.com/coder/agentapi) +- [Coder AI Agents Guide](https://coder.com/docs/tutorials/ai-agents) diff --git a/registry/coder-labs/modules/sourcegraph_amp/main.test.ts b/registry/coder-labs/modules/sourcegraph_amp/main.test.ts new file mode 100644 index 000000000..a08497087 --- /dev/null +++ b/registry/coder-labs/modules/sourcegraph_amp/main.test.ts @@ -0,0 +1,157 @@ +import { + test, + afterEach, + describe, + setDefaultTimeout, + beforeAll, + expect, +} from "bun:test"; +import { execContainer, readFileContainer, runTerraformInit } from "~test"; +import { + loadTestFile, + writeExecutable, + setup as setupUtil, + execModuleScript, + expectAgentAPIStarted, +} from "../../../coder/modules/agentapi/test-util"; + +let cleanupFunctions: (() => Promise)[] = []; +const registerCleanup = (cleanup: () => Promise) => { + cleanupFunctions.push(cleanup); +}; +afterEach(async () => { + const cleanupFnsCopy = cleanupFunctions.slice().reverse(); + cleanupFunctions = []; + for (const cleanup of cleanupFnsCopy) { + try { + await cleanup(); + } catch (error) { + console.error("Error during cleanup:", error); + } + } +}); + +interface SetupProps { + skipAgentAPIMock?: boolean; + skipAmpMock?: boolean; + moduleVariables?: Record; + agentapiMockScript?: string; +} + +const setup = async (props?: SetupProps): Promise<{ id: string }> => { + const projectDir = "/home/coder/project"; + const { id } = await setupUtil({ + moduleDir: import.meta.dir, + moduleVariables: { + install_sourcegraph_amp: props?.skipAmpMock ? "true" : "false", + install_agentapi: props?.skipAgentAPIMock ? "true" : "false", + sourcegraph_amp_model: "test-model", + ...props?.moduleVariables, + }, + registerCleanup, + projectDir, + skipAgentAPIMock: props?.skipAgentAPIMock, + agentapiMockScript: props?.agentapiMockScript, + }); + + // Place the AMP mock CLI binary inside the container + if (!props?.skipAmpMock) { + await writeExecutable({ + containerId: id, + filePath: "/usr/bin/amp", + content: await loadTestFile(`${import.meta.dir}`, "amp-mock.sh"), + }); + } + + return { id }; +}; + +setDefaultTimeout(60 * 1000); + +describe("sourcegraph-amp", async () => { + beforeAll(async () => { + await runTerraformInit(import.meta.dir); + }); + + test("happy-path", async () => { + const { id } = await setup(); + await execModuleScript(id); + await expectAgentAPIStarted(id); + }); + + test("api-key", async () => { + const apiKey = "test-api-key-123"; + const { id } = await setup({ + moduleVariables: { + sourcegraph_amp_api_key: apiKey, + }, + }); + await execModuleScript(id); + const resp = await readFileContainer( + id, + "/home/coder/.sourcegraph-amp-module/agentapi-start.log", + ); + expect(resp).toContain("sourcegraph_amp_api_key provided !"); + }); + + test("custom-folder", async () => { + const folder = "/tmp/sourcegraph-amp-test"; + const { id } = await setup({ + moduleVariables: { + folder, + }, + }); + await execModuleScript(id); + const resp = await readFileContainer( + id, + "/home/coder/.sourcegraph-amp-module/install.log", + ); + expect(resp).toContain(folder); + }); + + test("pre-post-install-scripts", async () => { + const { id } = await setup({ + moduleVariables: { + pre_install_script: "#!/bin/bash\necho 'pre-install-script'", + post_install_script: "#!/bin/bash\necho 'post-install-script'", + }, + }); + await execModuleScript(id); + const preLog = await readFileContainer( + id, + "/home/coder/.sourcegraph-amp-module/pre_install.log", + ); + expect(preLog).toContain("pre-install-script"); + const postLog = await readFileContainer( + id, + "/home/coder/.sourcegraph-amp-module/post_install.log", + ); + expect(postLog).toContain("post-install-script"); + }); + + test("system-prompt", async () => { + const prompt = "this is a system prompt for AMP"; + const { id } = await setup(); + await execModuleScript(id, { + SOURCEGRAPH_AMP_SYSTEM_PROMPT: prompt, + }); + const resp = await readFileContainer( + id, + "/home/coder/.sourcegraph-amp-module/SYSTEM_PROMPT.md", + ); + expect(resp).toContain(prompt); + }); + + test("task-prompt", async () => { + const prompt = "this is a task prompt for AMP"; + const { id } = await setup(); + await execModuleScript(id, { + SOURCEGRAPH_AMP_TASK_PROMPT: prompt, + }); + const resp = await readFileContainer( + id, + "/home/coder/.sourcegraph-amp-module/agentapi-start.log", + ); + expect(resp).toContain(`sourcegraph amp task prompt provided : ${prompt}`); + }); +}); diff --git a/registry/coder-labs/modules/sourcegraph_amp/main.tf b/registry/coder-labs/modules/sourcegraph_amp/main.tf new file mode 100644 index 000000000..033fc84ed --- /dev/null +++ b/registry/coder-labs/modules/sourcegraph_amp/main.tf @@ -0,0 +1,195 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 2.7" + } + } +} + +variable "agent_id" { + type = string + description = "The ID of a Coder agent." +} + +data "coder_workspace" "me" {} + +data "coder_workspace_owner" "me" {} + +variable "order" { + type = number + description = "The order determines the position of app in the UI presentation. The lowest order is shown first and apps with equal order are sorted by name (ascending order)." + default = null +} + +variable "group" { + type = string + description = "The name of a group that this app belongs to." + default = null +} + +variable "icon" { + type = string + description = "The icon to use for the app." + default = "/icon/sourcegraph-amp.svg" +} + +variable "folder" { + type = string + description = "The folder to run sourcegraph_amp in." + default = "/home/coder" +} + +variable "install_sourcegraph_amp" { + type = bool + description = "Whether to install sourcegraph-amp." + default = true +} + +variable "sourcegraph_amp_api_key" { + type = string + description = "sourcegraph-amp API Key" + default = "" +} + +resource "coder_env" "sourcegraph_amp_api_key" { + agent_id = var.agent_id + name = "SOURCEGRAPH_AMP_API_KEY" + value = var.sourcegraph_amp_api_key +} + +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.3.0" +} + +variable "pre_install_script" { + type = string + description = "Custom script to run before installing sourcegraph_amp" + default = null +} + +variable "post_install_script" { + type = string + description = "Custom script to run after installing sourcegraph_amp." + default = null +} + +variable "base_amp_config" { + type = string + description = <<-EOT + Base AMP configuration in JSON format. Can be overridden to customize AMP settings. + + If empty, defaults enable thinking and todos for autonomous operation. Additional options include: + - "amp.permissions": [] (tool permissions) + - "amp.tools.stopTimeout": 600 (extend timeout for long operations) + - "amp.terminal.commands.nodeSpawn.loadProfile": "daily" (environment loading) + - "amp.tools.disable": ["builtin:open"] (disable tools for containers) + - "amp.git.commit.ampThread.enabled": true (link commits to threads) + - "amp.git.commit.coauthor.enabled": true (add Amp as co-author) + + Reference: https://ampcode.com/manual + EOT + default = "" +} + +variable "additional_mcp_servers" { + type = string + description = "Additional MCP servers configuration in JSON format to append to amp.mcpServers." + default = null +} + +locals { + app_slug = "amp" + + default_base_config = { + "amp.anthropic.thinking.enabled" = true + "amp.todos.enabled" = true + } + + # Use provided config or default, then extract base settings (excluding mcpServers) + user_config = var.base_amp_config != "" ? jsondecode(var.base_amp_config) : local.default_base_config + base_amp_settings = { for k, v in local.user_config : k => v if k != "amp.mcpServers" } + + coder_mcp = { + "coder" = { + "command" = "coder" + "args" = ["exp", "mcp", "server"] + "env" = { + "CODER_MCP_APP_STATUS_SLUG" = local.app_slug + "CODER_MCP_AI_AGENTAPI_URL" = "http://localhost:3284" + } + "type" = "stdio" + } + } + + additional_mcp = var.additional_mcp_servers != null ? jsondecode(var.additional_mcp_servers) : {} + + merged_mcp_servers = merge( + lookup(local.user_config, "amp.mcpServers", {}), + local.coder_mcp, + local.additional_mcp + ) + + final_config = merge(local.base_amp_settings, { + "amp.mcpServers" = local.merged_mcp_servers + }) + + install_script = file("${path.module}/scripts/install.sh") + start_script = file("${path.module}/scripts/start.sh") + module_dir_name = ".sourcegraph-amp-module" +} + +module "agentapi" { + source = "registry.coder.com/coder/agentapi/coder" + version = "1.0.1" + + agent_id = var.agent_id + web_app_slug = local.app_slug + web_app_order = var.order + web_app_group = var.group + web_app_icon = var.icon + web_app_display_name = "Sourcegraph Amp" + cli_app_slug = "${local.app_slug}-cli" + cli_app_display_name = "Sourcegraph Amp CLI" + 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 + SOURCEGRAPH_AMP_API_KEY='${var.sourcegraph_amp_api_key}' \ + SOURCEGRAPH_AMP_START_DIRECTORY='${var.folder}' \ + /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_INSTALL_SOURCEGRAPH_AMP='${var.install_sourcegraph_amp}' \ + SOURCEGRAPH_AMP_START_DIRECTORY='${var.folder}' \ + ARG_AMP_CONFIG="$(echo -n '${base64encode(jsonencode(local.final_config))}' | base64 -d)" \ + /tmp/install.sh + EOT +} + + diff --git a/registry/coder-labs/modules/sourcegraph_amp/scripts/install.sh b/registry/coder-labs/modules/sourcegraph_amp/scripts/install.sh new file mode 100644 index 000000000..61e498b7b --- /dev/null +++ b/registry/coder-labs/modules/sourcegraph_amp/scripts/install.sh @@ -0,0 +1,96 @@ +#!/bin/bash +set -euo pipefail + +# ANSI colors +BOLD='\033[1m' + +echo "--------------------------------" +echo "Install flag: $ARG_INSTALL_SOURCEGRAPH_AMP" +echo "Workspace: $SOURCEGRAPH_AMP_START_DIRECTORY" +echo "--------------------------------" + +# Helper function to check if a command exists +command_exists() { + command -v "$1" > /dev/null 2>&1 +} + +function install_node() { + if ! command_exists npm; then + printf "npm not found, checking for Node.js installation...\n" + if ! command_exists node; then + printf "Node.js not found, installing Node.js via NVM...\n" + export NVM_DIR="$HOME/.nvm" + if [ ! -d "$NVM_DIR" ]; then + mkdir -p "$NVM_DIR" + curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash + [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" + else + [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" + fi + + # Temporarily disable nounset (-u) for nvm to avoid PROVIDED_VERSION error + set +u + nvm install --lts + nvm use --lts + nvm alias default node + set -u + + printf "Node.js installed: %s\n" "$(node --version)" + printf "npm installed: %s\n" "$(npm --version)" + else + printf "Node.js is installed but npm is not available. Please install npm manually.\n" + exit 1 + fi + fi +} + +function install_sourcegraph_amp() { + if [ "${ARG_INSTALL_SOURCEGRAPH_AMP}" = "true" ]; then + install_node + + # If nvm is not used, set up user npm global directory + if ! command_exists nvm; then + mkdir -p "$HOME/.npm-global" + npm config set prefix "$HOME/.npm-global" + export PATH="$HOME/.npm-global/bin:$PATH" + if ! grep -q "export PATH=$HOME/.npm-global/bin:\$PATH" ~/.bashrc; then + echo "export PATH=$HOME/.npm-global/bin:\$PATH" >> ~/.bashrc + fi + fi + + printf "%s Installing Sourcegraph AMP CLI...\n" "${BOLD}" + npm install -g @sourcegraph/amp@0.0.1754179307-gba1f97 + printf "%s Successfully installed Sourcegraph AMP CLI. Version: %s\n" "${BOLD}" "$(amp --version)" + fi +} + +function setup_system_prompt() { + if [ -n "${SOURCEGRAPH_AMP_SYSTEM_PROMPT:-}" ]; then + echo "Setting Sourcegraph AMP system prompt..." + mkdir -p "$HOME/.sourcegraph-amp-module" + echo "$SOURCEGRAPH_AMP_SYSTEM_PROMPT" > "$HOME/.sourcegraph-amp-module/SYSTEM_PROMPT.md" + echo "System prompt saved to $HOME/.sourcegraph-amp-module/SYSTEM_PROMPT.md" + else + echo "No system prompt provided for Sourcegraph AMP." + fi +} + +function configure_amp_settings() { + echo "Configuring AMP settings..." + SETTINGS_PATH="$HOME/.config/amp/settings.json" + mkdir -p "$(dirname "$SETTINGS_PATH")" + + if [ -z "${ARG_AMP_CONFIG:-}" ]; then + echo "No AMP config provided, skipping configuration" + return + fi + + echo "Writing AMP configuration to $SETTINGS_PATH" + printf '%s\n' "$ARG_AMP_CONFIG" > "$SETTINGS_PATH" + + echo "AMP configuration complete" +} + +install_sourcegraph_amp +setup_system_prompt +configure_amp_settings diff --git a/registry/coder-labs/modules/sourcegraph_amp/scripts/start.sh b/registry/coder-labs/modules/sourcegraph_amp/scripts/start.sh new file mode 100644 index 000000000..252b343f8 --- /dev/null +++ b/registry/coder-labs/modules/sourcegraph_amp/scripts/start.sh @@ -0,0 +1,49 @@ +#!/bin/bash +set -euo pipefail + +# Load user environment +# shellcheck source=/dev/null +source "$HOME/.bashrc" +# shellcheck source=/dev/null +if [ -f "$HOME/.nvm/nvm.sh" ]; then + source "$HOME"/.nvm/nvm.sh +else + export PATH="$HOME/.npm-global/bin:$PATH" +fi + +function ensure_command() { + command -v "$1" &> /dev/null || { + echo "Error: '$1' not found." >&2 + exit 1 + } +} + +ensure_command amp +echo "AMP version: $(amp --version)" + +dir="$SOURCEGRAPH_AMP_START_DIRECTORY" +if [[ -d "$dir" ]]; then + echo "Using existing directory: $dir" +else + echo "Creating directory: $dir" + mkdir -p "$dir" +fi +cd "$dir" + +if [ -n "$SOURCEGRAPH_AMP_API_KEY" ]; then + printf "sourcegraph_amp_api_key provided !\n" + export AMP_API_KEY=$SOURCEGRAPH_AMP_API_KEY +else + printf "sourcegraph_amp_api_key not provided\n" +fi + +if [ -n "${SOURCEGRAPH_AMP_TASK_PROMPT:-}" ]; then + printf "sourcegraph amp task prompt provided : $SOURCEGRAPH_AMP_TASK_PROMPT" + PROMPT="Every step of the way, report tasks to Coder with proper descriptions and statuses. Your task at hand: $SOURCEGRAPH_AMP_TASK_PROMPT" + + # Pipe the prompt into amp, which will be run inside agentapi + agentapi server --term-width=67 --term-height=1190 -- bash -c "echo \"$PROMPT\" | amp" +else + printf "No task prompt given.\n" + agentapi server --term-width=67 --term-height=1190 -- amp +fi diff --git a/registry/coder-labs/modules/sourcegraph_amp/testdata/amp-mock.sh b/registry/coder-labs/modules/sourcegraph_amp/testdata/amp-mock.sh new file mode 100644 index 000000000..259db57ad --- /dev/null +++ b/registry/coder-labs/modules/sourcegraph_amp/testdata/amp-mock.sh @@ -0,0 +1,14 @@ +#!/bin/bash + +# Mock behavior of the AMP CLI +if [[ "$1" == "--version" ]]; then + echo "AMP CLI mock version v1.0.0" + exit 0 +fi + +# Simulate AMP running in a loop for AgentAPI to connect +set -e +while true; do + echo "$(date) - AMP mock is running..." + sleep 15 +done diff --git a/registry/coder-labs/templates/tasks-docker/main.tf b/registry/coder-labs/templates/tasks-docker/main.tf index ad232317d..35a64540a 100644 --- a/registry/coder-labs/templates/tasks-docker/main.tf +++ b/registry/coder-labs/templates/tasks-docker/main.tf @@ -181,7 +181,7 @@ resource "coder_env" "claude_task_prompt" { resource "coder_env" "app_status_slug" { agent_id = coder_agent.main.id name = "CODER_MCP_APP_STATUS_SLUG" - value = "claude-code" + value = "ccw" } resource "coder_env" "claude_system_prompt" { agent_id = coder_agent.main.id diff --git a/registry/coder/.images/amazon-q-new.png b/registry/coder/.images/amazon-q-new.png new file mode 100644 index 000000000..6024135e8 Binary files /dev/null and b/registry/coder/.images/amazon-q-new.png differ diff --git a/registry/coder/modules/amazon-q/README.md b/registry/coder/modules/amazon-q/README.md index 2d6b081f4..41b347ed9 100644 --- a/registry/coder/modules/amazon-q/README.md +++ b/registry/coder/modules/amazon-q/README.md @@ -1,121 +1,376 @@ --- display_name: Amazon Q -description: Run Amazon Q in your workspace to access Amazon's AI coding assistant. +description: Run Amazon Q in your workspace to access Amazon's AI coding assistant with MCP integration and task reporting. icon: ../../../../.icons/amazon-q.svg verified: true -tags: [agent, ai, aws, amazon-q] +tags: [agent, ai, aws, amazon-q, mcp, agentapi] --- # Amazon Q -Run [Amazon Q](https://aws.amazon.com/q/) in your workspace to access Amazon's AI coding assistant. This module installs and launches Amazon Q, with support for background operation, task reporting, and custom pre/post install scripts. +Run [Amazon Q](https://aws.amazon.com/q/) in your workspace to access Amazon's AI coding assistant. This module provides a complete integration with Coder workspaces, including automatic installation, MCP (Model Context Protocol) integration for task reporting, and support for custom pre/post install scripts. + +## Quick Start ```tf module "amazon-q" { source = "registry.coder.com/coder/amazon-q/coder" - version = "1.1.2" + version = "2.0.0" agent_id = coder_agent.example.id - # Required: see below for how to generate - experiment_auth_tarball = var.amazon_q_auth_tarball + # Required: Authentication tarball (see below for generation) + auth_tarball = var.amazon_q_auth_tarball } ``` -![Amazon-Q in action](../../.images/amazon-q.png) +![Amazon-Q in action](../../.images/amazon-q-new.png) + +## Features + +- **🚀 Automatic Installation**: Downloads and installs Amazon Q CLI automatically +- **🔐 Authentication**: Supports pre-authenticated tarball for seamless login +- **📊 Task Reporting**: Built-in MCP integration for reporting progress to Coder +- **🎯 AI Prompts**: Support for initial task prompts and custom system prompts +- **🔧 Customization**: Pre/post install scripts for custom setup +- **🌐 AgentAPI Integration**: Web and CLI app integration through AgentAPI +- **🛠️ Tool Trust**: Configurable tool trust settings +- **📁 Flexible Deployment**: Configurable working directory and module structure ## Prerequisites -- You must generate an authenticated Amazon Q tarball on another machine: - ```sh - cd ~/.local/share/amazon-q && tar -c . | zstd | base64 -w 0 - ``` - Paste the result into the `experiment_auth_tarball` variable. -- To run in the background, your workspace must have `screen` or `tmux` installed. +### Authentication Tarball (Required) -
-How to generate the Amazon Q auth tarball (step-by-step) +You must generate an authenticated Amazon Q tarball on another machine where you have successfully logged in: -**1. Install and authenticate Amazon Q on your local machine:** +```bash +# 1. Install Amazon Q and login on your local machine +q login -- Download and install Amazon Q from the [official site](https://aws.amazon.com/q/developer/). -- Run `q login` and complete the authentication process in your terminal. +# 2. Generate the authentication tarball +cd ~/.local/share/amazon-q +tar -c . | zstd | base64 -w 0 +``` -**2. Locate your Amazon Q config directory:** +Copy the output and use it as the `auth_tarball` variable. -- The config is typically stored at `~/.local/share/amazon-q`. +
+Detailed Authentication Setup -**3. Generate the tarball:** +**Step 1: Install Amazon Q locally** -- Run the following command in your terminal: - ```sh - cd ~/.local/share/amazon-q - tar -c . | zstd | base64 -w 0 - ``` +- Download from [AWS Amazon Q Developer](https://aws.amazon.com/q/developer/) +- Follow the installation instructions for your platform -**4. Copy the output:** +**Step 2: Authenticate** -- The command will output a long string. Copy this entire string. +```bash +q login +``` + +Complete the authentication process in your browser. -**5. Paste into your Terraform variable:** +**Step 3: Generate tarball** -- Assign the string to the `experiment_auth_tarball` variable in your Terraform configuration, for example: - ```tf - variable "amazon_q_auth_tarball" { - type = string - default = "PASTE_LONG_STRING_HERE" - } - ``` +```bash +cd ~/.local/share/amazon-q +tar -c . | zstd | base64 -w 0 > /tmp/amazon-q-auth.txt +``` -**Note:** +**Step 4: Use in Terraform** + +```tf +variable "amazon_q_auth_tarball" { + type = string + sensitive = true + default = "PASTE_YOUR_TARBALL_HERE" +} +``` -- You must re-generate the tarball if you log out or re-authenticate Amazon Q on your local machine. -- This process is required for each user who wants to use Amazon Q in their workspace. +**Important Notes:** -[Reference: Amazon Q documentation](https://docs.aws.amazon.com/amazonq/latest/qdeveloper-ug/generate-docs.html) +- Regenerate the tarball if you logout or re-authenticate +- Each user needs their own authentication tarball +- Keep the tarball secure as it contains authentication credentials
-## Examples +## Configuration Variables + +### Required Variables + +| Variable | Type | Description | +| ---------- | -------- | ----------------------- | +| `agent_id` | `string` | The ID of a Coder agent | + +### Optional Variables + +| Variable | Type | Default | Description | +| --------------------- | -------- | --------------- | ----------------------------------------------------------------------------------------------------- | +| `auth_tarball` | `string` | `""` | Base64 encoded, zstd compressed tarball of authenticated Amazon Q directory | +| `amazon_q_version` | `string` | `"latest"` | Version of Amazon Q to install | +| `install_amazon_q` | `bool` | `true` | Whether to install Amazon Q CLI | +| `install_agentapi` | `bool` | `true` | Whether to install AgentAPI for web integration | +| `agentapi_version` | `string` | `"v0.5.0"` | Version of AgentAPI to install | +| `folder` | `string` | `"/home/coder"` | Working directory for Amazon Q | +| `trust_all_tools` | `bool` | `true` | Whether to trust all tools in Amazon Q | +| `ai_prompt` | `string` | `""` | Initial task prompt to send to Amazon Q | +| `system_prompt` | `string` | _See below_ | System prompt for task reporting behavior | +| `pre_install_script` | `string` | `null` | Script to run before installing Amazon Q | +| `post_install_script` | `string` | `null` | Script to run after installing Amazon Q | +| `agent_config` | `string` | `null` | Custom agent configuration JSON (See the [Default Agent configuration](#default-agent-configuration)) | + +### UI Configuration + +| Variable | Type | Default | Description | +| -------- | -------- | ---------------------- | ------------------------------------------- | +| `order` | `number` | `null` | Position in UI (lower numbers appear first) | +| `group` | `string` | `null` | Group name for organizing apps | +| `icon` | `string` | `"/icon/amazon-q.svg"` | Icon to display in UI | + +### Default System Prompt + +The module includes a comprehensive system prompt that instructs Amazon Q: + +``` +You are a helpful Coding assistant. Aim to autonomously investigate +and solve issues the user gives you and test your work, whenever possible. +Avoid shortcuts like mocking tests. When you get stuck, you can ask the user +but opt for autonomy. + +YOU MUST REPORT ALL TASKS TO CODER. +When reporting tasks, you MUST follow these EXACT instructions: +- IMMEDIATELY report status after receiving ANY user message. +- Be granular. If you are investigating with multiple steps, report each step to coder. + +Task state MUST be one of the following: +- Use "state": "working" when actively processing WITHOUT needing additional user input. +- Use "state": "complete" only when finished with a task. +- Use "state": "failure" when you need ANY user input, lack sufficient details, or encounter blockers. + +Task summaries MUST: +- Include specifics about what you're doing. +- Include clear and actionable steps for the user. +- Be less than 160 characters in length. +``` + +### System Prompt Features: + +- **Autonomous Operation:** Encourages Amazon Q to work independently and test solutions +- **Task Reporting:** Mandatory reporting to Coder via MCP integration +- **Granular Updates:** Step-by-step progress reporting for complex tasks +- **Clear State Management:** Three distinct states (working, complete, failure) +- **Concise Summaries:** 160-character limit for actionable task summaries +- **User Interaction:** Clear guidelines on when to ask for user input + +You can customize this behavior by providing your own system prompt via the `system_prompt` variable. + +## Default Agent Configuration + +The module includes a default agent configuration template that provides a comprehensive setup for Amazon Q integration: + +```json +{ + "name": "agent", + "description": "This is an default agent config", + "prompt": "${system_prompt}", + "mcpServers": {}, + "tools": ["fs_read", "fs_write", "execute_bash", "use_aws", "knowledge"], + "toolAliases": {}, + "allowedTools": ["fs_read"], + "resources": [ + "file://AmazonQ.md", + "file://README.md", + "file://.amazonq/rules/**/*.md" + ], + "hooks": {}, + "toolsSettings": {}, + "useLegacyMcpJson": true +} +``` + +### Configuration Details: + +- **Tools Available:** File operations, bash execution, AWS CLI, and knowledge base access +- **Allowed Tools:** By default, only `fs_read` is allowed (can be customized) +- **Resources:** Access to documentation and rule files in the workspace +- **MCP Servers:** Empty by default, can be configured via `agent_config` variable +- **System Prompt:** Dynamically populated from the `system_prompt` variable + +You can override this configuration by providing your own JSON via the `agent_config` variable. + +## Usage Examples + +### Basic Usage + +```tf +module "amazon-q" { + source = "registry.coder.com/coder/amazon-q/coder" + version = "2.0.0" + agent_id = coder_agent.example.id + auth_tarball = var.amazon_q_auth_tarball +} +``` + +### With Custom AI Prompt + +```tf +module "amazon-q" { + source = "registry.coder.com/coder/amazon-q/coder" + version = "2.0.0" + agent_id = coder_agent.example.id + auth_tarball = var.amazon_q_auth_tarball + ai_prompt = "Help me set up a Python FastAPI project with proper testing structure" +} +``` + +### With Custom Pre/Post Install Scripts + +```tf +module "amazon-q" { + source = "registry.coder.com/coder/amazon-q/coder" + version = "2.0.0" + agent_id = coder_agent.example.id + auth_tarball = var.amazon_q_auth_tarball + + pre_install_script = <<-EOT + #!/bin/bash + echo "Setting up custom environment..." + # Install additional dependencies + sudo apt-get update && sudo apt-get install -y zstd + EOT + + post_install_script = <<-EOT + #!/bin/bash + echo "Configuring Amazon Q settings..." + # Custom configuration commands + q settings chat.model claude-3-sonnet + EOT +} +``` -### Run Amazon Q in the background with tmux +### Specific Version Installation ```tf module "amazon-q" { - source = "registry.coder.com/coder/amazon-q/coder" - version = "1.1.2" - agent_id = coder_agent.example.id - experiment_auth_tarball = var.amazon_q_auth_tarball - experiment_use_tmux = true + source = "registry.coder.com/coder/amazon-q/coder" + version = "2.0.0" + agent_id = coder_agent.example.id + auth_tarball = var.amazon_q_auth_tarball + amazon_q_version = "1.14.0" # Specific version + install_amazon_q = true } ``` -### Enable task reporting (experimental) +### Custom Agent Configuration ```tf module "amazon-q" { - source = "registry.coder.com/coder/amazon-q/coder" - version = "1.1.2" - agent_id = coder_agent.example.id - experiment_auth_tarball = var.amazon_q_auth_tarball - experiment_report_tasks = true + source = "registry.coder.com/coder/amazon-q/coder" + version = "2.0.0" + agent_id = coder_agent.example.id + auth_tarball = var.amazon_q_auth_tarball + + agent_config = jsonencode({ + name = "custom-agent" + description = "Custom Amazon Q agent for my workspace" + prompt = "You are a specialized DevOps assistant..." + tools = ["fs_read", "fs_write", "execute_bash", "use_aws"] + }) } ``` -### Run custom scripts before/after install +### UI Customization ```tf module "amazon-q" { - source = "registry.coder.com/coder/amazon-q/coder" - version = "1.1.2" - agent_id = coder_agent.example.id - experiment_auth_tarball = var.amazon_q_auth_tarball - experiment_pre_install_script = "echo Pre-install!" - experiment_post_install_script = "echo Post-install!" + source = "registry.coder.com/coder/amazon-q/coder" + version = "2.0.0" + agent_id = coder_agent.example.id + auth_tarball = var.amazon_q_auth_tarball + + # UI configuration + order = 1 + group = "AI Tools" + icon = "/icon/custom-amazon-q.svg" } ``` -## Notes +## Architecture + +### Components + +1. **AgentAPI Module**: Provides web and CLI app integration +2. **Install Script**: Handles Amazon Q CLI installation and configuration +3. **Start Script**: Manages Amazon Q startup with proper environment +4. **MCP Integration**: Enables task reporting back to Coder +5. **Agent Configuration**: Customizable AI agent behavior + +### Installation Process + +1. **Pre-install**: Execute custom pre-install script (if provided) +2. **Download**: Fetch Amazon Q CLI for the appropriate architecture +3. **Install**: Install Amazon Q CLI to `~/.local/bin/q` +4. **Authenticate**: Extract and apply authentication tarball +5. **Configure**: Set up MCP integration and agent configuration +6. **Post-install**: Execute custom post-install script (if provided) + +### Runtime Behavior + +- Amazon Q runs in the specified working directory +- MCP integration reports task progress to Coder +- AgentAPI provides web interface integration +- All tools are trusted by default (configurable) +- Initial AI prompt is sent if provided + +## Troubleshooting + +### Common Issues + +**Amazon Q not found after installation:** + +```bash +# Check if Amazon Q is in PATH +which q +# If not found, add to PATH +export PATH="$PATH:$HOME/.local/bin" +``` + +**Authentication issues:** + +- Regenerate the auth tarball on your local machine +- Ensure the tarball is properly base64 encoded +- Check that the original authentication is still valid + +**MCP integration not working:** + +- Verify that AgentAPI is installed (`install_agentapi = true`) +- Check that the Coder agent is properly configured +- Review the system prompt configuration + +### Debug Mode + +Enable verbose logging by setting environment variables: + +```bash +export DEBUG=1 +export VERBOSE=1 +``` + +## Security Considerations + +- **Authentication Tarball**: Contains sensitive authentication data - mark as sensitive in Terraform +- **Tool Trust**: By default, all tools are trusted - review for security requirements +- **Pre/Post Scripts**: Custom scripts run with user permissions - validate content +- **Network Access**: Amazon Q requires internet access for AI model communication + +## Contributing + +For issues, feature requests, or contributions, please visit the [module repository](https://github.com/coder/registry). + +## License + +This module is provided under the same license as the Coder registry. + +--- -- Only one of `experiment_use_screen` or `experiment_use_tmux` can be true at a time. -- If neither is set, Amazon Q runs in the foreground. -- For more details, see the [main.tf](./main.tf) source. +**Note**: This module requires Coder v2.7+ and is designed to work with the AgentAPI integration system. diff --git a/registry/coder/modules/amazon-q/amazon-q.tftest.hcl b/registry/coder/modules/amazon-q/amazon-q.tftest.hcl new file mode 100644 index 000000000..34c0d8c27 --- /dev/null +++ b/registry/coder/modules/amazon-q/amazon-q.tftest.hcl @@ -0,0 +1,144 @@ +run "required_variables" { + command = plan + + variables { + agent_id = "test-agent-id" + } +} + +run "minimal_config" { + command = plan + + variables { + agent_id = "test-agent-id" + auth_tarball = "dGVzdA==" # base64 "test" + } + + assert { + condition = resource.coder_env.status_slug.name == "CODER_MCP_APP_STATUS_SLUG" + error_message = "Status slug environment variable not configured correctly" + } + + assert { + condition = resource.coder_env.status_slug.value == "amazonq" + error_message = "Status slug value should be 'amazonq'" + } +} + +run "full_config" { + command = plan + + variables { + agent_id = "test-agent-id" + folder = "/home/coder/project" + install_amazon_q = true + install_agentapi = true + agentapi_version = "v0.5.0" + amazon_q_version = "latest" + trust_all_tools = true + ai_prompt = "Build a web application" + auth_tarball = "dGVzdA==" + order = 1 + group = "AI Tools" + icon = "/icon/custom-amazon-q.svg" + pre_install_script = "echo 'pre-install'" + post_install_script = "echo 'post-install'" + agent_config = jsonencode({ + name = "test-agent" + description = "Test agent configuration" + }) + } + + assert { + condition = resource.coder_env.status_slug.name == "CODER_MCP_APP_STATUS_SLUG" + error_message = "Status slug environment variable not configured correctly" + } + + assert { + condition = resource.coder_env.status_slug.value == "amazonq" + error_message = "Status slug value should be 'amazonq'" + } + + assert { + condition = length(resource.coder_env.auth_tarball) == 1 + error_message = "Auth tarball environment variable should be created when provided" + } +} + +run "auth_tarball_environment" { + command = plan + + variables { + agent_id = "test-agent-id" + auth_tarball = "dGVzdEF1dGhUYXJiYWxs" # base64 "testAuthTarball" + } + + assert { + condition = resource.coder_env.auth_tarball[0].name == "AMAZON_Q_AUTH_TARBALL" + error_message = "Auth tarball environment variable name should be 'AMAZON_Q_AUTH_TARBALL'" + } + + assert { + condition = resource.coder_env.auth_tarball[0].value == "dGVzdEF1dGhUYXJiYWxs" + error_message = "Auth tarball environment variable value should match input" + } +} + +run "empty_auth_tarball" { + command = plan + + variables { + agent_id = "test-agent-id" + auth_tarball = "" + } + + assert { + condition = length(resource.coder_env.auth_tarball) == 0 + error_message = "Auth tarball environment variable should not be created when empty" + } +} + +run "custom_system_prompt" { + command = plan + + variables { + agent_id = "test-agent-id" + system_prompt = "Custom system prompt for testing" + } + + # Test that the system prompt is used in the agent config template + assert { + condition = length(local.agent_config) > 0 + error_message = "Agent config should be generated with custom system prompt" + } +} + +run "install_options" { + command = plan + + variables { + agent_id = "test-agent-id" + install_amazon_q = false + install_agentapi = false + } + + assert { + condition = resource.coder_env.status_slug.name == "CODER_MCP_APP_STATUS_SLUG" + error_message = "Status slug should still be configured even when install options are disabled" + } +} + +run "version_configuration" { + command = plan + + variables { + agent_id = "test-agent-id" + amazon_q_version = "2.15.0" + agentapi_version = "v0.4.0" + } + + assert { + condition = resource.coder_env.status_slug.value == "amazonq" + error_message = "Status slug value should remain 'amazonq' regardless of version" + } +} diff --git a/registry/coder/modules/amazon-q/main.test.ts b/registry/coder/modules/amazon-q/main.test.ts deleted file mode 100644 index 54553da87..000000000 --- a/registry/coder/modules/amazon-q/main.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { describe, it, expect } from "bun:test"; -import { - runTerraformApply, - runTerraformInit, - testRequiredVariables, - findResourceInstance, -} from "~test"; -import path from "path"; - -const moduleDir = path.resolve(__dirname); - -const requiredVars = { - agent_id: "dummy-agent-id", -}; - -describe("amazon-q module", async () => { - await runTerraformInit(moduleDir); - - // 1. Required variables - testRequiredVariables(moduleDir, requiredVars); - - // 2. coder_script resource is created - it("creates coder_script resource", async () => { - const state = await runTerraformApply(moduleDir, requiredVars); - const scriptResource = findResourceInstance(state, "coder_script"); - expect(scriptResource).toBeDefined(); - expect(scriptResource.agent_id).toBe(requiredVars.agent_id); - // Optionally, check that the script contains expected lines - expect(scriptResource.script).toContain("Installing Amazon Q"); - }); - - // 3. coder_app resource is created - it("creates coder_app resource", async () => { - const state = await runTerraformApply(moduleDir, requiredVars); - const appResource = findResourceInstance(state, "coder_app", "amazon_q"); - expect(appResource).toBeDefined(); - expect(appResource.agent_id).toBe(requiredVars.agent_id); - }); - - // Add more state-based tests as needed -}); diff --git a/registry/coder/modules/amazon-q/main.tf b/registry/coder/modules/amazon-q/main.tf index dcc03156a..9925c3975 100644 --- a/registry/coder/modules/amazon-q/main.tf +++ b/registry/coder/modules/amazon-q/main.tf @@ -1,10 +1,12 @@ +# Improved amazon-q module main.tf + terraform { required_version = ">= 1.0" required_providers { coder = { source = "coder/coder" - version = ">= 2.5" + version = ">= 2.7" # Updated to match cursor-cli } } } @@ -15,7 +17,6 @@ variable "agent_id" { } data "coder_workspace" "me" {} - data "coder_workspace_owner" "me" {} variable "order" { @@ -48,46 +49,34 @@ variable "install_amazon_q" { default = true } -variable "amazon_q_version" { - type = string - description = "The version of Amazon Q to install." - default = "latest" -} - -variable "experiment_use_screen" { - type = bool - description = "Whether to use screen for running Amazon Q in the background." - default = false -} - -variable "experiment_use_tmux" { +variable "install_agentapi" { type = bool - description = "Whether to use tmux instead of screen for running Amazon Q in the background." - default = false + description = "Whether to install AgentAPI." + default = true } -variable "experiment_report_tasks" { - type = bool - description = "Whether to enable task reporting." - default = false +variable "agentapi_version" { + type = string + description = "The version of AgentAPI to install." + default = "v0.5.0" } -variable "experiment_pre_install_script" { +variable "amazon_q_version" { type = string - description = "Custom script to run before installing Amazon Q." - default = null + description = "The version of Amazon Q to install." + default = "latest" } -variable "experiment_post_install_script" { - type = string - description = "Custom script to run after installing Amazon Q." - default = null +variable "trust_all_tools" { + type = bool + description = "Whether to trust all tools in Amazon Q." + default = true } -variable "experiment_auth_tarball" { +variable "ai_prompt" { type = string - description = "Base64 encoded, zstd compressed tarball of a pre-authenticated ~/.local/share/amazon-q directory. After running `q login` on another machine, you may generate it with: `cd ~/.local/share/amazon-q && tar -c . | zstd | base64 -w 0`" - default = "tarball" + description = "The initial task prompt to send to Amazon Q." + default = "" } variable "system_prompt" { @@ -116,204 +105,106 @@ variable "system_prompt" { EOT } -variable "ai_prompt" { +variable "auth_tarball" { type = string - description = "The initial task prompt to send to Amazon Q." - default = "Please help me with my coding tasks. I'll provide specific instructions as needed." + description = "Base64 encoded, zstd compressed tarball of a pre-authenticated ~/.local/share/amazon-q directory. After running `q login` on another machine, you may generate it with: `cd ~/.local/share/amazon-q && tar -c . | zstd | base64 -w 0`" + default = "" + sensitive = true } -locals { - encoded_pre_install_script = var.experiment_pre_install_script != null ? base64encode(var.experiment_pre_install_script) : "" - encoded_post_install_script = var.experiment_post_install_script != null ? base64encode(var.experiment_post_install_script) : "" - full_prompt = <<-EOT - ${var.system_prompt} +variable "pre_install_script" { + type = string + description = "Optional script to run before installing Amazon Q." + default = null +} - Your first task is: +variable "post_install_script" { + type = string + description = "Optional script to run after installing Amazon Q." + default = null +} - ${var.ai_prompt} - EOT +variable "agent_config" { + type = string + description = "Optional Agent configuration JSON for Amazon Q." + default = null } -resource "coder_script" "amazon_q" { - agent_id = var.agent_id - display_name = "Amazon Q" - icon = var.icon - script = <<-EOT +# Expose status slug to the agent environment +resource "coder_env" "status_slug" { + agent_id = var.agent_id + name = "CODER_MCP_APP_STATUS_SLUG" + value = local.app_slug +} + +# Expose auth tarball as environment variable for install script +resource "coder_env" "auth_tarball" { + count = var.auth_tarball != "" ? 1 : 0 + agent_id = var.agent_id + name = "AMAZON_Q_AUTH_TARBALL" + value = var.auth_tarball +} + +locals { + app_slug = "amazonq" + install_script = file("${path.module}/scripts/install.sh") + start_script = file("${path.module}/scripts/start.sh") + module_dir_name = ".amazonq" + agent_config = var.agent_config == null ? templatefile("${path.module}/templates/agent-config.json.tpl", { + system_prompt = var.system_prompt + }) : var.agent_config + full_prompt = var.ai_prompt != null ? "${var.ai_prompt}" : "" +} + + +module "agentapi" { + source = "registry.coder.com/coder/agentapi/coder" + version = "1.1.1" + + agent_id = var.agent_id + web_app_slug = local.app_slug + web_app_order = var.order + web_app_group = var.group + web_app_icon = var.icon + web_app_display_name = "Amazon Q" + cli_app_slug = local.app_slug + cli_app_display_name = "Amazon Q" + 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 - command_exists() { - command -v "$1" >/dev/null 2>&1 - } - - if [ -n "${local.encoded_pre_install_script}" ]; then - echo "Running pre-install script..." - echo "${local.encoded_pre_install_script}" | base64 -d > /tmp/pre_install.sh - chmod +x /tmp/pre_install.sh - /tmp/pre_install.sh - fi - - if [ "${var.install_amazon_q}" = "true" ]; then - echo "Installing Amazon Q..." - PREV_DIR="$PWD" - TMP_DIR="$(mktemp -d)" - cd "$TMP_DIR" - - ARCH="$(uname -m)" - case "$ARCH" in - "x86_64") - Q_URL="https://desktop-release.q.us-east-1.amazonaws.com/${var.amazon_q_version}/q-x86_64-linux.zip" - ;; - "aarch64"|"arm64") - Q_URL="https://desktop-release.codewhisperer.us-east-1.amazonaws.com/${var.amazon_q_version}/q-aarch64-linux.zip" - ;; - *) - echo "Error: Unsupported architecture: $ARCH. Amazon Q only supports x86_64 and arm64." - exit 1 - ;; - esac - - echo "Downloading Amazon Q for $ARCH..." - curl --proto '=https' --tlsv1.2 -sSf "$Q_URL" -o "q.zip" - unzip q.zip - ./q/install.sh --no-confirm - cd "$PREV_DIR" - export PATH="$PATH:$HOME/.local/bin" - echo "Installed Amazon Q version: $(q --version)" - fi - - echo "Extracting auth tarball..." - PREV_DIR="$PWD" - echo "${var.experiment_auth_tarball}" | base64 -d > /tmp/auth.tar.zst - rm -rf ~/.local/share/amazon-q - mkdir -p ~/.local/share/amazon-q - cd ~/.local/share/amazon-q - tar -I zstd -xf /tmp/auth.tar.zst - rm /tmp/auth.tar.zst - cd "$PREV_DIR" - echo "Extracted auth tarball" - - if [ "${var.experiment_report_tasks}" = "true" ]; then - echo "Configuring Amazon Q to report tasks via Coder MCP..." - q mcp add --name coder --command "coder" --args "exp,mcp,server,--allowed-tools,coder_report_task" --env "CODER_MCP_APP_STATUS_SLUG=amazon-q" --scope global --force - echo "Added Coder MCP server to Amazon Q configuration" - fi - - if [ -n "${local.encoded_post_install_script}" ]; then - echo "Running post-install script..." - echo "${local.encoded_post_install_script}" | base64 -d > /tmp/post_install.sh - chmod +x /tmp/post_install.sh - /tmp/post_install.sh - fi - - if [ "${var.experiment_use_tmux}" = "true" ] && [ "${var.experiment_use_screen}" = "true" ]; then - echo "Error: Both experiment_use_tmux and experiment_use_screen cannot be true simultaneously." - echo "Please set only one of them to true." - exit 1 - fi - - if [ "${var.experiment_use_tmux}" = "true" ]; then - echo "Running Amazon Q in the background with tmux..." - - if ! command_exists tmux; then - echo "Error: tmux is not installed. Please install tmux manually." - exit 1 - fi - - touch "$HOME/.amazon-q.log" - - export LANG=en_US.UTF-8 - export LC_ALL=en_US.UTF-8 - - tmux new-session -d -s amazon-q -c "${var.folder}" "q chat --trust-all-tools | tee -a "$HOME/.amazon-q.log" && exec bash" - - tmux send-keys -t amazon-q "${local.full_prompt}" - sleep 5 - tmux send-keys -t amazon-q Enter - fi - - if [ "${var.experiment_use_screen}" = "true" ]; then - echo "Running Amazon Q in the background..." - - if ! command_exists screen; then - echo "Error: screen is not installed. Please install screen manually." - exit 1 - fi - - touch "$HOME/.amazon-q.log" - - if [ ! -f "$HOME/.screenrc" ]; then - echo "Creating ~/.screenrc and adding multiuser settings..." | tee -a "$HOME/.amazon-q.log" - echo -e "multiuser on\nacladd $(whoami)" > "$HOME/.screenrc" - fi - - if ! grep -q "^multiuser on$" "$HOME/.screenrc"; then - echo "Adding 'multiuser on' to ~/.screenrc..." | tee -a "$HOME/.amazon-q.log" - echo "multiuser on" >> "$HOME/.screenrc" - fi - - if ! grep -q "^acladd $(whoami)$" "$HOME/.screenrc"; then - echo "Adding 'acladd $(whoami)' to ~/.screenrc..." | tee -a "$HOME/.amazon-q.log" - echo "acladd $(whoami)" >> "$HOME/.screenrc" - fi - export LANG=en_US.UTF-8 - export LC_ALL=en_US.UTF-8 - - screen -U -dmS amazon-q bash -c ' - cd ${var.folder} - q chat --trust-all-tools | tee -a "$HOME/.amazon-q.log - exec bash - ' - # Extremely hacky way to send the prompt to the screen session - # This will be fixed in the future, but `amazon-q` was not sending MCP - # tasks when an initial prompt is provided. - screen -S amazon-q -X stuff "${local.full_prompt}" - sleep 5 - screen -S amazon-q -X stuff "^M" - else - if ! command_exists q; then - echo "Error: Amazon Q is not installed. Please enable install_amazon_q or install it manually." - exit 1 - fi - fi - EOT - run_on_start = true -} + echo -n '${base64encode(local.start_script)}' | base64 -d > /tmp/start.sh + chmod +x /tmp/start.sh + ARG_TRUST_ALL_TOOLS='${var.trust_all_tools}' \ + ARG_AI_PROMPT='${base64encode(local.full_prompt)}' \ + ARG_MODULE_DIR_NAME='${local.module_dir_name}' \ + ARG_FOLDER='${var.folder}' \ + SERVER_PARAMETERS="/@${data.coder_workspace_owner.me.name}/${data.coder_workspace.me.name}.${var.agent_id}/apps/${local.app_slug}/chat" \ + /tmp/start.sh + EOT -resource "coder_app" "amazon_q" { - slug = "amazon-q" - display_name = "Amazon Q" - agent_id = var.agent_id - command = <<-EOT + install_script = <<-EOT #!/bin/bash - set -e - - export LANG=en_US.UTF-8 - export LC_ALL=en_US.UTF-8 - - if [ "${var.experiment_use_tmux}" = "true" ]; then - if tmux has-session -t amazon-q 2>/dev/null; then - echo "Attaching to existing Amazon Q tmux session." | tee -a "$HOME/.amazon-q.log" - tmux attach-session -t amazon-q - else - echo "Starting a new Amazon Q tmux session." | tee -a "$HOME/.amazon-q.log" - tmux new-session -s amazon-q -c ${var.folder} "q chat --trust-all-tools | tee -a \"$HOME/.amazon-q.log\"; exec bash" - fi - elif [ "${var.experiment_use_screen}" = "true" ]; then - if screen -list | grep -q "amazon-q"; then - echo "Attaching to existing Amazon Q screen session." | tee -a "$HOME/.amazon-q.log" - screen -xRR amazon-q - else - echo "Starting a new Amazon Q screen session." | tee -a "$HOME/.amazon-q.log" - screen -S amazon-q bash -c 'q chat --trust-all-tools | tee -a "$HOME/.amazon-q.log"; exec bash' - fi - else - cd ${var.folder} - q chat --trust-all-tools - fi - EOT - icon = var.icon - order = var.order - group = var.group + set -o errexit + set -o pipefail + + echo -n '${base64encode(local.install_script)}' | base64 -d > /tmp/install.sh + chmod +x /tmp/install.sh + ARG_INSTALL='${var.install_amazon_q}' \ + ARG_VERSION='${var.amazon_q_version}' \ + ARG_AUTH_TARBALL='${var.auth_tarball}' \ + ARG_AGENT_CONFIG='${local.agent_config != null ? base64encode(local.agent_config) : ""}' \ + ARG_MODULE_DIR_NAME='${local.module_dir_name}' \ + ARG_CODER_MCP_APP_STATUS_SLUG='${local.app_slug}' \ + ARG_PRE_INSTALL_SCRIPT='${var.pre_install_script != null ? base64encode(var.pre_install_script) : ""}' \ + ARG_POST_INSTALL_SCRIPT='${var.post_install_script != null ? base64encode(var.post_install_script) : ""}' \ + /tmp/install.sh + EOT } diff --git a/registry/coder/modules/amazon-q/scripts/install.sh b/registry/coder/modules/amazon-q/scripts/install.sh new file mode 100644 index 000000000..3553f1fb4 --- /dev/null +++ b/registry/coder/modules/amazon-q/scripts/install.sh @@ -0,0 +1,168 @@ +#!/bin/bash +# Install script for amazon-q module + +set -o errexit +set -o pipefail + +command_exists() { + command -v "$1" >/dev/null 2>&1 +} + +# Inputs +ARG_INSTALL=${ARG_INSTALL:-true} +ARG_VERSION=${ARG_VERSION:-latest} +ARG_AUTH_TARBALL=${ARG_AUTH_TARBALL:-} +ARG_AGENT_CONFIG=${ARG_AGENT_CONFIG:-} +ARG_MODULE_DIR_NAME=${ARG_MODULE_DIR_NAME:-.aws/.amazonq} +ARG_CODER_MCP_APP_STATUS_SLUG=${ARG_CODER_MCP_APP_STATUS_SLUG:-} +ARG_PRE_INSTALL_SCRIPT=${ARG_PRE_INSTALL_SCRIPT:-} +ARG_POST_INSTALL_SCRIPT=${ARG_POST_INSTALL_SCRIPT:-} + +mkdir -p "$HOME/$ARG_MODULE_DIR_NAME" + +# Decode base64 inputs +ARG_AGENT_CONFIG_DECODED="" +if [ -n "$ARG_AGENT_CONFIG" ]; then + ARG_AGENT_CONFIG_DECODED=$(echo -n "$ARG_AGENT_CONFIG" | base64 -d) +fi + +echo "--------------------------------" +echo "install: $ARG_INSTALL" +echo "version: $ARG_VERSION" +echo "coder_mcp_app_status_slug: $ARG_CODER_MCP_APP_STATUS_SLUG" +echo "module_dir_name: $ARG_MODULE_DIR_NAME" +echo "auth_tarball_provided: $([ -n "$ARG_AUTH_TARBALL" ] && echo "yes" || echo "no")" +echo "pre_install_script_provided: $([ -n "$ARG_PRE_INSTALL_SCRIPT" ] && echo "yes" || echo "no")" +echo "post_install_script_provided: $([ -n "$ARG_POST_INSTALL_SCRIPT" ] && echo "yes" || echo "no")" +echo "--------------------------------" + +# Execute pre-install script if provided +function pre_install() { + if [ -n "$ARG_PRE_INSTALL_SCRIPT" ]; then + echo "Executing pre-install script..." + # Decode base64 encoded script and execute it + echo "$ARG_PRE_INSTALL_SCRIPT" | base64 -d > /tmp/pre_install.sh + chmod +x /tmp/pre_install.sh + /tmp/pre_install.sh + rm -f /tmp/pre_install.sh + echo "Pre-install script completed successfully." + else + echo "No pre-install script provided, skipping..." + fi +} + +# Install Amazon Q if requested +function install_amazon_q() { + if [ "$ARG_INSTALL" = "true" ]; then + echo "Installing Amazon Q..." + PREV_DIR="$PWD" + TMP_DIR="$(mktemp -d)" + cd "$TMP_DIR" + + ARCH="$(uname -m)" + case "$ARCH" in + "x86_64") + Q_URL="https://desktop-release.q.us-east-1.amazonaws.com/${ARG_VERSION}/q-x86_64-linux.zip" + ;; + "aarch64" | "arm64") + Q_URL="https://desktop-release.codewhisperer.us-east-1.amazonaws.com/${ARG_VERSION}/q-aarch64-linux.zip" + ;; + *) + echo "Error: Unsupported architecture: $ARCH. Amazon Q only supports x86_64 and arm64." + exit 1 + ;; + esac + + echo "Downloading Amazon Q for $ARCH from $Q_URL..." + curl --proto '=https' --tlsv1.2 -sSf "$Q_URL" -o "q.zip" + unzip q.zip + ./q/install.sh --no-confirm + cd "$PREV_DIR" + rm -rf "$TMP_DIR" + + # Ensure binaries are discoverable; create stable symlink to q + CANDIDATES=( + "$(command -v q || true)" + "$HOME/.local/bin/q" + ) + FOUND_BIN="" + for c in "${CANDIDATES[@]}"; do + if [ -n "$c" ] && [ -x "$c" ]; then + FOUND_BIN="$c" + break + fi + done + export PATH="$PATH:$HOME/.local/bin" + echo "Installed Amazon Q at: $(command -v q || true) (resolved: $FOUND_BIN)" + fi +} + +# Extract authentication tarball +function extract_auth_tarball() { + if [ -n "$ARG_AUTH_TARBALL" ]; then + echo "Extracting auth tarball..." + PREV_DIR="$PWD" + echo "$ARG_AUTH_TARBALL" | base64 -d >/tmp/auth.tar.zst + rm -rf ~/.local/share/amazon-q + mkdir -p ~/.local/share/amazon-q + cd ~/.local/share/amazon-q + tar -I zstd -xf /tmp/auth.tar.zst + rm /tmp/auth.tar.zst + cd "$PREV_DIR" + echo "Extracted auth tarball to ~/.local/share/amazon-q" + else + echo "Warning: No auth tarball provided. Amazon Q may require manual authentication." + fi +} + +# Configure MCP integration and create agent +function configure_agent() { + # Create Amazon Q agent configuration directory + AGENT_CONFIG_DIR="$HOME/.aws/amazonq/cli-agents" + mkdir -p "$AGENT_CONFIG_DIR" + if [ -n "$ARG_CODER_MCP_APP_STATUS_SLUG" ]; then + echo "Configuring Amazon Q to report tasks via Coder MCP..." + # Apply custom MCP configuration if provided + if [ -n "$ARG_AGENT_CONFIG_DECODED" ]; then + echo "Applying custom MCP configuration..." + # Parse and apply MCP config - implementation depends on Amazon Q's MCP config format + echo "$ARG_AGENT_CONFIG_DECODED" >"$AGENT_CONFIG_DIR/agent.json" + echo "Custom configuration saved to $AGENT_CONFIG_DIR/agent.json" + fi + q mcp add --name coder \ + --command "coder" \ + --args "exp,mcp,server,--allowed-tools,coder_report_task" \ + --env "CODER_MCP_APP_STATUS_SLUG=${ARG_CODER_MCP_APP_STATUS_SLUG}" \ + --env "CODER_MCP_AI_AGENTAPI_URL=http://localhost:3284" \ + --env "CODER_AGENT_URL=${CODER_AGENT_URL}" \ + --env "CODER_AGENT_TOKEN=${CODER_AGENT_TOKEN}" \ + --agent agent \ + --force || echo "Warning: Failed to add Coder MCP server" + echo "Added Coder MCP server into agent in Amazon Q configuration" + q settings chat.defaultAgent agent + fi +} + +# Execute post-install script if provided +function post_install() { + if [ -n "$ARG_POST_INSTALL_SCRIPT" ]; then + echo "Executing post-install script..." + # Decode base64 encoded script and execute it + echo "$ARG_POST_INSTALL_SCRIPT" | base64 -d > /tmp/post_install.sh + chmod +x /tmp/post_install.sh + /tmp/post_install.sh + rm -f /tmp/post_install.sh + echo "Post-install script completed successfully." + else + echo "No post-install script provided, skipping..." + fi +} + +# Main execution +pre_install +install_amazon_q +extract_auth_tarball +configure_agent +post_install + +echo "Amazon Q installation and configuration complete!" diff --git a/registry/coder/modules/amazon-q/scripts/start.sh b/registry/coder/modules/amazon-q/scripts/start.sh new file mode 100644 index 000000000..2bf2d6372 --- /dev/null +++ b/registry/coder/modules/amazon-q/scripts/start.sh @@ -0,0 +1,64 @@ +#!/bin/bash +# Start script for amazon-q module + +set -o errexit +set -o pipefail + +command_exists() { + command -v "$1" >/dev/null 2>&1 +} + +# Decode inputs +ARG_AI_PROMPT=$(echo -n "${ARG_AI_PROMPT:-}" | base64 -d) +ARG_TRUST_ALL_TOOLS=${ARG_TRUST_ALL_TOOLS:-true} +ARG_MODULE_DIR_NAME=${ARG_MODULE_DIR_NAME:-.aws/amazonq} +ARG_FOLDER=${ARG_FOLDER:-$HOME} + +echo "--------------------------------" +echo "folder: $ARG_FOLDER" +echo "ai_prompt: $ARG_AI_PROMPT" +echo "trust_all_tools: $ARG_TRUST_ALL_TOOLS" +echo "module_dir_name: $ARG_MODULE_DIR_NAME" +echo "--------------------------------" + +mkdir -p "$HOME/$ARG_MODULE_DIR_NAME" + +# Find Amazon Q CLI +if command_exists q; then + Q_CMD=q +elif [ -x "$HOME/.local/bin/q" ]; then + Q_CMD="$HOME/.local/bin/q" +else + echo "Error: Amazon Q CLI not found. Install it or set install_amazon_q=true." + exit 1 +fi + +# Ensure working directory exists +if [ -d "$ARG_FOLDER" ]; then + cd "$ARG_FOLDER" +else + mkdir -p "$ARG_FOLDER" + cd "$ARG_FOLDER" +fi + +# Set up environment +export LANG=en_US.UTF-8 +export LC_ALL=en_US.UTF-8 + +# Build command arguments +ARGS=("chat") + +if [ "$ARG_TRUST_ALL_TOOLS" = "true" ]; then + ARGS+=("--trust-all-tools") +fi + +# Log and run with agentapi integration +printf "Running: %q %s\n" "$Q_CMD" "$(printf '%q ' "${ARGS[@]}")" + +# If we have an AI prompt, we need to handle it specially +if [ -n "$ARG_AI_PROMPT" ]; then + printf "AI prompt provided\n" + ARGS+=("\"Complete the task at hand in one go. Every step of the way, report your progress using coder_report_task tool through coder MCP with proper summary and statuses. Your task at hand: $ARG_AI_PROMPT\"") +fi +# Use agentapi to manage the interactive session with initial prompt +agentapi server -c "$SERVER_PARAMETERS" -- "$Q_CMD" "${ARGS[@]}" diff --git a/registry/coder/modules/amazon-q/templates/agent-config.json.tpl b/registry/coder/modules/amazon-q/templates/agent-config.json.tpl new file mode 100644 index 000000000..b21810497 --- /dev/null +++ b/registry/coder/modules/amazon-q/templates/agent-config.json.tpl @@ -0,0 +1,26 @@ +{ + "name": "agent", + "description": "This is an default agent config", + "prompt": "${system_prompt}", + "mcpServers": {}, + "tools": [ + "fs_read", + "fs_write", + "execute_bash", + "use_aws", + "knowledge" + ], + "toolAliases": {}, + "allowedTools": [ + "fs_read" + ], + "resources": [ + "file://AmazonQ.md", + "file://README.md", + "file://.amazonq/rules/**/*.md" + ], + "hooks": {}, + "toolsSettings": {}, + "useLegacyMcpJson": true +} + diff --git a/registry/coder/modules/kasmvnc/README.md b/registry/coder/modules/kasmvnc/README.md index 1f65aa295..b9b16b269 100644 --- a/registry/coder/modules/kasmvnc/README.md +++ b/registry/coder/modules/kasmvnc/README.md @@ -14,7 +14,7 @@ Automatically install [KasmVNC](https://kasmweb.com/kasmvnc) in a workspace, and module "kasmvnc" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/kasmvnc/coder" - version = "1.2.1" + version = "1.2.2" agent_id = coder_agent.example.id desktop_environment = "xfce" subdomain = true diff --git a/registry/coder/modules/kasmvnc/run.sh b/registry/coder/modules/kasmvnc/run.sh index 67a8a310c..5223fb9f0 100644 --- a/registry/coder/modules/kasmvnc/run.sh +++ b/registry/coder/modules/kasmvnc/run.sh @@ -240,7 +240,7 @@ get_http_dir() { # Check the home directory for overriding values if [[ -e "$HOME/.vnc/kasmvnc.yaml" ]]; then - d=($(grep -E "^\s*httpd_directory:.*$" /etc/kasmvnc/kasmvnc.yaml)) + d=($(grep -E "^\s*httpd_directory:.*$" "$HOME/.vnc/kasmvnc.yaml")) if [[ $${#d[@]} -eq 2 && -d "$${d[1]}" ]]; then httpd_directory="$${d[1]}" fi diff --git a/registry/coder/modules/vscode-web/README.md b/registry/coder/modules/vscode-web/README.md index 474578577..d4ca481e0 100644 --- a/registry/coder/modules/vscode-web/README.md +++ b/registry/coder/modules/vscode-web/README.md @@ -14,7 +14,7 @@ Automatically install [Visual Studio Code Server](https://code.visualstudio.com/ module "vscode-web" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/vscode-web/coder" - version = "1.3.1" + version = "1.4.1" agent_id = coder_agent.example.id accept_license = true } @@ -30,7 +30,7 @@ module "vscode-web" { module "vscode-web" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/vscode-web/coder" - version = "1.3.1" + version = "1.4.1" agent_id = coder_agent.example.id install_prefix = "/home/coder/.vscode-web" folder = "/home/coder" @@ -44,7 +44,7 @@ module "vscode-web" { module "vscode-web" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/vscode-web/coder" - version = "1.3.1" + version = "1.4.1" agent_id = coder_agent.example.id extensions = ["github.copilot", "ms-python.python", "ms-toolsai.jupyter"] accept_license = true @@ -59,7 +59,7 @@ Configure VS Code's [settings.json](https://code.visualstudio.com/docs/getstarte module "vscode-web" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/vscode-web/coder" - version = "1.3.1" + version = "1.4.1" agent_id = coder_agent.example.id extensions = ["dracula-theme.theme-dracula"] settings = { @@ -77,9 +77,24 @@ By default, this module installs the latest. To pin a specific version, retrieve module "vscode-web" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/vscode-web/coder" - version = "1.3.1" + version = "1.4.1" agent_id = coder_agent.example.id commit_id = "e54c774e0add60467559eb0d1e229c6452cf8447" accept_license = true } ``` + +### Open an existing workspace on startup + +To open an existing workspace on startup the `workspace` parameter can be used to represent a path on disk to a `code-workspace` file. +Note: Either `workspace` or `folder` can be used, but not both simultaneously. The `code-workspace` file must already be present on disk. + +```tf +module "vscode-web" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/vscode-web/coder" + version = "1.4.1" + agent_id = coder_agent.example.id + workspace = "/home/coder/coder.code-workspace" +} +``` diff --git a/registry/coder/modules/vscode-web/main.tf b/registry/coder/modules/vscode-web/main.tf index c00724544..7a2029c87 100644 --- a/registry/coder/modules/vscode-web/main.tf +++ b/registry/coder/modules/vscode-web/main.tf @@ -158,6 +158,12 @@ variable "platform" { } } +variable "workspace" { + type = string + description = "Path to a .code-workspace file to open in vscode-web." + default = "" +} + data "coder_workspace_owner" "me" {} data "coder_workspace" "me" {} @@ -178,6 +184,7 @@ resource "coder_script" "vscode-web" { DISABLE_TRUST : var.disable_trust, EXTENSIONS_DIR : var.extensions_dir, FOLDER : var.folder, + WORKSPACE : var.workspace, AUTO_INSTALL_EXTENSIONS : var.auto_install_extensions, SERVER_BASE_PATH : local.server_base_path, COMMIT_ID : var.commit_id, @@ -195,6 +202,11 @@ resource "coder_script" "vscode-web" { condition = !var.offline || !var.use_cached error_message = "Offline and Use Cached can not be used together" } + + precondition { + condition = (var.workspace == "" || var.folder == "") + error_message = "Set only one of `workspace` or `folder`." + } } } @@ -218,6 +230,12 @@ resource "coder_app" "vscode-web" { locals { server_base_path = var.subdomain ? "" : format("/@%s/%s/apps/%s/", data.coder_workspace_owner.me.name, data.coder_workspace.me.name, var.slug) - url = var.folder == "" ? "http://localhost:${var.port}${local.server_base_path}" : "http://localhost:${var.port}${local.server_base_path}?folder=${var.folder}" - healthcheck_url = var.subdomain ? "http://localhost:${var.port}/healthz" : "http://localhost:${var.port}${local.server_base_path}/healthz" + url = ( + var.workspace != "" ? + "http://localhost:${var.port}${local.server_base_path}?workspace=${urlencode(var.workspace)}" : + var.folder != "" ? + "http://localhost:${var.port}${local.server_base_path}?folder=${urlencode(var.folder)}" : + "http://localhost:${var.port}${local.server_base_path}" + ) + healthcheck_url = var.subdomain ? "http://localhost:${var.port}/healthz" : "http://localhost:${var.port}${local.server_base_path}/healthz" } diff --git a/registry/coder/modules/vscode-web/run.sh b/registry/coder/modules/vscode-web/run.sh index 9346b4bdb..98881d721 100644 --- a/registry/coder/modules/vscode-web/run.sh +++ b/registry/coder/modules/vscode-web/run.sh @@ -109,18 +109,27 @@ if [ "${AUTO_INSTALL_EXTENSIONS}" = true ]; then if ! command -v jq > /dev/null; then echo "jq is required to install extensions from a workspace file." else - WORKSPACE_DIR="$HOME" - if [ -n "${FOLDER}" ]; then - WORKSPACE_DIR="${FOLDER}" - fi - - if [ -f "$WORKSPACE_DIR/.vscode/extensions.json" ]; then - printf "🧩 Installing extensions from %s/.vscode/extensions.json...\n" "$WORKSPACE_DIR" - # Use sed to remove single-line comments before parsing with jq - extensions=$(sed 's|//.*||g' "$WORKSPACE_DIR"/.vscode/extensions.json | jq -r '.recommendations[]') + # Prefer WORKSPACE if set and points to a file + if [ -n "${WORKSPACE}" ] && [ -f "${WORKSPACE}" ]; then + printf "🧩 Installing extensions from %s...\n" "${WORKSPACE}" + # Strip single-line comments then parse .extensions.recommendations[] + extensions=$(sed 's|//.*||g' "${WORKSPACE}" | jq -r '(.extensions.recommendations // [])[]') for extension in $extensions; do $VSCODE_WEB "$EXTENSION_ARG" --install-extension "$extension" --force done + else + # Fallback to folder-based .vscode/extensions.json (existing behavior) + WORKSPACE_DIR="$HOME" + if [ -n "${FOLDER}" ]; then + WORKSPACE_DIR="${FOLDER}" + fi + if [ -f "$WORKSPACE_DIR/.vscode/extensions.json" ]; then + printf "🧩 Installing extensions from %s/.vscode/extensions.json...\n" "$WORKSPACE_DIR" + extensions=$(sed 's|//.*||g' "$WORKSPACE_DIR/.vscode/extensions.json" | jq -r '.recommendations[]') + for extension in $extensions; do + $VSCODE_WEB "$EXTENSION_ARG" --install-extension "$extension" --force + done + fi fi fi fi diff --git a/registry/umair/.images/avatar.jpeg b/registry/umair/.images/avatar.jpeg new file mode 100644 index 000000000..6495306b9 Binary files /dev/null and b/registry/umair/.images/avatar.jpeg differ diff --git a/registry/umair/README.md b/registry/umair/README.md new file mode 100644 index 000000000..c579b28bf --- /dev/null +++ b/registry/umair/README.md @@ -0,0 +1,13 @@ +--- +display_name: "Muhammad Uamir Ali" +bio: "Cloud Engineer | Infrastructure as code, Kubernetes | SRE" +github: "m4rrypro" +avatar: "./.images/avatar.jpeg" +linkedin: "https://www.linkedin.com/in/m4rry" +support_email: "m.umair.ali200@gmail.com" +status: "community" +--- + +# Muhammad Umair Ali + +Cloud Engineer | Infrastructure as code, Kubernetes | SRE diff --git a/registry/umair/templates/proxmox-vm/README.md b/registry/umair/templates/proxmox-vm/README.md new file mode 100644 index 000000000..0f2f44ea5 --- /dev/null +++ b/registry/umair/templates/proxmox-vm/README.md @@ -0,0 +1,161 @@ +--- +display_name: Proxmox VM +description: Provision VMs on Proxmox VE as Coder workspaces +icon: ../../../../.icons/proxmox.svg +verified: false +tags: [proxmox, vm, cloud-init, qemu] +--- + +# Proxmox VM Template for Coder + +Provision Linux VMs on Proxmox as [Coder workspaces](https://coder.com/docs/workspaces). The template clones a cloud‑init base image, injects user‑data via Snippets, and runs the Coder agent under the workspace owner's Linux user. + +## Prerequisites + +- Proxmox VE 8/9 +- Proxmox API token with access to nodes and storages +- SSH access from Coder provisioner to Proxmox VE +- Storage with "Snippets" content enabled +- Ubuntu cloud‑init image/template on Proxmox + - Latest images: https://cloud-images.ubuntu.com/ ([source](https://cloud-images.ubuntu.com/)) + +## Prepare a Proxmox Cloud‑Init Template (once) + +Run on the Proxmox node. This uses a RELEASE variable so you always pull a current image. + +```bash +# Choose a release (e.g., jammy or noble) +RELEASE=jammy +IMG_URL="https://cloud-images.ubuntu.com/${RELEASE}/current/${RELEASE}-server-cloudimg-amd64.img" +IMG_PATH="/var/lib/vz/template/iso/${RELEASE}-server-cloudimg-amd64.img" + +# Download cloud image +wget "$IMG_URL" -O "$IMG_PATH" + +# Create base VM (example ID 999), enable QGA, correct boot order +NAME="ubuntu-${RELEASE}-cloudinit" +qm create 999 --name "$NAME" --memory 4096 --cores 2 \ + --net0 virtio,bridge=vmbr0 --agent enabled=1 +qm set 999 --scsihw virtio-scsi-pci +qm importdisk 999 "$IMG_PATH" local-lvm +qm set 999 --scsi0 local-lvm:vm-999-disk-0 +qm set 999 --ide2 local-lvm:cloudinit +qm set 999 --serial0 socket --vga serial0 +qm set 999 --boot 'order=scsi0;ide2;net0' + +# Enable Snippets on storage 'local' (one‑time) +pvesm set local --content snippets,vztmpl,backup,iso + +# Convert to template +qm template 999 +``` + +Verify: + +```bash +qm config 999 | grep -E 'template:|agent:|boot:|ide2:|scsi0:' +``` + +### Enable Snippets via GUI + +- Datacenter → Storage → select `local` → Edit → Content → check "Snippets" → OK +- Ensure `/var/lib/vz/snippets/` exists on the node for snippet files +- Template page → Cloud‑Init → Snippet Storage: `local` → File: your yml → Apply + +## Configure this template + +Edit `terraform.tfvars` with your environment: + +```hcl +# Proxmox API +proxmox_api_url = "https://:8006/api2/json" +proxmox_api_token_id = "!" +proxmox_api_token_secret = "" + +# SSH to the node (for snippet upload) +proxmox_host = "" +proxmox_password = "" +proxmox_ssh_user = "root" + +# Infra defaults +proxmox_node = "pve" +disk_storage = "local-lvm" +snippet_storage = "local" +bridge = "vmbr0" +vlan = 0 +clone_template_vmid = 999 +``` + +### Variables (terraform.tfvars) + +- These values are standard Terraform variables that the template reads at apply time. +- Place secrets (e.g., `proxmox_api_token_secret`, `proxmox_password`) in `terraform.tfvars` or inject with environment variables using `TF_VAR_*` (e.g., `TF_VAR_proxmox_api_token_secret`). +- You can also override with `-var`/`-var-file` if you run Terraform directly. With Coder, the repo's `terraform.tfvars` is bundled when pushing the template. + +Variables expected: + +- `proxmox_api_url`, `proxmox_api_token_id`, `proxmox_api_token_secret` (sensitive) +- `proxmox_host`, `proxmox_password` (sensitive), `proxmox_ssh_user` +- `proxmox_node`, `disk_storage`, `snippet_storage`, `bridge`, `vlan`, `clone_template_vmid` +- Coder parameters: `cpu_cores`, `memory_mb`, `disk_size_gb` + +## Proxmox API Token (GUI/CLI) + +Docs: https://pve.proxmox.com/wiki/User_Management#pveum_tokens + +GUI: + +1. (Optional) Create automation user: Datacenter → Permissions → Users → Add (e.g., `terraform@pve`) +2. Permissions: Datacenter → Permissions → Add → User Permission + - Path: `/` (or narrower covering your nodes/storages) + - Role: `PVEVMAdmin` + `PVEStorageAdmin` (or `PVEAdmin` for simplicity) +3. Token: Datacenter → Permissions → API Tokens → Add → copy Token ID and Secret +4. Test: + +```bash +curl -k -H "Authorization: PVEAPIToken=!=" \ + https:// < PVE_HOST > :8006/api2/json/version +``` + +CLI: + +```bash +pveum user add terraform@pve --comment 'Terraform automation user' +pveum aclmod / -user terraform@pve -role PVEAdmin +pveum user token add terraform@pve terraform --privsep 0 +``` + +## Use + +```bash +# From this directory +coder templates push --yes proxmox-cloudinit --directory . | cat +``` + +Create a workspace from the template in the Coder UI. First boot usually takes 60–120s while cloud‑init runs. + +## How it works + +- Uploads rendered cloud‑init user‑data to `:snippets/.yml` via the provider's `proxmox_virtual_environment_file` +- VM config: `virtio-scsi-pci`, boot order `scsi0, ide2, net0`, QGA enabled +- Linux user equals Coder workspace owner (sanitized). To avoid collisions, reserved names (`admin`, `root`, etc.) get a suffix (e.g., `admin1`). User is created with `primary_group: adm`, `groups: [sudo]`, `no_user_group: true` +- systemd service runs as that user: + - `coder-agent.service` + +## Troubleshooting quick hits + +- iPXE boot loop: ensure template has bootable root disk and boot order `scsi0,ide2,net0` +- QGA not responding: install/enable QGA in template; allow 60–120s on first boot +- Snippet upload errors: storage must include `Snippets`; token needs Datastore permissions; path format `:snippets/` handled by provider +- Permissions errors: ensure the token's role covers the target node(s) and storages +- Verify snippet/QGA: `qm config | egrep 'cicustom|ide2|ciuser'` + +## References + +- Ubuntu Cloud Images (latest): https://cloud-images.ubuntu.com/ ([source](https://cloud-images.ubuntu.com/)) +- Proxmox qm(1) manual: https://pve.proxmox.com/pve-docs/qm.1.html +- Proxmox Cloud‑Init Support: https://pve.proxmox.com/wiki/Cloud-Init_Support +- Terraform Proxmox provider (bpg): `bpg/proxmox` on the Terraform Registry +- Coder – Best practices & templates: + - https://coder.com/docs/tutorials/best-practices/speed-up-templates + - https://coder.com/docs/tutorials/template-from-scratch diff --git a/registry/umair/templates/proxmox-vm/cloud-init/user-data.tftpl b/registry/umair/templates/proxmox-vm/cloud-init/user-data.tftpl new file mode 100644 index 000000000..2cd34d0ac --- /dev/null +++ b/registry/umair/templates/proxmox-vm/cloud-init/user-data.tftpl @@ -0,0 +1,53 @@ +#cloud-config +hostname: ${hostname} + +users: + - name: ${linux_user} + groups: [sudo] + shell: /bin/bash + sudo: ["ALL=(ALL) NOPASSWD:ALL"] + +package_update: false +package_upgrade: false +packages: + - curl + - ca-certificates + - git + - jq + +write_files: + - path: /opt/coder/init.sh + permissions: "0755" + owner: root:root + encoding: b64 + content: | + ${coder_init_script_b64} + + - path: /etc/systemd/system/coder-agent.service + permissions: "0644" + owner: root:root + content: | + [Unit] + Description=Coder Agent + Wants=network-online.target + After=network-online.target + + [Service] + Type=simple + User=${linux_user} + WorkingDirectory=/home/${linux_user} + Environment=HOME=/home/${linux_user} + Environment=CODER_AGENT_TOKEN=${coder_token} + ExecStart=/opt/coder/init.sh + OOMScoreAdjust=-1000 + Restart=always + RestartSec=5 + + [Install] + WantedBy=multi-user.target + +runcmd: + - systemctl daemon-reload + - systemctl enable --now coder-agent.service + +final_message: "Cloud-init complete on ${hostname}" \ No newline at end of file diff --git a/registry/umair/templates/proxmox-vm/main.tf b/registry/umair/templates/proxmox-vm/main.tf new file mode 100644 index 000000000..86da81b6c --- /dev/null +++ b/registry/umair/templates/proxmox-vm/main.tf @@ -0,0 +1,283 @@ +terraform { + required_providers { + coder = { + source = "coder/coder" + } + proxmox = { + source = "bpg/proxmox" + } + } +} + +provider "coder" {} + +provider "proxmox" { + endpoint = var.proxmox_api_url + api_token = "${var.proxmox_api_token_id}=${var.proxmox_api_token_secret}" + insecure = true + + # SSH is needed for file uploads to Proxmox + ssh { + username = var.proxmox_ssh_user + password = var.proxmox_password + + node { + name = var.proxmox_node + address = var.proxmox_host + } + } +} + +variable "proxmox_api_url" { + type = string +} + +variable "proxmox_api_token_id" { + type = string + sensitive = true +} + +variable "proxmox_api_token_secret" { + type = string + sensitive = true +} + + +variable "proxmox_host" { + description = "Proxmox node IP or DNS for SSH" + type = string +} + +variable "proxmox_password" { + description = "Proxmox password (used for SSH)" + type = string + sensitive = true +} + +variable "proxmox_ssh_user" { + description = "SSH username on Proxmox node" + type = string + default = "root" +} + +variable "proxmox_node" { + description = "Target Proxmox node" + type = string + default = "pve" +} +variable "disk_storage" { + description = "Disk storage (e.g., local-lvm)" + type = string + default = "local-lvm" +} + +variable "snippet_storage" { + description = "Storage with Snippets content" + type = string + default = "local" +} + +variable "bridge" { + description = "Bridge (e.g., vmbr0)" + type = string + default = "vmbr0" +} + +variable "vlan" { + description = "VLAN tag (0 none)" + type = number + default = 0 +} + +variable "clone_template_vmid" { + description = "VMID of the cloud-init base template to clone" + type = number +} + +data "coder_workspace" "me" {} +data "coder_workspace_owner" "me" {} + +data "coder_parameter" "cpu_cores" { + name = "cpu_cores" + display_name = "CPU Cores" + type = "number" + default = 2 + mutable = true +} + +data "coder_parameter" "memory_mb" { + name = "memory_mb" + display_name = "Memory (MB)" + type = "number" + default = 4096 + mutable = true +} + +data "coder_parameter" "disk_size_gb" { + name = "disk_size_gb" + display_name = "Disk Size (GB)" + type = "number" + default = 20 + mutable = true + validation { + min = 10 + max = 100 + monotonic = "increasing" + } +} + +resource "coder_agent" "dev" { + arch = "amd64" + os = "linux" + + env = { + GIT_AUTHOR_NAME = data.coder_workspace_owner.me.name + GIT_AUTHOR_EMAIL = data.coder_workspace_owner.me.email + } + + startup_script_behavior = "non-blocking" + startup_script = <<-EOT + set -e + # Add any startup scripts here + EOT + + metadata { + display_name = "CPU Usage" + key = "cpu_usage" + script = "coder stat cpu" + interval = 10 + timeout = 1 + order = 1 + } + + metadata { + display_name = "RAM Usage" + key = "ram_usage" + script = "coder stat mem" + interval = 10 + timeout = 1 + order = 2 + } + + metadata { + display_name = "Disk Usage" + key = "disk_usage" + script = "coder stat disk" + interval = 600 + timeout = 30 + order = 3 + } +} + +locals { + hostname = lower(data.coder_workspace.me.name) + vm_name = "coder-${lower(data.coder_workspace_owner.me.name)}-${local.hostname}" + snippet_filename = "${local.vm_name}.yml" + base_user = replace(replace(replace(lower(data.coder_workspace_owner.me.name), " ", "-"), "/", "-"), "@", "-") # to avoid special characters in the username + linux_user = contains(["root", "admin", "daemon", "bin", "sys"], local.base_user) ? "${local.base_user}1" : local.base_user # to avoid conflict with system users + + rendered_user_data = templatefile("${path.module}/cloud-init/user-data.tftpl", { + coder_token = coder_agent.dev.token + coder_init_script_b64 = base64encode(coder_agent.dev.init_script) + hostname = local.vm_name + linux_user = local.linux_user + }) +} + +resource "proxmox_virtual_environment_file" "cloud_init_user_data" { + content_type = "snippets" + datastore_id = var.snippet_storage + node_name = var.proxmox_node + + source_raw { + data = local.rendered_user_data + file_name = local.snippet_filename + } +} + +resource "proxmox_virtual_environment_vm" "workspace" { + name = local.vm_name + node_name = var.proxmox_node + + clone { + node_name = var.proxmox_node + vm_id = var.clone_template_vmid + full = false + retries = 5 + } + + agent { + enabled = true + } + + on_boot = true + started = true + + startup { + order = 1 + } + + scsi_hardware = "virtio-scsi-pci" + boot_order = ["scsi0", "ide2"] + + memory { + dedicated = data.coder_parameter.memory_mb.value + } + + cpu { + cores = data.coder_parameter.cpu_cores.value + sockets = 1 + type = "host" + } + + network_device { + bridge = var.bridge + model = "virtio" + vlan_id = var.vlan == 0 ? null : var.vlan + } + + vga { + type = "serial0" + } + + serial_device { + device = "socket" + } + + disk { + interface = "scsi0" + datastore_id = var.disk_storage + size = data.coder_parameter.disk_size_gb.value + } + + initialization { + type = "nocloud" + datastore_id = var.disk_storage + + user_data_file_id = proxmox_virtual_environment_file.cloud_init_user_data.id + + ip_config { + ipv4 { + address = "dhcp" + } + } + } + + tags = ["coder", "workspace", local.vm_name] + + depends_on = [proxmox_virtual_environment_file.cloud_init_user_data] +} + +module "code-server" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/code-server/coder" + version = "1.3.1" + agent_id = coder_agent.dev.id +} + +module "cursor" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/cursor/coder" + version = "1.3.0" + agent_id = coder_agent.dev.id +} \ No newline at end of file diff --git a/scripts/tag_release.sh b/scripts/tag_release.sh index c73f93e2b..d2d85f60b 100755 --- a/scripts/tag_release.sh +++ b/scripts/tag_release.sh @@ -28,7 +28,6 @@ JSON_OUTPUT='{ readonly EXIT_SUCCESS=0 readonly EXIT_ERROR=1 readonly EXIT_NO_ACTION_NEEDED=2 -readonly EXIT_VALIDATION_FAILED=3 usage() { cat << EOF @@ -52,7 +51,7 @@ EXAMPLES: $0 -m code-server -d # Target specific module $0 -n coder -m code-server -d # Target module in namespace -Exit codes: 0=success, 1=error, 2=no action needed, 3=validation failed +Exit codes: 0=success, 1=error, 2=no action needed EOF exit 0 } @@ -230,29 +229,38 @@ extract_version_from_readme() { return 1 } - local version_line - version_line=$(grep -E "source[[:space:]]*=[[:space:]]*\"registry\.coder\.com/${namespace}/${module_name}" "$readme_path" | head -1 || echo "") + local version + version=$(extract_version_from_module_block "$readme_path" "$namespace" "$module_name") - if [ -n "$version_line" ]; then - local version - version=$(echo "$version_line" | sed -n 's/.*version[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p') - if [ -n "$version" ]; then - log "DEBUG" "Found version '$version' from source line: $version_line" - echo "$version" - return 0 - fi + if [ -n "$version" ]; then + log "DEBUG" "Found version '$version' from module block for $namespace/$module_name" + echo "$version" + return 0 fi - local fallback_version - fallback_version=$(grep -E 'version[[:space:]]*=[[:space:]]*"[0-9]+\.[0-9]+\.[0-9]+"' "$readme_path" | head -1 | sed 's/.*version[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/' || echo "") + log "DEBUG" "No version found in module block for $namespace/$module_name in $readme_path" + return 1 +} + +extract_version_from_module_block() { + local readme_path="$1" + local namespace="$2" + local module_name="$3" + + local version + version=$(grep -A 10 "source[[:space:]]*=[[:space:]]*\"registry\.coder\.com/${namespace}/${module_name}/coder" "$readme_path" \ + | sed '/^[[:space:]]*}/q' \ + | grep -E "version[[:space:]]*=[[:space:]]*\"[^\"]+\"" \ + | head -1 \ + | sed 's/.*version[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/') - if [ -n "$fallback_version" ]; then - log "DEBUG" "Found fallback version '$fallback_version'" - echo "$fallback_version" + if [ -n "$version" ]; then + log "DEBUG" "Found version '$version' for $namespace/$module_name" + echo "$version" return 0 fi - log "DEBUG" "No version found in $readme_path" + log "DEBUG" "No version found within module block for $namespace/$module_name" return 1 } @@ -300,7 +308,9 @@ detect_modules_needing_tags() { fi local all_modules - all_modules=$(find registry -mindepth 3 -maxdepth 3 -type d -path "*/modules/*" | sort -u || echo "") + # Find all module directories, excluding hidden directories + # This works on both macOS and Linux + all_modules=$(find registry -mindepth 3 -maxdepth 3 -type d -path "*/modules/*" ! -name ".*" | sort -u || echo "") [ -z "$all_modules" ] && { log "ERROR" "No modules found to check" @@ -546,7 +556,7 @@ create_and_push_tags() { echo "" fi - if [ $pushed_tags -gt 0 ]; then + if [ "$pushed_tags" -gt 0 ]; then if [[ "$OUTPUT_FORMAT" != "json" ]]; then log "SUCCESS" "🎉 Successfully created and pushed $pushed_tags release tags!" echo "" @@ -606,7 +616,7 @@ main() { detect_exit_code=$? case $detect_exit_code in - $EXIT_NO_ACTION_NEEDED) + "$EXIT_NO_ACTION_NEEDED") if [[ "$OUTPUT_FORMAT" == "json" ]]; then finalize_json_output "$@" else @@ -614,7 +624,7 @@ main() { fi exit $EXIT_SUCCESS ;; - $EXIT_ERROR) + "$EXIT_ERROR") if [[ "$OUTPUT_FORMAT" == "json" ]]; then JSON_OUTPUT=$(echo "$JSON_OUTPUT" | jq '.summary.operation_status = "scan_failed"') finalize_json_output "$@"