Skip to content

feat(bots): add first-class bot use-case listings - #426

Open
mattppal wants to merge 7 commits into
mainfrom
cursor/bots-first-class-listing-0136
Open

feat(bots): add first-class bot use-case listings#426
mattppal wants to merge 7 commits into
mainfrom
cursor/bots-first-class-listing-0136

Conversation

@mattppal

@mattppal mattppal commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator

Why

cursor.directory needs a listing type for copyable bot templates that compose plugins and skills into one searchable use-case page. That is not a plugin category. Open Plugins agents/*.md files stay plugin components.

This site already hosts sibling routes (/plugins, /members) and sibling tables (plugins, mcps). /bots fits here. A separate bot.directory is not required.

Scope

  • New public.bots table, RLS, slug trigger, and one seeded row (review-a-pull-request) in supabase/migrations/20260824_bots.sql.
  • Domain module apps/cursor/src/lib/bots/ (types, parseGitHubBot, insertBot, editorial seed fallback). Parser reads bot.json / BOT.md / writeup files. It does not call parseGitHubPlugin. A repo that only has agents/*.md is rejected with a pointer to /plugins/new.
  • Submit: /bots/new (GitHub or Google sign-in), createBotAction, pending scan_status without enqueueing plugin_scans.
  • Review: /admin/bots approve/decline, same shape as plugin hidden-queue actions.
  • Public: /bots list, /bots/[slug] detail (template, copy path, plugin/skill needs, writeup), sitemap + Bots nav.
  • README: how to submit a bot vs a plugin.
  • If public.bots is missing (preview/prod before 20260824_bots.sql), reads fall back to the editorial seed so Cache Components can prerender /bots/review-a-pull-request. Transient query failures are not cached as that seed. No production schema grant is required for this PR.
  • Writes go through the service-role admin client. RLS allows select of active/own rows and owner delete. There is no insert or update policy, so an authenticated user cannot self-publish by setting active = true.
  • Parser keeps a plugin need when repository is present but not a URL (drops only that field). Invalid bot.json does not abort; it tries .cursor/bot.json and markdown fallbacks.

Out of scope: plugin security scan for bots, Slack bots, a new Anysphere repo, changing plugin submit/scan/trending.

Tradeoffs

We store needs as jsonb parsed into a kind union, not a join table. The detail page still links /plugins/{slug} when that plugin exists. An empty plugins table does not 500.

Bot scan is stubbed. User submit inserts active=false. We do not put bot ids on plugin_scans, because drain calls runPluginScan and would regress plugins.

The seed listing exists twice until the migration is applied: SQL for databases that have the table, TypeScript for builds that do not. Queries prefer the table when it exists.

Blast Radius

Public nav gains a Bots link. Plugin homepage trending, createPluginAction, parseGitHubPlugin, and the scan drain path are untouched.

The migration inserts one editorial bot. Applying it in production publishes that seed.

Verification

  • bun run typecheck in apps/cursor (pass).
  • bunx biome ci . (pass, pre-existing <img> warnings only).
  • GitHub Actions "Lint & typecheck" green. Vercel preview green on 82e74d2.
  • Parser: invalid plugin repository keeps name/slug. Invalid bot.json falls through to .cursor/bot.json or BOT.md.
  • RLS: owner insert of active=true is 42501. Owner patch of active=true updates 0 rows. Service-role insert succeeds.
  • Local Postgres + PostgREST: /bots and /bots/review-a-pull-request 200. /bots/new redirects to login.

Do not merge until review.

Open in Web Open in Cursor 

cursoragent and others added 2 commits August 24, 2026 16:39
Add a `bots` table sibling to plugins, a GitHub parser that looks for
bot.json/BOT.md instead of Open Plugins agents/*.md, and insert/review
actions that do not enqueue plugin_scans.

Co-authored-by: Matt <mattppal@users.noreply.github.com>
Ship /bots, /bots/[slug], /bots/new, and /admin/bots. Header and README
treat bots as a listing type next to plugins, not a plugin category.

Co-authored-by: Matt <mattppal@users.noreply.github.com>
@vercel

vercel Bot commented Aug 24, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
cursor-directory Building Building Preview Aug 24, 2026 6:16pm

Request Review

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 3 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for all 3 issues found in the latest run.

  • ✅ Fixed: RLS lets owners self-publish
    • Added active = false to the with-check clauses of bots_insert_own and bots_update_own so owner-scoped writes cannot insert published bots or flip active, keeping publishing admin-only via the service-role client.
  • ✅ Fixed: Bad manifest blocks fallback path
    • The manifest loop now ignores invalid JSON at a path and continues to the next MANIFEST_PATHS candidate instead of throwing, matching the plugin parser's fallback behavior (verified with a stubbed-network test).
  • ✅ Fixed: Invalid repo URL drops needs
    • parseNeeds now retries the schema parse without the optional repository field when it fails, so a need with an invalid repository URL keeps its name/slug and only drops the bad field (verified with a stubbed-network test).

Create PR

Or push these changes by commenting:

@cursor push e1c042c47c
Preview (e1c042c47c)
diff --git a/apps/cursor/src/lib/bots/parse.ts b/apps/cursor/src/lib/bots/parse.ts
--- a/apps/cursor/src/lib/bots/parse.ts
+++ b/apps/cursor/src/lib/bots/parse.ts
@@ -109,16 +109,25 @@ function parseNeeds(manifest: Record<string, unknown>): BotNeed[] {
       if (!rec) continue;
       const name = typeof rec.name === "string" ? rec.name.trim() : "";
       if (!name) continue;
-      const parsed = botNeedSchema.safeParse({
-        kind: "plugin",
+      const base = {
+        kind: "plugin" as const,
         name,
         ...(typeof rec.slug === "string" && rec.slug.trim()
           ? { slug: rec.slug.trim() }
           : { slug: slugify(name) }),
-        ...(typeof rec.repository === "string" && rec.repository.trim()
-          ? { repository: rec.repository.trim() }
-          : {}),
-      });
+      };
+      const repository =
+        typeof rec.repository === "string" && rec.repository.trim()
+          ? rec.repository.trim()
+          : undefined;
+      // repository is optional link metadata: if its URL is invalid, keep
+      // the need and drop only that field instead of the whole entry.
+      const withRepository = repository
+        ? botNeedSchema.safeParse({ ...base, repository })
+        : undefined;
+      const parsed = withRepository?.success
+        ? withRepository
+        : botNeedSchema.safeParse(base);
       if (parsed.success) needs.push(parsed.data);
     }
   }

@@ -109,16 +109,25 @@ function parseNeeds(manifest: Record<string, unknown>): BotNeed[] {
       if (!rec) continue;
       const name = typeof rec.name === "string" ? rec.name.trim() : "";
       if (!name) continue;
-      const parsed = botNeedSchema.safeParse({
-        kind: "plugin",
+      const base = {
+        kind: "plugin" as const,
         name,
         ...(typeof rec.slug === "string" && rec.slug.trim()
           ? { slug: rec.slug.trim() }
           : { slug: slugify(name) }),
-        ...(typeof rec.repository === "string" && rec.repository.trim()
-          ? { repository: rec.repository.trim() }
-          : {}),
-      });
+      };
+      const repository =
+        typeof rec.repository === "string" && rec.repository.trim()
+          ? rec.repository.trim()
+          : undefined;
+      // repository is optional link metadata: if its URL is invalid, keep
+      // the need and drop only that field instead of the whole entry.
+      const withRepository = repository
+        ? botNeedSchema.safeParse({ ...base, repository })
+        : undefined;
+      const parsed = withRepository?.success
+        ? withRepository
+        : botNeedSchema.safeParse(base);
       if (parsed.success) needs.push(parsed.data);
     }
   }
@@ -184,7 +193,7 @@ export async function parseGitHubBot(
         break;
       }
     } catch {
-      throw new BotParseError(`Could not parse ${path} as JSON.`, "no_bot");
+      // Invalid JSON at this path must not block the fallback paths.
     }
   }
 

@@ -184,7 +193,7 @@ export async function parseGitHubBot(
         break;
       }
     } catch {
-      throw new BotParseError(`Could not parse ${path} as JSON.`, "no_bot");
+      // Invalid JSON at this path must not block the fallback paths.
     }
   }
 

diff --git a/supabase/migrations/20260824_bots.sql b/supabase/migrations/20260824_bots.sql
--- a/supabase/migrations/20260824_bots.sql
+++ b/supabase/migrations/20260824_bots.sql
@@ -103,14 +103,17 @@ begin
       for select using (active = true or (select auth.uid()) = owner_id);
   end if;
 
+  -- Publishing is admin-only (service role, /admin/bots). Owner-scoped
+  -- writes must not be able to set or keep active = true, or a signed-in
+  -- user could bypass the review queue via the user-scoped API key.
   if not exists (
     select 1 from pg_policies
     where schemaname = 'public' and tablename = 'bots'
       and policyname = 'bots_insert_own'
   ) then
     create policy bots_insert_own on public.bots
       for insert to authenticated
-      with check ((select auth.uid()) = owner_id);
+      with check ((select auth.uid()) = owner_id and active = false);
   end if;
 
   if not exists (

@@ -103,14 +103,17 @@ begin
       for select using (active = true or (select auth.uid()) = owner_id);
   end if;
 
+  -- Publishing is admin-only (service role, /admin/bots). Owner-scoped
+  -- writes must not be able to set or keep active = true, or a signed-in
+  -- user could bypass the review queue via the user-scoped API key.
   if not exists (
     select 1 from pg_policies
     where schemaname = 'public' and tablename = 'bots'
       and policyname = 'bots_insert_own'
   ) then
     create policy bots_insert_own on public.bots
       for insert to authenticated
-      with check ((select auth.uid()) = owner_id);
+      with check ((select auth.uid()) = owner_id and active = false);
   end if;
 
   if not exists (
@@ -121,7 +124,7 @@ begin
     create policy bots_update_own on public.bots
       for update to authenticated
       using ((select auth.uid()) = owner_id)
-      with check ((select auth.uid()) = owner_id);
+      with check ((select auth.uid()) = owner_id and active = false);
   end if;
 
   if not exists (

@@ -121,7 +124,7 @@ begin
     create policy bots_update_own on public.bots
       for update to authenticated
       using ((select auth.uid()) = owner_id)
-      with check ((select auth.uid()) = owner_id);
+      with check ((select auth.uid()) = owner_id and active = false);
   end if;
 
   if not exists (

You can send follow-ups to the cloud agent here.

Comment thread supabase/migrations/20260824_bots.sql
Comment thread apps/cursor/src/lib/bots/parse.ts
Comment thread apps/cursor/src/lib/bots/parse.ts Outdated
Keep the parse/insert why-docs that stop reuse of plugin scan and parseGitHubPlugin.

Co-authored-by: Matt <mattppal@users.noreply.github.com>
Preview and hosted builds still talk to a database that has not applied
20260824_bots.sql. Treat a missing public.bots relation as the editorial
use-case page so Cache Components has a param and /bots does not 500.

Co-authored-by: Matt <mattppal@users.noreply.github.com>
Drop insert/update RLS so only the service-role client can write.
Keep a plugin need when repository is not a URL. Skip invalid bot.json
and try .cursor/bot.json or markdown fallbacks.

Co-authored-by: Matt <mattppal@users.noreply.github.com>
generateStaticParams always includes review-a-pull-request. If hosted
Postgres has no row (empty table, PGRST116, or a thrown client error),
render the editorial seed instead of 500ing the Vercel preview.

Co-authored-by: Matt <mattppal@users.noreply.github.com>

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 3 potential issues.

Fix All in Cursor

Bugbot Autofix prepared fixes for all 3 issues found in the latest run.

  • ✅ Fixed: Seed served when DB row missing
    • Removed the seed-slug branch under if (!data) so a missing row in an existing bots table returns not-found, keeping the detail page consistent with the list; the seed is now served only via the missing-relation paths.
  • ✅ Fixed: Failures cached as seed catalog
    • The outer catch in getBots now returns the seed only when the thrown error is a missing-relation error and otherwise returns { data: null, error }, so transient failures are no longer cached as a healthy seed-only catalog.
  • ✅ Fixed: Plugin links skip existence check
    • unresolvedBotNeeds now sets href: null for plugin needs so the failure path shows the honest "not in the directory yet" state instead of unverified /plugins/{slug} links that may 404.

Create PR

Or push these changes by commenting:

@cursor push 77620c0de3
Preview (77620c0de3)
diff --git a/apps/cursor/src/data/queries.ts b/apps/cursor/src/data/queries.ts
--- a/apps/cursor/src/data/queries.ts
+++ b/apps/cursor/src/data/queries.ts
@@ -81,12 +81,11 @@
 }
 
 function unresolvedBotNeeds(needs: BotNeed[]): ResolvedBotNeed[] {
+  // Existence is unknown, so link nothing rather than point at plugin pages
+  // that may be missing or inactive.
   return needs.map((need) => {
     if (need.kind === "skill") return need;
-    return {
-      ...need,
-      href: need.slug ? `/plugins/${need.slug}` : null,
-    };
+    return { ...need, href: null };
   });
 }
 
@@ -757,10 +756,14 @@
       data: (data ?? []).map((row) => asBotRow(row as Record<string, unknown>)),
       error,
     };
-  } catch {
+  } catch (error) {
     // Cache Components prerenders /bots. A thrown client error must not 500
-    // the preview build when public.bots is missing or the query dies.
-    return { data: [SEED_BOT], error: null };
+    // the preview build when public.bots is missing. Other throws surface as
+    // errors so an outage is never cached as a healthy seed-only catalog.
+    if (isMissingRelationError(error, "bots")) {
+      return { data: [SEED_BOT], error: null };
+    }
+    return { data: null, error };
   }
 }
 
@@ -786,9 +789,8 @@
     }
 
     if (!data) {
-      if (slug === SEED_BOT_SLUG) {
-        return { data: await seedBotDetail(), error: null };
-      }
+      // The table exists but the row does not: not found, even for the seed
+      // slug, so the detail page never disagrees with the list.
       return { data: null, error };
     }

You can send follow-ups to the cloud agent here.

Reviewed by Cursor Bugbot for commit 82e74d2. Configure here.

Comment thread apps/cursor/src/data/queries.ts Outdated
Comment thread apps/cursor/src/data/queries.ts Outdated
Comment thread apps/cursor/src/data/queries.ts Outdated
A thrown client error must not be stored as the editorial listing for
hours. Plugin need hrefs stay null unless an active plugin row exists.
getBotBySlug uses the TypeScript seed only for a missing relation.

Co-authored-by: Matt <mattppal@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants