Skip to content
Open
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
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@ on:
branches: [main]
pull_request:

permissions:
contents: read

concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
checks:
name: Lint & typecheck
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ Lint from repo root: `bunx biome ci .` (matches CI in `.github/workflows/ci.yml`
- App env lives in `apps/cursor/.env` (gitignored). For local dev it uses the Supabase CLI's well-known local default keys (`supabase status` prints them) with `NEXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:54321`. `CRON_SECRET` guards `/api/queue/plugin-scans/drain` and `/api/cron/*`.
- The login page only offers GitHub/Google OAuth, which are not configured locally. Create a confirmed user via the GoTrue admin API (`POST {SUPABASE_URL}/auth/v1/admin/users` with the secret key, `"email_confirm": true`); a DB trigger provisions the `public.users` profile row. To get a browser session, add a temporary route that calls `supabase.auth.signInWithPassword(...)` with the server client (do not commit it). To use `/admin/plugins` and the MCP server, add the user to the `public.admins` table: `insert into public.admins (user_id) values ('<id>')`. Email rows only match Google/GitHub identities, so local password users need a `user_id` row.
- Cache Components (`cacheComponents: true`) makes pages with `generateStaticParams` throw 500 on an empty database — seed at least one active plugin. Use `bun run seed:extract` / `seed:insert`, but note: the insert script must be run as `bun run --conditions=react-server --env-file=apps/cursor/.env apps/cursor/src/scripts/insert-from-jsonl.ts` or the `server-only` import throws under plain `bun run`. GitHub Code Search (used by `seed:extract` discovery) is heavily rate-limited; hand-writing `apps/cursor/.seed/candidates.json` with `{"candidates":[{"owner":...,"repo":...,"source":"seed:topic","matchedQuery":"manual"}]}` skips discovery.
- Seeded plugin logos hosted on `raw.githubusercontent.com` 500 plugin pages because that host is not in `next.config.mjs` `images.remotePatterns`; null them: `update plugins set logo = null where logo like 'https://raw.githubusercontent.com%'`.
- Plugin logos are rendered `unoptimized` in `PluginLogo`, so hosts outside `next.config.mjs` `images.remotePatterns` (for example `raw.githubusercontent.com` for manifests with a relative `logo`) no longer 500 the plugin page. Only `next/image` usages that go through the optimizer need the allowlist.
- After changing `.env` or database content backing cached pages, restart `bun dev` (and `rm -rf apps/cursor/.next` if stale renders persist) — hot reload does not invalidate Cache Components output.
- Without `CURSOR_API_KEY`, plugin submission works but the security scan errors (plugin lands in the admin "Scan issues" queue, where an admin can publish it manually). This is expected locally.
- Supabase `edge_runtime` is disabled in `supabase/config.toml` (no edge functions in this app; its boot probe needs deno.land which is blocked).
Expand Down
11 changes: 7 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,21 +77,24 @@ All content is submitted through the website — no pull requests needed for dat

1. Go to [cursor.directory/plugins/new](https://cursor.directory/plugins/new)
2. Sign in with GitHub or Google
3. Paste a GitHub repo URL — we auto-detect components following the [Open Plugins](https://open-plugins.com) standard
3. Paste a GitHub repo URL — we auto-detect components following the [Open Plugins](https://agent-plugins.org) standard
4. Click **Submit**

Auto-detected components:

| Component | Path |
|-----------|------|
| Rules | `rules/*.mdc` |
| MCP Servers | `.mcp.json` |
| MCP Servers | `.mcp.json` or `mcp.json` |
| Skills | `skills/*/SKILL.md` |
| Agents | `agents/*.md` |
| Commands | `commands/*.md` |
| Hooks | `hooks/hooks.json` |
| LSP Servers | `.lsp.json` |

See the [Open Plugins specification](https://open-plugins.com/plugin-builders/specification) and [plugin template](https://github.com/cursor/plugin-template) for details.
The manifest is read from `.plugin/plugin.json`, `.cursor-plugin/plugin.json` or `.claude-plugin/plugin.json` (first match wins). For monorepos, the same paths are also scanned inside every directory listed as `plugins[].source` in `.cursor-plugin/marketplace.json` and every directory that contains its own `.cursor-plugin/plugin.json`.

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

---

Expand All @@ -104,7 +107,7 @@ See the [Open Plugins specification](https://open-plugins.com/plugin-builders/sp
- **Styling**: [Tailwind CSS](https://tailwindcss.com)
- **UI**: [Radix UI](https://radix-ui.com) + [shadcn/ui](https://ui.shadcn.com)
- **Search**: [Fuse.js](https://fusejs.io) (client-side fuzzy search)
- **URL State**: [nuqs](https://nuqs.47ng.com)
- **URL State**: [nuqs](https://nuqs.dev)
- **Linting**: [Biome](https://biomejs.dev)

## Plugin security scan
Expand Down
4 changes: 2 additions & 2 deletions apps/cursor/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "next lint",
"lint": "biome check .",
"typecheck": "tsc --noEmit",
"seed:extract": "bun run --env-file=.env src/scripts/extract-from-github.ts",
"seed:insert": "bun run --env-file=.env src/scripts/insert-from-jsonl.ts",
Expand Down Expand Up @@ -60,7 +60,7 @@
"zod": "^4.4.3"
},
"devDependencies": {
"@biomejs/biome": "2.4.14",
"@biomejs/biome": "2.4.15",
"@tailwindcss/postcss": "^4.2.4",
"@types/node": "^25.6.0",
"@types/react": "^19",
Expand Down
14 changes: 12 additions & 2 deletions apps/cursor/src/actions/delete-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,16 +33,26 @@ export const deletePluginAction = authActionClient
);
}

const { error } = await supabase
const { data: deleted, error } = await supabase
.from("plugins")
.delete()
.eq("id", id)
.eq("owner_id", userId);
.eq("owner_id", userId)
.select("id");

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

// A delete that matches no rows is not an error for PostgREST, so without
// this check a row the policy refuses to delete would still produce the
// "Plugin deleted." toast while staying live and keeping its slug.
if (!deleted || deleted.length === 0) {
throw new ActionError(
"The plugin could not be deleted. It is still listed; contact support to have it removed.",
);
}

// Deletions must disappear from cached lists immediately for the owner.
updateTag("plugins");
updateTag(`plugin-${existing.slug}`);
Expand Down
80 changes: 80 additions & 0 deletions apps/cursor/src/actions/rescan-own-plugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
"use server";

import { z } from "zod";
import {
ModerationError,
rescanPlugin,
webModerationContext,
} from "@/lib/plugins/moderation";
import { pluginScanLimit } from "@/lib/rate-limit";
import { createClient } from "@/utils/supabase/server";
import { ActionError, authActionClient } from "./safe-action";

/**
* Scan states an owner may re-run themselves. `pending` and `scanning` are
* already in flight; `safe` has nothing to fix; `permanently_blocked` rows are
* excluded below regardless of status.
*/
const OWNER_RESCANNABLE = new Set(["flagged", "error", "unscanned"]);

/**
* Owner-initiated re-scan. Same path as the admin "Rescan" (status reset,
* queue, audit event with the owner as actor), gated by ownership, the
* permanent block, and the per-user scan rate limit that already caps
* create/update scans.
*/
export const rescanOwnPluginAction = authActionClient
.metadata({ actionName: "rescan-own-plugin" })
.schema(z.object({ pluginId: z.string().uuid() }))
.action(async ({ parsedInput: { pluginId }, ctx: { userId } }) => {
const supabase = await createClient();

const { data: existing, error: fetchError } = await supabase
.from("plugins")
.select("id, owner_id, scan_status, permanently_blocked")
.eq("id", pluginId)
.single();

if (fetchError || !existing) {
throw new ActionError("Plugin not found.");
}

if (existing.owner_id !== userId) {
throw new ActionError(
"You do not have permission to update this plugin.",
);
}

if (existing.permanently_blocked) {
throw new ActionError(
"This plugin has been blocked and cannot be re-scanned. Contact support if you believe this is a mistake.",
);
}

if (!OWNER_RESCANNABLE.has(existing.scan_status)) {
throw new ActionError(
existing.scan_status === "safe"
? "This plugin already passed its last scan."
: "A scan is already in progress.",
);
}

const { success } = await pluginScanLimit(userId);
if (!success) {
throw new ActionError(
"Too many scans in the last hour. Please try again later.",
);
}

try {
await rescanPlugin(webModerationContext(userId), pluginId);
} catch (err) {
throw new ActionError(
err instanceof ModerationError
? err.message
: "Failed to start the scan.",
);
}

return { success: true };
});
2 changes: 1 addition & 1 deletion apps/cursor/src/app/plugins/new/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export default function Page() {
<br />
We follow the{" "}
<a
href="https://open-plugins.com"
href="https://agent-plugins.org"
target="_blank"
rel="noreferrer"
className="text-foreground border-b border-border border-dashed"
Expand Down
2 changes: 1 addition & 1 deletion apps/cursor/src/components/forms/plugin-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -386,7 +386,7 @@ export function PluginForm() {
We&apos;ll scan for rules, MCP servers, skills, agents, and more
following the{" "}
<a
href="https://open-plugins.com"
href="https://agent-plugins.org"
target="_blank"
rel="noreferrer"
className="border-b border-border border-dashed hover:text-foreground transition-colors"
Expand Down
33 changes: 24 additions & 9 deletions apps/cursor/src/components/plugins/detail/mcp-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { buildMCPInstallDeepLink } from "./deeplinks";
function resolveMcpConfig(
content: string | null,
meta: Record<string, unknown>,
fallbackName: string,
): { name: string; config: Record<string, unknown> } | null {
// Try content first, then metadata.config
let parsed: Record<string, unknown> | null = null;
Expand Down Expand Up @@ -43,8 +44,13 @@ function resolveMcpConfig(
}
}

// Content is already a raw config (no mcpServers wrapper)
return { name: (meta?.name as string) ?? "server", config: parsed };
// Content is already a raw config (no mcpServers wrapper). Ingestion strips
// the wrapper, so fall back to the component name before the generic default
// -- otherwise every such server installs as literally "server".
return {
name: (meta?.name as string) ?? fallbackName ?? "server",
config: parsed,
};
}

export function McpSection({
Expand All @@ -70,10 +76,15 @@ export function McpSection({
let installLink = mcpLink ?? null;
let configPreview: string | null = null;
try {
const resolved = resolveMcpConfig(mcp.content, meta);
const resolved = resolveMcpConfig(mcp.content, meta, mcp.name);
if (resolved) {
const serialized = JSON.stringify(resolved.config, null, 2);
configPreview = serialized;
// Show the wrapped form: this is what a user pastes into
// ~/.cursor/mcp.json. The bare inner object is not valid there.
configPreview = JSON.stringify(
{ mcpServers: { [resolved.name]: resolved.config } },
null,
2,
);
if (!installLink) {
installLink = buildMCPInstallDeepLink(
resolved.name,
Expand Down Expand Up @@ -133,17 +144,21 @@ export function McpSection({
mcp_link={installLink}
onInstall={onInstall}
/>
) : installable && mcp.content ? (
<CopyButton text={mcp.content} onCopy={onInstall} />
) : installable && (configPreview ?? mcp.content) ? (
<CopyButton
text={configPreview ?? mcp.content ?? ""}
onCopy={onInstall}
/>
) : null}
</div>
</div>

{isExpanded && configPreview && (
<div className="px-4 pb-4">
<p className="mb-2 text-[11px] uppercase tracking-wide text-muted-foreground/80">
This config will be passed to Cursor on install. Inspect
`command`, `args`, and `env` before continuing.
This config will be passed to Cursor on install, and is what
you paste into `~/.cursor/mcp.json` to add the server by hand.
Inspect `command`, `args`, and `env` before continuing.
</p>
<div className="max-h-96 overflow-y-auto rounded-lg border border-border bg-editor p-4 font-mono text-xs leading-6 text-muted-foreground">
<code className="block whitespace-pre-wrap">
Expand Down
6 changes: 6 additions & 0 deletions apps/cursor/src/components/plugins/detail/plugin-logo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,12 @@ export function PluginLogo({
logo.endsWith(".svg") && "invert",
)}
onError={() => setError(true)}
// Logos come from whatever host the plugin manifest points at (raw
// GitHub content for relative paths, anything for absolute URLs), so
// the image optimizer's remotePatterns allowlist would 500 the page
// for most of them. The card and leaderboard already render the same
// URLs as plain <img>; do the same here.
unoptimized
/>
);
}
34 changes: 33 additions & 1 deletion apps/cursor/src/components/plugins/plugin-owner-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
Loader2,
MoreHorizontal,
Pencil,
RefreshCw,
Trash2,
} from "lucide-react";
import Link from "next/link";
Expand All @@ -16,6 +17,7 @@ import { useEffect, useState } from "react";
import { toast } from "sonner";
import { deletePluginAction } from "@/actions/delete-plugin";
import { requestPluginVerificationAction } from "@/actions/request-plugin-verification";
import { rescanOwnPluginAction } from "@/actions/rescan-own-plugin";
import { togglePluginListingAction } from "@/actions/toggle-plugin-listing";
import {
AlertDialog,
Expand Down Expand Up @@ -106,6 +108,19 @@ export function PluginOwnerMenu({ plugin }: Props) {
},
);

const { execute: rescanPlugin, isExecuting: isRescanning } = useAction(
rescanOwnPluginAction,
{
onSuccess: () => {
toast.success("Scan queued. The listing updates when it finishes.");
router.refresh();
},
onError: ({ error }) => {
toast.error(error.serverError ?? "Failed to start the scan.");
},
},
);

const { execute: deletePlugin, isExecuting: isDeleting } = useAction(
deletePluginAction,
{
Expand All @@ -124,9 +139,15 @@ export function PluginOwnerMenu({ plugin }: Props) {
return null;
}

const busy = isRequesting || isToggling || isDeleting;
const busy = isRequesting || isToggling || isRescanning || isDeleting;
const canVerify = !plugin.verified && !requestPending;
const canPublish = !active && !plugin.permanently_blocked;
// Mirrors the server-side gate in rescanOwnPluginAction.
const canRescan =
!plugin.permanently_blocked &&
(plugin.scan_status === "flagged" ||
plugin.scan_status === "error" ||
plugin.scan_status === "unscanned");

return (
<>
Expand Down Expand Up @@ -192,6 +213,17 @@ export function PluginOwnerMenu({ plugin }: Props) {
</DropdownMenuItem>
) : null}

{canRescan ? (
<DropdownMenuItem
className={menuItemClass}
disabled={busy}
onClick={() => rescanPlugin({ pluginId: plugin.id })}
>
<RefreshCw className="size-4 shrink-0" />
Re-run scan
</DropdownMenuItem>
) : null}

<DropdownMenuSeparator />

<DropdownMenuItem
Expand Down
11 changes: 9 additions & 2 deletions apps/cursor/src/lib/cron-auth.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { timingSafeEqual } from "node:crypto";
import { NextResponse } from "next/server";

/**
Expand All @@ -24,8 +25,14 @@ export function requireCronAuth(request: Request): NextResponse | null {
);
}

const header = request.headers.get("authorization");
if (header !== `Bearer ${cronSecret}`) {
const header = request.headers.get("authorization") ?? "";
const expected = Buffer.from(`Bearer ${cronSecret}`);
const received = Buffer.from(header);
// Constant-time compare so the response time does not leak how much of
// the secret matched. Lengths must match first or timingSafeEqual throws.
const authorized =
received.length === expected.length && timingSafeEqual(received, expected);
if (!authorized) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

Expand Down
3 changes: 2 additions & 1 deletion apps/cursor/src/lib/github-plugin/parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,7 @@ export async function parseGitHubPlugin(
description: `MCP server: ${name}`,
content: JSON.stringify(cfg, null, 2),
metadata: {
name,
command: cfg.command,
args: cfg.args,
env: cfg.env,
Expand Down Expand Up @@ -578,7 +579,7 @@ export async function parseGitHubPlugin(
throw new GitHubParseError(
`No plugin components found in: ${scannedPrefixes}. ` +
`We looked for: ${hints.join(", ")}. ` +
`Make sure your repo follows the Open Plugins standard (https://open-plugins.com).`,
`Make sure your repo follows the Open Plugins standard (https://agent-plugins.org).`,
"no_components",
);
}
Expand Down
Loading