feat(bots): add first-class bot use-case listings - #426
Open
mattppal wants to merge 7 commits into
Open
Conversation
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>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Contributor
There was a problem hiding this comment.
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 = falseto 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.
- Added
- ✅ 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).
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.
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>
Contributor
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 3 potential issues.
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.
- Removed the seed-slug branch under
- ✅ 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.
- The outer catch in getBots now returns the seed only when the thrown error is a missing-relation error and otherwise returns
- ✅ Fixed: Plugin links skip existence check
- unresolvedBotNeeds now sets
href: nullfor plugin needs so the failure path shows the honest "not in the directory yet" state instead of unverified /plugins/{slug} links that may 404.
- unresolvedBotNeeds now sets
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.
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.


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/*.mdfiles stay plugin components.This site already hosts sibling routes (
/plugins,/members) and sibling tables (plugins,mcps)./botsfits here. A separate bot.directory is not required.Scope
public.botstable, RLS, slug trigger, and one seeded row (review-a-pull-request) insupabase/migrations/20260824_bots.sql.apps/cursor/src/lib/bots/(types,parseGitHubBot,insertBot, editorialseedfallback). Parser readsbot.json/BOT.md/ writeup files. It does not callparseGitHubPlugin. A repo that only hasagents/*.mdis rejected with a pointer to/plugins/new./bots/new(GitHub or Google sign-in),createBotAction, pendingscan_statuswithout enqueueingplugin_scans./admin/botsapprove/decline, same shape as plugin hidden-queue actions./botslist,/bots/[slug]detail (template, copy path, plugin/skill needs, writeup), sitemap + Bots nav.public.botsis missing (preview/prod before20260824_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.active = true.repositoryis present but not a URL (drops only that field). Invalidbot.jsondoes not abort; it tries.cursor/bot.jsonand 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
kindunion, 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 onplugin_scans, because drain callsrunPluginScanand 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 typecheckinapps/cursor(pass).bunx biome ci .(pass, pre-existing<img>warnings only).82e74d2.repositorykeepsname/slug. Invalidbot.jsonfalls through to.cursor/bot.jsonorBOT.md.active=trueis42501. Owner patch ofactive=trueupdates 0 rows. Service-role insert succeeds./botsand/bots/review-a-pull-request200./bots/newredirects to login.Do not merge until review.