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
37 changes: 28 additions & 9 deletions apps/cursor/.env.example
Original file line number Diff line number Diff line change
@@ -1,26 +1,45 @@
# Supabase
NEXT_PUBLIC_SUPABASE_URL=<your_supabase_project_url>
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=<your_supabase_publishable_key>
SUPABASE_SECRET_KEY=<your_supabase_secret_key>

RESEND_API_KEY=<your_resend_api_key>

NEXT_PUBLIC_APP_URL=http://localhost:3000

MCP_OWNER_ID=
# Email (Resend)
RESEND_API_KEY=

# Admin allow-list (comma-separated Supabase user IDs).
# NEXT_PUBLIC_ADMIN_USER_IDS mirrors ADMIN_USER_IDS to the browser so admin-only
# UI (e.g. the verify controls on a plugin page) can render conditionally.
# Server-side enforcement still uses ADMIN_USER_IDS — the public copy is purely
# for UI gating.
ADMIN_USER_IDS=
# Mirror of ADMIN_USER_IDS exposed to the browser so admin-only UI (e.g. the
# verify controls on a plugin page) can render conditionally. Server-side
# enforcement still uses ADMIN_USER_IDS — this is purely for UI gating.
NEXT_PUBLIC_ADMIN_USER_IDS=

# Upstash Redis — backs the rate limiters in src/lib/rate-limit.ts
# (install throttling + per-user plugin-scan budget). Read implicitly by
# `Redis.fromEnv()`.
UPSTASH_REDIS_REST_URL=
UPSTASH_REDIS_REST_TOKEN=

# Cursor SDK — required for the plugin security scan workflow
# (src/workflows/scan-plugin.ts). Mint a key at
# https://cursor.com/dashboard/cloud-agents or use a team service-account key.
CURSOR_API_KEY=

# GitHub API token — optional. Without it, unauthenticated GitHub requests
# from the plugin parser and extract scripts are limited to 60/hr.
GITHUB_TOKEN=

# OpenPanel analytics (disabled automatically when NODE_ENV=development).
NEXT_PUBLIC_OPENPANEL_CLIENT_ID=

# Airtable — source for the ambassadors cron sync
# (src/app/api/cron/sync-ambassadors/route.ts). Only required if you run that
# cron locally.
AIRTABLE_API_KEY=
AIRTABLE_BASE_ID=
AIRTABLE_AMBASSADORS_TABLE=Directory
# Comma-separated list of field names to read emails from (case-sensitive).
AIRTABLE_AMBASSADORS_EMAIL_FIELD=Email,Cursor email

CRON_SECRET=
# Shared secret guarding /api/cron/* routes against unauthenticated callers.
CRON_SECRET=
94 changes: 37 additions & 57 deletions apps/cursor/src/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import type { Metadata } from "next";
import { Suspense } from "react";
import type { PluginCardData } from "@/components/plugins/plugin-card";
import type { LeaderboardItem } from "@/components/plugins/plugin-leaderboard";
import { Startpage } from "@/components/startpage";
import { getMembers, getPlugins, getTotalUsers } from "@/data/queries";
import {
getPluginInstallVelocity,
getPlugins,
getTotalUsers,
} from "@/data/queries";

