Conversation
WalkthroughThis PR adds a standalone Next.js 16 demo app that runs Cursor's CLI agent as a Trigger.dev task and streams its NDJSON output to a browser terminal. Changes include a Trigger.dev build extension to bundle the cursor-agent, a runtime spawn helper that exposes a ReadableStream of CursorEvent, Trigger task and stream definitions, API route to start runs, React UI components (control bar, terminal, event renderers, agent runner), TypeScript event schemas, configs (Next, Tailwind, PostCSS, Trigger), and README and env example files. Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes 🚥 Pre-merge checks | ✅ 1 | ❌ 2❌ Failed checks (1 warning, 1 inconclusive)
✅ Passed checks (1 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
@CodeRabbit review |
✅ Actions performedReview triggered.
|
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Fix all issues with AI agents
In `@cursor-cli-demo/app/api/trigger/route.ts`:
- Around line 4-11: The POST handler currently calls req.json() directly which
will throw on malformed JSON; wrap the parsing in a try/catch inside the POST
function (around the req.json() call) and on parse failure return
Response.json({ error: "invalid JSON" }, { status: 400 }); then proceed to set
prompt and model as before; reference the POST function and the prompt/model
variables so you update the block that sets const body = await req.json() and
the subsequent prompt/model validation to handle parse errors cleanly.
In `@cursor-cli-demo/components/terminal.tsx`:
- Around line 48-69: Hooks useRef and useEffect are currently called after an
early return when error is truthy, breaking the Rules of Hooks; move the hook
calls so they're invoked unconditionally before the early return: create the
notified ref via useRef(false) and the useEffect (which watches isComplete,
isFailed, and onComplete and sets notified.current and calls onComplete) above
the error check/return, ensuring useRef, useEffect, and any derived status
variables (status, isRunning, isQueued, isFailed, isComplete) are declared in
the same order on every render so hooks are always called.
In `@cursor-cli-demo/README.md`:
- Around line 15-24: Update the Markdown fenced code blocks in README.md so they
include a language identifier (use "text") instead of plain ```; specifically
change the diagram block starting with "[Browser] <-- Realtime Streams v2 -->
..." and the file tree block starting with "├── app/" (and the similar block
around lines 73-88) from ``` to ```text so markdownlint stops flagging missing
language identifiers and the blocks render with the intended plain-text
formatting.
🧹 Nitpick comments (4)
cursor-cli-demo/trigger/cursor-agent.ts (1)
113-117: Dead code:stderrwill always beundefinedin the return value.The condition
exitCode !== 0on line 116 is unreachable because line 108-110 already throws whenexitCode !== 0. At this point,exitCodeis guaranteed to be0, sostderrwill always beundefined.🧹 Simplify the return statement
return { exitCode, prompt: payload.prompt, - stderr: exitCode !== 0 ? stderr : undefined, };cursor-cli-demo/extensions/cursor-cli.ts (1)
65-66: Consider validating bundled node binary exists before copying.The code validates that
entryPointexists but doesn't check ifbundledNodeexists before callingcopyFileSync. If the bundled node binary is missing, the error will be less informative than the custom error thrown for a missing entry point.🛡️ Add existence check for bundledNode
+ if (!existsSync(bundledNode)) { + throw new Error( + `Bundled node binary not found at ${bundledNode}. Contents: ${readdirSync(CURSOR_AGENT_DIR).join(", ")}`, + ); + } + copyFileSync(bundledNode, tmpNode); chmodSync(tmpNode, 0o755);cursor-cli-demo/trigger.config.ts (1)
5-5: Non-null assertion on environment variable may cause confusing errors.If
TRIGGER_PROJECT_REFis not set,process.env.TRIGGER_PROJECT_REF!evaluates toundefined, which may cause confusing errors downstream rather than a clear "missing configuration" error.🛡️ Add explicit validation
+const projectRef = process.env.TRIGGER_PROJECT_REF; +if (!projectRef) { + throw new Error("TRIGGER_PROJECT_REF environment variable is required"); +} + export default defineConfig({ - project: process.env.TRIGGER_PROJECT_REF!, + project: projectRef, runtime: "node",cursor-cli-demo/components/control-bar.tsx (1)
60-133: Add accessible labels for the form controls (optional).
Input/select rely on placeholder text only; screen readers benefit from explicit labels.♿ Suggested a11y tweak
<input type="text" value={prompt} onChange={(e) => setPrompt(e.target.value)} disabled={isDisabled} placeholder="Describe what to create..." + aria-label="Prompt" className="flex-1 bg-[var(--terminal-bg)] border border-[var(--terminal-border)] rounded-lg px-4 py-2.5 text-sm text-white placeholder:text-white/30 focus:outline-none focus:border-white/30 disabled:opacity-50 font-[family-name:var(--font-geist-mono)]" /> <select value={model} onChange={(e) => setModel(e.target.value)} disabled={isDisabled} + aria-label="Model" className="bg-[var(--terminal-bg)] border border-[var(--terminal-border)] rounded-lg px-3 py-2.5 text-sm text-white/80 focus:outline-none focus:border-white/30 disabled:opacity-50" >
Static header markup now renders server-side instead of shipping as client JS.
…dexedAccess - Use type guard instead of `as` casts in parseCursorEvent - Narrow run output via `in` checks instead of casting to Record - Enable noUncheckedIndexedAccess in tsconfig for safer indexing
There was a problem hiding this comment.
Actionable comments posted: 5
🤖 Fix all issues with AI agents
In `@cursor-cli-demo/app/api/trigger/route.ts`:
- Around line 4-25: Add a minimal shared-secret auth gate to the POST handler:
read an authorization header (e.g., req.headers.get("authorization") or a custom
header) and compare it to a secret stored in environment (e.g.,
process.env.DEMO_SHARED_SECRET); if missing or mismatched, return a 401/403 JSON
response before proceeding to parse body or call tasks.trigger; keep the rest of
POST (parsing body, validating prompt, calling tasks.trigger and returning
handle.id and handle.publicAccessToken) unchanged and ensure the comparison
happens prior to invoking cursorAgentTask via tasks.trigger.
In `@cursor-cli-demo/extensions/cursor-cli.ts`:
- Around line 17-25: Replace the unpinned "curl -fsSL https://cursor.com/install
| bash" layer in the context.addLayer block with a deterministic
fetch-and-verify flow: download a specific Cursor CLI release artifact
(referencing the release version), save it to a file, verify its
checksum/signature against a pinned value, then install or extract into
CURSOR_AGENT_DIR; update the RUN line that currently invokes curl | bash to
instead perform the download, checksum/signature verification, and controlled
install (or vendor the binary into the repo and copy it into CURSOR_AGENT_DIR)
to eliminate the supply-chain risk.
In `@cursor-cli-demo/lib/cursor-events.ts`:
- Around line 69-78: isCursorEvent currently only verifies that data.type is a
known string, which allows malformed payloads with missing per-type fields to
pass; update isCursorEvent to perform minimal shape validation per event type
(e.g., for type "move" require numeric x and y, for "enter"/"leave" require a
string userId, for "message" require a string text, etc.) so it returns true
only when required fields exist and have correct primitive types, and adjust
parseCursorEvent to continue returning data when isCursorEvent passes; reference
isCursorEvent and parseCursorEvent and the knownTypes set when adding these
per-type checks.
- Around line 81-90: The getToolName and getToolArgs helpers are unsafely
casting Object.keys to ToolCallKind and returning arbitrary keys; update
getToolName to iterate Object.keys(event.tool_call) and return the first key
that is present in the known ToolCallKind union (e.g., compare against a Set or
array of allowed ToolCallKind values) otherwise return "unknown", and update
getToolArgs to check the name !== "unknown" and defensively validate that
event.tool_call[name]?.args is an object (Record<string, unknown>) before
returning it, falling back to {} if the shape is invalid; refer to getToolName,
getToolArgs, ToolCallEvent, ToolCallKind and event.tool_call when locating the
changes.
In `@cursor-cli-demo/trigger/cursor-agent.ts`:
- Around line 20-31: Add a precondition in run (CursorAgentPayload) to fail fast
if the CURSOR_API_KEY env var is missing: check process.env.CURSOR_API_KEY at
the start of run, log an error via logger (include workspace and model context
from existing variables), and throw or return an error/exit before calling
spawnCursorAgent so the process does not proceed to spawnCursorAgent(...)
without a key; reference the run function, process.env.CURSOR_API_KEY, logger,
and spawnCursorAgent when implementing this guard.
🧹 Nitpick comments (1)
cursor-cli-demo/components/agent-runner.tsx (1)
9-27: Show the terminal on failed runs to preserve logs.Hiding the terminal on failure removes run output that could help debug. Consider including the failed state in the terminal visibility check.
🔧 Proposed tweak
- const showTerminal = runState.status === "running" || runState.status === "complete"; + const showTerminal = + runState.status === "running" || + runState.status === "complete" || + runState.status === "failed";
| export async function POST(req: Request) { | ||
| let body: unknown; | ||
| try { | ||
| body = await req.json(); | ||
| } catch { | ||
| return Response.json({ error: "invalid JSON" }, { status: 400 }); | ||
| } | ||
|
|
||
| const parsed = body as Record<string, unknown>; | ||
| const prompt = typeof parsed.prompt === "string" ? parsed.prompt.trim() : ""; | ||
| const model = typeof parsed.model === "string" ? parsed.model : undefined; | ||
|
|
||
| if (!prompt) { | ||
| return Response.json({ error: "prompt is required" }, { status: 400 }); | ||
| } | ||
|
|
||
| const handle = await tasks.trigger<typeof cursorAgentTask>("cursor-agent", { prompt, model }); | ||
|
|
||
| return Response.json({ | ||
| runId: handle.id, | ||
| publicAccessToken: handle.publicAccessToken, | ||
| }); |
There was a problem hiding this comment.
Add an auth gate to prevent unauthenticated task triggers.
The endpoint currently allows anyone to trigger runs, which is a security/cost risk for any deployed demo.
🛡️ Minimal shared-secret guard
export async function POST(req: Request) {
+ const expectedToken = process.env.CURSOR_DEMO_API_TOKEN;
+ if (!expectedToken) {
+ return Response.json({ error: "server misconfigured" }, { status: 500 });
+ }
+
+ const authHeader = req.headers.get("authorization");
+ if (authHeader !== `Bearer ${expectedToken}`) {
+ return Response.json({ error: "unauthorized" }, { status: 401 });
+ }
+
let body: unknown;
try {
body = await req.json();📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| export async function POST(req: Request) { | |
| let body: unknown; | |
| try { | |
| body = await req.json(); | |
| } catch { | |
| return Response.json({ error: "invalid JSON" }, { status: 400 }); | |
| } | |
| const parsed = body as Record<string, unknown>; | |
| const prompt = typeof parsed.prompt === "string" ? parsed.prompt.trim() : ""; | |
| const model = typeof parsed.model === "string" ? parsed.model : undefined; | |
| if (!prompt) { | |
| return Response.json({ error: "prompt is required" }, { status: 400 }); | |
| } | |
| const handle = await tasks.trigger<typeof cursorAgentTask>("cursor-agent", { prompt, model }); | |
| return Response.json({ | |
| runId: handle.id, | |
| publicAccessToken: handle.publicAccessToken, | |
| }); | |
| export async function POST(req: Request) { | |
| const expectedToken = process.env.CURSOR_DEMO_API_TOKEN; | |
| if (!expectedToken) { | |
| return Response.json({ error: "server misconfigured" }, { status: 500 }); | |
| } | |
| const authHeader = req.headers.get("authorization"); | |
| if (authHeader !== `Bearer ${expectedToken}`) { | |
| return Response.json({ error: "unauthorized" }, { status: 401 }); | |
| } | |
| let body: unknown; | |
| try { | |
| body = await req.json(); | |
| } catch { | |
| return Response.json({ error: "invalid JSON" }, { status: 400 }); | |
| } | |
| const parsed = body as Record<string, unknown>; | |
| const prompt = typeof parsed.prompt === "string" ? parsed.prompt.trim() : ""; | |
| const model = typeof parsed.model === "string" ? parsed.model : undefined; | |
| if (!prompt) { | |
| return Response.json({ error: "prompt is required" }, { status: 400 }); | |
| } | |
| const handle = await tasks.trigger<typeof cursorAgentTask>("cursor-agent", { prompt, model }); | |
| return Response.json({ | |
| runId: handle.id, | |
| publicAccessToken: handle.publicAccessToken, | |
| }); | |
| } |
🤖 Prompt for AI Agents
In `@cursor-cli-demo/app/api/trigger/route.ts` around lines 4 - 25, Add a minimal
shared-secret auth gate to the POST handler: read an authorization header (e.g.,
req.headers.get("authorization") or a custom header) and compare it to a secret
stored in environment (e.g., process.env.DEMO_SHARED_SECRET); if missing or
mismatched, return a 401/403 JSON response before proceeding to parse body or
call tasks.trigger; keep the rest of POST (parsing body, validating prompt,
calling tasks.trigger and returning handle.id and handle.publicAccessToken)
unchanged and ensure the comparison happens prior to invoking cursorAgentTask
via tasks.trigger.
| context.addLayer({ | ||
| id: "cursor-cli", | ||
| image: { | ||
| instructions: [ | ||
| "RUN apt-get update && apt-get install -y curl ca-certificates && rm -rf /var/lib/apt/lists/*", | ||
| 'ENV PATH="/root/.local/bin:$PATH"', | ||
| "RUN curl -fsSL https://cursor.com/install | bash", | ||
| // Copy the resolved index.js + deps to a fixed path so we can invoke with process.execPath at runtime | ||
| `RUN cp -r $(dirname $(readlink -f /root/.local/bin/cursor-agent)) ${CURSOR_AGENT_DIR}`, |
There was a problem hiding this comment.
🧩 Analysis chain
🌐 Web query:
Cursor CLI official installation method Linux versioning checksums
💡 Result:
Official install method (Linux)
- Run the installer script:
curl -fsS https://cursor.com/install | bash[1][2]
Versioning / where the version is defined
- The installer currently hard-codes a build version in the download path and install directory (example shown in the script:
2026.01.28-fd13201). It installs into:
~/.local/share/cursor-agent/versions/<version>/and then symlinks~/.local/bin/agentand~/.local/bin/cursor-agentto that version. [3]
How to check the installed version
- Official verification command:
cursor-agent --version[1]
Checksums / integrity verification
- The official installer script downloads the tarball over HTTPS and extracts it, but (as written) does not perform any checksum/signature verification (no SHA256/SHA512/GPG step in the script). [3]
Sources: [1] Cursor docs (CLI installation) • [2] Cursor CLI page • [3] https://cursor.com/install installer script content
🌐 Web query:
cursor-agent Linux installation documentation github
💡 Result:
- Official Cursor Agent (cursor-agent) Linux install docs: Cursor CLI “Installation” page (macOS/Linux/Windows WSL) shows the one-liner installer and verification step (
cursor-agent --version). [1] - Official install command (same as docs, on cursor.com):
curl https://cursor.com/install -fsS | bash(Cursor CLI page). [2] - If you specifically want a GitHub-based Linux installer script (community):
watzon/cursor-linux-installerprovides an install script that sets up acursorcommand and handles updates. [3]
Sources: [1] [2] [3]
Avoid unpinned curl | bash installs in the build layer.
The official Cursor CLI installer (curl -fsS https://cursor.com/install | bash) downloads and executes without checksum or signature verification, creating a supply-chain risk. Prefer downloading a specific version and validating its checksum, or vendor the binary in the repo.
🤖 Prompt for AI Agents
In `@cursor-cli-demo/extensions/cursor-cli.ts` around lines 17 - 25, Replace the
unpinned "curl -fsSL https://cursor.com/install | bash" layer in the
context.addLayer block with a deterministic fetch-and-verify flow: download a
specific Cursor CLI release artifact (referencing the release version), save it
to a file, verify its checksum/signature against a pinned value, then install or
extract into CURSOR_AGENT_DIR; update the RUN line that currently invokes curl |
bash to instead perform the download, checksum/signature verification, and
controlled install (or vendor the binary into the repo and copy it into
CURSOR_AGENT_DIR) to eliminate the supply-chain risk.
| function isCursorEvent(data: unknown): data is CursorEvent { | ||
| if (typeof data !== "object" || data === null) return false; | ||
| if (!("type" in data)) return false; | ||
| return typeof data.type === "string" && knownTypes.has(data.type); | ||
| } | ||
|
|
||
| /** Parse raw JSON into a CursorEvent, returns null for unknown types */ | ||
| export function parseCursorEvent(data: unknown): CursorEvent | null { | ||
| return isCursorEvent(data) ? data : null; | ||
| } |
There was a problem hiding this comment.
Tighten runtime validation before accepting external input.
Line 69-73 only checks type, so malformed payloads with a valid type will pass and can crash downstream renderers when required fields are accessed. Add minimal per-type shape checks before returning true.
🔧 Suggested hardening for isCursorEvent
function isCursorEvent(data: unknown): data is CursorEvent {
if (typeof data !== "object" || data === null) return false;
if (!("type" in data)) return false;
- return typeof data.type === "string" && knownTypes.has(data.type);
+ if (typeof (data as any).type !== "string" || !knownTypes.has((data as any).type)) return false;
+ switch ((data as any).type) {
+ case "system":
+ return (data as any).subtype === "init" && typeof (data as any).session_id === "string";
+ case "user":
+ case "assistant":
+ return (
+ typeof (data as any).session_id === "string" &&
+ (data as any).message?.role === (data as any).type &&
+ Array.isArray((data as any).message?.content)
+ );
+ case "tool_call":
+ return (
+ typeof (data as any).session_id === "string" &&
+ typeof (data as any).call_id === "string" &&
+ typeof (data as any).tool_call === "object" &&
+ (data as any).tool_call !== null
+ );
+ case "result":
+ return (
+ typeof (data as any).session_id === "string" &&
+ typeof (data as any).result === "string" &&
+ typeof (data as any).duration_ms === "number"
+ );
+ default:
+ return false;
+ }
}🤖 Prompt for AI Agents
In `@cursor-cli-demo/lib/cursor-events.ts` around lines 69 - 78, isCursorEvent
currently only verifies that data.type is a known string, which allows malformed
payloads with missing per-type fields to pass; update isCursorEvent to perform
minimal shape validation per event type (e.g., for type "move" require numeric x
and y, for "enter"/"leave" require a string userId, for "message" require a
string text, etc.) so it returns true only when required fields exist and have
correct primitive types, and adjust parseCursorEvent to continue returning data
when isCursorEvent passes; reference isCursorEvent and parseCursorEvent and the
knownTypes set when adding these per-type checks.
| export function getToolName(event: ToolCallEvent): ToolCallKind | "unknown" { | ||
| const keys = Object.keys(event.tool_call) as ToolCallKind[]; | ||
| return keys[0] ?? "unknown"; | ||
| } | ||
|
|
||
| /** Extract the tool args from a tool_call event */ | ||
| export function getToolArgs(event: ToolCallEvent): Record<string, unknown> { | ||
| const name = getToolName(event); | ||
| if (name === "unknown") return {}; | ||
| return event.tool_call[name]?.args ?? {}; |
There was a problem hiding this comment.
Avoid unsafe key casting in tool-call helpers.
Line 82 casts Object.keys() to ToolCallKind[], so an unexpected key can be returned as a valid tool kind. Filter against the allowed set and defensively validate args.
✅ Safer tool key selection and args shape guard
export function getToolName(event: ToolCallEvent): ToolCallKind | "unknown" {
- const keys = Object.keys(event.tool_call) as ToolCallKind[];
- return keys[0] ?? "unknown";
+ const toolCallKinds: ToolCallKind[] = [
+ "shellToolCall",
+ "readToolCall",
+ "editToolCall",
+ "writeToolCall",
+ "deleteToolCall",
+ "grepToolCall",
+ "lsToolCall",
+ "globToolCall",
+ "todoToolCall",
+ ];
+ const key = Object.keys(event.tool_call).find(
+ (candidate): candidate is ToolCallKind =>
+ toolCallKinds.includes(candidate as ToolCallKind)
+ );
+ return key ?? "unknown";
}
export function getToolArgs(event: ToolCallEvent): Record<string, unknown> {
const name = getToolName(event);
if (name === "unknown") return {};
- return event.tool_call[name]?.args ?? {};
+ const args = event.tool_call[name]?.args;
+ return args && typeof args === "object" && !Array.isArray(args) ? args : {};
}🤖 Prompt for AI Agents
In `@cursor-cli-demo/lib/cursor-events.ts` around lines 81 - 90, The getToolName
and getToolArgs helpers are unsafely casting Object.keys to ToolCallKind and
returning arbitrary keys; update getToolName to iterate
Object.keys(event.tool_call) and return the first key that is present in the
known ToolCallKind union (e.g., compare against a Set or array of allowed
ToolCallKind values) otherwise return "unknown", and update getToolArgs to check
the name !== "unknown" and defensively validate that event.tool_call[name]?.args
is an object (Record<string, unknown>) before returning it, falling back to {}
if the shape is invalid; refer to getToolName, getToolArgs, ToolCallEvent,
ToolCallKind and event.tool_call when locating the changes.
| run: async (payload: CursorAgentPayload) => { | ||
| const workspace = `/tmp/workspace-${Date.now()}`; | ||
| mkdirSync(workspace, { recursive: true }); | ||
|
|
||
| const model = payload.model ?? "sonnet-4.5"; | ||
|
|
||
| logger.info("Spawning cursor-agent", { workspace, model }); | ||
|
|
||
| const agent = spawnCursorAgent( | ||
| ["-p", "--force", "--output-format", "stream-json", "--model", model, payload.prompt], | ||
| { cwd: workspace, env: { CURSOR_API_KEY: process.env.CURSOR_API_KEY } }, | ||
| ); |
There was a problem hiding this comment.
Fail fast when CURSOR_API_KEY is missing.
Without an early check, runs will fail later with a less clear error.
🧯 Proposed guard
run: async (payload: CursorAgentPayload) => {
const workspace = `/tmp/workspace-${Date.now()}`;
mkdirSync(workspace, { recursive: true });
const model = payload.model ?? "sonnet-4.5";
+ const cursorApiKey = process.env.CURSOR_API_KEY;
+ if (!cursorApiKey) {
+ throw new Error("CURSOR_API_KEY is required");
+ }
logger.info("Spawning cursor-agent", { workspace, model });
const agent = spawnCursorAgent(
["-p", "--force", "--output-format", "stream-json", "--model", model, payload.prompt],
- { cwd: workspace, env: { CURSOR_API_KEY: process.env.CURSOR_API_KEY } },
+ { cwd: workspace, env: { CURSOR_API_KEY: cursorApiKey } },
);🤖 Prompt for AI Agents
In `@cursor-cli-demo/trigger/cursor-agent.ts` around lines 20 - 31, Add a
precondition in run (CursorAgentPayload) to fail fast if the CURSOR_API_KEY env
var is missing: check process.env.CURSOR_API_KEY at the start of run, log an
error via logger (include workspace and model context from existing variables),
and throw or return an error/exit before calling spawnCursorAgent so the process
does not proceed to spawnCursorAgent(...) without a key; reference the run
function, process.env.CURSOR_API_KEY, logger, and spawnCursorAgent when
implementing this guard.
Summary by CodeRabbit
New Features
Documentation
Chores