Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,27 @@ Auto-detected components:

See the [Open Plugins specification](https://open-plugins.com/plugin-builders/specification) and [plugin template](https://github.com/cursor/plugin-template) for details.

### Submit a Bot

A bot listing is a use-case page, not a plugin. It is a copyable template, the plugins and skills that template needs, and a writeup that can rank in search. It is not an Open Plugins `agents/*.md` file. Submit those as plugins.

1. Go to [cursor.directory/bots/new](https://cursor.directory/bots/new)
2. Sign in with GitHub or Google
3. Paste a GitHub repo URL, or fill in the template and writeup by hand
4. Click **Submit**

The listing stays unpublished until an admin reviews it at `/admin/bots`. Security scan is not wired for bots yet. Plugin submit, scan, and trending are unchanged.

Auto-detected bot files:

| File | What we read |
|------|----------------|
| `bot.json` or `.cursor/bot.json` | `name`, `description`, `template`, `writeup`, `plugins`, `skills` |
| `BOT.md` or `template.md` | Copyable template if JSON omits `template` |
| `WRITEUP.md` or `README.md` | Use-case writeup if JSON omits `writeup` |

If the repo only contains `agents/*.md` (and no bot manifest), the submit form tells you to use [plugin submit](https://cursor.directory/plugins/new) instead. `parseGitHubPlugin` cannot serve this flow: it requires Open Plugins components and treats `agents/*.md` as plugin body, so a bot-only repo fails with `no_components`.

---

## Tech Stack
Expand Down
87 changes: 87 additions & 0 deletions apps/cursor/src/actions/create-bot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
"use server";

import { updateTag } from "next/cache";
import { z } from "zod";
import { InsertBotError, insertBot } from "@/lib/bots/insert";
import { botNeedSchema } from "@/lib/bots/types";
import { resolveGithubRepoIdFromRepository } from "@/lib/github-plugin/parse";
import { pluginScanLimit } from "@/lib/rate-limit";
import { ActionError, authActionClient } from "./safe-action";

export const createBotAction = authActionClient
.metadata({
actionName: "create-bot",
})
.schema(
z.object({
name: z.string().min(2, "Name must be at least 2 characters"),
description: z
.string()
.min(10, "Description must be at least 10 characters"),
writeup: z.string().min(40, "Writeup must be at least 40 characters"),
template: z.string().min(20, "Template must be at least 20 characters"),
needs: z.array(botNeedSchema).optional(),
repository: z.string().url().nullable().optional(),
homepage: z.string().url().nullable().optional(),
}),
)
.action(
async ({
parsedInput: {
name,
description,
writeup,
template,
needs,
repository,
homepage,
},
ctx: { userId },
}) => {
const { success } = await pluginScanLimit(userId);
if (!success) {
throw new ActionError(
"Too many submissions in the last hour. Please try again later.",
);
}

const githubRepoId = await resolveGithubRepoIdFromRepository(repository, {
maxWaitMs: 3000,
});

let result: { id: string; slug: string };
try {
result = await insertBot(
{
name,
description,
writeup,
template,
needs,
repository,
homepage,
},
{
ownerId: userId,
source: "user",
skipReview: false,
githubRepoId,
},
);
} catch (err) {
if (err instanceof InsertBotError) {
if (err.code === "duplicate_name" || err.code === "duplicate_repo") {
throw new ActionError(
"A bot with this name or repository already exists. Please choose a different name or repository.",
);
}
throw new ActionError(err.message);
}
throw err;
}

updateTag("bots");

return { slug: result.slug };
},
);
23 changes: 23 additions & 0 deletions apps/cursor/src/actions/parse-github-bot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
"use server";

import { z } from "zod";
import { BotParseError, parseGitHubBot } from "@/lib/bots/parse";
import { ActionError, authActionClient } from "./safe-action";

export const parseGitHubBotAction = authActionClient
.metadata({ actionName: "parse-github-bot" })
.schema(
z.object({
url: z.string().url("Please enter a valid GitHub URL"),
}),
)
.action(async ({ parsedInput: { url } }) => {
try {
return await parseGitHubBot(url, { maxWaitMs: 3000 });
} catch (err) {
if (err instanceof BotParseError) {
throw new ActionError(err.message);
}
throw err;
}
});
55 changes: 55 additions & 0 deletions apps/cursor/src/actions/review-bot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
"use server";

import { revalidatePath, updateTag } from "next/cache";
import { z } from "zod";
import { createClient } from "@/utils/supabase/admin-client";
import { ActionError, adminActionClient } from "./safe-action";

export const approveBotAction = adminActionClient
.metadata({ actionName: "approve-bot" })
.schema(z.object({ botId: z.string().uuid() }))
.action(async ({ parsedInput: { botId } }) => {
const supabase = await createClient();

const { error } = await supabase
.from("bots")
.update({ active: true })
.eq("id", botId);

if (error) {
throw new ActionError(`Failed to approve bot: ${error.message}`);
}

const { data: bot } = await supabase
.from("bots")
.select("slug")
.eq("id", botId)
.single();

revalidatePath("/admin/bots");
updateTag("bots");

if (bot?.slug) {
updateTag(`bot-${bot.slug}`);
}

return { success: true };
});

export const declineBotAction = adminActionClient
.metadata({ actionName: "decline-bot" })
.schema(z.object({ botId: z.string().uuid() }))
.action(async ({ parsedInput: { botId } }) => {
const supabase = await createClient();

const { error } = await supabase.from("bots").delete().eq("id", botId);

if (error) {
throw new ActionError(`Failed to decline bot: ${error.message}`);
}

revalidatePath("/admin/bots");
updateTag("bots");

return { success: true };
});
111 changes: 111 additions & 0 deletions apps/cursor/src/app/admin/bots/bot-review-list.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
"use client";

import { Check, ExternalLink, Loader2, Trash2 } from "lucide-react";
import Link from "next/link";
import { useAction } from "next-safe-action/hooks";
import { useState } from "react";
import { toast } from "sonner";
import { approveBotAction, declineBotAction } from "@/actions/review-bot";
import { Button } from "@/components/ui/button";
import type { BotRow } from "@/lib/bots/types";

function BotReviewCard({ bot }: { bot: BotRow }) {
const [dismissed, setDismissed] = useState(false);

const { execute: approve, isExecuting: isApproving } = useAction(
approveBotAction,
{
onSuccess: () => {
toast.success(`"${bot.name}" approved and now live.`);
setDismissed(true);
},
onError: ({ error }) => {
toast.error(error.serverError ?? "Failed to approve bot.");
},
},
);

const { execute: decline, isExecuting: isDeclining } = useAction(
declineBotAction,
{
onSuccess: () => {
toast.success(`"${bot.name}" declined and removed.`);
setDismissed(true);
},
onError: ({ error }) => {
toast.error(error.serverError ?? "Failed to decline bot.");
},
},
);

if (dismissed) return null;

const busy = isApproving || isDeclining;

return (
<div className="rounded-lg border border-border bg-card p-5 shadow-cursor">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<Link
href={`/bots/${bot.slug}`}
target="_blank"
className="group flex items-center gap-1.5 truncate text-sm font-medium hover:underline"
>
{bot.name}
<ExternalLink className="size-3 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100" />
</Link>
<p className="mt-1 line-clamp-2 text-sm text-muted-foreground">
{bot.description}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={busy}
onClick={() => decline({ botId: bot.id })}
>
{isDeclining ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<Trash2 className="size-3.5" />
)}
<span className="ml-1.5">Decline</span>
</Button>
<Button
size="sm"
disabled={busy}
onClick={() => approve({ botId: bot.id })}
>
{isApproving ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<Check className="size-3.5" />
)}
<span className="ml-1.5">Approve</span>
</Button>
</div>
</div>
</div>
);
}

export function BotReviewList({ bots }: { bots: BotRow[] }) {
if (bots.length === 0) {
return (
<div className="rounded-lg border border-border bg-card p-10 text-center shadow-cursor">
<p className="text-sm text-muted-foreground">
No pending bots to review.
</p>
</div>
);
}

return (
<div className="space-y-3">
{bots.map((bot) => (
<BotReviewCard key={bot.id} bot={bot} />
))}
</div>
);
}
42 changes: 42 additions & 0 deletions apps/cursor/src/app/admin/bots/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { Suspense } from "react";
import { getPendingBots } from "@/data/queries";
import { isAdmin } from "@/utils/admin";
import { getSession } from "@/utils/supabase/auth";
import { BotReviewList } from "./bot-review-list";

export const metadata: Metadata = {
title: "Review Bots | Admin",
};

async function AdminBotsContent() {
const session = await getSession();

if (!session || !isAdmin(session.user.id)) {
redirect("/");
}

const { data: pending } = await getPendingBots();

return <BotReviewList bots={pending ?? []} />;
}

export default function AdminBotsPage() {
return (
<div className="min-h-screen px-6 pt-24 md:pt-32 pb-32">
<div className="mx-auto w-full max-w-3xl">
<div className="mb-10">
<h1 className="marketing-page-title mb-3">Review Bots</h1>
<p className="marketing-copy text-muted-foreground">
Bot submissions land here unpublished. Scan is stubbed. Approve to
list the use case on /bots.
</p>
</div>
<Suspense fallback={null}>
<AdminBotsContent />
</Suspense>
</div>
</div>
);
}
47 changes: 47 additions & 0 deletions apps/cursor/src/app/bots/[slug]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { BotDetailView } from "@/components/bots/bot-detail";
import { getBotBySlug, getBots } from "@/data/queries";

type Params = Promise<{ slug: string }>;

export async function generateMetadata({
params,
}: {
params: Params;
}): Promise<Metadata> {
const { slug } = await params;
const { data: bot } = await getBotBySlug(slug);

if (bot?.active) {
const title = `${bot.name} | Cursor Directory`;
const description = bot.description;
return {
title,
description,
openGraph: { title, description },
twitter: { title, description },
};
}

if (bot && !bot.active) {
return {
title: `${bot.name} | Cursor Directory`,
robots: { index: false },
};
}

return { title: "Bot Not Found" };
}

export async function generateStaticParams() {
const { data: bots } = await getBots({ fetchAll: true });
return (bots ?? []).map((bot) => ({ slug: bot.slug }));
}

export default async function Page({ params }: { params: Params }) {
const { slug } = await params;
const { data: bot } = await getBotBySlug(slug);
if (!bot) notFound();
return <BotDetailView bot={bot} />;
}
Loading
Loading