Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ For information on how to run each project, see the README in each directory.
| [Article summary workflow](/article-summary-workflow) | Create audio summaries of newspaper articles using a human-in-the-loop workflow with [ReactFlow](https://reactflow.dev/), [Trigger.dev Realtime](https://trigger.dev/docs/realtime/overview) and [waitpoints](https://trigger.dev/blog/v4-beta-launch#waitpoints) |
| [Batch LLM evaluator](/batch-llm-evaluator) | Batch processing tool for evaluating LLM responses from a single prompt using Vercel's [AI SDK](https://sdk.vercel.ai/docs/introduction) and [Trigger.dev Realtime](https://trigger.dev/docs/realtime/overview) |
| [Building effective agents](/building-effective-agents) | 5 different patterns for building effective AI agents with Trigger.dev; [Prompt chaining](/building-effective-agents/src/trigger/trigger/translate-copy.ts), [Routing](/building-effective-agents/src/trigger/trigger/routing-questions.ts), [Parallelization](/building-effective-agents/src/trigger/trigger/parallel-llm-calls.ts), [Orchestrator-workers](/building-effective-agents/src/trigger/trigger/orchestrator-workers.ts) |
| [Cursor CLI demo](/cursor-cli-demo) | Run [Cursor's CLI](https://www.cursor.com/docs/cli/overview) in a [Trigger.dev](https://trigger.dev) task, and stream the output to the frontend. |
| [Claude thinking chatbot](/claude-thinking-chatbot) | A chatbot that uses Claude's thinking capabilities to generate responses |
| [Claude agent SDK](/claude-agent-sdk-trigger) | A simple example of how to use the [Claude Agent SDK](https://docs.claude.com/en/docs/agent-sdk/overview) with Trigger.dev |
| [Claude changelog generator](/changelog-generator) | Generate changelogs from a GitHub repository using the [Claude Agent SDK](https://docs.claude.com/en/docs/agent-sdk/overview) with Trigger.dev |
Expand Down
53 changes: 53 additions & 0 deletions cursor-cli-demo/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# Dependencies
node_modules/
.pnp
.pnp.js

# Build
.next/
out/
build/
dist/

# Environment
.env
.env.local
.env.development.local
.env.test.local
.env.production.local

# Logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*

# Vercel
.vercel

# TypeScript
*.tsbuildinfo
next-env.d.ts

# IDE
.idea/
.vscode/
*.swp
*.swo
.DS_Store

# Testing
coverage/

# Supabase
supabase/.branches
supabase/.temp

# Trigger.dev
.trigger/

# Project specific
ship/
.claude
progress.txt
prd.json
SPEC.md
59 changes: 59 additions & 0 deletions cursor-cli-demo/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# Cursor Agent using the Cursor CLI and Trigger.dev

Run Cursor's headless CLI agent inside a Trigger.dev task, parsing NDJSON stdout into a Realtime Stream that renders live in a browser terminal. Built with Next.js and Trigger.dev.

## Tech stack

- **[Next.js](https://nextjs.org)** – App Router frontend with server actions to trigger runs
- **[Cursor CLI](https://cursor.com)** – Headless AI coding agent spawned as a child process
- **[Trigger.dev](https://trigger.dev)** – Background task orchestration with real-time streaming to the frontend, observability, and deployment
- **[Tailwind CSS](https://tailwindcss.com)** – Styling with Geist Mono for the terminal UI

## Running the project locally

1. **Install dependencies**

```bash
pnpm install
```

2. **Configure environment variables**

```bash
cp env.local.example .env.local
```

- `TRIGGER_SECRET_KEY` – From [Trigger.dev dashboard](https://cloud.trigger.dev/) (starts with `tr_dev_` or `tr_`)
- `TRIGGER_PROJECT_REF` – Your project ref (starts with `proj_`)
- `CURSOR_API_KEY` – Your Cursor API key for headless CLI access

3. **Start development servers**

```bash
# Terminal 1: Next.js
pnpm dev

# Terminal 2: Trigger.dev
npx trigger.dev@latest dev
```

4. Open [http://localhost:3000](http://localhost:3000) in your browser to see the demo

## Features

- **Build extensions** – Installs `cursor-agent` into the task container image via `addLayer`, so any system binary can ship with your task
- **Realtime Streams v2** – NDJSON from a child process stdout is parsed and piped directly to the browser using `streams.define()` and `.pipe()`
- **Live terminal rendering** – Each cursor event (system, assistant, tool_call, result) renders as a distinct row with auto-scroll
- **Long-running tasks** – cursor-agent runs for minutes; Trigger.dev handles lifecycle, timeouts, and retries
- **Machine selection** – `medium-2x` preset for resource-intensive CLI tools
- **Model picker** – Switch between Claude models from the UI before triggering a run
- **Container binary workaround** – Demonstrates the `/tmp` copy + `chmod` pattern needed when the runtime strips execute permissions

## Relevant files

- [extensions/cursor-cli.ts](extensions/cursor-cli.ts) – Build extension + spawn helper that returns a typed NDJSON stream and `waitUntilExit()`
- [trigger/cursor-agent.ts](trigger/cursor-agent.ts) – The task: spawns the CLI, pipes the stream, waits for exit
- [trigger/cursor-stream.ts](trigger/cursor-stream.ts) – Realtime Streams v2 stream definition
- [components/terminal.tsx](components/terminal.tsx) – Realtime terminal UI with `useRealtimeRunWithStreams`
- [lib/cursor-events.ts](lib/cursor-events.ts) – TypeScript types and parsers for cursor NDJSON events
- [trigger.config.ts](trigger.config.ts) – Trigger.dev config with the cursor CLI build extension
26 changes: 26 additions & 0 deletions cursor-cli-demo/app/api/trigger/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { tasks } from "@trigger.dev/sdk";
import type { cursorAgentTask } from "@/trigger/cursor-agent";

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,
});
Comment on lines +4 to +25
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Suggested change
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.

}
17 changes: 17 additions & 0 deletions cursor-cli-demo/app/globals.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
@import "tailwindcss";

:root {
--background: #0a0a0a;
--foreground: #ededed;
--terminal-bg: #1a1a1a;
--terminal-border: #2a2a2a;
--muted: #666;
--green: #4ade80;
--red: #f87171;
}

body {
background: var(--background);
color: var(--foreground);
font-family: var(--font-geist-sans), system-ui, sans-serif;
}
32 changes: 32 additions & 0 deletions cursor-cli-demo/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";

const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});

const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});

export const metadata: Metadata = {
title: "Cursor Agent Runner",
description: "Run Cursor's CLI agent on Trigger.dev, streamed live",
};

export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
{children}
</body>
</html>
);
}
18 changes: 18 additions & 0 deletions cursor-cli-demo/app/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { AgentRunner } from "@/components/agent-runner";

export default function Home() {
return (
<main className="min-h-screen p-6 md:p-10 max-w-4xl mx-auto flex flex-col gap-6">
<div>
<h1 className="text-xl font-bold font-[family-name:var(--font-geist-mono)]">
Cursor Agent Runner
</h1>
<p className="text-xs text-white/30 mt-1">
Powered by Trigger.dev — watch an AI agent generate code in real time
</p>
</div>

<AgentRunner />
</main>
);
}
30 changes: 30 additions & 0 deletions cursor-cli-demo/components/agent-runner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
"use client";

import { ControlBar, useRunState } from "@/components/control-bar";
import { Terminal } from "@/components/terminal";

export function AgentRunner() {
const { runState, startRun, reset, markComplete } = useRunState();

const showTerminal = runState.status === "running" || runState.status === "complete";

return (
<>
<ControlBar runState={runState} onRun={startRun} onReset={reset} />

{showTerminal && (
<Terminal
runId={runState.runId}
publicAccessToken={runState.publicAccessToken}
onComplete={markComplete}
/>
)}

{runState.status === "failed" && (
<div className="bg-red-500/10 border border-red-500/20 rounded-lg p-4 text-sm text-red-400 font-mono">
{runState.error}
</div>
)}
</>
);
}
147 changes: 147 additions & 0 deletions cursor-cli-demo/components/control-bar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
"use client";

import { useState } from "react";

const models = [
{ value: "sonnet-4.5", label: "Sonnet 4.5" },
{ value: "opus-4.5", label: "Opus 4.5" },
{ value: "gemini-3-pro", label: "Gemini 3 Pro" },
];

type RunState =
| { status: "idle" }
| { status: "starting" }
| { status: "running"; runId: string; publicAccessToken: string }
| { status: "complete"; runId: string; publicAccessToken: string }
| { status: "failed"; error: string };

export function useRunState() {
const [runState, setRunState] = useState<RunState>({ status: "idle" });

async function startRun(prompt: string, model: string) {
setRunState({ status: "starting" });

try {
const res = await fetch("/api/trigger", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ prompt, model }),
});

if (!res.ok) {
const data = await res.json();
setRunState({ status: "failed", error: data.error ?? "Request failed" });
return;
}

const { runId, publicAccessToken } = await res.json();
setRunState({ status: "running", runId, publicAccessToken });
} catch (err) {
setRunState({ status: "failed", error: err instanceof Error ? err.message : "Unknown error" });
}
}

