-
Notifications
You must be signed in to change notification settings - Fork 23
Cursor cli demo #96
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Cursor cli demo #96
Changes from all commits
150b057
0ad83fd
e29f1d4
c2f7b99
b1f769e
87d895d
8007564
f75811b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 |
| 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 |
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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; | ||
| } |
| 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> | ||
| ); | ||
| } |
| 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> | ||
| ); | ||
| } |
| 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> | ||
| )} | ||
| </> | ||
| ); | ||
| } |
| 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}`} />; | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.