export const metadata: Metadata = {
title: "Cursor Directory - Plugins for Cursor",
Expand All @@ -18,82 +22,58 @@ export const metadata: Metadata = {
};

export const dynamic = "force-static";
export const revalidate = 86400;
// Velocity data refreshes daily via the snapshot cron. Revalidating
// the homepage every hour keeps the leaderboard close to live install
// activity without sacrificing the static cache benefit.
export const revalidate = 3600;

function getPluginType(
components: { type: string }[],
): "rules" | "mcp" | "both" {
const hasRules = components.some((c) => c.type === "rule");
const hasMcp = components.some((c) => c.type === "mcp_server");
if (hasRules && hasMcp) return "both";
if (hasMcp) return "mcp";
return "rules";
}

function toPluginCard(
function toLeaderboardItem(
p: NonNullable<Awaited<ReturnType<typeof getPlugins>>["data"]>[number],
): PluginCardData {
const components = p.plugin_components ?? [];
installs30d: number,
): LeaderboardItem {
return {
name: p.name,
slug: p.slug,
description: p.description ?? "",
logo: p.logo,
type: getPluginType(components),
rulesCount: components.filter((c) => c.type === "rule").length,
mcpCount: components.filter((c) => c.type === "mcp_server").length,
keywords: p.keywords,
installCount: p.install_count,
author: p.author_name,
authorUrl: p.author_url,
verified: p.verified,
installCount: p.install_count,
installs30d,
starCount: p.star_count,
createdAt: p.created_at,
updatedAt: p.updated_at,
permanentlyBlocked: p.permanently_blocked,
flagSeverity: p.flag_severity,
scanStatus: p.scan_status,
href: `/plugins/${p.slug}`,
};
}

export default async function Page() {
const [{ data: totalUsers }, { data: members }, { data: allPluginsData }] =
await Promise.all([
getTotalUsers(),
getMembers({ page: 1, limit: 16 }),
getPlugins({ fetchAll: true }),
]);

const allPluginsRaw = allPluginsData ?? [];
const [
{ data: totalUsers },
{ data: allPluginsData },
{ data: velocityMap },
] = await Promise.all([
getTotalUsers(),
getPlugins({ fetchAll: true }),
getPluginInstallVelocity(30),
]);

const allPlugins = allPluginsRaw
.map(toPluginCard)
.sort((a, b) => a.name.localeCompare(b.name));

const popularPlugins = allPluginsRaw
.filter((p) => p.install_count > 0)
.sort((a, b) => b.install_count - a.install_count)
.slice(0, 12)
.map(toPluginCard);

const recentPlugins = [...allPluginsRaw]
.sort(
(a, b) =>
new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
)
.slice(0, 20)
.map(toPluginCard);

const starredPlugins = allPluginsRaw
.filter((p) => p.star_count > 0)
.sort((a, b) => b.star_count - a.star_count)
.slice(0, 8)
.map(toPluginCard);
const velocity = velocityMap ?? new Map<string, number>();
const leaderboardItems = (allPluginsData ?? []).map((p) =>
toLeaderboardItem(p, velocity.get(p.id) ?? 0),
);

return (
<div className="min-h-screen w-full">
<div className="w-full">
<Suspense>
<Startpage
popularPlugins={popularPlugins}
allPlugins={allPlugins}
recentPlugins={recentPlugins}
starredPlugins={starredPlugins}
leaderboardItems={leaderboardItems}
totalUsers={totalUsers?.count ?? 0}
members={members}
/>
</Suspense>
</div>
Expand Down
2 changes: 1 addition & 1 deletion apps/cursor/src/components/global-search-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export function GlobalSearchInput() {
const [search, setSearch] = useQueryState("q", { defaultValue: "" });
const router = useRouter();

const placeholder = "Search plugins, MCP servers, events, members...";
const placeholder = "Search plugins...";

const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
Expand Down
11 changes: 6 additions & 5 deletions apps/cursor/src/components/hero-title.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,19 @@ export function HeroTitle({ totalUsers }: { totalUsers: number }) {
return (
<div className="mb-14 text-center">
<h1 className="marketing-hero-title mx-auto mb-5 max-w-[980px] text-balance text-foreground">
Explore what the community is building
Extend Cursor with community plugins.
</h1>

<p className="marketing-copy mx-auto max-w-[760px] text-balance">
Discover and install{" "}
<Link href="/plugins" className={linkClass}>
Plugins
plugins
</Link>{" "}
and{" "}
from{" "}
<Link href="/members" className={linkClass}>
{formatNumber(totalUsers)}+ developers
</Link>{" "}
building with Cursor.
</Link>
, ranked by what&rsquo;s trending.
</p>
</div>
);
Expand Down
8 changes: 4 additions & 4 deletions apps/cursor/src/components/plugins/plugin-detail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -137,15 +137,15 @@ function ScanStatusBanner({
<div className="flex-1">
<p className="text-sm font-medium text-red-600 dark:text-red-400">
{live
? "Flagged by the security agent — under review."
: "Flagged by the security agent and hidden from the directory."}
? "Flagged by the security agent — pending manual review."
: "Flagged by the security agent. Hidden from the directory pending manual review."}
</p>
{plugin.flag_summary && (
{isOwner && plugin.flag_summary && (
<p className="mt-1 text-sm text-red-600/90 dark:text-red-400/90">
{plugin.flag_summary}
</p>
)}
{reasons.length > 0 && (
{isOwner && reasons.length > 0 && (
<ul className="mt-2 space-y-0.5 text-xs text-red-600/80 dark:text-red-400/80">
{reasons.map((reason) => (
<li key={reason}>• {reason}</li>
Expand Down
Loading