function reset() {
setRunState({ status: "idle" });
}

function markComplete() {
setRunState((prev) => {
if (prev.status === "running") {
return { status: "complete", runId: prev.runId, publicAccessToken: prev.publicAccessToken };
}
return prev;
});
}

return { runState, startRun, reset, markComplete };
}

export function ControlBar({
runState,
onRun,
onReset,
}: {
runState: RunState;
onRun: (prompt: string, model: string) => void;
onReset: () => void;
}) {
const [prompt, setPrompt] = useState("Create a TypeScript CLI tool that converts celsius to fahrenheit with input validation");
const [model, setModel] = useState("sonnet-4.5");

const isDisabled = runState.status === "starting" || runState.status === "running";

function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
if (!prompt.trim() || isDisabled) return;
onRun(prompt.trim(), model);
}

return (
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
<div className="flex gap-3">
<input
type="text"
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
disabled={isDisabled}
placeholder="Describe what to create..."
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}
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"
>
{models.map((m) => (
<option key={m.value} value={m.value}>{m.label}</option>
))}
</select>

{runState.status === "complete" || runState.status === "failed" ? (
<button
type="button"
onClick={onReset}
className="px-5 py-2.5 rounded-lg bg-white/10 text-white text-sm font-medium hover:bg-white/15 transition-colors"
>
New run
</button>
) : (
<button
type="submit"
disabled={isDisabled || !prompt.trim()}
className="px-5 py-2.5 rounded-lg bg-white text-black text-sm font-medium hover:bg-white/90 transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
>
{runState.status === "starting" ? "Starting..." : "Run"}
</button>
)}
</div>

<div className="flex items-center gap-2">
<StatusDot status={runState.status} />
<span className="text-xs text-white/40">
{runState.status === "idle" && "Ready"}
{runState.status === "starting" && "Triggering task..."}
{runState.status === "running" && "Agent is working..."}
{runState.status === "complete" && "Complete"}
{runState.status === "failed" && `Failed: ${runState.error}`}
</span>
</div>
</form>
);
}

function StatusDot({ status }: { status: RunState["status"] }) {
const color =
status === "running" || status === "starting"
? "bg-yellow-400 animate-pulse"
: status === "complete"
? "bg-green-400"
: status === "failed"
? "bg-red-400"
: "bg-white/20";

return <div className={`w-2 h-2 rounded-full ${color}`} />;
}
Loading