Skip to content

Commit ca504f7

Browse files
authored
Merge pull request #96 from triggerdotdev/cursor-cli-demo
Cursor cli demo
2 parents 5d4268b + f75811b commit ca504f7

22 files changed

Lines changed: 6283 additions & 0 deletions

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ For information on how to run each project, see the README in each directory.
2525
| [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) |
2626
| [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) |
2727
| [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) |
28+
| [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. |
2829
| [Claude thinking chatbot](/claude-thinking-chatbot) | A chatbot that uses Claude's thinking capabilities to generate responses |
2930
| [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 |
3031
| [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 |

cursor-cli-demo/.gitignore

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# Dependencies
2+
node_modules/
3+
.pnp
4+
.pnp.js
5+
6+
# Build
7+
.next/
8+
out/
9+
build/
10+
dist/
11+
12+
# Environment
13+
.env
14+
.env.local
15+
.env.development.local
16+
.env.test.local
17+
.env.production.local
18+
19+
# Logs
20+
npm-debug.log*
21+
yarn-debug.log*
22+
yarn-error.log*
23+
24+
# Vercel
25+
.vercel
26+
27+
# TypeScript
28+
*.tsbuildinfo
29+
next-env.d.ts
30+
31+
# IDE
32+
.idea/
33+
.vscode/
34+
*.swp
35+
*.swo
36+
.DS_Store
37+
38+
# Testing
39+
coverage/
40+
41+
# Supabase
42+
supabase/.branches
43+
supabase/.temp
44+
45+
# Trigger.dev
46+
.trigger/
47+
48+
# Project specific
49+
ship/
50+
.claude
51+
progress.txt
52+
prd.json
53+
SPEC.md

cursor-cli-demo/README.md

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# Cursor Agent using the Cursor CLI and Trigger.dev
2+
3+
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.
4+
5+
## Tech stack
6+
7+
- **[Next.js](https://nextjs.org)** – App Router frontend with server actions to trigger runs
8+
- **[Cursor CLI](https://cursor.com)** – Headless AI coding agent spawned as a child process
9+
- **[Trigger.dev](https://trigger.dev)** – Background task orchestration with real-time streaming to the frontend, observability, and deployment
10+
- **[Tailwind CSS](https://tailwindcss.com)** – Styling with Geist Mono for the terminal UI
11+
12+
## Running the project locally
13+
14+
1. **Install dependencies**
15+
16+
```bash
17+
pnpm install
18+
```
19+
20+
2. **Configure environment variables**
21+
22+
```bash
23+
cp env.local.example .env.local
24+
```
25+
26+
- `TRIGGER_SECRET_KEY` – From [Trigger.dev dashboard](https://cloud.trigger.dev/) (starts with `tr_dev_` or `tr_`)
27+
- `TRIGGER_PROJECT_REF` – Your project ref (starts with `proj_`)
28+
- `CURSOR_API_KEY` – Your Cursor API key for headless CLI access
29+
30+
3. **Start development servers**
31+
32+
```bash
33+
# Terminal 1: Next.js
34+
pnpm dev
35+
36+
# Terminal 2: Trigger.dev
37+
npx trigger.dev@latest dev
38+
```
39+
40+
4. Open [http://localhost:3000](http://localhost:3000) in your browser to see the demo
41+
42+
## Features
43+
44+
- **Build extensions** – Installs `cursor-agent` into the task container image via `addLayer`, so any system binary can ship with your task
45+
- **Realtime Streams v2** – NDJSON from a child process stdout is parsed and piped directly to the browser using `streams.define()` and `.pipe()`
46+
- **Live terminal rendering** – Each cursor event (system, assistant, tool_call, result) renders as a distinct row with auto-scroll
47+
- **Long-running tasks** – cursor-agent runs for minutes; Trigger.dev handles lifecycle, timeouts, and retries
48+
- **Machine selection**`medium-2x` preset for resource-intensive CLI tools
49+
- **Model picker** – Switch between Claude models from the UI before triggering a run
50+
- **Container binary workaround** – Demonstrates the `/tmp` copy + `chmod` pattern needed when the runtime strips execute permissions
51+
52+
## Relevant files
53+
54+
- [extensions/cursor-cli.ts](extensions/cursor-cli.ts) – Build extension + spawn helper that returns a typed NDJSON stream and `waitUntilExit()`
55+
- [trigger/cursor-agent.ts](trigger/cursor-agent.ts) – The task: spawns the CLI, pipes the stream, waits for exit
56+
- [trigger/cursor-stream.ts](trigger/cursor-stream.ts) – Realtime Streams v2 stream definition
57+
- [components/terminal.tsx](components/terminal.tsx) – Realtime terminal UI with `useRealtimeRunWithStreams`
58+
- [lib/cursor-events.ts](lib/cursor-events.ts) – TypeScript types and parsers for cursor NDJSON events
59+
- [trigger.config.ts](trigger.config.ts) – Trigger.dev config with the cursor CLI build extension
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { tasks } from "@trigger.dev/sdk";
2+
import type { cursorAgentTask } from "@/trigger/cursor-agent";
3+
4+
export async function POST(req: Request) {
5+
let body: unknown;
6+
try {
7+
body = await req.json();
8+
} catch {
9+
return Response.json({ error: "invalid JSON" }, { status: 400 });
10+
}
11+
12+
const parsed = body as Record<string, unknown>;
13+
const prompt = typeof parsed.prompt === "string" ? parsed.prompt.trim() : "";
14+
const model = typeof parsed.model === "string" ? parsed.model : undefined;
15+
16+
if (!prompt) {
17+
return Response.json({ error: "prompt is required" }, { status: 400 });
18+
}
19+
20+
const handle = await tasks.trigger<typeof cursorAgentTask>("cursor-agent", { prompt, model });
21+
22+
return Response.json({
23+
runId: handle.id,
24+
publicAccessToken: handle.publicAccessToken,
25+
});
26+
}

cursor-cli-demo/app/globals.css

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
@import "tailwindcss";
2+
3+
:root {
4+
--background: #0a0a0a;
5+
--foreground: #ededed;
6+
--terminal-bg: #1a1a1a;
7+
--terminal-border: #2a2a2a;
8+
--muted: #666;
9+
--green: #4ade80;
10+
--red: #f87171;
11+
}
12+
13+
body {
14+
background: var(--background);
15+
color: var(--foreground);
16+
font-family: var(--font-geist-sans), system-ui, sans-serif;
17+
}

cursor-cli-demo/app/layout.tsx

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import type { Metadata } from "next";
2+
import { Geist, Geist_Mono } from "next/font/google";
3+
import "./globals.css";
4+
5+
const geistSans = Geist({
6+
variable: "--font-geist-sans",
7+
subsets: ["latin"],
8+
});
9+
10+
const geistMono = Geist_Mono({
11+
variable: "--font-geist-mono",
12+
subsets: ["latin"],
13+
});
14+
15+
export const metadata: Metadata = {
16+
title: "Cursor Agent Runner",
17+
description: "Run Cursor's CLI agent on Trigger.dev, streamed live",
18+
};
19+
20+
export default function RootLayout({
21+
children,
22+
}: {
23+
children: React.ReactNode;
24+
}) {
25+
return (
26+
<html lang="en">
27+
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
28+
{children}
29+
</body>
30+
</html>
31+
);
32+
}

cursor-cli-demo/app/page.tsx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { AgentRunner } from "@/components/agent-runner";
2+
3+
export default function Home() {
4+
return (
5+
<main className="min-h-screen p-6 md:p-10 max-w-4xl mx-auto flex flex-col gap-6">
6+
<div>
7+
<h1 className="text-xl font-bold font-[family-name:var(--font-geist-mono)]">
8+
Cursor Agent Runner
9+
</h1>
10+
<p className="text-xs text-white/30 mt-1">
11+
Powered by Trigger.dev — watch an AI agent generate code in real time
12+
</p>
13+
</div>
14+
15+
<AgentRunner />
16+
</main>
17+
);
18+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
"use client";
2+
3+
import { ControlBar, useRunState } from "@/components/control-bar";
4+
import { Terminal } from "@/components/terminal";
5+
6+
export function AgentRunner() {
7+
const { runState, startRun, reset, markComplete } = useRunState();
8+
9+
const showTerminal = runState.status === "running" || runState.status === "complete";
10+
11+
return (
12+
<>
13+
<ControlBar runState={runState} onRun={startRun} onReset={reset} />
14+
15+
{showTerminal && (
16+
<Terminal
17+
runId={runState.runId}
18+
publicAccessToken={runState.publicAccessToken}
19+
onComplete={markComplete}
20+
/>
21+
)}
22+
23+
{runState.status === "failed" && (
24+
<div className="bg-red-500/10 border border-red-500/20 rounded-lg p-4 text-sm text-red-400 font-mono">
25+
{runState.error}
26+
</div>
27+
)}
28+
</>
29+
);
30+
}
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
"use client";
2+
3+
import { useState } from "react";
4+
5+
const models = [
6+
{ value: "sonnet-4.5", label: "Sonnet 4.5" },
7+
{ value: "opus-4.5", label: "Opus 4.5" },
8+
{ value: "gemini-3-pro", label: "Gemini 3 Pro" },
9+
];
10+
11+
type RunState =
12+
| { status: "idle" }
13+
| { status: "starting" }
14+
| { status: "running"; runId: string; publicAccessToken: string }
15+
| { status: "complete"; runId: string; publicAccessToken: string }
16+
| { status: "failed"; error: string };
17+
18+
export function useRunState() {
19+
const [runState, setRunState] = useState<RunState>({ status: "idle" });
20+
21+
async function startRun(prompt: string, model: string) {
22+
setRunState({ status: "starting" });
23+
24+
try {
25+
const res = await fetch("/api/trigger", {
26+
method: "POST",
27+
headers: { "Content-Type": "application/json" },
28+
body: JSON.stringify({ prompt, model }),
29+
});
30+
31+
if (!res.ok) {
32+
const data = await res.json();
33+
setRunState({ status: "failed", error: data.error ?? "Request failed" });
34+
return;
35+
}
36+
37+
const { runId, publicAccessToken } = await res.json();
38+
setRunState({ status: "running", runId, publicAccessToken });
39+
} catch (err) {
40+
setRunState({ status: "failed", error: err instanceof Error ? err.message : "Unknown error" });
41+
}
42+
}
43+
44+
function reset() {
45+
setRunState({ status: "idle" });
46+
}
47+
48+
function markComplete() {
49+
setRunState((prev) => {
50+
if (prev.status === "running") {
51+
return { status: "complete", runId: prev.runId, publicAccessToken: prev.publicAccessToken };
52+
}
53+
return prev;
54+
});
55+
}
56+
57+
return { runState, startRun, reset, markComplete };
58+
}
59+
60+
export function ControlBar({
61+
runState,
62+
onRun,
63+
onReset,
64+
}: {
65+
runState: RunState;
66+
onRun: (prompt: string, model: string) => void;
67+
onReset: () => void;
68+
}) {
69+
const [prompt, setPrompt] = useState("Create a TypeScript CLI tool that converts celsius to fahrenheit with input validation");
70+
const [model, setModel] = useState("sonnet-4.5");
71+
72+
const isDisabled = runState.status === "starting" || runState.status === "running";
73+
74+
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
75+
e.preventDefault();
76+
if (!prompt.trim() || isDisabled) return;
77+
onRun(prompt.trim(), model);
78+
}
79+
80+
return (
81+
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
82+
<div className="flex gap-3">
83+
<input
84+
type="text"
85+
value={prompt}
86+
onChange={(e) => setPrompt(e.target.value)}
87+
disabled={isDisabled}
88+
placeholder="Describe what to create..."
89+
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)]"
90+
/>
91+
92+
<select
93+
value={model}
94+
onChange={(e) => setModel(e.target.value)}
95+
disabled={isDisabled}
96+
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"
97+
>
98+
{models.map((m) => (
99+
<option key={m.value} value={m.value}>{m.label}</option>
100+
))}
101+
</select>
102+
103+
{runState.status === "complete" || runState.status === "failed" ? (
104+
<button
105+
type="button"
106+
onClick={onReset}
107+
className="px-5 py-2.5 rounded-lg bg-white/10 text-white text-sm font-medium hover:bg-white/15 transition-colors"
108+
>
109+
New run
110+
</button>
111+
) : (
112+
<button
113+
type="submit"
114+
disabled={isDisabled || !prompt.trim()}
115+
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"
116+
>
117+
{runState.status === "starting" ? "Starting..." : "Run"}
118+
</button>
119+
)}
120+
</div>
121+
122+
<div className="flex items-center gap-2">
123+
<StatusDot status={runState.status} />
124+
<span className="text-xs text-white/40">
125+
{runState.status === "idle" && "Ready"}
126+
{runState.status === "starting" && "Triggering task..."}
127+
{runState.status === "running" && "Agent is working..."}
128+
{runState.status === "complete" && "Complete"}
129+
{runState.status === "failed" && `Failed: ${runState.error}`}
130+
</span>
131+
</div>
132+
</form>
133+
);
134+
}
135+
136+
function StatusDot({ status }: { status: RunState["status"] }) {
137+
const color =
138+
status === "running" || status === "starting"
139+
? "bg-yellow-400 animate-pulse"
140+
: status === "complete"
141+
? "bg-green-400"
142+
: status === "failed"
143+
? "bg-red-400"
144+
: "bg-white/20";
145+
146+
return <div className={`w-2 h-2 rounded-full ${color}`} />;
147+
}

0 commit comments

Comments
 (0)