Skip to content

Commit a8d9b71

Browse files
committed
feat(cursor-cli): add Cursor Agent CLI module (interactive default, MCP settings, model/force)
- Runs `cursor-agent` directly (no AgentAPI); interactive chat by default - Supports non-interactive prints (-p) with output-format, model (-m), force (-f) - Merges MCP settings into ~/.cursor/settings.json - Installs via npm (uses nvm if needed); terraform tests added
1 parent 673caf2 commit a8d9b71

File tree

7 files changed

+618
-0
lines changed

7 files changed

+618
-0
lines changed
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
---
2+
display_name: Cursor CLI
3+
icon: ../../../../.icons/cursor.svg
4+
description: Run Cursor CLI agent in your workspace (no AgentAPI)
5+
verified: true
6+
tags: [agent, cursor, ai, cli]
7+
---
8+
9+
# Cursor CLI
10+
11+
Run the Cursor Coding Agent in your workspace using the Cursor CLI directly. This module does not use AgentAPI and executes the Cursor agent process itself.
12+
13+
- Defaults to interactive mode, with an option for non-interactive mode
14+
- Supports `--force` runs
15+
- Allows configuring MCP servers (settings merge)
16+
- Lets you choose a model and pass extra CLI arguments
17+
18+
```tf
19+
module "cursor_cli" {
20+
source = "registry.coder.com/coder-labs/cursor-cli/coder"
21+
version = "0.1.0"
22+
agent_id = coder_agent.example.id
23+
24+
# Optional
25+
folder = "/home/coder/project"
26+
install_cursor_cli = true
27+
cursor_cli_version = "latest"
28+
interactive = true
29+
non_interactive_cmd = "run --once"
30+
force = false
31+
model = "gpt-4o"
32+
additional_settings = jsonencode({
33+
mcpServers = {
34+
coder = {
35+
command = "coder"
36+
args = ["exp", "mcp", "server"]
37+
type = "stdio"
38+
name = "Coder"
39+
env = {}
40+
enabled = true
41+
}
42+
}
43+
})
44+
extra_args = ["--verbose"]
45+
}
46+
```
47+
48+
## Notes
49+
50+
- See Cursor CLI docs: `https://docs.cursor.com/en/cli/overview`
51+
- The module writes merged settings to `~/.cursor/settings.json`
52+
- Interactive by default; set `interactive = false` to run non-interactively via `non_interactive_cmd`
53+
54+
## Troubleshooting
55+
56+
- Ensure the CLI is installed (enable `install_cursor_cli = true` or preinstall it in your image)
57+
- Logs are written to `~/.cursor-cli-module/`
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
// Terraform tests for the cursor-cli module
2+
// Validates that we render expected script content given inputs
3+
4+
run "defaults_interactive" {
5+
command = plan
6+
7+
variables {
8+
agent_id = "test-agent"
9+
}
10+
11+
assert {
12+
condition = can(regex("INTERACTIVE='true'", resource.coder_script.cursor_cli.script))
13+
error_message = "Expected INTERACTIVE default to be true"
14+
}
15+
16+
assert {
17+
condition = can(regex("BINARY_NAME='cursor-agent'", resource.coder_script.cursor_cli.script))
18+
error_message = "Expected default binary_name to be cursor-agent"
19+
}
20+
}
21+
22+
run "non_interactive_mode" {
23+
command = plan
24+
25+
variables {
26+
agent_id = "test-agent"
27+
interactive = false
28+
non_interactive_cmd = "run --once"
29+
}
30+
31+
assert {
32+
condition = can(regex("INTERACTIVE='false'", resource.coder_script.cursor_cli.script))
33+
error_message = "Expected INTERACTIVE to be false when interactive=false"
34+
}
35+
36+
assert {
37+
condition = can(regex("NON_INTERACTIVE_CMD='run --once'", resource.coder_script.cursor_cli.script))
38+
error_message = "Expected NON_INTERACTIVE_CMD to be propagated"
39+
}
40+
}
41+
42+
run "model_and_force" {
43+
command = plan
44+
45+
variables {
46+
agent_id = "test-agent"
47+
model = "test-model"
48+
force = true
49+
}
50+
51+
assert {
52+
condition = can(regex("MODEL='test-model'", resource.coder_script.cursor_cli.script))
53+
error_message = "Expected MODEL to be propagated"
54+
}
55+
56+
assert {
57+
condition = can(regex("FORCE='true'", resource.coder_script.cursor_cli.script))
58+
error_message = "Expected FORCE true to be propagated"
59+
}
60+
}
61+
62+
run "additional_settings_propagated" {
63+
command = plan
64+
65+
variables {
66+
agent_id = "test-agent"
67+
additional_settings = jsonencode({
68+
mcpServers = {
69+
coder = {
70+
command = "coder"
71+
args = ["exp", "mcp", "server"]
72+
type = "stdio"
73+
}
74+
}
75+
})
76+
}
77+
78+
// Ensure the encoded settings are passed into the install invocation
79+
assert {
80+
condition = can(regex(base64encode(jsonencode({
81+
mcpServers = {
82+
coder = {
83+
command = "coder"
84+
args = ["exp", "mcp", "server"]
85+
type = "stdio"
86+
}
87+
}
88+
})), resource.coder_script.cursor_cli.script))
89+
error_message = "Expected ADDITIONAL_SETTINGS (base64) to be in the install step"
90+
}
91+
}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { test, afterEach, describe, setDefaultTimeout, beforeAll, expect } from "bun:test";
2+
import { execContainer, readFileContainer, runTerraformInit, runTerraformApply, writeFileContainer, runContainer, removeContainer, findResourceInstance } from "~test";
3+
import dedent from "dedent";
4+
5+
let cleanupFunctions: (() => Promise<void>)[] = [];
6+
const registerCleanup = (cleanup: () => Promise<void>) => {
7+
cleanupFunctions.push(cleanup);
8+
};
9+
10+
afterEach(async () => {
11+
const cleanupFnsCopy = cleanupFunctions.slice().reverse();
12+
cleanupFunctions = [];
13+
for (const cleanup of cleanupFnsCopy) {
14+
try {
15+
await cleanup();
16+
} catch (error) {
17+
console.error("Error during cleanup:", error);
18+
}
19+
}
20+
});
21+
22+
const writeExecutable = async (containerId: string, filePath: string, content: string) => {
23+
await writeFileContainer(containerId, filePath, content, { user: "root" });
24+
await execContainer(containerId, ["bash", "-c", `chmod 755 ${filePath}`], ["--user", "root"]);
25+
};
26+
27+
const loadTestFile = async (...relativePath: string[]) => {
28+
return await Bun.file(new URL(`./testdata/${relativePath.join("/")}`, import.meta.url)).text();
29+
};
30+
31+
const setup = async (vars?: Record<string, string>): Promise<{ id: string }> => {
32+
const state = await runTerraformApply(import.meta.dir, {
33+
agent_id: "foo",
34+
install_cursor_cli: "false",
35+
...vars,
36+
});
37+
const coderScript = findResourceInstance(state, "coder_script");
38+
const id = await runContainer("codercom/enterprise-node:latest");
39+
registerCleanup(async () => removeContainer(id));
40+
await writeExecutable(id, "/home/coder/script.sh", coderScript.script);
41+
await writeExecutable(id, "/usr/bin/cursor", await loadTestFile("cursor-mock.sh"));
42+
return { id };
43+
};
44+
45+
setDefaultTimeout(60 * 1000);
46+
47+
describe("cursor-cli", async () => {
48+
beforeAll(async () => {
49+
await runTerraformInit(import.meta.dir);
50+
});
51+
52+
test("happy-path-interactive", async () => {
53+
const { id } = await setup();
54+
const resp = await execContainer(id, ["bash", "-c", "cd /home/coder && ./script.sh"]);
55+
if (resp.exitCode !== 0) {
56+
console.log(resp.stdout);
57+
console.log(resp.stderr);
58+
}
59+
expect(resp.exitCode).toBe(0);
60+
const startLog = await readFileContainer(id, "/home/coder/.cursor-cli-module/start.log");
61+
expect(startLog).toContain("agent");
62+
expect(startLog).toContain("--interactive");
63+
});
64+
65+
test("non-interactive-with-cmd", async () => {
66+
const { id } = await setup({ interactive: "false", non_interactive_cmd: "run --once" });
67+
const resp = await execContainer(id, ["bash", "-c", "cd /home/coder && ./script.sh"]);
68+
expect(resp.exitCode).toBe(0);
69+
const startLog = await readFileContainer(id, "/home/coder/.cursor-cli-module/start.log");
70+
expect(startLog).toContain("run");
71+
expect(startLog).toContain("--once");
72+
expect(startLog).not.toContain("--interactive");
73+
});
74+
75+
test("model-and-force-and-extra-args", async () => {
76+
const { id } = await setup({ model: "test-model", force: "true" });
77+
const resp = await execContainer(id, ["bash", "-c", "cd /home/coder && ./script.sh"], ["--env", "TF_VAR_extra_args=--foo\nbar"]);
78+
expect(resp.exitCode).toBe(0);
79+
const startLog = await readFileContainer(id, "/home/coder/.cursor-cli-module/start.log");
80+
expect(startLog).toContain("--model");
81+
expect(startLog).toContain("test-model");
82+
expect(startLog).toContain("--force");
83+
});
84+
85+
test("additional-settings-merge", async () => {
86+
const settings = dedent`
87+
{"mcpServers": {"coder": {"command": "coder", "args": ["exp","mcp","server"], "type": "stdio"}}}
88+
`;
89+
const { id } = await setup({ additional_settings: settings });
90+
const resp = await execContainer(id, ["bash", "-c", "cd /home/coder && ./script.sh"]);
91+
expect(resp.exitCode).toBe(0);
92+
const cfg = await readFileContainer(id, "/home/coder/.cursor/settings.json");
93+
expect(cfg).toContain("mcpServers");
94+
expect(cfg).toContain("coder");
95+
});
96+
});

0 commit comments

Comments
 (0)