From 94e31e504b7d45acd282e6b2a39fd98aec804599 Mon Sep 17 00:00:00 2001 From: 35C4n0r Date: Mon, 28 Sep 2026 17:20:57 +0000 Subject: [PATCH 1/5] feat(.agents/skills/migrate-agent-module): add skill for migrating agent modules off AgentAPI --- .agents/skills/migrate-agent-module/SKILL.md | 126 ++++++++++++ .../skills/migrate-agent-module/reference.md | 193 ++++++++++++++++++ 2 files changed, 319 insertions(+) create mode 100644 .agents/skills/migrate-agent-module/SKILL.md create mode 100644 .agents/skills/migrate-agent-module/reference.md diff --git a/.agents/skills/migrate-agent-module/SKILL.md b/.agents/skills/migrate-agent-module/SKILL.md new file mode 100644 index 000000000..05d2ccb7e --- /dev/null +++ b/.agents/skills/migrate-agent-module/SKILL.md @@ -0,0 +1,126 @@ +--- +name: migrate-agent-module +description: Migrate a Coder AI agent module (Copilot, Claude Code, Codex, Cursor CLI, etc.) off AgentAPI/Coder Tasks to an install-only v1 module that the user launches with their own coder_app. Captures the config/precedence, auth, MCP, managed-settings, and testing patterns learned from the Copilot v1 migration. +--- + +# Migrate Agent Module (AgentAPI/Tasks -> install-only v1) + +Use this when converting a CLI-agent module that currently embeds `module "agentapi"` and Coder Tasks (web app, task reporting, initial prompt) into a **v1 install-only module**: the module installs and configures the tool, and the workspace author launches it themselves with a `coder_app`. This is the pattern used by `coder/claude-code`, `coder-labs/codex`, and `coder-labs/copilot`. + +Read [coder-modules](../coder-modules/SKILL.md) first for the base module conventions (scaffolding, README frontmatter, testing helpers, versioning). This skill only covers what is specific to the AgentAPI -> install-only migration. + +## Golden rule: research the target tool before editing + +Every agent CLI stores config differently and the schemas change. Do not assume. Before writing code, read the tool's own docs and verify against a real install: + +- Config file locations, exact key names, and **casing** (e.g. Copilot uses `trustedFolders`, camelCase, not `trusted_folders`). +- Settings **precedence** (managed/policy vs user vs env vs CLI flags). +- Which keys are valid in each file (e.g. Copilot managed settings only accept a fixed policy key set; `banner`/`theme` are user `settings.json`, not `config.json`). +- Authentication env vars and their precedence. +- Whether a CLI subcommand you plan to call actually exists. Run ` --help` / ` --help`. Example bug caught in the Copilot migration: `copilot config model ...` is **not a command** (`config` is only a help topic); the model is set via the `COPILOT_MODEL` env var. +- MCP add/merge semantics (does the native `mcp add` overwrite or error on duplicate names?). + +When docs and third-party sources conflict, trust the official docs and confirm against the installed binary. Cite the doc URL in variable descriptions. + +## What to remove (AgentAPI / Tasks surface) + +Delete the `module "agentapi"` block and every input/resource that only existed to feed it: + +- Web/app inputs: `order`, `group`, `web_app_display_name`, `cli_app`, `cli_app_display_name`, `subdomain`. +- AgentAPI inputs: `install_agentapi`, `agentapi_version`. +- Tasks inputs: `report_tasks`, `ai_prompt`, `system_prompt`, `resume_session`, and any `task_app_id`. +- The task-reporting MCP server injection (the "coder" MCP wrapper that ran `coder exp mcp server`). +- `scripts/start.sh` (there is no launch step anymore) and the old `.tftest.hcl`. + +## What to add / keep + +- `module "coder_utils"` (`registry.coder.com/coder/coder-utils/coder`) to run the install pipeline and expose the ordered `scripts` output. It takes `install_script`, `pre_install_script`, `post_install_script`, `module_directory`, `display_name_prefix`, `icon`, `agent_id`. +- An install-only `scripts/install.sh.tftpl` rendered via `templatefile()` with base64-encoded args. +- Keep `workdir` (optional, `default = null`): create it if missing and auto-trust it (the tool's equivalent of accepting the trust prompt). +- Keep pre/post install script passthrough. +- A `scripts` output for downstream `coder_script` serialization via `coder exp sync`. + +See `reference.md` in this folder for the concrete `coder_utils` block, install-script skeleton, and jq merge snippets. + +## Install script (`scripts/install.sh.tftpl`) + +- Shebang `#!/usr/bin/env bash` (repo convention), `set -euo pipefail`. +- Prefer the tool's **official installer** over npm/node. Download to a temp file, validate with `bash -n`, then execute. Use these curl flags: + ```sh + curl --fail --silent --show-error --location \ + --retry 2 --retry-delay 1 --retry-all-errors \ + --connect-timeout 10 --max-time 300 + ``` + On download or validation failure, print an explicit message and return non-zero. Run cleanup in a subshell-scoped `trap`. +- Do not add Node.js/npm install logic unless the tool genuinely requires it; the official installers ship self-contained binaries. (Consequence to document: MCP servers whose `command` is `npx`/`uv` will fail with ENOENT unless that runtime is installed separately or an absolute path is used.) +- Pass version straight through (e.g. `VERSION="$ARG_VERSION"`); the official installer normalizes `latest`/tags. Do not reimplement normalization. +- Put the binary on PATH: link into `CODER_SCRIPT_BIN_DIR` when set, and append the bin dir to shell profiles (`.profile`, `.bashrc`, `.zshrc`, fish) because the coder scripts run non-interactively. +- Only create the workdir when set and missing (`mkdir -p`). + +## Configuration ownership model + +Follow "clear ownership like codex/claude-code": + +- The module owns only the keys it writes; **preserve unrelated on-disk state** (authentication, installed plugins, user-picked values). +- Before a jq merge, validate the existing file is valid JSON (`jq empty`); config files may be JSONC. Fall back to `{}` if invalid so the script never crashes. Require `jq` and fail with a clear message if missing. +- Union list-like keys (e.g. trusted folders) rather than replacing them. +- Prefer a **policy/managed-settings input** over a generic "dump arbitrary JSON into the user settings file" input, mirroring claude-code's `managed_settings`: + - `variable "managed_settings" { type = any, default = null }`. + - Written verbatim to the tool's managed-settings file (Copilot: `/etc/github-copilot/managed-settings.json`; Claude Code: `/etc/claude-code/managed-settings.d/10-coder.json`). + - Write as **root** (`sudo tee`, `sudo chmod 0644`); these files are rejected if world-writable, symlinked, or not root-owned. Validate JSON first; skip with a warning if invalid. +- workdir trust: write only the trust key/array and preserve everything else in that file. + +## Authentication + +- Use one tool-specific token env var when the tool has one, instead of exporting several. Copilot precedence is `COPILOT_GITHUB_TOKEN` > `GH_TOKEN` > `GITHUB_TOKEN`, so setting `COPILOT_GITHUB_TOKEN` alone is enough for the agent. Only export extra vars if you deliberately want to authenticate other workspace tooling (`gh`, git); document that choice. +- In README examples, fetch the token with the `coder_external_auth` data source and pass `.access_token` to the module (`github_token = data.coder_external_auth.github.access_token`) rather than shelling out to `coder external-auth access-token` inside the `coder_app` command. + +## Model selection + +- Set the model via the documented env var (Copilot: `COPILOT_MODEL`) using a `coder_env`. Confirm the env var still exists in the current docs. +- Default the model variable to `""` (let the tool pick its own default); gate the `coder_env` on `!= ""`. Mirrors claude-code's `model` variable. +- Do not call unverified persistence subcommands. + +## MCP + +- Write MCP servers to the tool's user-level MCP file (Copilot: `~/.copilot/mcp-config.json`) via jq. +- Match the tool's native duplicate-name behavior. Copilot/Claude Code `mcp add` is **not** an upsert: it errors on an existing name and keeps the current one. So the merge should let **existing on-disk servers win** on duplicate names and only add new names: `.mcpServers = ($custom + (.mcpServers // {}))` (right operand wins in jq `+`). + +## AI Gateway / proxy + +- If the pre-migration module had AI Bridge Proxy support, keep it renamed to the current product name (`ai_gateway`), matching whatever `main` already renamed. Keep both env vars: `HTTPS_PROXY` (routes traffic) and `NODE_EXTRA_CA_CERTS` (makes Node trust the gateway's intercepting CA) — they serve different purposes. +- Without a start script the env vars are set at the agent level (workspace-wide) instead of process-scoped. Decide whether to document that difference; keep validations that require the auth URL and cert path when enabled. + +## README + +- Add a `> [!WARNING]` that v1 is a major refactor dropping Coder Tasks/AgentAPI and that the user now launches the tool with their own `coder_app`. +- Provide a launcher example: `coder_external_auth` data source -> module (`github_token`) -> `coder_app` whose command `cd`s to the workdir and `exec`s the CLI. +- Keep it DRY: no variable/output tables (the registry generates those). Use `tf` code fences, GFM alerts, relative icon paths, pinned `version`, and `#!/usr/bin/env bash` in shell snippets. +- Remove Tasks/web-app examples and any notes that no longer apply. + +## Tests + +- Replace the old `.tftest.hcl` with `main.tftest.hcl` (plan-level): defaults, token env var name/value, model env created only when non-empty, validation `expect_failures`, and the `scripts` output ordering. +- Add container-based `main.test.ts` modeled on codex/claude-code: render the module, run the `coder_utils` scripts inside `codercom/enterprise-node:latest`, mock `/usr/bin/coder` (and the agent binary) so `coder exp sync` and validation succeed, then assert on the written files. `sudo` works in that image, so managed-settings-to-`/etc` writes are testable (`cat` the file; assert the "Wrote ... managed settings" log line and an absence test when unset). +- Import helpers from `../../../coder/modules/agentapi/test-util` (`extractCoderEnvVars`, `writeExecutable`) and `~test`. + +## Process and conventions + +- Commit as `type(scope): message` where the scope is a real path containing every changed file. +- Run before hand-off: `bun x prettier --write `, `terraform fmt`, `terraform validate`, `terraform test`, `bun test main.test.ts`. +- Use concise, substantive comments only. No section-divider banners, no narration of what the code does, no "mirrors claude-code" storytelling. +- End files with a newline; use `/usr/bin/env bash` shebangs. +- Never bypass git hooks; do not commit to protected branches; use a feature branch. + +## Migration checklist + +1. Read the tool's config/auth/MCP/model docs and verify subcommands against `--help`. +2. Remove the AgentAPI/Tasks surface (variables, `module "agentapi"`, `start.sh`, old tftest, task MCP). +3. Add `coder_utils` + `install.sh.tftpl` using the official installer. +4. Implement config ownership: `managed_settings` (root-written policy), workdir trust, MCP merge with correct duplicate semantics. +5. Auth via a single documented token env var; model via the documented env var, default `""`. +6. Port AI Gateway/proxy if present (both proxy env vars, validations). +7. Rewrite README (WARNING, coder_external_auth launcher, DRY). +8. Rewrite tests (`main.tftest.hcl` + container `main.test.ts`). +9. Format, validate, run both test suites. +10. Bump version as a **major** (removed inputs, changed defaults, new required behavior) per [coder-modules](../coder-modules/SKILL.md) versioning. diff --git a/.agents/skills/migrate-agent-module/reference.md b/.agents/skills/migrate-agent-module/reference.md new file mode 100644 index 000000000..14b3059be --- /dev/null +++ b/.agents/skills/migrate-agent-module/reference.md @@ -0,0 +1,193 @@ +# Reference snippets (from the Copilot v1 migration) + +Concrete, working patterns extracted from `registry/coder-labs/modules/copilot`. Adapt names/paths to the target tool. These are illustrative, not drop-in. + +## `coder_utils` wiring (main.tf) + +```tf +locals { + workdir = var.workdir != null ? trimsuffix(var.workdir, "/") : "" + workdir_trusted_folders = local.workdir != "" ? [local.workdir] : [] + + install_script = templatefile("${path.module}/scripts/install.sh.tftpl", { + ARG_INSTALL = tostring(var.install_copilot) + ARG_COPILOT_VERSION = var.copilot_version + ARG_WORKDIR = local.workdir != "" ? base64encode(local.workdir) : "" + ARG_MANAGED_SETTINGS_JSON = var.managed_settings != null ? base64encode(jsonencode(var.managed_settings)) : "" + ARG_TRUSTED_FOLDERS = length(local.workdir_trusted_folders) > 0 ? base64encode(jsonencode(local.workdir_trusted_folders)) : "" + ARG_MCP_CONFIG = var.mcp != "" ? base64encode(var.mcp) : "" + }) + + module_dir_name = ".coder-modules/coder-labs/copilot" +} + +module "coder_utils" { + source = "registry.coder.com/coder/coder-utils/coder" + version = "0.0.1" + + agent_id = var.agent_id + module_directory = "$HOME/${local.module_dir_name}" + display_name_prefix = "Copilot" + icon = var.icon + pre_install_script = var.pre_install_script + post_install_script = var.post_install_script + install_script = local.install_script +} + +output "scripts" { + description = "Ordered coder exp sync names (pre_install, install, post_install)." + value = module.coder_utils.scripts +} +``` + +## Auth + model env (main.tf) + +```tf +resource "coder_env" "copilot_model" { + count = var.copilot_model != "" ? 1 : 0 + agent_id = var.agent_id + name = "COPILOT_MODEL" + value = var.copilot_model +} + +resource "coder_env" "github_token" { + count = var.github_token != "" ? 1 : 0 + agent_id = var.agent_id + name = "COPILOT_GITHUB_TOKEN" + value = var.github_token +} +``` + +## managed_settings variable + root write + +```tf +variable "managed_settings" { + type = any + description = "Policy written to the tool's managed-settings file. Highest precedence; only supported keys apply. See ." + default = null +} +``` + +```sh +# highest-precedence policy; rejected unless root-owned and not world-writable +write_managed_settings() { + if [ -z "$${ARG_MANAGED_SETTINGS_JSON}" ]; then + return + fi + if ! echo "$${ARG_MANAGED_SETTINGS_JSON}" | jq empty 2> /dev/null; then + echo "Warning: managed_settings is not valid JSON, skipping policy write" + return + fi + local target="/etc/github-copilot/managed-settings.json" + if command_exists sudo; then + sudo mkdir -p "$(dirname "$${target}")" + echo "$${ARG_MANAGED_SETTINGS_JSON}" | sudo tee "$${target}" > /dev/null + sudo chmod 0644 "$${target}" + else + mkdir -p "$(dirname "$${target}")" + echo "$${ARG_MANAGED_SETTINGS_JSON}" > "$${target}" + chmod 0644 "$${target}" + fi + echo "Wrote Copilot managed settings to $${target}" +} +``` + +## Official installer download/validate/run + +```sh +install_with_official_installer() ( + local installer_file + installer_file=$(mktemp) + trap 'rm -f "$${installer_file}"' EXIT + + if ! curl --fail --silent --show-error --location \ + --retry 2 --retry-delay 1 --retry-all-errors \ + --connect-timeout 10 --max-time 300 \ + --output "$${installer_file}" https://gh.io/copilot-install; then + echo "GitHub Copilot CLI could not be downloaded after up to 3 attempts." >&2 + return 1 + fi + + if ! bash -n "$${installer_file}"; then + echo "GitHub Copilot CLI installer download was invalid." >&2 + return 1 + fi + + # VERSION accepts 'latest' or a version tag; PREFIX installs under $HOME/.local/bin. + if ! VERSION="$${ARG_COPILOT_VERSION}" PREFIX="$HOME/.local" bash "$${installer_file}"; then + echo "GitHub Copilot CLI installation failed." >&2 + return 1 + fi +) +``` + +## workdir trust (config.json trustedFolders, union, preserve rest) + +```sh +setup_trusted_folders() { + local config_file="$HOME/.copilot/config.json" + [ -z "$${ARG_TRUSTED_FOLDERS}" ] && { + echo "No workdir to trust." + return + } + + local existing='{}' + if [ -f "$${config_file}" ] && jq empty "$${config_file}" > /dev/null 2>&1; then + existing=$(cat "$${config_file}") + fi + echo "$${existing}" | jq --argjson folders "$${ARG_TRUSTED_FOLDERS}" \ + '.trustedFolders = (((.trustedFolders // []) + $folders) | unique)' > "$${config_file}" +} +``` + +## MCP merge (existing wins on duplicate names) + +```sh +# right operand wins in jq '+', so existing on-disk servers win on duplicates +echo "$${existing}" | jq --argjson custom "$${custom}" \ + '.mcpServers = ($custom + (.mcpServers // {}))' > "$${mcp_config_file}" +``` + +## README launcher example + +```tf +data "coder_external_auth" "github" { + id = "github" +} + +module "copilot" { + source = "registry.coder.com/coder-labs/copilot/coder" + version = "1.0.88" + agent_id = coder_agent.example.id + workdir = "/home/coder/project" + github_token = data.coder_external_auth.github.access_token +} + +resource "coder_app" "copilot" { + agent_id = coder_agent.example.id + slug = "copilot" + display_name = "Copilot" + icon = "/icon/github.svg" + open_in = "slim-window" + command = <<-EOT + #!/usr/bin/env bash + set -e + cd "/home/coder/project" + exec copilot --allow-all-tools + EOT +} +``` + +## Copilot migration diff shape (for scale reference) + +``` +README.md | rewritten +main.tf | agentapi block + Tasks vars removed; coder_utils + config vars added +main.test.ts | container tests rewritten +main.tftest.hcl | new (replaces copilot.tftest.hcl) +copilot.tftest.hcl | deleted +scripts/install.sh | deleted (replaced by install.sh.tftpl) +scripts/start.sh | deleted +scripts/install.sh.tftpl | new (official installer, config ownership) +testdata/*-mock.sh | updated +``` From 9b86a7f6b16f8ae477e5d4ae50d5ffba482255d6 Mon Sep 17 00:00:00 2001 From: 35C4n0r Date: Mon, 28 Sep 2026 17:20:57 +0000 Subject: [PATCH 2/5] refactor(registry/coder-labs/modules/cursor-cli)!: remove agentapi, tasks and start logic Make cursor-cli an install-only module built on coder-utils. The user now launches Cursor CLI with their own coder_app. - Drop the agentapi module, Tasks inputs, start script and the task-reporting MCP server. - Install via the official installer (temp file, bash -n validation). - Rename folder to optional workdir; create it and write Cursor's workspace trust marker. - Merge mcp into ~/.cursor/mcp.json with existing servers winning. - Keep rules_files (requires workdir) and api_key (CURSOR_API_KEY). - Remove model, force and ai_prompt in favor of launcher CLI flags. - Add scripts output; rewrite README and tests; bump to 1.0.0. --- .../coder-labs/modules/cursor-cli/README.md | 184 +++---- .../modules/cursor-cli/cursor-cli.tftest.hcl | 152 ------ .../modules/cursor-cli/main.test.ts | 459 ++++++++++++------ .../coder-labs/modules/cursor-cli/main.tf | 192 +++----- .../modules/cursor-cli/main.tftest.hcl | 137 ++++++ .../modules/cursor-cli/scripts/install.sh | 122 ----- .../cursor-cli/scripts/install.sh.tftpl | 223 +++++++++ .../modules/cursor-cli/scripts/start.sh | 67 --- .../cursor-cli/testdata/cursor-cli-mock.sh | 13 +- 9 files changed, 841 insertions(+), 708 deletions(-) delete mode 100644 registry/coder-labs/modules/cursor-cli/cursor-cli.tftest.hcl create mode 100644 registry/coder-labs/modules/cursor-cli/main.tftest.hcl delete mode 100644 registry/coder-labs/modules/cursor-cli/scripts/install.sh create mode 100644 registry/coder-labs/modules/cursor-cli/scripts/install.sh.tftpl delete mode 100644 registry/coder-labs/modules/cursor-cli/scripts/start.sh diff --git a/registry/coder-labs/modules/cursor-cli/README.md b/registry/coder-labs/modules/cursor-cli/README.md index bff150721..d962d21c4 100644 --- a/registry/coder-labs/modules/cursor-cli/README.md +++ b/registry/coder-labs/modules/cursor-cli/README.md @@ -1,136 +1,146 @@ --- display_name: Cursor CLI icon: ../../../../.icons/cursor.svg -description: Run Cursor Agent CLI in your workspace for AI pair programming +description: Install and configure the Cursor Agent CLI in your workspace. verified: true -tags: [agent, cursor, ai, tasks] +tags: [agent, cursor, ai] --- # Cursor CLI -Run the Cursor Agent CLI in your workspace for interactive coding assistance and automated task execution. +Install and configure the [Cursor Agent CLI](https://cursor.com/docs/cli/overview) in your workspace. ```tf module "cursor_cli" { source = "registry.coder.com/coder-labs/cursor-cli/coder" - version = "0.3.1" + version = "1.0.0" agent_id = coder_agent.main.id - folder = "/home/coder/project" + api_key = var.cursor_api_key } ``` -## Basic setup +> [!WARNING] +> If upgrading from v0.x.x of this module: v1 is a major refactor that drops support for Coder Tasks and AgentAPI. The module now only installs and configures Cursor CLI; launch it with your own `coder_app`. `folder` is renamed to `workdir` and is optional. `model`, `force`, and `ai_prompt` are removed in favor of CLI flags in your launcher. MCP servers are now written to the user-level `~/.cursor/mcp.json` instead of `/.cursor/mcp.json`. Keep using v0.x.x if you depend on Coder Tasks. -A full example with MCP, rules, and pre/post install scripts: +## Examples -```tf +### Standalone mode with a launcher app -data "coder_parameter" "ai_prompt" { - type = "string" - name = "AI Prompt" - default = "" - description = "Build a Minesweeper in Python." - mutable = true +```tf +locals { + cursor_workdir = "/home/coder/project" } -module "coder-login" { - count = data.coder_workspace.me.start_count - source = "registry.coder.com/coder/coder-login/coder" - version = "1.0.31" +module "cursor_cli" { + source = "registry.coder.com/coder-labs/cursor-cli/coder" + version = "1.0.0" agent_id = coder_agent.main.id + workdir = local.cursor_workdir + api_key = var.cursor_api_key } +resource "coder_app" "cursor_cli" { + agent_id = coder_agent.main.id + slug = "cursor-cli" + display_name = "Cursor CLI" + icon = "/icon/cursor.svg" + open_in = "slim-window" + command = <<-EOT + #!/usr/bin/env bash + set -e + cd "${local.cursor_workdir}" + exec cursor-agent --model gpt-5 --force + EOT +} +``` + +When `workdir` is set, the module creates it if missing and marks it as a trusted workspace, so Cursor CLI does not show the trust prompt there. Pass `--model`, `--force`, `--approve-mcps`, or any other [CLI flag](https://cursor.com/docs/cli/reference/parameters) in the launcher command. + +> [!NOTE] +> The `coder_app` command re-executes on every pane reconnect. This works for interactive `cursor-agent`, but one-shot commands like `cursor-agent -p` will re-run each time. For one-shot prompts, use a `coder_script` (runs once at startup) and a `coder_app` that attaches to the existing session (for example, with tmux). + +### MCP servers and rules + +```tf module "cursor_cli" { source = "registry.coder.com/coder-labs/cursor-cli/coder" - version = "0.3.1" + version = "1.0.0" agent_id = coder_agent.main.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 + workdir = "/home/coder/project" + api_key = var.cursor_api_key - # 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 + --- + description: Python conventions + alwaysApply: true + --- + + - Use type hints on public functions. + EOT } } ``` +`mcp` is merged into the user-level `~/.cursor/mcp.json`, so the servers apply to every Cursor CLI session in the workspace. Servers already in that file win on duplicate names, so edits made inside the workspace are never overwritten. `rules_files` are written to `/.cursor/rules/` and require `workdir`. + > [!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) +> The official installer ships a self-contained binary and does not install Node.js. MCP servers whose `command` is `npx` or `uvx` need that runtime available in the workspace image, or installed with `pre_install_script`. -## References +### Serialize a downstream `coder_script` after the install pipeline -- 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/`. +The module exposes the `scripts` output: an ordered list of `coder exp sync` names for the scripts this module creates (pre_install, install, post_install). Scripts that were not configured are absent. + +```tf +module "cursor_cli" { + source = "registry.coder.com/coder-labs/cursor-cli/coder" + version = "1.0.0" + agent_id = coder_agent.main.id +} + +resource "coder_script" "post_cursor_cli" { + agent_id = coder_agent.main.id + display_name = "Run after Cursor CLI install" + run_on_start = true + script = <<-EOT + #!/usr/bin/env bash + set -euo pipefail + trap 'coder exp sync complete post-cursor-cli' EXIT + coder exp sync want post-cursor-cli ${join(" ", module.cursor_cli.scripts)} + coder exp sync start post-cursor-cli + + cursor-agent --version + EOT +} +``` + +## Configuration + +When `api_key` is set, it is exported as `CURSOR_API_KEY` and is not rendered into the install script. To create a key, see [Cursor CLI authentication](https://cursor.com/docs/cli/reference/authentication). Without a key, run `cursor-agent login` in the workspace. + +The module always installs the latest Cursor CLI with the [official installer](https://cursor.com/install) and skips the install when `cursor-agent` is already on `PATH`. If `install_cursor_cli = false`, a working `cursor-agent` must already be available on `PATH`, or workspace startup fails. ## Troubleshooting -- Ensure the CLI is installed (enable `install_cursor_cli = true` or preinstall it in your image) -- Logs are written to `~/.cursor-cli-module/` +Check the log files in `~/.coder-modules/coder-labs/cursor-cli/logs/` for detailed information. + +```bash +cat ~/.coder-modules/coder-labs/cursor-cli/logs/install.log +cat ~/.coder-modules/coder-labs/cursor-cli/logs/pre_install.log +cat ~/.coder-modules/coder-labs/cursor-cli/logs/post_install.log +``` + +## References + +- [Cursor CLI documentation](https://cursor.com/docs/cli/overview) +- [Cursor CLI MCP](https://cursor.com/docs/cli/mcp) +- [Cursor rules](https://cursor.com/docs/context/rules) diff --git a/registry/coder-labs/modules/cursor-cli/cursor-cli.tftest.hcl b/registry/coder-labs/modules/cursor-cli/cursor-cli.tftest.hcl deleted file mode 100644 index f8e917a1f..000000000 --- a/registry/coder-labs/modules/cursor-cli/cursor-cli.tftest.hcl +++ /dev/null @@ -1,152 +0,0 @@ -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 index b7f9947cd..4da1e127b 100644 --- a/registry/coder-labs/modules/cursor-cli/main.test.ts +++ b/registry/coder-labs/modules/cursor-cli/main.test.ts @@ -1,220 +1,399 @@ import { + test, afterEach, - beforeAll, describe, - expect, setDefaultTimeout, - test, + beforeAll, + expect, } from "bun:test"; -import { execContainer, runTerraformInit, writeFileContainer } from "~test"; import { - execModuleScript, - expectAgentAPIStarted, - loadTestFile, - setup as setupUtil, -} from "../../../coder/modules/agentapi/test-util"; + execContainer, + readFileContainer, + removeContainer, + runContainer, + runTerraformApply, + runTerraformInit, + TerraformState, +} from "~test"; import { - setupContainer, + extractCoderEnvVars, writeExecutable, } from "../../../coder/modules/agentapi/test-util"; +import path from "path"; + +interface ModuleScripts { + pre_install?: string; + install: string; + post_install?: string; +} + +const SCRIPT_SUFFIXES = [ + "Pre-Install Script", + "Install Script", + "Post-Install Script", +] as const; -let cleanupFns: (() => Promise)[] = []; -const registerCleanup = (fn: () => Promise) => cleanupFns.push(fn); +const collectScripts = (state: TerraformState): ModuleScripts => { + const byDisplayName: Record = {}; + for (const resource of state.resources) { + if (resource.type !== "coder_script") continue; + for (const instance of resource.instances) { + const attrs = instance.attributes as Record; + const displayName = attrs.display_name as string | undefined; + const script = attrs.script as string | undefined; + if (displayName && script) { + byDisplayName[displayName] = script; + } + } + } + const scripts: Partial = {}; + for (const suffix of SCRIPT_SUFFIXES) { + const key = `Cursor CLI: ${suffix}`; + if (!(key in byDisplayName)) continue; + switch (suffix) { + case "Pre-Install Script": + scripts.pre_install = byDisplayName[key]; + break; + case "Install Script": + scripts.install = byDisplayName[key]; + break; + case "Post-Install Script": + scripts.post_install = byDisplayName[key]; + break; + } + } + if (!scripts.install) { + throw new Error("install script not found in terraform state"); + } + return scripts as ModuleScripts; +}; +let cleanupFunctions: (() => Promise)[] = []; +const registerCleanup = (cleanup: () => Promise) => { + cleanupFunctions.push(cleanup); +}; afterEach(async () => { - const fns = cleanupFns.slice().reverse(); - cleanupFns = []; - for (const fn of fns) { + const cleanupFnsCopy = cleanupFunctions.slice().reverse(); + cleanupFunctions = []; + for (const cleanup of cleanupFnsCopy) { try { - await fn(); - } catch (err) { - console.error(err); + await cleanup(); + } catch (error) { + console.error("Error during cleanup:", error); } } }); interface SetupProps { - skipAgentAPIMock?: boolean; - skipCursorCliMock?: boolean; + skipCursorMock?: 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) { +const projectDir = "/home/coder/project"; + +const setup = async ( + props?: SetupProps, +): Promise<{ + id: string; + coderEnvVars: Record; + scripts: ModuleScripts; +}> => { + const moduleDir = path.resolve(import.meta.dir); + const state = await runTerraformApply(moduleDir, { + agent_id: "foo", + workdir: projectDir, + install_cursor_cli: "false", + ...props?.moduleVariables, + }); + const scripts = collectScripts(state); + const coderEnvVars = extractCoderEnvVars(state); + + const id = await runContainer("codercom/enterprise-node:latest"); + registerCleanup(async () => { + if (process.env["DEBUG"] === "true" || process.env["DEBUG"] === "1") { + console.log(`Not removing container ${id} in debug mode`); + return; + } + await removeContainer(id); + }); + + await writeExecutable({ + containerId: id, + filePath: "/usr/bin/coder", + content: "#!/bin/bash\nexit 0\n", + }); + if (!props?.skipCursorMock) { await writeExecutable({ containerId: id, filePath: "/usr/bin/cursor-agent", - content: await loadTestFile(import.meta.dir, "cursor-cli-mock.sh"), + content: await Bun.file( + path.join(moduleDir, "testdata", "cursor-cli-mock.sh"), + ).text(), }); } - return { id }; + return { id, coderEnvVars, scripts }; +}; + +const runScript = async (id: string, name: string, script: string) => { + const target = `/tmp/coder-utils-${name}.sh`; + await writeExecutable({ containerId: id, filePath: target, content: script }); + return execContainer(id, ["bash", "-c", target]); +}; + +const runScripts = async (id: string, scripts: ModuleScripts) => { + const ordered: [string, string | undefined][] = [ + ["pre_install", scripts.pre_install], + ["install", scripts.install], + ["post_install", scripts.post_install], + ]; + for (const [name, script] of ordered) { + if (!script) continue; + const resp = await runScript(id, name, script); + if (resp.exitCode !== 0) { + console.log(`script ${name} failed:`); + console.log(resp.stdout); + console.log(resp.stderr); + throw new Error(`coder-utils ${name} script exited ${resp.exitCode}`); + } + } }; -setDefaultTimeout(180 * 1000); +const moduleLogDir = "/home/coder/.coder-modules/coder-labs/cursor-cli/logs"; +const installLog = (id: string) => + readFileContainer(id, `${moduleLogDir}/install.log`); +const mcpConfigPath = "/home/coder/.cursor/mcp.json"; +const trustMarkerPath = + "/home/coder/.cursor/projects/home-coder-project/.workspace-trusted"; + +setDefaultTimeout(60 * 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); + test("happy-path-validates-existing-binary", async () => { + const { id, scripts } = await setup(); + await runScripts(id, scripts); + const log = await installLog(id); + expect(log).toContain("Skipping Cursor CLI installation"); + expect(log).toContain("Validated existing Cursor CLI"); + expect(log).toContain("Cursor CLI module setup completed."); + // No agentapi or task-reporting leftovers. + expect(log).not.toContain("agentapi"); + }); - await expectAgentAPIStarted(id); + test("preinstalled-binary-required-when-install-disabled", async () => { + const { id, scripts } = await setup({ skipCursorMock: true }); + const resp = await runScript(id, "install", scripts.install); + expect(resp.exitCode).not.toBe(0); + const log = await installLog(id); + expect(log).toContain("was not found or is not executable"); }); - test("agentapi-mcp-json", async () => { - const mcpJson = - '{"mcpServers": {"test": {"command": "test-cmd", "type": "stdio"}}}'; - const { id } = await setup({ - moduleVariables: { - mcp: mcpJson, - }, + test("official-installer-is-used", async () => { + const { id, scripts } = await setup({ + skipCursorMock: true, + moduleVariables: { install_cursor_cli: "true" }, + }); + await writeExecutable({ + containerId: id, + filePath: "/tmp/cursor-installer-fixture.sh", + content: [ + "#!/usr/bin/env bash", + 'mkdir -p "$HOME/.local/bin"', + `cat > "$HOME/.local/bin/cursor-agent" <<'EOF'`, + "#!/bin/sh", + 'if [ "$1" = "--version" ]; then echo "2026.02.02-fixture"; fi', + "EOF", + 'chmod +x "$HOME/.local/bin/cursor-agent"', + ].join("\n"), + }); + await writeExecutable({ + containerId: id, + filePath: "/usr/local/bin/curl", + content: [ + "#!/bin/bash", + "printf '%s\\n' \"$*\" > /tmp/cursor-curl-args", + 'while [ $# -gt 0 ]; do if [ "$1" = "--output" ]; then out="$2"; fi; shift; done', + 'cp /tmp/cursor-installer-fixture.sh "$out"', + ].join("\n"), }); - const resp = await execModuleScript(id); - expect(resp.exitCode).toBe(0); + await runScripts(id, scripts); + const log = await installLog(id); + expect(log).toContain("Installed Cursor CLI"); + expect(log).toContain("2026.02.02-fixture"); + const curlArgs = await readFileContainer(id, "/tmp/cursor-curl-args"); + expect(curlArgs).toContain("https://cursor.com/install"); + expect(curlArgs).toContain("--retry 2"); + expect(curlArgs).toContain("--connect-timeout 10"); + expect(curlArgs).toContain("--max-time 300"); + }); + + test("installer-download-failure-is-terminal", async () => { + const { id, scripts } = await setup({ + skipCursorMock: true, + moduleVariables: { install_cursor_cli: "true" }, + }); + await writeExecutable({ + containerId: id, + filePath: "/usr/local/bin/curl", + content: "#!/bin/bash\nexit 22\n", + }); + const resp = await runScript(id, "install", scripts.install); + expect(resp.exitCode).not.toBe(0); + const log = await installLog(id); + expect(log).toContain("could not be downloaded"); + }); - const mcpContent = await execContainer(id, [ + test("workdir-created-and-trusted", async () => { + const { id, scripts } = await setup(); + await runScripts(id, scripts); + const dir = await execContainer(id, ["test", "-d", projectDir]); + expect(dir.exitCode).toBe(0); + const marker = JSON.parse(await readFileContainer(id, trustMarkerPath)); + expect(marker.workspacePath).toBe(projectDir); + expect(marker.trustedAt).toBeDefined(); + }); + + test("existing-trust-marker-is-preserved", async () => { + const { id, scripts } = await setup(); + const seed = JSON.stringify({ + trustedAt: "2020-01-01T00:00:00.000Z", + workspacePath: projectDir, + trustMethod: "dialog", + }); + await execContainer(id, [ "bash", "-c", - `cat '/home/coder/project/.cursor/mcp.json'`, + `mkdir -p "$(dirname '${trustMarkerPath}')" && echo '${seed}' > '${trustMarkerPath}'`, ]); - 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"); + await runScripts(id, scripts); + const marker = JSON.parse(await readFileContainer(id, trustMarkerPath)); + expect(marker.trustedAt).toBe("2020-01-01T00:00:00.000Z"); }); - 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, [ + test("no-workdir-no-trust-marker", async () => { + const { id, scripts } = await setup({ moduleVariables: { workdir: "" } }); + await runScripts(id, scripts); + const resp = await execContainer(id, [ "bash", "-c", - `cat '/home/coder/project/.cursor/rules/typescript.md'`, + "test -e /home/coder/.cursor/projects && echo EXISTS || echo ABSENT", ]); - expect(rulesFile.exitCode).toBe(0); - expect(rulesFile.stdout).toContain(rulesContent); + expect(resp.stdout.trim()).toBe("ABSENT"); }); - test("agentapi-api-key", async () => { - const apiKey = "test-cursor-api-key-123"; - const { id } = await setup({ + test("writes-mcp-servers-without-coder-server", async () => { + const { id, scripts } = await setup({ moduleVariables: { - api_key: apiKey, + mcp: JSON.stringify({ + mcpServers: { + playwright: { command: "npx", args: ["-y", "@playwright/mcp"] }, + }, + }), }, }); - 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"`, + await runScripts(id, scripts); + const mcp = JSON.parse(await readFileContainer(id, mcpConfigPath)); + expect(mcp.mcpServers.playwright.command).toBe("npx"); + expect(mcp.mcpServers.coder).toBeUndefined(); + // The project-level mcp.json is no longer written. + const projectMcp = await execContainer(id, [ + "test", + "-e", + `${projectDir}/.cursor/mcp.json`, ]); - expect(envCheck.stdout).toContain("CURSOR_API_KEY"); + expect(projectMcp.exitCode).not.toBe(0); }); - test("agentapi-model-and-force-flags", async () => { - const model = "sonnet-4"; - const { id } = await setup({ + test("merges-mcp-config-existing-servers-win", async () => { + const { id, scripts } = await setup({ moduleVariables: { - model: model, - force: "true", - ai_prompt: "test prompt", + mcp: JSON.stringify({ + mcpServers: { + shared: { command: "module-command" }, + extra: { command: "extra-command" }, + }, + }), }, }); - const resp = await execModuleScript(id); - expect(resp.exitCode).toBe(0); - - const startLog = await execContainer(id, [ + const seed = JSON.stringify({ + mcpServers: { + shared: { command: "existing-command" }, + seeded: { command: "seeded-command" }, + }, + }); + await execContainer(id, [ "bash", "-c", - "cat /home/coder/.cursor-cli-module/agentapi-start.log || cat /home/coder/.cursor-cli-module/start.log || true", + `mkdir -p /home/coder/.cursor && echo '${seed}' > ${mcpConfigPath}`, ]); - expect(startLog.stdout).toContain(`--model ${model}`); - expect(startLog.stdout).toContain("-f"); - expect(startLog.stdout).toContain("test prompt"); + await runScripts(id, scripts); + const mcp = JSON.parse(await readFileContainer(id, mcpConfigPath)); + expect(mcp.mcpServers.shared.command).toBe("existing-command"); + expect(mcp.mcpServers.seeded.command).toBe("seeded-command"); + expect(mcp.mcpServers.extra.command).toBe("extra-command"); }); - test("agentapi-pre-post-install-scripts", async () => { - const { id } = await setup({ + test("invalid-existing-mcp-config-is-backed-up", async () => { + const { id, scripts } = await setup({ moduleVariables: { - pre_install_script: "#!/bin/bash\necho 'cursor-pre-install-script'", - post_install_script: "#!/bin/bash\necho 'cursor-post-install-script'", + mcp: JSON.stringify({ mcpServers: { extra: { command: "x" } } }), }, }); - 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, [ + await execContainer(id, [ "bash", "-c", - "cat /home/coder/.cursor-cli-module/post_install.log || true", + `mkdir -p /home/coder/.cursor && echo '{ // jsonc' > ${mcpConfigPath}`, ]); - expect(postInstallLog.stdout).toContain("cursor-post-install-script"); + await runScripts(id, scripts); + const mcp = JSON.parse(await readFileContainer(id, mcpConfigPath)); + expect(mcp.mcpServers.extra.command).toBe("x"); + const backup = await readFileContainer(id, `${mcpConfigPath}.bak`); + expect(backup).toContain("// jsonc"); }); - test("agentapi-folder-variable", async () => { - const folder = "/tmp/cursor-test-folder"; - const { id } = await setup({ + test("rules-files-written-to-workdir", async () => { + const content = "---\nalwaysApply: true\n---\n\n- Use TypeScript\n"; + const { id, scripts } = await setup({ moduleVariables: { - folder: folder, + rules_files: JSON.stringify({ "typescript.mdc": content }), }, }); - 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); + await runScripts(id, scripts); + const rule = await readFileContainer( + id, + `${projectDir}/.cursor/rules/typescript.mdc`, + ); + expect(rule).toBe(content); }); - test("install-test-cursor-cli-latest", async () => { - const { id } = await setup({ - skipCursorCliMock: true, - skipAgentAPIMock: true, + test("api-key-env-var-not-in-script", async () => { + const apiKey = "test-cursor-api-key-123"; + const { coderEnvVars, scripts } = await setup({ + moduleVariables: { api_key: apiKey }, }); - const resp = await execModuleScript(id); - expect(resp.exitCode).toBe(0); + expect(coderEnvVars["CURSOR_API_KEY"]).toBe(apiKey); + expect(scripts.install).not.toContain(apiKey); + expect(scripts.install).not.toContain( + Buffer.from(apiKey).toString("base64"), + ); + }); - await expectAgentAPIStarted(id); + test("pre-post-install-scripts", async () => { + const { id, scripts } = await setup({ + moduleVariables: { + pre_install_script: "#!/bin/bash\necho 'cursor-pre-install-script'", + post_install_script: "#!/bin/bash\necho 'cursor-post-install-script'", + }, + }); + await runScripts(id, scripts); + expect( + await readFileContainer(id, `${moduleLogDir}/pre_install.log`), + ).toContain("cursor-pre-install-script"); + expect( + await readFileContainer(id, `${moduleLogDir}/post_install.log`), + ).toContain("cursor-post-install-script"); }); }); diff --git a/registry/coder-labs/modules/cursor-cli/main.tf b/registry/coder-labs/modules/cursor-cli/main.tf index 58fc138f5..d4ea7ee4b 100644 --- a/registry/coder-labs/modules/cursor-cli/main.tf +++ b/registry/coder-labs/modules/cursor-cli/main.tf @@ -1,5 +1,5 @@ terraform { - required_version = ">= 1.0" + required_version = ">= 1.9" required_providers { coder = { @@ -7,120 +7,75 @@ terraform { version = ">= 2.12" } } -} +}m 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." + description = "The icon to use for the install scripts." default = "/icon/cursor.svg" } -variable "folder" { +variable "workdir" { type = string - description = "The folder to run Cursor CLI in." -} - -variable "install_cursor_cli" { - type = bool - description = "Whether to install Cursor CLI." - default = true + description = "Optional project directory. When set, the module pre-creates it if missing and marks it as a trusted workspace for Cursor CLI, equivalent to accepting the trust prompt or passing --trust." + default = null } -variable "install_agentapi" { - type = bool - description = "Whether to install AgentAPI." - default = true +variable "pre_install_script" { + type = string + description = "Custom script to run before installing Cursor CLI." + default = null } -variable "agentapi_version" { +variable "post_install_script" { type = string - description = "The version of AgentAPI to install." - default = "v0.10.0" + description = "Custom script to run after installing Cursor CLI." + default = null } -variable "force" { +variable "install_cursor_cli" { type = bool - description = "Force allow commands unless explicitly denied" + description = "Whether to install Cursor CLI with the official installer. When false, a working cursor-agent must already be on PATH." 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 = "" + description = "Cursor API key, exported as CURSOR_API_KEY. See https://cursor.com/docs/cli/reference/authentication" sensitive = true + default = "" } 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 + description = "MCP servers as JSON in Cursor's mcp.json format ({\"mcpServers\": {...}}). Merged into the user-level ~/.cursor/mcp.json; servers already on disk win on duplicate names. See https://cursor.com/docs/cli/mcp" + default = "" + + validation { + condition = var.mcp == "" || can(jsondecode(var.mcp).mcpServers) + error_message = "mcp must be a JSON object with an mcpServers key." + } } 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 -} + description = "Optional map of rule file name to content, written to /.cursor/rules/. Requires workdir. See https://cursor.com/docs/context/rules" + default = {} -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" - folder = trimsuffix(var.folder, "/") -} + validation { + condition = length(var.rules_files) == 0 || (var.workdir != null && var.workdir != "") + error_message = "rules_files requires workdir to be set." + } -# 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 + validation { + condition = alltrue([for name in keys(var.rules_files) : can(regex("^[A-Za-z0-9][A-Za-z0-9._-]*$", name))]) + error_message = "rules_files names must be plain file names (letters, digits, '.', '_', '-') without path separators." + } } resource "coder_env" "cursor_api_key" { @@ -130,56 +85,31 @@ resource "coder_env" "cursor_api_key" { value = var.api_key } -module "agentapi" { - source = "registry.coder.com/coder/agentapi/coder" - version = "2.0.0" - - agent_id = var.agent_id - folder = local.folder - 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 - #!/usr/bin/env bash - set -o errexit - set -o pipefail - - echo -n '${base64encode(local.start_script)}' | base64 -d > /tmp/start.sh - chmod +x /tmp/start.sh - ARG_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 - #!/usr/bin/env bash - set -o errexit - set -o pipefail - - echo -n '${base64encode(local.install_script)}' | base64 -d > /tmp/install.sh - chmod +x /tmp/install.sh - ARG_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 -} - -output "task_app_id" { - value = module.agentapi.task_app_id +locals { + workdir = var.workdir != null ? trimsuffix(var.workdir, "/") : "" + install_script = templatefile("${path.module}/scripts/install.sh.tftpl", { + ARG_INSTALL = tostring(var.install_cursor_cli) + ARG_WORKDIR = local.workdir != "" ? base64encode(local.workdir) : "" + ARG_MCP_CONFIG = var.mcp != "" ? base64encode(var.mcp) : "" + ARG_RULES_FILES = length(var.rules_files) > 0 ? base64encode(jsonencode(var.rules_files)) : "" + }) + module_dir_name = ".coder-modules/coder-labs/cursor-cli" +} + +module "coder_utils" { + source = "registry.coder.com/coder/coder-utils/coder" + version = "0.0.1" + + agent_id = var.agent_id + module_directory = "$HOME/${local.module_dir_name}" + display_name_prefix = "Cursor CLI" + icon = var.icon + pre_install_script = var.pre_install_script + post_install_script = var.post_install_script + install_script = local.install_script +} + +output "scripts" { + description = "Ordered list of coder exp sync names for the coder_script resources this module creates, in run order (pre_install, install, post_install). Scripts that were not configured are absent from the list." + value = module.coder_utils.scripts } diff --git a/registry/coder-labs/modules/cursor-cli/main.tftest.hcl b/registry/coder-labs/modules/cursor-cli/main.tftest.hcl new file mode 100644 index 000000000..eebcb050e --- /dev/null +++ b/registry/coder-labs/modules/cursor-cli/main.tftest.hcl @@ -0,0 +1,137 @@ +run "defaults_are_correct" { + command = plan + + variables { + agent_id = "test-agent" + } + + assert { + condition = var.install_cursor_cli == true + error_message = "install_cursor_cli should default to true" + } + + assert { + condition = local.workdir == "" + error_message = "workdir should be empty by default" + } + + assert { + condition = local.module_dir_name == ".coder-modules/coder-labs/cursor-cli" + error_message = "module_dir_name should be '.coder-modules/coder-labs/cursor-cli'" + } + + assert { + condition = length(coder_env.cursor_api_key) == 0 + error_message = "CURSOR_API_KEY should not be created when api_key is empty" + } +} + +run "workdir_trailing_slash_is_trimmed" { + command = plan + + variables { + agent_id = "test-agent" + workdir = "/home/coder/project/" + } + + assert { + condition = local.workdir == "/home/coder/project" + error_message = "workdir should have its trailing slash trimmed" + } +} + +run "api_key_creates_env_var" { + command = plan + + variables { + agent_id = "test-agent" + api_key = "test-cursor-key" + } + + assert { + condition = coder_env.cursor_api_key[0].name == "CURSOR_API_KEY" && coder_env.cursor_api_key[0].value == "test-cursor-key" + error_message = "CURSOR_API_KEY env var should be created with the provided key" + } + + assert { + condition = !strcontains(local.install_script, nonsensitive(var.api_key)) && !strcontains(local.install_script, base64encode(nonsensitive(var.api_key))) + error_message = "API key should not be rendered into the install script" + } +} + +run "invalid_mcp_fails" { + command = plan + + variables { + agent_id = "test-agent" + mcp = "{\"servers\": {}}" + } + + expect_failures = [ + var.mcp, + ] +} + +run "rules_files_require_workdir" { + command = plan + + variables { + agent_id = "test-agent" + rules_files = { + "general.mdc" = "Write clean code" + } + } + + expect_failures = [ + var.rules_files, + ] +} + +run "rules_files_reject_paths" { + command = plan + + variables { + agent_id = "test-agent" + workdir = "/home/coder/project" + rules_files = { + "../escape.mdc" = "nope" + } + } + + expect_failures = [ + var.rules_files, + ] +} + +run "scripts_output_is_ordered" { + command = plan + + variables { + agent_id = "test-agent" + pre_install_script = "echo pre" + post_install_script = "echo post" + } + + assert { + condition = length(output.scripts) == 3 + error_message = "scripts output should list pre_install, install, and post_install" + } + + assert { + condition = output.scripts == ["coder-labs-cursor-cli-pre_install_script", "coder-labs-cursor-cli-install_script", "coder-labs-cursor-cli-post_install_script"] + error_message = "scripts output should be ordered pre_install, install, post_install" + } +} + +run "scripts_output_install_only" { + command = plan + + variables { + agent_id = "test-agent" + } + + assert { + condition = output.scripts == ["coder-labs-cursor-cli-install_script"] + error_message = "scripts output should only contain install when no pre/post scripts are set" + } +} diff --git a/registry/coder-labs/modules/cursor-cli/scripts/install.sh b/registry/coder-labs/modules/cursor-cli/scripts/install.sh deleted file mode 100644 index e6fd77224..000000000 --- a/registry/coder-labs/modules/cursor-cli/scripts/install.sh +++ /dev/null @@ -1,122 +0,0 @@ -#!/usr/bin/env 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/install.sh.tftpl b/registry/coder-labs/modules/cursor-cli/scripts/install.sh.tftpl new file mode 100644 index 000000000..e53618008 --- /dev/null +++ b/registry/coder-labs/modules/cursor-cli/scripts/install.sh.tftpl @@ -0,0 +1,223 @@ +#!/usr/bin/env bash + +set -euo pipefail + +BOLD='\033[0;1m' + +command_exists() { + command -v "$1" > /dev/null 2>&1 +} + +ARG_INSTALL='${ARG_INSTALL}' +ARG_WORKDIR=$(echo -n '${ARG_WORKDIR}' | base64 -d) +ARG_MCP_CONFIG=$(echo -n '${ARG_MCP_CONFIG}' | base64 -d) +ARG_RULES_FILES=$(echo -n '${ARG_RULES_FILES}' | base64 -d) + +export PATH="$HOME/.local/bin:$PATH" + +echo "--------------------------------" +printf "install_cursor_cli: %s\n" "$${ARG_INSTALL}" +printf "workdir: %s\n" "$${ARG_WORKDIR}" +echo "--------------------------------" + +require_jq() { + if ! command_exists jq; then + echo "ERROR: 'jq' is required to $1 but was not found." >&2 + exit 1 + fi +} + +function add_path_to_shell_profiles() { + local path_dir="$1" + + for profile in "$HOME/.profile" "$HOME/.bash_profile" "$HOME/.bashrc" "$HOME/.zprofile" "$HOME/.zshrc"; do + if [ -f "$${profile}" ]; then + if ! grep -q "$${path_dir}" "$${profile}" 2> /dev/null; then + echo "export PATH=\"\$PATH:$${path_dir}\"" >> "$${profile}" + echo "Added $${path_dir} to $${profile}" + fi + fi + done + + local fish_config="$HOME/.config/fish/config.fish" + if [ -f "$${fish_config}" ]; then + if ! grep -q "$${path_dir}" "$${fish_config}" 2> /dev/null; then + echo "fish_add_path $${path_dir}" >> "$${fish_config}" + echo "Added $${path_dir} to $${fish_config}" + fi + fi +} + +# The official installer links both `agent` and the legacy `cursor-agent`; +# `cursor-agent` is used here because `agent` is too generic to probe reliably. +function ensure_cursor_in_path() { + local CURSOR_BIN="" + if command_exists cursor-agent; then + CURSOR_BIN=$(command -v cursor-agent) + fi + + if [ -z "$${CURSOR_BIN}" ] || [ ! -x "$${CURSOR_BIN}" ]; then + echo "Cursor CLI binary (cursor-agent) was not found or is not executable." >&2 + return 1 + fi + + local CURSOR_VERSION + if ! CURSOR_VERSION=$("$${CURSOR_BIN}" --version); then + echo "Cursor CLI binary could not be executed." >&2 + return 1 + fi + printf "Cursor CLI version: %s\n" "$${CURSOR_VERSION}" + + if [ -n "$${CODER_SCRIPT_BIN_DIR:-}" ] && [ ! -e "$${CODER_SCRIPT_BIN_DIR}/cursor-agent" ]; then + ln -s "$${CURSOR_BIN}" "$${CODER_SCRIPT_BIN_DIR}/cursor-agent" + echo "Created symlink: $${CODER_SCRIPT_BIN_DIR}/cursor-agent -> $${CURSOR_BIN}" + fi + + add_path_to_shell_profiles "$(dirname "$${CURSOR_BIN}")" +} + +# Subshell scopes the temp-file cleanup trap to this function. +install_with_official_installer() ( + local installer_file + installer_file=$(mktemp) + trap 'rm -f "$${installer_file}"' EXIT + + if ! curl --fail --silent --show-error --location \ + --retry 2 --retry-delay 1 --retry-all-errors \ + --connect-timeout 10 --max-time 300 \ + --output "$${installer_file}" https://cursor.com/install; then + echo "Cursor CLI installer could not be downloaded after up to 3 attempts." >&2 + return 1 + fi + + if ! bash -n "$${installer_file}"; then + echo "Cursor CLI installer download was invalid." >&2 + return 1 + fi + + # The installer always installs its pinned latest build under ~/.local. + if ! bash "$${installer_file}"; then + echo "Cursor CLI installation failed." >&2 + return 1 + fi +) + +install_cursor_cli() { + if [ "$${ARG_INSTALL}" != "true" ]; then + echo "Skipping Cursor CLI installation as per configuration." + if ! ensure_cursor_in_path; then + echo "ERROR: install_cursor_cli is false but a working 'cursor-agent' was not found on PATH." >&2 + exit 1 + fi + printf "%s Validated existing Cursor CLI\n" "$${BOLD}" + return + fi + + if command_exists cursor-agent; then + echo "Cursor CLI already installed" + ensure_cursor_in_path + return + fi + + printf "%s Installing Cursor CLI\n" "$${BOLD}" + install_with_official_installer + + if ! ensure_cursor_in_path; then + echo "ERROR: Failed to install Cursor CLI" >&2 + exit 1 + fi + printf "%s Installed Cursor CLI\n" "$${BOLD}" +} + +setup_workdir() { + if [ -n "$${ARG_WORKDIR}" ] && [ ! -d "$${ARG_WORKDIR}" ]; then + echo "Creating workdir: $${ARG_WORKDIR}" + mkdir -p "$${ARG_WORKDIR}" + fi +} + +# Cursor CLI treats a workspace as trusted when +# $${CURSOR_DATA_DIR:-~/.cursor}/projects//.workspace-trusted exists, where +# is the path with non-alphanumerics collapsed to single dashes. This is +# the same marker `--trust` or accepting the trust prompt writes. +setup_workspace_trust() { + if [ -z "$${ARG_WORKDIR}" ]; then + echo "No workdir to trust." + return + fi + + require_jq "mark the workdir as trusted" + + local slug data_dir marker + slug=$(printf '%s' "$${ARG_WORKDIR}" | sed -E 's/[^a-zA-Z0-9]/-/g; s/-+/-/g; s/^-+//; s/-+$//') + data_dir="$${CURSOR_DATA_DIR:-$HOME/.cursor}" + marker="$${data_dir}/projects/$${slug}/.workspace-trusted" + + if [ -f "$${marker}" ]; then + echo "Workdir already trusted: $${marker}" + return + fi + + mkdir -p "$(dirname "$${marker}")" + jq -n --arg path "$${ARG_WORKDIR}" --arg at "$(date -u +%Y-%m-%dT%H:%M:%S.000Z)" \ + '{trustedAt: $at, workspacePath: $path, trustMethod: "cli-flag"}' > "$${marker}" + echo "Trusted workdir: $${marker}" +} + +# Merge MCP servers into the user-level mcp.json; servers already on disk win +# on duplicate names so interactive edits are never overwritten. +setup_mcp_config() { + local mcp_config_file="$HOME/.cursor/mcp.json" + + if [ -z "$${ARG_MCP_CONFIG}" ]; then + echo "No custom MCP servers configured; leaving $${mcp_config_file} unchanged." + return + fi + + require_jq "configure MCP servers" + + mkdir -p "$(dirname "$${mcp_config_file}")" + echo "Merging MCP server configuration into $${mcp_config_file}" + + local existing='{}' + if [ -f "$${mcp_config_file}" ]; then + if jq empty "$${mcp_config_file}" > /dev/null 2>&1; then + existing=$(cat "$${mcp_config_file}") + else + cp "$${mcp_config_file}" "$${mcp_config_file}.bak" + echo "Warning: $${mcp_config_file} is not valid JSON; backed it up to $${mcp_config_file}.bak" + fi + fi + + local custom + custom=$(echo "$${ARG_MCP_CONFIG}" | jq '.mcpServers // {}') + # Right operand wins in jq '+', so existing on-disk servers win on duplicates. + echo "$${existing}" | jq --argjson custom "$${custom}" \ + '.mcpServers = ($custom + (.mcpServers // {}))' > "$${mcp_config_file}" + echo "MCP configuration completed: $${mcp_config_file}" +} + +write_rules_files() { + if [ -z "$${ARG_RULES_FILES}" ]; then + return + fi + + require_jq "write rules files" + + local rules_dir="$${ARG_WORKDIR}/.cursor/rules" + mkdir -p "$${rules_dir}" + + local name + while IFS= read -r name; do + echo "$${ARG_RULES_FILES}" | jq -j --arg name "$${name}" '.[$name]' > "$${rules_dir}/$${name}" + echo "Wrote rule: $${rules_dir}/$${name}" + done < <(echo "$${ARG_RULES_FILES}" | jq -r 'keys[]') +} + +install_cursor_cli +setup_workdir +setup_workspace_trust +setup_mcp_config +write_rules_files + +echo "Cursor CLI module setup completed." diff --git a/registry/coder-labs/modules/cursor-cli/scripts/start.sh b/registry/coder-labs/modules/cursor-cli/scripts/start.sh deleted file mode 100644 index cadc0199a..000000000 --- a/registry/coder-labs/modules/cursor-cli/scripts/start.sh +++ /dev/null @@ -1,67 +0,0 @@ -#!/usr/bin/env 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+=("--model" "$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 index b5a761c58..70cb5b3fd 100644 --- a/registry/coder-labs/modules/cursor-cli/testdata/cursor-cli-mock.sh +++ b/registry/coder-labs/modules/cursor-cli/testdata/cursor-cli-mock.sh @@ -1,14 +1,9 @@ -#!/bin/bash +#!/usr/bin/env bash if [[ "$1" == "--version" ]]; then - echo "HELLO: $(bash -c env)" - echo "cursor-agent version v2.5.0" + echo "2026.01.01-mock" exit 0 fi -set -e - -while true; do - echo "$(date) - cursor-agent-mock" - sleep 15 -done +echo "cursor-agent invoked with: $*" +exit 0 From f17eda7d24f0240b86ad61c28818e5c1583a6a74 Mon Sep 17 00:00:00 2001 From: 35C4n0r Date: Mon, 28 Sep 2026 17:27:23 +0000 Subject: [PATCH 3/5] fix(registry/coder-labs/modules/cursor-cli): remove stray character in terraform block --- registry/coder-labs/modules/cursor-cli/main.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/registry/coder-labs/modules/cursor-cli/main.tf b/registry/coder-labs/modules/cursor-cli/main.tf index d4ea7ee4b..33f72de6b 100644 --- a/registry/coder-labs/modules/cursor-cli/main.tf +++ b/registry/coder-labs/modules/cursor-cli/main.tf @@ -7,7 +7,7 @@ terraform { version = ">= 2.12" } } -}m +} variable "agent_id" { type = string From f02c0d5ab49acea83db5ee02732a96cfcd612b61 Mon Sep 17 00:00:00 2001 From: 35C4n0r Date: Mon, 28 Sep 2026 18:10:45 +0000 Subject: [PATCH 4/5] chore: remove migrate-agent-module skill from this change --- .agents/skills/migrate-agent-module/SKILL.md | 126 ------------ .../skills/migrate-agent-module/reference.md | 193 ------------------ 2 files changed, 319 deletions(-) delete mode 100644 .agents/skills/migrate-agent-module/SKILL.md delete mode 100644 .agents/skills/migrate-agent-module/reference.md diff --git a/.agents/skills/migrate-agent-module/SKILL.md b/.agents/skills/migrate-agent-module/SKILL.md deleted file mode 100644 index 05d2ccb7e..000000000 --- a/.agents/skills/migrate-agent-module/SKILL.md +++ /dev/null @@ -1,126 +0,0 @@ ---- -name: migrate-agent-module -description: Migrate a Coder AI agent module (Copilot, Claude Code, Codex, Cursor CLI, etc.) off AgentAPI/Coder Tasks to an install-only v1 module that the user launches with their own coder_app. Captures the config/precedence, auth, MCP, managed-settings, and testing patterns learned from the Copilot v1 migration. ---- - -# Migrate Agent Module (AgentAPI/Tasks -> install-only v1) - -Use this when converting a CLI-agent module that currently embeds `module "agentapi"` and Coder Tasks (web app, task reporting, initial prompt) into a **v1 install-only module**: the module installs and configures the tool, and the workspace author launches it themselves with a `coder_app`. This is the pattern used by `coder/claude-code`, `coder-labs/codex`, and `coder-labs/copilot`. - -Read [coder-modules](../coder-modules/SKILL.md) first for the base module conventions (scaffolding, README frontmatter, testing helpers, versioning). This skill only covers what is specific to the AgentAPI -> install-only migration. - -## Golden rule: research the target tool before editing - -Every agent CLI stores config differently and the schemas change. Do not assume. Before writing code, read the tool's own docs and verify against a real install: - -- Config file locations, exact key names, and **casing** (e.g. Copilot uses `trustedFolders`, camelCase, not `trusted_folders`). -- Settings **precedence** (managed/policy vs user vs env vs CLI flags). -- Which keys are valid in each file (e.g. Copilot managed settings only accept a fixed policy key set; `banner`/`theme` are user `settings.json`, not `config.json`). -- Authentication env vars and their precedence. -- Whether a CLI subcommand you plan to call actually exists. Run ` --help` / ` --help`. Example bug caught in the Copilot migration: `copilot config model ...` is **not a command** (`config` is only a help topic); the model is set via the `COPILOT_MODEL` env var. -- MCP add/merge semantics (does the native `mcp add` overwrite or error on duplicate names?). - -When docs and third-party sources conflict, trust the official docs and confirm against the installed binary. Cite the doc URL in variable descriptions. - -## What to remove (AgentAPI / Tasks surface) - -Delete the `module "agentapi"` block and every input/resource that only existed to feed it: - -- Web/app inputs: `order`, `group`, `web_app_display_name`, `cli_app`, `cli_app_display_name`, `subdomain`. -- AgentAPI inputs: `install_agentapi`, `agentapi_version`. -- Tasks inputs: `report_tasks`, `ai_prompt`, `system_prompt`, `resume_session`, and any `task_app_id`. -- The task-reporting MCP server injection (the "coder" MCP wrapper that ran `coder exp mcp server`). -- `scripts/start.sh` (there is no launch step anymore) and the old `.tftest.hcl`. - -## What to add / keep - -- `module "coder_utils"` (`registry.coder.com/coder/coder-utils/coder`) to run the install pipeline and expose the ordered `scripts` output. It takes `install_script`, `pre_install_script`, `post_install_script`, `module_directory`, `display_name_prefix`, `icon`, `agent_id`. -- An install-only `scripts/install.sh.tftpl` rendered via `templatefile()` with base64-encoded args. -- Keep `workdir` (optional, `default = null`): create it if missing and auto-trust it (the tool's equivalent of accepting the trust prompt). -- Keep pre/post install script passthrough. -- A `scripts` output for downstream `coder_script` serialization via `coder exp sync`. - -See `reference.md` in this folder for the concrete `coder_utils` block, install-script skeleton, and jq merge snippets. - -## Install script (`scripts/install.sh.tftpl`) - -- Shebang `#!/usr/bin/env bash` (repo convention), `set -euo pipefail`. -- Prefer the tool's **official installer** over npm/node. Download to a temp file, validate with `bash -n`, then execute. Use these curl flags: - ```sh - curl --fail --silent --show-error --location \ - --retry 2 --retry-delay 1 --retry-all-errors \ - --connect-timeout 10 --max-time 300 - ``` - On download or validation failure, print an explicit message and return non-zero. Run cleanup in a subshell-scoped `trap`. -- Do not add Node.js/npm install logic unless the tool genuinely requires it; the official installers ship self-contained binaries. (Consequence to document: MCP servers whose `command` is `npx`/`uv` will fail with ENOENT unless that runtime is installed separately or an absolute path is used.) -- Pass version straight through (e.g. `VERSION="$ARG_VERSION"`); the official installer normalizes `latest`/tags. Do not reimplement normalization. -- Put the binary on PATH: link into `CODER_SCRIPT_BIN_DIR` when set, and append the bin dir to shell profiles (`.profile`, `.bashrc`, `.zshrc`, fish) because the coder scripts run non-interactively. -- Only create the workdir when set and missing (`mkdir -p`). - -## Configuration ownership model - -Follow "clear ownership like codex/claude-code": - -- The module owns only the keys it writes; **preserve unrelated on-disk state** (authentication, installed plugins, user-picked values). -- Before a jq merge, validate the existing file is valid JSON (`jq empty`); config files may be JSONC. Fall back to `{}` if invalid so the script never crashes. Require `jq` and fail with a clear message if missing. -- Union list-like keys (e.g. trusted folders) rather than replacing them. -- Prefer a **policy/managed-settings input** over a generic "dump arbitrary JSON into the user settings file" input, mirroring claude-code's `managed_settings`: - - `variable "managed_settings" { type = any, default = null }`. - - Written verbatim to the tool's managed-settings file (Copilot: `/etc/github-copilot/managed-settings.json`; Claude Code: `/etc/claude-code/managed-settings.d/10-coder.json`). - - Write as **root** (`sudo tee`, `sudo chmod 0644`); these files are rejected if world-writable, symlinked, or not root-owned. Validate JSON first; skip with a warning if invalid. -- workdir trust: write only the trust key/array and preserve everything else in that file. - -## Authentication - -- Use one tool-specific token env var when the tool has one, instead of exporting several. Copilot precedence is `COPILOT_GITHUB_TOKEN` > `GH_TOKEN` > `GITHUB_TOKEN`, so setting `COPILOT_GITHUB_TOKEN` alone is enough for the agent. Only export extra vars if you deliberately want to authenticate other workspace tooling (`gh`, git); document that choice. -- In README examples, fetch the token with the `coder_external_auth` data source and pass `.access_token` to the module (`github_token = data.coder_external_auth.github.access_token`) rather than shelling out to `coder external-auth access-token` inside the `coder_app` command. - -## Model selection - -- Set the model via the documented env var (Copilot: `COPILOT_MODEL`) using a `coder_env`. Confirm the env var still exists in the current docs. -- Default the model variable to `""` (let the tool pick its own default); gate the `coder_env` on `!= ""`. Mirrors claude-code's `model` variable. -- Do not call unverified persistence subcommands. - -## MCP - -- Write MCP servers to the tool's user-level MCP file (Copilot: `~/.copilot/mcp-config.json`) via jq. -- Match the tool's native duplicate-name behavior. Copilot/Claude Code `mcp add` is **not** an upsert: it errors on an existing name and keeps the current one. So the merge should let **existing on-disk servers win** on duplicate names and only add new names: `.mcpServers = ($custom + (.mcpServers // {}))` (right operand wins in jq `+`). - -## AI Gateway / proxy - -- If the pre-migration module had AI Bridge Proxy support, keep it renamed to the current product name (`ai_gateway`), matching whatever `main` already renamed. Keep both env vars: `HTTPS_PROXY` (routes traffic) and `NODE_EXTRA_CA_CERTS` (makes Node trust the gateway's intercepting CA) — they serve different purposes. -- Without a start script the env vars are set at the agent level (workspace-wide) instead of process-scoped. Decide whether to document that difference; keep validations that require the auth URL and cert path when enabled. - -## README - -- Add a `> [!WARNING]` that v1 is a major refactor dropping Coder Tasks/AgentAPI and that the user now launches the tool with their own `coder_app`. -- Provide a launcher example: `coder_external_auth` data source -> module (`github_token`) -> `coder_app` whose command `cd`s to the workdir and `exec`s the CLI. -- Keep it DRY: no variable/output tables (the registry generates those). Use `tf` code fences, GFM alerts, relative icon paths, pinned `version`, and `#!/usr/bin/env bash` in shell snippets. -- Remove Tasks/web-app examples and any notes that no longer apply. - -## Tests - -- Replace the old `.tftest.hcl` with `main.tftest.hcl` (plan-level): defaults, token env var name/value, model env created only when non-empty, validation `expect_failures`, and the `scripts` output ordering. -- Add container-based `main.test.ts` modeled on codex/claude-code: render the module, run the `coder_utils` scripts inside `codercom/enterprise-node:latest`, mock `/usr/bin/coder` (and the agent binary) so `coder exp sync` and validation succeed, then assert on the written files. `sudo` works in that image, so managed-settings-to-`/etc` writes are testable (`cat` the file; assert the "Wrote ... managed settings" log line and an absence test when unset). -- Import helpers from `../../../coder/modules/agentapi/test-util` (`extractCoderEnvVars`, `writeExecutable`) and `~test`. - -## Process and conventions - -- Commit as `type(scope): message` where the scope is a real path containing every changed file. -- Run before hand-off: `bun x prettier --write `, `terraform fmt`, `terraform validate`, `terraform test`, `bun test main.test.ts`. -- Use concise, substantive comments only. No section-divider banners, no narration of what the code does, no "mirrors claude-code" storytelling. -- End files with a newline; use `/usr/bin/env bash` shebangs. -- Never bypass git hooks; do not commit to protected branches; use a feature branch. - -## Migration checklist - -1. Read the tool's config/auth/MCP/model docs and verify subcommands against `--help`. -2. Remove the AgentAPI/Tasks surface (variables, `module "agentapi"`, `start.sh`, old tftest, task MCP). -3. Add `coder_utils` + `install.sh.tftpl` using the official installer. -4. Implement config ownership: `managed_settings` (root-written policy), workdir trust, MCP merge with correct duplicate semantics. -5. Auth via a single documented token env var; model via the documented env var, default `""`. -6. Port AI Gateway/proxy if present (both proxy env vars, validations). -7. Rewrite README (WARNING, coder_external_auth launcher, DRY). -8. Rewrite tests (`main.tftest.hcl` + container `main.test.ts`). -9. Format, validate, run both test suites. -10. Bump version as a **major** (removed inputs, changed defaults, new required behavior) per [coder-modules](../coder-modules/SKILL.md) versioning. diff --git a/.agents/skills/migrate-agent-module/reference.md b/.agents/skills/migrate-agent-module/reference.md deleted file mode 100644 index 14b3059be..000000000 --- a/.agents/skills/migrate-agent-module/reference.md +++ /dev/null @@ -1,193 +0,0 @@ -# Reference snippets (from the Copilot v1 migration) - -Concrete, working patterns extracted from `registry/coder-labs/modules/copilot`. Adapt names/paths to the target tool. These are illustrative, not drop-in. - -## `coder_utils` wiring (main.tf) - -```tf -locals { - workdir = var.workdir != null ? trimsuffix(var.workdir, "/") : "" - workdir_trusted_folders = local.workdir != "" ? [local.workdir] : [] - - install_script = templatefile("${path.module}/scripts/install.sh.tftpl", { - ARG_INSTALL = tostring(var.install_copilot) - ARG_COPILOT_VERSION = var.copilot_version - ARG_WORKDIR = local.workdir != "" ? base64encode(local.workdir) : "" - ARG_MANAGED_SETTINGS_JSON = var.managed_settings != null ? base64encode(jsonencode(var.managed_settings)) : "" - ARG_TRUSTED_FOLDERS = length(local.workdir_trusted_folders) > 0 ? base64encode(jsonencode(local.workdir_trusted_folders)) : "" - ARG_MCP_CONFIG = var.mcp != "" ? base64encode(var.mcp) : "" - }) - - module_dir_name = ".coder-modules/coder-labs/copilot" -} - -module "coder_utils" { - source = "registry.coder.com/coder/coder-utils/coder" - version = "0.0.1" - - agent_id = var.agent_id - module_directory = "$HOME/${local.module_dir_name}" - display_name_prefix = "Copilot" - icon = var.icon - pre_install_script = var.pre_install_script - post_install_script = var.post_install_script - install_script = local.install_script -} - -output "scripts" { - description = "Ordered coder exp sync names (pre_install, install, post_install)." - value = module.coder_utils.scripts -} -``` - -## Auth + model env (main.tf) - -```tf -resource "coder_env" "copilot_model" { - count = var.copilot_model != "" ? 1 : 0 - agent_id = var.agent_id - name = "COPILOT_MODEL" - value = var.copilot_model -} - -resource "coder_env" "github_token" { - count = var.github_token != "" ? 1 : 0 - agent_id = var.agent_id - name = "COPILOT_GITHUB_TOKEN" - value = var.github_token -} -``` - -## managed_settings variable + root write - -```tf -variable "managed_settings" { - type = any - description = "Policy written to the tool's managed-settings file. Highest precedence; only supported keys apply. See ." - default = null -} -``` - -```sh -# highest-precedence policy; rejected unless root-owned and not world-writable -write_managed_settings() { - if [ -z "$${ARG_MANAGED_SETTINGS_JSON}" ]; then - return - fi - if ! echo "$${ARG_MANAGED_SETTINGS_JSON}" | jq empty 2> /dev/null; then - echo "Warning: managed_settings is not valid JSON, skipping policy write" - return - fi - local target="/etc/github-copilot/managed-settings.json" - if command_exists sudo; then - sudo mkdir -p "$(dirname "$${target}")" - echo "$${ARG_MANAGED_SETTINGS_JSON}" | sudo tee "$${target}" > /dev/null - sudo chmod 0644 "$${target}" - else - mkdir -p "$(dirname "$${target}")" - echo "$${ARG_MANAGED_SETTINGS_JSON}" > "$${target}" - chmod 0644 "$${target}" - fi - echo "Wrote Copilot managed settings to $${target}" -} -``` - -## Official installer download/validate/run - -```sh -install_with_official_installer() ( - local installer_file - installer_file=$(mktemp) - trap 'rm -f "$${installer_file}"' EXIT - - if ! curl --fail --silent --show-error --location \ - --retry 2 --retry-delay 1 --retry-all-errors \ - --connect-timeout 10 --max-time 300 \ - --output "$${installer_file}" https://gh.io/copilot-install; then - echo "GitHub Copilot CLI could not be downloaded after up to 3 attempts." >&2 - return 1 - fi - - if ! bash -n "$${installer_file}"; then - echo "GitHub Copilot CLI installer download was invalid." >&2 - return 1 - fi - - # VERSION accepts 'latest' or a version tag; PREFIX installs under $HOME/.local/bin. - if ! VERSION="$${ARG_COPILOT_VERSION}" PREFIX="$HOME/.local" bash "$${installer_file}"; then - echo "GitHub Copilot CLI installation failed." >&2 - return 1 - fi -) -``` - -## workdir trust (config.json trustedFolders, union, preserve rest) - -```sh -setup_trusted_folders() { - local config_file="$HOME/.copilot/config.json" - [ -z "$${ARG_TRUSTED_FOLDERS}" ] && { - echo "No workdir to trust." - return - } - - local existing='{}' - if [ -f "$${config_file}" ] && jq empty "$${config_file}" > /dev/null 2>&1; then - existing=$(cat "$${config_file}") - fi - echo "$${existing}" | jq --argjson folders "$${ARG_TRUSTED_FOLDERS}" \ - '.trustedFolders = (((.trustedFolders // []) + $folders) | unique)' > "$${config_file}" -} -``` - -## MCP merge (existing wins on duplicate names) - -```sh -# right operand wins in jq '+', so existing on-disk servers win on duplicates -echo "$${existing}" | jq --argjson custom "$${custom}" \ - '.mcpServers = ($custom + (.mcpServers // {}))' > "$${mcp_config_file}" -``` - -## README launcher example - -```tf -data "coder_external_auth" "github" { - id = "github" -} - -module "copilot" { - source = "registry.coder.com/coder-labs/copilot/coder" - version = "1.0.88" - agent_id = coder_agent.example.id - workdir = "/home/coder/project" - github_token = data.coder_external_auth.github.access_token -} - -resource "coder_app" "copilot" { - agent_id = coder_agent.example.id - slug = "copilot" - display_name = "Copilot" - icon = "/icon/github.svg" - open_in = "slim-window" - command = <<-EOT - #!/usr/bin/env bash - set -e - cd "/home/coder/project" - exec copilot --allow-all-tools - EOT -} -``` - -## Copilot migration diff shape (for scale reference) - -``` -README.md | rewritten -main.tf | agentapi block + Tasks vars removed; coder_utils + config vars added -main.test.ts | container tests rewritten -main.tftest.hcl | new (replaces copilot.tftest.hcl) -copilot.tftest.hcl | deleted -scripts/install.sh | deleted (replaced by install.sh.tftpl) -scripts/start.sh | deleted -scripts/install.sh.tftpl | new (official installer, config ownership) -testdata/*-mock.sh | updated -``` From 61be2d303248182addebaebeb943e1e8252168bd Mon Sep 17 00:00:00 2001 From: 35C4n0r Date: Fri, 9 Oct 2026 02:41:27 +0000 Subject: [PATCH 5/5] feat(registry/coder-labs/modules/cursor-cli): add cursor_config_dir to set CURSOR_CONFIG_DIR --- .../coder-labs/modules/cursor-cli/README.md | 2 + .../modules/cursor-cli/main.test.ts | 38 +++++++++++++++++-- .../coder-labs/modules/cursor-cli/main.tf | 13 +++++++ .../modules/cursor-cli/main.tftest.hcl | 27 +++++++++++++ 4 files changed, 76 insertions(+), 4 deletions(-) diff --git a/registry/coder-labs/modules/cursor-cli/README.md b/registry/coder-labs/modules/cursor-cli/README.md index d962d21c4..12c9721e2 100644 --- a/registry/coder-labs/modules/cursor-cli/README.md +++ b/registry/coder-labs/modules/cursor-cli/README.md @@ -127,6 +127,8 @@ resource "coder_script" "post_cursor_cli" { When `api_key` is set, it is exported as `CURSOR_API_KEY` and is not rendered into the install script. To create a key, see [Cursor CLI authentication](https://cursor.com/docs/cli/reference/authentication). Without a key, run `cursor-agent login` in the workspace. +When `cursor_config_dir` is set, it is exported as `CURSOR_CONFIG_DIR`, which moves Cursor CLI's [`cli-config.json`](https://cursor.com/docs/cli/reference/configuration). When empty (the default), the module leaves `CURSOR_CONFIG_DIR` untouched. Cursor CLI still reads the user-level `mcp.json` from `~/.cursor`, so the module always merges `mcp` there. + The module always installs the latest Cursor CLI with the [official installer](https://cursor.com/install) and skips the install when `cursor-agent` is already on `PATH`. If `install_cursor_cli = false`, a working `cursor-agent` must already be available on `PATH`, or workspace startup fails. ## Troubleshooting diff --git a/registry/coder-labs/modules/cursor-cli/main.test.ts b/registry/coder-labs/modules/cursor-cli/main.test.ts index 4da1e127b..6596e13d8 100644 --- a/registry/coder-labs/modules/cursor-cli/main.test.ts +++ b/registry/coder-labs/modules/cursor-cli/main.test.ts @@ -134,13 +134,23 @@ const setup = async ( return { id, coderEnvVars, scripts }; }; -const runScript = async (id: string, name: string, script: string) => { +const runScript = async ( + id: string, + name: string, + script: string, + env?: Record, +) => { const target = `/tmp/coder-utils-${name}.sh`; await writeExecutable({ containerId: id, filePath: target, content: script }); - return execContainer(id, ["bash", "-c", target]); + const envArgs = Object.entries(env ?? {}).map(([k, v]) => `${k}=${v}`); + return execContainer(id, ["env", ...envArgs, "bash", "-c", target]); }; -const runScripts = async (id: string, scripts: ModuleScripts) => { +const runScripts = async ( + id: string, + scripts: ModuleScripts, + env?: Record, +) => { const ordered: [string, string | undefined][] = [ ["pre_install", scripts.pre_install], ["install", scripts.install], @@ -148,7 +158,7 @@ const runScripts = async (id: string, scripts: ModuleScripts) => { ]; for (const [name, script] of ordered) { if (!script) continue; - const resp = await runScript(id, name, script); + const resp = await runScript(id, name, script, env); if (resp.exitCode !== 0) { console.log(`script ${name} failed:`); console.log(resp.stdout); @@ -369,6 +379,26 @@ describe("cursor-cli", async () => { expect(rule).toBe(content); }); + test("cursor-config-dir-does-not-move-mcp-config", async () => { + const configDir = "/home/coder/.config/cursor-cli"; + const { id, coderEnvVars, scripts } = await setup({ + moduleVariables: { + cursor_config_dir: configDir, + mcp: JSON.stringify({ mcpServers: { extra: { command: "x" } } }), + }, + }); + expect(coderEnvVars["CURSOR_CONFIG_DIR"]).toBe(configDir); + await runScripts(id, scripts, coderEnvVars); + const mcp = JSON.parse(await readFileContainer(id, mcpConfigPath)); + expect(mcp.mcpServers.extra.command).toBe("x"); + const moved = await execContainer(id, [ + "test", + "-e", + `${configDir}/mcp.json`, + ]); + expect(moved.exitCode).not.toBe(0); + }); + test("api-key-env-var-not-in-script", async () => { const apiKey = "test-cursor-api-key-123"; const { coderEnvVars, scripts } = await setup({ diff --git a/registry/coder-labs/modules/cursor-cli/main.tf b/registry/coder-labs/modules/cursor-cli/main.tf index 33f72de6b..bfbd0f9e8 100644 --- a/registry/coder-labs/modules/cursor-cli/main.tf +++ b/registry/coder-labs/modules/cursor-cli/main.tf @@ -51,6 +51,12 @@ variable "api_key" { default = "" } +variable "cursor_config_dir" { + type = string + description = "Directory for Cursor CLI's cli-config.json, exported as CURSOR_CONFIG_DIR. Empty (default) leaves CURSOR_CONFIG_DIR unset so an existing value or the default ~/.cursor is used. Does not move mcp.json, which Cursor CLI always reads from ~/.cursor. See https://cursor.com/docs/cli/reference/configuration" + default = "" +} + variable "mcp" { type = string description = "MCP servers as JSON in Cursor's mcp.json format ({\"mcpServers\": {...}}). Merged into the user-level ~/.cursor/mcp.json; servers already on disk win on duplicate names. See https://cursor.com/docs/cli/mcp" @@ -85,6 +91,13 @@ resource "coder_env" "cursor_api_key" { value = var.api_key } +resource "coder_env" "cursor_config_dir" { + count = var.cursor_config_dir != "" ? 1 : 0 + agent_id = var.agent_id + name = "CURSOR_CONFIG_DIR" + value = var.cursor_config_dir +} + locals { workdir = var.workdir != null ? trimsuffix(var.workdir, "/") : "" install_script = templatefile("${path.module}/scripts/install.sh.tftpl", { diff --git a/registry/coder-labs/modules/cursor-cli/main.tftest.hcl b/registry/coder-labs/modules/cursor-cli/main.tftest.hcl index eebcb050e..e929e3ef9 100644 --- a/registry/coder-labs/modules/cursor-cli/main.tftest.hcl +++ b/registry/coder-labs/modules/cursor-cli/main.tftest.hcl @@ -59,6 +59,33 @@ run "api_key_creates_env_var" { } } +run "cursor_config_dir_not_set_by_default" { + command = plan + + variables { + agent_id = "test-agent" + } + + assert { + condition = length(coder_env.cursor_config_dir) == 0 + error_message = "CURSOR_CONFIG_DIR should not be created when cursor_config_dir is empty" + } +} + +run "cursor_config_dir_creates_env_var" { + command = plan + + variables { + agent_id = "test-agent" + cursor_config_dir = "/home/coder/.config/cursor-cli" + } + + assert { + condition = coder_env.cursor_config_dir[0].name == "CURSOR_CONFIG_DIR" && coder_env.cursor_config_dir[0].value == "/home/coder/.config/cursor-cli" + error_message = "CURSOR_CONFIG_DIR env var should be created with the provided directory" + } +} + run "invalid_mcp_fails" { command = plan