diff --git a/registry/coder-labs/modules/cursor-cli/README.md b/registry/coder-labs/modules/cursor-cli/README.md new file mode 100644 index 000000000..76f535846 --- /dev/null +++ b/registry/coder-labs/modules/cursor-cli/README.md @@ -0,0 +1,102 @@ +--- +display_name: Cursor CLI +icon: ../../../../.icons/cursor.svg +description: Run Cursor CLI agent in your workspace (no AgentAPI) +verified: true +tags: [agent, cursor, ai, cli] +--- + +# Cursor CLI + +Run the Cursor Coding Agent in your workspace using the Cursor CLI directly. + +A full example with MCP, rules, and pre/post install scripts: + +```tf + +data "coder_parameter" "ai_prompt" { + name = "ai_prompt" + type = "string" + default = "Write a simple hello world program in Python" +} + +module "cursor_cli" { + source = "registry.coder.com/coder-labs/cursor-cli/coder" + version = "0.1.0" + agent_id = coder_agent.example.id + folder = "/home/coder/project" + + # Optional + install_cursor_cli = true + cursor_cli_version = "latest" + force = true + model = "gpt-5" + ai_prompt = data.coder_parameter.ai_prompt.value + + # Minimal MCP server (writes `~/.cursor/mcp.json`): + mcp_json = 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 `~/.cursor/rules/`. + rules_files = { + "python.yml" = <<-EOT + version: 1 + rules: + - name: python + include: ['**/*.py'] + description: Python-focused guidance + EOT + + "frontend.yml" = <<-EOT + version: 1 + rules: + - name: web + include: ['**/*.{ts,tsx,js,jsx,css}'] + exclude: ['**/dist/**'] + description: Frontend rules + EOT + } +} +``` + +## References + +- See Cursor CLI docs: `https://docs.cursor.com/en/cli/overview` +- For MCP project config, see `https://docs.cursor.com/en/context/mcp#using-mcp-json`. This module writes your `mcp_json` into `~/.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 `~/.cursor/rules/`. + +## Troubleshooting + +- Ensure the CLI is installed (enable `install_cursor_cli = true` or preinstall it in your image) +- Logs are written to `~/.cursor-cli-module/` diff --git a/registry/coder-labs/modules/cursor-cli/cursor-cli.tftest.hcl b/registry/coder-labs/modules/cursor-cli/cursor-cli.tftest.hcl new file mode 100644 index 000000000..0e30ed67a --- /dev/null +++ b/registry/coder-labs/modules/cursor-cli/cursor-cli.tftest.hcl @@ -0,0 +1,116 @@ +// Terraform tests for the cursor-cli module +// Validates that we render expected script content given inputs + +run "defaults" { + command = plan + + variables { + agent_id = "test-agent" + folder = "/home/coder" + } + + assert { + condition = can(regex("Cursor CLI", resource.coder_script.cursor_cli.display_name)) + error_message = "Expected coder_script to be created" + } +} + +run "non_interactive_mode" { + command = plan + + variables { + agent_id = "test-agent" + folder = "/home/coder" + output_format = "json" + ai_prompt = "refactor the auth module to use JWT tokens" + } + + assert { + // non-interactive always prints; output format propagates + condition = can(regex("OUTPUT_FORMAT='json'", resource.coder_script.cursor_cli.script)) + error_message = "Expected OUTPUT_FORMAT to be propagated" + } + + assert { + condition = can(regex("AI_PROMPT='refactor the auth module to use JWT tokens'", resource.coder_script.cursor_cli.script)) + error_message = "Expected ai_prompt to be propagated via AI_PROMPT" + } +} + +run "model_and_force" { + command = plan + + variables { + agent_id = "test-agent" + folder = "/home/coder" + model = "test-model" + force = true + } + + assert { + condition = can(regex("MODEL='test-model'", resource.coder_script.cursor_cli.script)) + error_message = "Expected MODEL to be propagated" + } + + assert { + condition = can(regex("FORCE='true'", resource.coder_script.cursor_cli.script)) + error_message = "Expected FORCE true to be propagated" + } +} + +run "additional_settings_propagated" { + command = plan + + variables { + agent_id = "test-agent" + folder = "/home/coder" + mcp_json = jsonencode({ mcpServers = { foo = { command = "foo", type = "stdio" } } }) + rules_files = { + "global.yml" = "version: 1\nrules:\n - name: global\n include: ['**/*']\n description: global rule" + } + pre_install_script = "#!/bin/bash\necho pre-install" + post_install_script = "#!/bin/bash\necho post-install" + } + + // Ensure project mcp_json is passed + assert { + condition = can(regex(base64encode(jsonencode({ mcpServers = { foo = { command = "foo", type = "stdio" } } })), resource.coder_script.cursor_cli.script)) + error_message = "Expected PROJECT_MCP_JSON (base64) to be in the install step" + } + + // Ensure rules map is passed + assert { + condition = can(regex(base64encode(jsonencode({ "global.yml" : "version: 1\nrules:\n - name: global\n include: ['**/*']\n description: global rule" })), resource.coder_script.cursor_cli.script)) + error_message = "Expected PROJECT_RULES_JSON (base64) to be in the install step" + } + + // Ensure pre/post install scripts are embedded + assert { + condition = can(regex(base64encode("#!/bin/bash\necho pre-install"), resource.coder_script.cursor_cli.script)) + error_message = "Expected pre-install script to be embedded" + } + assert { + condition = can(regex(base64encode("#!/bin/bash\necho post-install"), resource.coder_script.cursor_cli.script)) + error_message = "Expected post-install script to be embedded" + } +} + +run "api_key_env_var" { + command = plan + + variables { + agent_id = "test-agent" + folder = "/home/coder" + api_key = "sk-test-123" + } + + assert { + condition = resource.coder_env.cursor_api_key[0].name == "CURSOR_API_KEY" + error_message = "Expected CURSOR_API_KEY env to be created when api_key is set" + } + + assert { + condition = resource.coder_env.cursor_api_key[0].value == "sk-test-123" + error_message = "Expected CURSOR_API_KEY env value to be set from api_key" + } +} diff --git a/registry/coder-labs/modules/cursor-cli/main.test.ts b/registry/coder-labs/modules/cursor-cli/main.test.ts new file mode 100644 index 000000000..2f14ade8f --- /dev/null +++ b/registry/coder-labs/modules/cursor-cli/main.test.ts @@ -0,0 +1,140 @@ +import { afterEach, beforeAll, describe, expect, setDefaultTimeout, test } from "bun:test"; +import { execContainer, runTerraformInit, writeFileContainer } from "~test"; +import { execModuleScript } from "../../../coder/modules/agentapi/test-util"; +import { setupContainer, writeExecutable } from "../../../coder/modules/agentapi/test-util"; + +let cleanupFns: (() => Promise)[] = []; +const registerCleanup = (fn: () => Promise) => cleanupFns.push(fn); + +afterEach(async () => { + const fns = cleanupFns.slice().reverse(); + cleanupFns = []; + for (const fn of fns) { + try { + await fn(); + } catch (err) { + console.error(err); + } + } +}); + +const setup = async (vars?: Record) => { + const projectDir = "/home/coder/project"; + const { id, coderScript, cleanup } = await setupContainer({ + moduleDir: import.meta.dir, + image: "codercom/enterprise-minimal:latest", + vars: { + folder: projectDir, + install_cursor_cli: "false", + ...vars, + }, + }); + registerCleanup(cleanup); + // Ensure project dir exists + await execContainer(id, ["bash", "-c", `mkdir -p '${projectDir}'`]); + // Write the module's script to the container + await writeExecutable({ + containerId: id, + filePath: "/home/coder/script.sh", + content: coderScript.script, + }); + return { id, projectDir }; +}; + +setDefaultTimeout(180 * 1000); + +describe("cursor-cli", async () => { + beforeAll(async () => { + await runTerraformInit(import.meta.dir); + }); + + test("installs Cursor via official installer and runs --help", async () => { + const { id } = await setup({ install_cursor_cli: "true", ai_prompt: "--help" }); + const resp = await execModuleScript(id); + expect(resp.exitCode).toBe(0); + + // Verify the start log captured the invocation + const startLog = await execContainer(id, [ + "bash", + "-c", + "cat /home/coder/script.log", + ]); + expect(startLog.exitCode).toBe(0); + expect(startLog.stdout).toContain("cursor-agent"); + }); + + test("model and force flags propagate", async () => { + const { id } = await setup({ model: "sonnet-4", force: "true", ai_prompt: "status" }); + await writeExecutable({ + containerId: id, + filePath: "/usr/bin/cursor-agent", + content: `#!/bin/sh\necho cursor-agent invoked\nfor a in "$@"; do echo arg:$a; done\nexit 0\n`, + }); + + const resp = await execModuleScript(id); + expect(resp.exitCode).toBe(0); + + const startLog = await execContainer(id, [ + "bash", + "-c", + "cat /home/coder/script.log", + ]); + expect(startLog.exitCode).toBe(0); + expect(startLog.stdout).toContain("-m sonnet-4"); + expect(startLog.stdout).toContain("-f"); + expect(startLog.stdout).toContain("status"); + }); + + test("writes workspace mcp.json when provided", async () => { + const mcp = JSON.stringify({ mcpServers: { foo: { command: "foo", type: "stdio" } } }); + const { id } = await setup({ mcp_json: mcp }); + await writeExecutable({ + containerId: id, + filePath: "/usr/bin/cursor-agent", + content: `#!/bin/sh\necho ok\n`, + }); + const resp = await execModuleScript(id); + expect(resp.exitCode).toBe(0); + + const mcpContent = await execContainer(id, [ + "bash", + "-c", + `cat '/home/coder/.cursor/mcp.json'`, + ]); + expect(mcpContent.exitCode).toBe(0); + expect(mcpContent.stdout).toContain("mcpServers"); + expect(mcpContent.stdout).toContain("foo"); + }); + + test("fails when cursor-agent missing", async () => { + const { id } = await setup(); + const resp = await execModuleScript(id); + expect(resp.exitCode).not.toBe(0); + const startLog = await execContainer(id, [ + "bash", + "-c", + "cat /home/coder/script.log || true", + ]); + expect(startLog.stdout).toContain("cursor-agent not found"); + }); + + test("install step logs folder", async () => { + const { id } = await setup({ install_cursor_cli: "false" }); + await writeExecutable({ + containerId: id, + filePath: "/usr/bin/cursor-agent", + content: `#!/bin/sh\necho ok\n`, + }); + const resp = await execModuleScript(id); + expect(resp.exitCode).toBe(0); + const installLog = await execContainer(id, [ + "bash", + "-c", + "cat /home/coder/script.log", + ]); + expect(installLog.exitCode).toBe(0); + expect(installLog.stdout).toContain("folder: /home/coder/project"); + }); +}); + + diff --git a/registry/coder-labs/modules/cursor-cli/main.tf b/registry/coder-labs/modules/cursor-cli/main.tf new file mode 100644 index 000000000..515ddba7d --- /dev/null +++ b/registry/coder-labs/modules/cursor-cli/main.tf @@ -0,0 +1,203 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 2.7" + } + } +} + +variable "agent_id" { + type = string + description = "The ID of a Coder agent." +} + +data "coder_workspace" "me" {} + +data "coder_workspace_owner" "me" {} + +variable "order" { + type = number + description = "The order determines the position of app in the UI presentation. The lowest order is shown first and apps with equal order are sorted by name (ascending order)." + default = null +} + +variable "group" { + type = string + description = "The name of a group that this app belongs to." + default = null +} + +variable "icon" { + type = string + description = "The icon to use for the app." + default = "/icon/cursor.svg" +} + +variable "folder" { + type = string + description = "The folder to run Cursor CLI in." +} + +variable "install_cursor_cli" { + type = bool + description = "Whether to install Cursor CLI." + default = true +} + +variable "cursor_cli_version" { + type = string + description = "The version of Cursor CLI to install (latest for latest)." + default = "latest" +} + +# Running mode is non-interactive by design for automation. + + +variable "force" { + type = bool + description = "Pass -f/--force to allow commands unless explicitly denied." + default = false +} + +variable "model" { + type = string + description = "Pass -m/--model to select model (e.g., sonnet-4, 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 (sets CURSOR_API_KEY env or pass via -a)." + default = "" + sensitive = true +} + +variable "mcp_json" { + type = string + description = "Workspace-specific MCP JSON to write to ~/.cursor/mcp.json. See https://docs.cursor.com/en/context/mcp#using-mcp-json" + default = null +} + +variable "rules_files" { + type = map(string) + description = "Optional map of rule file name to content. Files will be written to ~/.cursor/rules/. See https://docs.cursor.com/en/context/rules#project-rules" + default = null +} + +variable "pre_install_script" { + type = string + description = "Optional script to run before installing Cursor CLI." + default = null +} + +variable "post_install_script" { + type = string + description = "Optional script to run after installing Cursor CLI." + default = null +} + +locals { + app_slug = "cursor-cli" + install_script = file("${path.module}/scripts/install.sh") + start_script = file("${path.module}/scripts/start.sh") + module_dir_name = ".cursor-cli-module" + encoded_pre_install_script = var.pre_install_script != null ? base64encode(var.pre_install_script) : "" + encoded_post_install_script = var.post_install_script != null ? base64encode(var.post_install_script) : "" +} + +# Expose status slug and API key to the agent environment +resource "coder_env" "status_slug" { + agent_id = var.agent_id + name = "CODER_MCP_APP_STATUS_SLUG" + value = local.app_slug +} + +resource "coder_env" "cursor_api_key" { + count = var.api_key != "" ? 1 : 0 + agent_id = var.agent_id + name = "CURSOR_API_KEY" + value = var.api_key +} + +resource "coder_script" "cursor_cli" { + agent_id = var.agent_id + display_name = "Cursor CLI" + icon = var.icon + script = <<-EOT + #!/bin/bash + set -o errexit + set -o pipefail + + # Ensure module log directory exists before piping logs + mkdir -p "$HOME/${local.module_dir_name}" + + echo -n '${base64encode(local.install_script)}' | base64 -d > /tmp/install.sh + chmod +x /tmp/install.sh + # Run optional pre-install script + if [ -n "${local.encoded_pre_install_script}" ]; then + echo "${local.encoded_pre_install_script}" | base64 -d > /tmp/pre_install.sh + chmod +x /tmp/pre_install.sh + echo "[cursor-cli] running pre-install script" | tee -a "$HOME/${local.module_dir_name}/install.log" + FOLDER='${var.folder}' /tmp/pre_install.sh | tee -a "$HOME/${local.module_dir_name}/pre_install.log" + fi + ARG_INSTALL='${var.install_cursor_cli}' \ + ARG_VERSION='${var.cursor_cli_version}' \ + WORKSPACE_MCP_JSON='${var.mcp_json != null ? base64encode(replace(var.mcp_json, "'", "'\\''")) : ""}' \ + WORKSPACE_RULES_JSON='${var.rules_files != null ? base64encode(jsonencode(var.rules_files)) : ""}' \ + MODULE_DIR_NAME='${local.module_dir_name}' \ + FOLDER='${var.folder}' \ + /tmp/install.sh | tee "$HOME/${local.module_dir_name}/install.log" + + # Run optional post-install script + if [ -n "${local.encoded_post_install_script}" ]; then + echo "${local.encoded_post_install_script}" | base64 -d > /tmp/post_install.sh + chmod +x /tmp/post_install.sh + echo "[cursor-cli] running post-install script" | tee -a "$HOME/${local.module_dir_name}/install.log" + FOLDER='${var.folder}' /tmp/post_install.sh | tee -a "$HOME/${local.module_dir_name}/post_install.log" + fi + + echo -n '${base64encode(local.start_script)}' | base64 -d > /tmp/start.sh + chmod +x /tmp/start.sh + FORCE='${var.force}' \ + MODEL='${var.model}' \ + AI_PROMPT='${var.ai_prompt}' \ + MODULE_DIR_NAME='${local.module_dir_name}' \ + FOLDER='${var.folder}' \ + /tmp/start.sh | tee "$HOME/${local.module_dir_name}/start.log" + EOT + run_on_start = true +} + +resource "coder_app" "cursor_cli" { + agent_id = var.agent_id + slug = local.app_slug + display_name = "Cursor CLI" + icon = var.icon + order = var.order + group = var.group + command = <<-EOT + #!/bin/bash + set -o errexit + set -o pipefail + if [ -f "$HOME/${local.module_dir_name}/start.log" ]; then + tail -n +1 -f "$HOME/${local.module_dir_name}/start.log" + else + echo "Cursor CLI not started yet. Check install/start logs in $HOME/${local.module_dir_name}/" + /bin/bash + fi + EOT +} + +output "app_id" { + description = "The ID of the Cursor CLI app." + value = coder_app.cursor_cli.id +} diff --git a/registry/coder-labs/modules/cursor-cli/scripts/install.sh b/registry/coder-labs/modules/cursor-cli/scripts/install.sh new file mode 100644 index 000000000..d029a578b --- /dev/null +++ b/registry/coder-labs/modules/cursor-cli/scripts/install.sh @@ -0,0 +1,88 @@ +#!/bin/bash + +set -o errexit +set -o pipefail + +command_exists() { + command -v "$1" >/dev/null 2>&1 +} + +# Inputs +ARG_INSTALL=${ARG_INSTALL:-true} +ARG_VERSION=${ARG_VERSION:-latest} +MODULE_DIR_NAME=${MODULE_DIR_NAME:-.cursor-cli-module} +FOLDER=${FOLDER:-$HOME} + +mkdir -p "$HOME/$MODULE_DIR_NAME" + +WORKSPACE_MCP_JSON=$(echo -n "$WORKSPACE_MCP_JSON" | base64 -d) +WORKSPACE_RULES_JSON=$(echo -n "$WORKSPACE_RULES_JSON" | base64 -d) + +{ + echo "--------------------------------" + echo "install: $ARG_INSTALL" + echo "version: $ARG_VERSION" + echo "folder: $FOLDER" + echo "--------------------------------" +} | tee -a "$HOME/$MODULE_DIR_NAME/install.log" + +# Install Cursor via official installer if requested +if [ "$ARG_INSTALL" = "true" ]; then + echo "Installing Cursor via official installer..." | tee -a "$HOME/$MODULE_DIR_NAME/install.log" + set +e + curl https://cursor.com/install -fsS | bash 2>&1 | tee -a "$HOME/$MODULE_DIR_NAME/install.log" + CURL_EXIT=${PIPESTATUS[0]} + set -e + if [ $CURL_EXIT -ne 0 ]; then + echo "Cursor installer failed with exit code $CURL_EXIT" | tee -a "$HOME/$MODULE_DIR_NAME/install.log" + 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)" | tee -a "$HOME/$MODULE_DIR_NAME/install.log" +fi + +# Ensure status slug env is exported for downstream processes +if [ -n "${STATUS_SLUG:-}" ]; then + echo "export CODER_MCP_APP_STATUS_SLUG=$STATUS_SLUG" >> "$HOME/.bashrc" + export CODER_MCP_APP_STATUS_SLUG="$STATUS_SLUG" +fi + +# Write MCP config to user's home if provided (~/.cursor/mcp.json) +if [ -n "$WORKSPACE_MCP_JSON" ]; then + TARGET_DIR="$HOME/.cursor" + TARGET_FILE="$TARGET_DIR/mcp.json" + mkdir -p "$TARGET_DIR" + echo "$WORKSPACE_MCP_JSON" > "$TARGET_FILE" + echo "Wrote workspace MCP to $TARGET_FILE" | tee -a "$HOME/$MODULE_DIR_NAME/install.log" +fi + +# Write rules files to user's home (~/.cursor/rules) +if [ -n "$WORKSPACE_RULES_JSON" ]; then + RULES_DIR="$HOME/.cursor/rules" + mkdir -p "$RULES_DIR" + echo "$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" | tee -a "$HOME/$MODULE_DIR_NAME/install.log" + done +fi + + +exit 0 diff --git a/registry/coder-labs/modules/cursor-cli/scripts/start.sh b/registry/coder-labs/modules/cursor-cli/scripts/start.sh new file mode 100644 index 000000000..d4d84a44f --- /dev/null +++ b/registry/coder-labs/modules/cursor-cli/scripts/start.sh @@ -0,0 +1,54 @@ +#!/bin/bash + +set -o errexit +set -o pipefail + +command_exists() { + command -v "$1" >/dev/null 2>&1 +} + +AI_PROMPT=${AI_PROMPT:-} +FORCE=${FORCE:-false} +MODEL=${MODEL:-} +OUTPUT_FORMAT=${OUTPUT_FORMAT:-json} +MODULE_DIR_NAME=${MODULE_DIR_NAME:-.cursor-cli-module} +FOLDER=${FOLDER:-$HOME} + +mkdir -p "$HOME/$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." | tee -a "$HOME/$MODULE_DIR_NAME/start.log" + exit 1 +fi + +# Ensure working directory exists +if [ -d "$FOLDER" ]; then + cd "$FOLDER" +else + mkdir -p "$FOLDER" + cd "$FOLDER" +fi + +ARGS=() + +# global flags +if [ -n "$MODEL" ]; then + ARGS+=("-m" "$MODEL") +fi +if [ "$FORCE" = "true" ]; then + ARGS+=("-f") +fi + +if [ -n "$AI_PROMPT" ]; then + ARGS+=("$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[@]}")" | tee -a "$HOME/$MODULE_DIR_NAME/start.log" +("$CURSOR_CMD" "${ARGS[@]}" >> "$HOME/$MODULE_DIR_NAME/start.log" 2>&1) &