diff --git a/registry/coder-labs/modules/cursor-cli/README.md b/registry/coder-labs/modules/cursor-cli/README.md index bff150721..12c9721e2 100644 --- a/registry/coder-labs/modules/cursor-cli/README.md +++ b/registry/coder-labs/modules/cursor-cli/README.md @@ -1,136 +1,148 @@ --- 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. + +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 -- 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..6596e13d8 100644 --- a/registry/coder-labs/modules/cursor-cli/main.test.ts +++ b/registry/coder-labs/modules/cursor-cli/main.test.ts @@ -1,220 +1,429 @@ 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; +} -let cleanupFns: (() => Promise)[] = []; -const registerCleanup = (fn: () => Promise) => cleanupFns.push(fn); +const SCRIPT_SUFFIXES = [ + "Pre-Install Script", + "Install Script", + "Post-Install Script", +] as const; +const collectScripts = (state: TerraformState): ModuleScripts => { + const byDisplayName: Record = {}; + for (const resource of state.resources) { + if (resource.type !== "coder_script") continue; + for (const instance of resource.instances) { + const attrs = instance.attributes as Record; + const displayName = attrs.display_name as string | undefined; + const script = attrs.script as string | undefined; + if (displayName && script) { + byDisplayName[displayName] = script; + } + } + } + const scripts: Partial = {}; + for (const suffix of SCRIPT_SUFFIXES) { + const key = `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, + env?: Record, +) => { + const target = `/tmp/coder-utils-${name}.sh`; + await writeExecutable({ containerId: id, filePath: target, content: script }); + 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, + env?: Record, +) => { + const ordered: [string, string | undefined][] = [ + ["pre_install", scripts.pre_install], + ["install", scripts.install], + ["post_install", scripts.post_install], + ]; + for (const [name, script] of ordered) { + if (!script) continue; + const resp = await runScript(id, name, script, env); + if (resp.exitCode !== 0) { + console.log(`script ${name} failed:`); + console.log(resp.stdout); + console.log(resp.stderr); + throw new Error(`coder-utils ${name} script exited ${resp.exitCode}`); + } + } }; -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"), }); - const resp = await execModuleScript(id); - expect(resp.exitCode).toBe(0); + 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"), + }); + 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"); + }); - const mcpContent = await execContainer(id, [ + 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"); + }); + + 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); + await runScripts(id, scripts); + const rule = await readFileContainer( + id, + `${projectDir}/.cursor/rules/typescript.mdc`, + ); + expect(rule).toBe(content); + }); - const installLog = await execContainer(id, [ - "bash", - "-c", - "cat /home/coder/.cursor-cli-module/install.log || true", + 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(installLog.stdout).toContain(folder); + expect(moved.exitCode).not.toBe(0); }); - 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..bfbd0f9e8 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 = { @@ -14,113 +14,74 @@ variable "agent_id" { description = "The ID of a Coder agent." } -data "coder_workspace" "me" {} - -data "coder_workspace_owner" "me" {} - -variable "order" { - type = number - description = "The order determines the position of app in the UI presentation. The lowest order is shown first and apps with equal order are sorted by name (ascending order)." - default = null -} - -variable "group" { - type = string - description = "The name of a group that this app belongs to." - default = null -} - variable "icon" { type = string - description = "The icon to use for the app." + description = "The icon to use for the install scripts." default = "/icon/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" { +variable "api_key" { type = string - description = "Model to use (e.g., sonnet-4, sonnet-4-thinking, gpt-5)" + description = "Cursor API key, exported as CURSOR_API_KEY. See https://cursor.com/docs/cli/reference/authentication" + sensitive = true default = "" } -variable "ai_prompt" { +variable "cursor_config_dir" { type = string - description = "AI prompt/task passed to cursor-agent." + 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 "api_key" { +variable "mcp" { type = string - description = "API key for Cursor CLI." + 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 = "" - sensitive = true -} -variable "mcp" { - type = string - description = "Workspace-specific MCP JSON to write to folder/.cursor/mcp.json. See https://docs.cursor.com/en/context/mcp#using-mcp-json" - default = null + 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 +91,38 @@ 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 +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", { + 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..e929e3ef9 --- /dev/null +++ b/registry/coder-labs/modules/cursor-cli/main.tftest.hcl @@ -0,0 +1,164 @@ +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 "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 + + 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