From 9b50133fc756c9261b689ec1c7c2ac74abdb276c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 24 Aug 2026 16:39:36 +0000 Subject: [PATCH 1/7] feat(bots): add listing schema, parser, and queries 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 --- apps/cursor/src/actions/create-bot.ts | 87 +++++++ apps/cursor/src/actions/parse-github-bot.ts | 23 ++ apps/cursor/src/actions/review-bot.ts | 55 +++++ apps/cursor/src/data/queries.ts | 171 +++++++++++++ apps/cursor/src/lib/bots/insert.ts | 87 +++++++ apps/cursor/src/lib/bots/parse.ts | 253 ++++++++++++++++++++ apps/cursor/src/lib/bots/types.ts | 79 ++++++ supabase/migrations/20260824_bots.sql | 190 +++++++++++++++ 8 files changed, 945 insertions(+) create mode 100644 apps/cursor/src/actions/create-bot.ts create mode 100644 apps/cursor/src/actions/parse-github-bot.ts create mode 100644 apps/cursor/src/actions/review-bot.ts create mode 100644 apps/cursor/src/lib/bots/insert.ts create mode 100644 apps/cursor/src/lib/bots/parse.ts create mode 100644 apps/cursor/src/lib/bots/types.ts create mode 100644 supabase/migrations/20260824_bots.sql diff --git a/apps/cursor/src/actions/create-bot.ts b/apps/cursor/src/actions/create-bot.ts new file mode 100644 index 00000000..c6b4e005 --- /dev/null +++ b/apps/cursor/src/actions/create-bot.ts @@ -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 }; + }, + ); diff --git a/apps/cursor/src/actions/parse-github-bot.ts b/apps/cursor/src/actions/parse-github-bot.ts new file mode 100644 index 00000000..1be8c2ce --- /dev/null +++ b/apps/cursor/src/actions/parse-github-bot.ts @@ -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; + } + }); diff --git a/apps/cursor/src/actions/review-bot.ts b/apps/cursor/src/actions/review-bot.ts new file mode 100644 index 00000000..a277b8bb --- /dev/null +++ b/apps/cursor/src/actions/review-bot.ts @@ -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 }; + }); diff --git a/apps/cursor/src/data/queries.ts b/apps/cursor/src/data/queries.ts index bfb9dbd8..195d2d92 100644 --- a/apps/cursor/src/data/queries.ts +++ b/apps/cursor/src/data/queries.ts @@ -23,13 +23,85 @@ * companies — company lists * company-{slug} — a single company profile * mcps — MCP listings + * bots — bot use-case listings + * bot-{slug} — a single bot */ import { cacheLife, cacheTag } from "next/cache"; +import { + type BotDetail, + type BotNeed, + type BotRow, + parseBotNeeds, + parseScanStatus, + type ResolvedBotNeed, +} from "@/lib/bots/types"; import type { PluginRow } from "@/lib/plugins/types"; import { createClient } from "@/utils/supabase/admin-client"; import { fetchAllPages } from "@/utils/supabase/pagination"; +function asOptionalString(value: unknown): string | null { + return typeof value === "string" ? value : null; +} + +function asOptionalNumber(value: unknown): number | null { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "string" && value !== "") { + const n = Number(value); + return Number.isFinite(n) ? n : null; + } + return null; +} + +function asBotRow(row: Record): BotRow { + return { + id: String(row.id), + name: String(row.name), + slug: String(row.slug), + description: String(row.description ?? ""), + writeup: String(row.writeup ?? ""), + template: String(row.template ?? ""), + needs: parseBotNeeds(row.needs), + repository: asOptionalString(row.repository), + homepage: asOptionalString(row.homepage), + logo: asOptionalString(row.logo), + owner_id: asOptionalString(row.owner_id), + active: Boolean(row.active), + scan_status: parseScanStatus(row.scan_status), + discovery_source: asOptionalString(row.discovery_source), + github_repo_id: asOptionalNumber(row.github_repo_id), + created_at: String(row.created_at), + updated_at: String(row.updated_at), + }; +} + +async function resolveBotNeeds(needs: BotNeed[]): Promise { + const slugs = needs.flatMap((n) => + n.kind === "plugin" && n.slug ? [n.slug] : [], + ); + const hrefBySlug = new Map(); + + if (slugs.length > 0) { + const supabase = await createClient(); + const { data } = await supabase + .from("plugins") + .select("slug") + .eq("active", true) + .in("slug", slugs); + for (const plugin of data ?? []) { + hrefBySlug.set(plugin.slug, `/plugins/${plugin.slug}`); + } + } + + return needs.map((need) => { + if (need.kind === "skill") return need; + return { + ...need, + href: need.slug ? (hrefBySlug.get(need.slug) ?? null) : null, + }; + }); +} + async function fetchUserProfile(slug: string, userId?: string) { const supabase = await createClient(); @@ -614,3 +686,102 @@ export async function getMembers({ return { data, error }; } + +// --------------------------------------------------------------------------- +// Bots (use-case listings) +// --------------------------------------------------------------------------- + +export async function getBots({ + fetchAll = true, +}: { + fetchAll?: boolean; +} = {}): Promise<{ data: BotRow[] | null; error: unknown }> { + "use cache"; + cacheLife("hours"); + cacheTag("bots"); + + const supabase = await createClient(); + + const baseQuery = () => + supabase + .from("bots") + .select("*") + .eq("active", true) + .order("created_at", { ascending: false }); + + if (fetchAll) { + const result = await fetchAllPages>( + async (from, to) => { + const { data, error } = await baseQuery().range(from, to); + return { data: data as Record[] | null, error }; + }, + 100, + ); + if (result.error || !result.data) { + return { data: null, error: result.error }; + } + return { + data: result.data.map(asBotRow), + error: null, + }; + } + + const { data, error } = await baseQuery(); + return { + data: (data ?? []).map((row) => asBotRow(row as Record)), + error, + }; +} + +export async function getBotBySlug(slug: string): Promise<{ + data: BotDetail | null; + error: unknown; +}> { + "use cache"; + cacheLife("hours"); + cacheTag("bots", `bot-${slug}`, "plugins"); + + const supabase = await createClient(); + const { data, error } = await supabase + .from("bots") + .select("*") + .eq("slug", slug) + .single(); + + if (!data) return { data: null, error }; + + const bot = asBotRow(data as Record); + return { + data: { ...bot, needs: await resolveBotNeeds(bot.needs) }, + error, + }; +} + +export async function getPendingBots(): Promise<{ + data: BotRow[] | null; + error: unknown; +}> { + const supabase = await createClient(); + + const result = await fetchAllPages>( + async (from, to) => { + const { data, error } = await supabase + .from("bots") + .select("*") + .eq("active", false) + .order("created_at", { ascending: false }) + .range(from, to); + return { data: data as Record[] | null, error }; + }, + 100, + ); + + if (result.error || !result.data) { + return { data: null, error: result.error }; + } + + return { + data: result.data.map(asBotRow), + error: null, + }; +} diff --git a/apps/cursor/src/lib/bots/insert.ts b/apps/cursor/src/lib/bots/insert.ts new file mode 100644 index 00000000..724bbea6 --- /dev/null +++ b/apps/cursor/src/lib/bots/insert.ts @@ -0,0 +1,87 @@ +/** + * Insert a bot listing. The create-bot action owns auth and rate limits. + * Does not enqueue plugin_scans: that queue's drain runs runPluginScan + * and expects a plugins row. + */ + +import { createClient } from "@/utils/supabase/admin-client"; +import { type BotNeed, botNeedsSchema } from "./types"; + +export type InsertBotInput = { + name: string; + description: string; + writeup: string; + template: string; + needs?: BotNeed[]; + repository?: string | null; + homepage?: string | null; + logo?: string | null; +}; + +export type InsertBotOptions = { + ownerId: string | null; + source: string; + githubRepoId?: number | null; + skipReview?: boolean; +}; + +export class InsertBotError extends Error { + constructor( + message: string, + public readonly code: "duplicate_name" | "duplicate_repo" | "insert_failed", + ) { + super(message); + this.name = "InsertBotError"; + } +} + +export async function insertBot( + input: InsertBotInput, + options: InsertBotOptions, +): Promise<{ id: string; slug: string }> { + const supabase = await createClient(); + const skipReview = options.skipReview === true; + const needs = botNeedsSchema.parse(input.needs ?? []); + + const { data: bot, error } = await supabase + .from("bots") + .insert({ + name: input.name, + description: input.description, + writeup: input.writeup, + template: input.template, + needs, + repository: input.repository || null, + homepage: input.homepage || null, + logo: input.logo || null, + owner_id: options.ownerId, + active: skipReview, + scan_status: skipReview ? "unscanned" : "pending", + discovery_source: options.source, + github_repo_id: options.githubRepoId ?? null, + }) + .select("id, slug") + .single(); + + if (error) { + if (error.code === "23505") { + const detail = error.message?.toLowerCase() ?? ""; + if (detail.includes("github_repo_id")) { + throw new InsertBotError( + "A bot with this GitHub repository already exists.", + "duplicate_repo", + ); + } + throw new InsertBotError( + "A bot with this name already exists.", + "duplicate_name", + ); + } + throw new InsertBotError( + `Failed to create bot: ${error.message}`, + "insert_failed", + ); + } + + return { id: bot.id, slug: bot.slug }; +} diff --git a/apps/cursor/src/lib/bots/parse.ts b/apps/cursor/src/lib/bots/parse.ts new file mode 100644 index 00000000..66cff8b6 --- /dev/null +++ b/apps/cursor/src/lib/bots/parse.ts @@ -0,0 +1,253 @@ +/** + * Parse a public GitHub repo into a bot listing. + * + * Do not call parseGitHubPlugin here. That parser requires Open Plugins + * files and maps `agents/*.md` to plugin components. A bot listing is a + * use-case template (`bot.json` / `BOT.md`). Reuse would reject a valid bot + * repo (`no_components`) and would ingest agent files as this listing's body. + */ + +import { + type FetchOptions, + fetchGitHubRepoMeta, + fetchWithRateLimit, + githubAuthHeaders, + parseGitHubUrl, +} from "@/lib/github-plugin/parse"; +import { slugify } from "@/lib/slug"; +import { type BotNeed, botNeedSchema } from "./types"; + +export type ParsedBot = { + name: string; + description: string; + writeup: string; + template: string; + needs: BotNeed[]; + repository: string; + homepage?: string; + github_repo_id?: number; +}; + +export class BotParseError extends Error { + constructor( + message: string, + public readonly code: + | "invalid_url" + | "repo_unreadable" + | "no_bot" + | "open_plugin_agent", + ) { + super(message); + this.name = "BotParseError"; + } +} + +const MANIFEST_PATHS = ["bot.json", ".cursor/bot.json"]; +const TEMPLATE_PATHS = ["BOT.md", "bot.md", "template.md"]; +const WRITEUP_PATHS = ["WRITEUP.md", "writeup.md", "README.md"]; + +async function fetchGitHubFile( + owner: string, + repo: string, + path: string, +): Promise { + const url = `https://raw.githubusercontent.com/${owner}/${repo}/HEAD/${path}`; + try { + const res = await fetch(url, { cache: "no-store" }); + if (!res.ok) return null; + return res.text(); + } catch { + return null; + } +} + +async function fetchGitHubTree( + owner: string, + repo: string, + opts: FetchOptions = {}, +): Promise<{ path: string; type: string }[]> { + const url = `https://api.github.com/repos/${owner}/${repo}/git/trees/HEAD?recursive=1`; + try { + const res = await fetchWithRateLimit(url, { + cache: "no-store", + headers: { + Accept: "application/vnd.github.v3+json", + ...githubAuthHeaders(), + }, + maxWaitMs: opts.maxWaitMs, + }); + if (!res.ok) return []; + const data = await res.json(); + return (data.tree ?? []).map((t: { path: string; type: string }) => ({ + path: t.path, + type: t.type, + })); + } catch { + return []; + } +} + +function asRecord(value: unknown): Record | null { + if (value && typeof value === "object" && !Array.isArray(value)) { + return value as Record; + } + return null; +} + +function parseNeeds(manifest: Record): BotNeed[] { + const needs: BotNeed[] = []; + + const plugins = manifest.plugins; + if (Array.isArray(plugins)) { + for (const entry of plugins) { + if (typeof entry === "string" && entry.trim()) { + const name = entry.trim(); + needs.push({ kind: "plugin", name, slug: slugify(name) }); + continue; + } + const rec = asRecord(entry); + if (!rec) continue; + const name = typeof rec.name === "string" ? rec.name.trim() : ""; + if (!name) continue; + const parsed = botNeedSchema.safeParse({ + kind: "plugin", + 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() } + : {}), + }); + if (parsed.success) needs.push(parsed.data); + } + } + + const skills = manifest.skills; + if (Array.isArray(skills)) { + for (const entry of skills) { + if (typeof entry === "string" && entry.trim()) { + needs.push({ kind: "skill", name: entry.trim() }); + continue; + } + const rec = asRecord(entry); + if (!rec) continue; + const name = typeof rec.name === "string" ? rec.name.trim() : ""; + if (!name) continue; + needs.push({ kind: "skill", name }); + } + } + + return needs; +} + +function hasOpenPluginAgents(tree: { path: string; type: string }[]): boolean { + return tree.some( + (f) => f.type === "blob" && /(^|\/)agents\/[^/]+\.md$/.test(f.path), + ); +} + +export async function parseGitHubBot( + url: string, + options: { maxWaitMs?: number } = {}, +): Promise { + const parsed = parseGitHubUrl(url); + if (!parsed) { + throw new BotParseError( + "Invalid GitHub URL. Expected format: https://github.com/owner/repo", + "invalid_url", + ); + } + + const { owner, repo } = parsed; + const fetchOpts: FetchOptions = { maxWaitMs: options.maxWaitMs }; + + const tree = await fetchGitHubTree(owner, repo, fetchOpts); + if (tree.length === 0) { + throw new BotParseError( + "Could not read repository. Make sure the repo exists, is public, and the URL is correct.", + "repo_unreadable", + ); + } + + let manifest: Record = {}; + let foundManifest = false; + for (const path of MANIFEST_PATHS) { + const content = await fetchGitHubFile(owner, repo, path); + if (!content) continue; + try { + const json: unknown = JSON.parse(content); + const rec = asRecord(json); + if (rec) { + manifest = rec; + foundManifest = true; + break; + } + } catch { + throw new BotParseError(`Could not parse ${path} as JSON.`, "no_bot"); + } + } + + let template = + typeof manifest.template === "string" ? manifest.template.trim() : ""; + if (!template) { + for (const path of TEMPLATE_PATHS) { + const content = await fetchGitHubFile(owner, repo, path); + if (content?.trim()) { + template = content.trim(); + break; + } + } + } + + let writeup = + typeof manifest.writeup === "string" ? manifest.writeup.trim() : ""; + if (!writeup) { + for (const path of WRITEUP_PATHS) { + const content = await fetchGitHubFile(owner, repo, path); + if (content?.trim()) { + writeup = content.trim(); + break; + } + } + } + + const name = + (typeof manifest.name === "string" && manifest.name.trim()) || + repo.replace(/[-_]+/g, " "); + const description = + (typeof manifest.description === "string" && manifest.description.trim()) || + writeup.slice(0, 180) || + template.slice(0, 180); + + if (!template || !writeup) { + if (!foundManifest && hasOpenPluginAgents(tree)) { + throw new BotParseError( + "This repo looks like an Open Plugins agent (`agents/*.md`). That is a plugin component, not a bot listing. Submit it at /plugins/new. A bot repo needs bot.json or BOT.md plus a use-case writeup.", + "open_plugin_agent", + ); + } + throw new BotParseError( + "No bot listing found. Add bot.json (name, description, template, writeup, plugins, skills) or BOT.md plus a README/WRITEUP.md. Open Plugins `agents/*.md` files are plugins, not bots.", + "no_bot", + ); + } + + const homepage = + typeof manifest.homepage === "string" && manifest.homepage.trim() + ? manifest.homepage.trim() + : undefined; + + const meta = await fetchGitHubRepoMeta(owner, repo, fetchOpts); + + return { + name, + description: description.slice(0, 280), + writeup, + template, + needs: parseNeeds(manifest), + repository: `https://github.com/${owner}/${repo}`, + homepage, + github_repo_id: meta?.id, + }; +} diff --git a/apps/cursor/src/lib/bots/types.ts b/apps/cursor/src/lib/bots/types.ts new file mode 100644 index 00000000..49a34068 --- /dev/null +++ b/apps/cursor/src/lib/bots/types.ts @@ -0,0 +1,79 @@ +/** + * Bot listing domain types. A bot is a use-case page: copyable template, + * plugins/skills it needs, SEO writeup. Not a plugin category and not an + * Open Plugins `agents/*.md` component. + */ + +import { z } from "zod"; +import type { ScanStatus } from "@/lib/plugins/types"; + +const SCAN_STATUSES: readonly ScanStatus[] = [ + "pending", + "scanning", + "safe", + "flagged", + "error", + "unscanned", +]; + +export function parseScanStatus(value: unknown): ScanStatus { + for (const status of SCAN_STATUSES) { + if (value === status) return status; + } + return "pending"; +} + +export const botNeedSchema = z.discriminatedUnion("kind", [ + z.object({ + kind: z.literal("plugin"), + name: z.string().min(1), + slug: z.string().min(1).optional(), + repository: z.string().url().optional(), + }), + z.object({ + kind: z.literal("skill"), + name: z.string().min(1), + }), +]); + +export type BotNeed = z.infer; + +export const botNeedsSchema = z.array(botNeedSchema); + +export type ResolvedBotNeed = + | (Extract & { href: string | null }) + | Extract; + +export type BotRow = { + id: string; + name: string; + slug: string; + description: string; + writeup: string; + template: string; + needs: BotNeed[]; + repository: string | null; + homepage: string | null; + logo: string | null; + owner_id: string | null; + active: boolean; + scan_status: ScanStatus; + discovery_source: string | null; + github_repo_id: number | null; + created_at: string; + updated_at: string; +}; + +export type BotDetail = Omit & { + needs: ResolvedBotNeed[]; +}; + +export function parseBotNeeds(value: unknown): BotNeed[] { + if (!Array.isArray(value)) return []; + const needs: BotNeed[] = []; + for (const item of value) { + const parsed = botNeedSchema.safeParse(item); + if (parsed.success) needs.push(parsed.data); + } + return needs; +} diff --git a/supabase/migrations/20260824_bots.sql b/supabase/migrations/20260824_bots.sql new file mode 100644 index 00000000..531e74fd --- /dev/null +++ b/supabase/migrations/20260824_bots.sql @@ -0,0 +1,190 @@ +-- First-class bot listings (use-case pages), sibling to plugins / mcps. +-- A bot is a copyable template + the plugins/skills it needs + a writeup. +-- It is not an Open Plugins `agents/*.md` component. + +create table if not exists public.bots ( + id uuid primary key default gen_random_uuid(), + name text not null unique, + slug text not null unique, + description text not null, + writeup text not null, + template text not null, + needs jsonb not null default '[]'::jsonb, + repository text, + homepage text, + logo text, + owner_id uuid references public.users (id) on delete set null, + active boolean not null default false, + scan_status text not null default 'pending' + check (scan_status in ( + 'pending', + 'scanning', + 'safe', + 'flagged', + 'error', + 'unscanned' + )), + discovery_source text, + github_repo_id bigint, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + constraint bots_needs_is_array check (jsonb_typeof(needs) = 'array') +); + +create unique index if not exists bots_github_repo_id_unique + on public.bots (github_repo_id) + where github_repo_id is not null; + +create index if not exists bots_active_created_at_idx + on public.bots (created_at desc) + where active = true; + +do $$ +begin + if not exists ( + select 1 from pg_proc p + join pg_namespace n on n.oid = p.pronamespace + where n.nspname = 'public' and p.proname = 'generate_bot_slug' + ) then + create function public.generate_bot_slug() + returns trigger + language plpgsql + set search_path = public + as $fn$ + declare + v_slug text; + begin + if new.slug is not null and new.slug <> '' then + return new; + end if; + v_slug := btrim(regexp_replace(lower(new.name), '[^a-z0-9]+', '-', 'g'), '-'); + if v_slug is null or v_slug = '' then + v_slug := 'bot'; + end if; + v_slug := left(v_slug, 80); + if exists (select 1 from public.bots b where b.slug = v_slug) then + v_slug := left(v_slug, 73) || '-' || substr(md5(random()::text), 1, 6); + end if; + new.slug := v_slug; + return new; + end; + $fn$; + end if; + + if not exists (select 1 from pg_trigger where tgname = 'bots_generate_slug') then + create trigger bots_generate_slug + before insert on public.bots + for each row execute function public.generate_bot_slug(); + end if; + + if exists ( + select 1 from pg_proc p + join pg_namespace n on n.oid = p.pronamespace + where n.nspname = 'public' and p.proname = 'set_updated_at' + ) and not exists ( + select 1 from pg_trigger where tgname = 'bots_set_updated_at' + ) then + create trigger bots_set_updated_at + before update on public.bots + for each row execute function public.set_updated_at(); + end if; +end$$; + +alter table public.bots enable row level security; + +do $$ +begin + if not exists ( + select 1 from pg_policies + where schemaname = 'public' and tablename = 'bots' + and policyname = 'bots_select_active_or_own' + ) then + create policy bots_select_active_or_own on public.bots + for select using (active = true or (select auth.uid()) = owner_id); + end if; + + 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); + end if; + + if not exists ( + select 1 from pg_policies + where schemaname = 'public' and tablename = 'bots' + and policyname = 'bots_update_own' + ) then + 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); + end if; + + if not exists ( + select 1 from pg_policies + where schemaname = 'public' and tablename = 'bots' + and policyname = 'bots_delete_own' + ) then + create policy bots_delete_own on public.bots + for delete to authenticated using ((select auth.uid()) = owner_id); + end if; +end$$; + +-- Editorial seed so /bots and generateStaticParams have one use-case page +-- on an empty database. Idempotent on slug. +insert into public.bots ( + name, + slug, + description, + writeup, + template, + needs, + repository, + active, + scan_status, + discovery_source +) +values ( + 'Review a pull request', + 'review-a-pull-request', + 'Paste this template into Cursor, install the plugins it lists, and review a GitHub pull request from the agent chat.', + $writeup$ +Review a pull request from Cursor without leaving the editor. + +This page is a use-case listing, not a plugin. It ships a copyable bot template, the plugins and skills that template expects, and the steps to run it. Search engines should rank this URL for the job (review a PR in Cursor), not for a plugin name. + +A bot listing is not an Open Plugins agent file. `agents/*.md` in a repo is a plugin component. Submit that repo at /plugins/new. Submit a bot when the repo describes a use case: a template someone can copy, plus the plugins or skills it needs. + +How it works + +Copy the template on this page. Paste it into Cursor agent chat. Install any listed plugins you do not already have. Then give the agent the pull request URL or the local branch. + +What to ask the agent + +Name the files that changed. Ask it to check tests, error handling, and secrets. Ask it to say what it would not merge, and why. +$writeup$, + $template$ +You are reviewing a GitHub pull request in this repo. + +1. Identify the PR (URL, branch, or `gh pr view` output I paste). +2. Summarize the change in three sentences or fewer. +3. List the files that matter and what each one does in this diff. +4. Call out bugs, missing tests, secret leaks, and API contract breaks. +5. Say whether you would merge, request changes, or reject. Give one reason. + +Do not invent files that are not in the diff. If you cannot see the PR, ask me for the URL. +$template$, + '[ + {"kind":"plugin","name":"GitHub","slug":"github"}, + {"kind":"skill","name":"code-review"} + ]'::jsonb, + 'https://github.com/cursor/community-plugins', + true, + 'unscanned', + 'seed:cursor-directory' +) +on conflict (slug) do nothing; From ef56f258a1b0459f8b80fbc6a4ca8a266f22b4d0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 24 Aug 2026 16:39:40 +0000 Subject: [PATCH 2/7] feat(bots): add list, detail, submit, and nav 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 --- README.md | 21 + .../src/app/admin/bots/bot-review-list.tsx | 111 ++++++ apps/cursor/src/app/admin/bots/page.tsx | 42 ++ apps/cursor/src/app/bots/[slug]/page.tsx | 47 +++ apps/cursor/src/app/bots/new/page.tsx | 58 +++ apps/cursor/src/app/bots/page.tsx | 50 +++ apps/cursor/src/app/layout.tsx | 6 +- apps/cursor/src/app/sitemap.ts | 22 +- .../cursor/src/components/bots/bot-detail.tsx | 119 ++++++ apps/cursor/src/components/bots/bot-list.tsx | 51 +++ apps/cursor/src/components/footer.tsx | 2 + apps/cursor/src/components/forms/bot-form.tsx | 368 ++++++++++++++++++ apps/cursor/src/components/header.tsx | 5 + apps/cursor/src/components/mobile-menu.tsx | 8 + apps/cursor/src/components/user-menu.tsx | 3 + 15 files changed, 908 insertions(+), 5 deletions(-) create mode 100644 apps/cursor/src/app/admin/bots/bot-review-list.tsx create mode 100644 apps/cursor/src/app/admin/bots/page.tsx create mode 100644 apps/cursor/src/app/bots/[slug]/page.tsx create mode 100644 apps/cursor/src/app/bots/new/page.tsx create mode 100644 apps/cursor/src/app/bots/page.tsx create mode 100644 apps/cursor/src/components/bots/bot-detail.tsx create mode 100644 apps/cursor/src/components/bots/bot-list.tsx create mode 100644 apps/cursor/src/components/forms/bot-form.tsx diff --git a/README.md b/README.md index 25bd9c7f..7bb3cf83 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/apps/cursor/src/app/admin/bots/bot-review-list.tsx b/apps/cursor/src/app/admin/bots/bot-review-list.tsx new file mode 100644 index 00000000..2d47a1b4 --- /dev/null +++ b/apps/cursor/src/app/admin/bots/bot-review-list.tsx @@ -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 ( +
+
+
+ + {bot.name} + + +

+ {bot.description} +

+
+
+ + +
+
+
+ ); +} + +export function BotReviewList({ bots }: { bots: BotRow[] }) { + if (bots.length === 0) { + return ( +
+

+ No pending bots to review. +

+
+ ); + } + + return ( +
+ {bots.map((bot) => ( + + ))} +
+ ); +} diff --git a/apps/cursor/src/app/admin/bots/page.tsx b/apps/cursor/src/app/admin/bots/page.tsx new file mode 100644 index 00000000..820af60e --- /dev/null +++ b/apps/cursor/src/app/admin/bots/page.tsx @@ -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 ; +} + +export default function AdminBotsPage() { + return ( +
+
+
+

Review Bots

+

+ Bot submissions land here unpublished. Scan is stubbed. Approve to + list the use case on /bots. +

+
+ + + +
+
+ ); +} diff --git a/apps/cursor/src/app/bots/[slug]/page.tsx b/apps/cursor/src/app/bots/[slug]/page.tsx new file mode 100644 index 00000000..a6bb7847 --- /dev/null +++ b/apps/cursor/src/app/bots/[slug]/page.tsx @@ -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 { + 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 ; +} diff --git a/apps/cursor/src/app/bots/new/page.tsx b/apps/cursor/src/app/bots/new/page.tsx new file mode 100644 index 00000000..41837345 --- /dev/null +++ b/apps/cursor/src/app/bots/new/page.tsx @@ -0,0 +1,58 @@ +import type { Metadata } from "next"; +import { redirect } from "next/navigation"; +import { Suspense } from "react"; +import { BotForm } from "@/components/forms/bot-form"; +import { getSession } from "@/utils/supabase/auth"; + +export const metadata: Metadata = { + title: "Submit a Bot | Cursor Directory", + description: + "Submit a bot use case to Cursor Directory. Paste a GitHub repo with bot.json or BOT.md.", + openGraph: { + title: "Submit a Bot | Cursor Directory", + description: + "Submit a bot use case to Cursor Directory. Paste a GitHub repo with bot.json or BOT.md.", + }, + twitter: { + title: "Submit a Bot | Cursor Directory", + description: + "Submit a bot use case to Cursor Directory. Paste a GitHub repo with bot.json or BOT.md.", + }, +}; + +async function NewBotGate() { + const session = await getSession(); + + if (!session) { + redirect("/login?next=/bots/new"); + } + + return ; +} + +export default function Page() { + return ( +
+
+
+

Submit a Bot

+

+ Paste a GitHub repo. We look for bot.json or BOT.md plus a use-case + writeup. Open Plugins agents/*.md files belong on{" "} + + plugin submit + + . +

+
+ + + + +
+
+ ); +} diff --git a/apps/cursor/src/app/bots/page.tsx b/apps/cursor/src/app/bots/page.tsx new file mode 100644 index 00000000..59367492 --- /dev/null +++ b/apps/cursor/src/app/bots/page.tsx @@ -0,0 +1,50 @@ +import type { Metadata } from "next"; +import { cacheLife, cacheTag } from "next/cache"; +import Link from "next/link"; +import { BotList } from "@/components/bots/bot-list"; +import { Button } from "@/components/ui/button"; +import { getBots } from "@/data/queries"; + +export const metadata: Metadata = { + title: "Bots", + description: + "Copyable Cursor bot templates composed with the plugins and skills they need. Use-case pages from the community.", + openGraph: { + title: "Bots | Cursor Directory", + description: + "Copyable Cursor bot templates composed with the plugins and skills they need.", + }, + twitter: { + title: "Bots | Cursor Directory", + description: + "Copyable Cursor bot templates composed with the plugins and skills they need.", + }, +}; + +export default async function Page() { + "use cache"; + cacheLife("hours"); + cacheTag("bots"); + + const { data: bots } = await getBots({ fetchAll: true }); + + return ( +
+
+
+

Bots

+

+ Use-case pages. Each one is a copyable bot template, the plugins and + skills it needs, and a writeup you can rank for search. +

+
+ + + +
+ +
+ ); +} diff --git a/apps/cursor/src/app/layout.tsx b/apps/cursor/src/app/layout.tsx index 99d40fda..21e79a66 100644 --- a/apps/cursor/src/app/layout.tsx +++ b/apps/cursor/src/app/layout.tsx @@ -19,7 +19,7 @@ export const metadata: Metadata = { template: "%s | Cursor Directory", }, description: - "Discover plugins, MCP servers, rules, and resources for Cursor — the AI code editor. Join thousands of developers.", + "Discover plugins, bots, MCP servers, rules, and resources for Cursor. Join thousands of developers.", icons: [ { rel: "icon", @@ -30,7 +30,7 @@ export const metadata: Metadata = { openGraph: { title: "Cursor Directory", description: - "Discover plugins, MCP servers, rules, and resources for Cursor — the AI code editor.", + "Discover plugins, bots, MCP servers, rules, and resources for Cursor.", url: "https://cursor.directory", siteName: "Cursor Directory", locale: "en_US", @@ -40,7 +40,7 @@ export const metadata: Metadata = { card: "summary_large_image", title: "Cursor Directory", description: - "Discover plugins, MCP servers, rules, and resources for Cursor — the AI code editor.", + "Discover plugins, bots, MCP servers, rules, and resources for Cursor.", }, }; diff --git a/apps/cursor/src/app/sitemap.ts b/apps/cursor/src/app/sitemap.ts index 50c49433..4fccd9aa 100644 --- a/apps/cursor/src/app/sitemap.ts +++ b/apps/cursor/src/app/sitemap.ts @@ -1,13 +1,13 @@ import type { MetadataRoute } from "next"; import { cacheLife, cacheTag } from "next/cache"; -import { getCompanies, getPlugins } from "@/data/queries"; +import { getBots, getCompanies, getPlugins } from "@/data/queries"; const BASE_URL = "https://cursor.directory"; export default async function sitemap(): Promise { "use cache"; cacheLife("hours"); - cacheTag("plugins", "companies"); + cacheTag("plugins", "companies", "bots"); const routes: MetadataRoute.Sitemap = [ { @@ -22,6 +22,12 @@ export default async function sitemap(): Promise { changeFrequency: "daily", priority: 0.9, }, + { + url: `${BASE_URL}/bots`, + lastModified: new Date(), + changeFrequency: "daily", + priority: 0.9, + }, { url: `${BASE_URL}/members`, lastModified: new Date(), @@ -54,6 +60,18 @@ export default async function sitemap(): Promise { } } + const { data: bots } = await getBots({ fetchAll: true }); + if (bots) { + for (const bot of bots) { + routes.push({ + url: `${BASE_URL}/bots/${bot.slug}`, + lastModified: new Date(bot.updated_at), + changeFrequency: "weekly", + priority: 0.7, + }); + } + } + const { data: companyData } = await getCompanies(); if (companyData) { for (const company of companyData) { diff --git a/apps/cursor/src/components/bots/bot-detail.tsx b/apps/cursor/src/components/bots/bot-detail.tsx new file mode 100644 index 00000000..1e19e0c9 --- /dev/null +++ b/apps/cursor/src/components/bots/bot-detail.tsx @@ -0,0 +1,119 @@ +"use client"; + +import Link from "next/link"; +import { CopyButton } from "@/components/plugins/detail/copy-button"; +import type { BotDetail } from "@/lib/bots/types"; + +export function BotDetailView({ bot }: { bot: BotDetail }) { + const plugins = bot.needs.filter((n) => n.kind === "plugin"); + const skills = bot.needs.filter((n) => n.kind === "skill"); + + return ( +
+
+ {!bot.active && ( +
+ This bot is in the review queue. It is not listed on /bots until an + admin publishes it. +
+ )} + +

Bot use case

+

{bot.name}

+

{bot.description}

+ +
+

+ Copy this template +

+
    +
  1. Copy the template below.
  2. +
  3. Open Cursor agent chat and paste it.
  4. +
  5. + Install the plugins listed on this page if you do not have them. +
  6. +
  7. Give the agent the task (a PR URL, a repo, a file).
  8. +
+
+
+ Template + +
+
+              {bot.template}
+            
+
+
+ + {(plugins.length > 0 || skills.length > 0) && ( +
+

+ Plugins and skills it needs +

+
    + {plugins.map((need) => ( +
  • +
    +

    {need.name}

    +

    Plugin

    +
    + {need.href ? ( + + Open listing + + ) : need.repository ? ( + + Repository + + ) : ( + + Not in the directory yet + + )} +
  • + ))} + {skills.map((need) => ( +
  • +

    {need.name}

    +

    Skill

    +
  • + ))} +
+
+ )} + +
+

Use case

+
+ {bot.writeup} +
+
+ + {bot.repository && ( + + Source repository + + )} +
+
+ ); +} diff --git a/apps/cursor/src/components/bots/bot-list.tsx b/apps/cursor/src/components/bots/bot-list.tsx new file mode 100644 index 00000000..90e4f7c9 --- /dev/null +++ b/apps/cursor/src/components/bots/bot-list.tsx @@ -0,0 +1,51 @@ +"use client"; + +import Link from "next/link"; +import type { BotRow } from "@/lib/bots/types"; + +export function BotList({ bots }: { bots: BotRow[] }) { + if (bots.length === 0) { + return ( +
+

No bot use cases yet.

+ + Submit a bot + +
+ ); + } + + return ( +
    + {bots.map((bot) => ( +
  • + +

    + {bot.name} +

    +

    + {bot.description} +

    + {bot.needs.length > 0 && ( +

    + {bot.needs + .map((need) => + need.kind === "plugin" + ? `Plugin: ${need.name}` + : `Skill: ${need.name}`, + ) + .join(" · ")} +

    + )} + +
  • + ))} +
+ ); +} diff --git a/apps/cursor/src/components/footer.tsx b/apps/cursor/src/components/footer.tsx index 40f0ea50..ccd661c6 100644 --- a/apps/cursor/src/components/footer.tsx +++ b/apps/cursor/src/components/footer.tsx @@ -10,6 +10,7 @@ const columns = [ title: "Explore", links: [ { href: "/", label: "Plugins" }, + { href: "/bots", label: "Bots" }, { href: "/plugins/new", label: "Submit a Plugin" }, ], }, @@ -49,6 +50,7 @@ const columns = [ title: "Contribute", links: [ { href: "/plugins/new", label: "Submit a Plugin" }, + { href: "/bots/new", label: "Submit a Bot" }, { href: "https://github.com/cursor/community-plugins", label: "GitHub", diff --git a/apps/cursor/src/components/forms/bot-form.tsx b/apps/cursor/src/components/forms/bot-form.tsx new file mode 100644 index 00000000..73f9e2fd --- /dev/null +++ b/apps/cursor/src/components/forms/bot-form.tsx @@ -0,0 +1,368 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { AlertCircle, Loader2 } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useAction } from "next-safe-action/hooks"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { z } from "zod"; +import { createBotAction } from "@/actions/create-bot"; +import { parseGitHubBotAction } from "@/actions/parse-github-bot"; +import { GithubIcon } from "@/components/icons/github-icon"; +import { Button } from "@/components/ui/button"; +import { + Form, + FormControl, + FormField, + FormItem, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Textarea } from "@/components/ui/textarea"; +import type { ParsedBot } from "@/lib/bots/parse"; +import type { BotNeed } from "@/lib/bots/types"; +import { slugify } from "@/lib/slug"; + +const autoFormSchema = z.object({ + url: z + .string() + .url("Please enter a valid URL") + .regex(/github\.com/, "Must be a GitHub URL"), +}); + +function parseNeedList(pluginsRaw: string, skillsRaw: string): BotNeed[] { + const plugins = pluginsRaw + .split(",") + .map((s) => s.trim()) + .filter(Boolean) + .map((name) => ({ kind: "plugin" as const, name, slug: slugify(name) })); + const skills = skillsRaw + .split(",") + .map((s) => s.trim()) + .filter(Boolean) + .map((name) => ({ kind: "skill" as const, name })); + return [...plugins, ...skills]; +} + +export function BotForm() { + const router = useRouter(); + const [mode, setMode] = useState<"auto" | "manual">("auto"); + const [parsed, setParsed] = useState(null); + const [editedName, setEditedName] = useState(""); + const [editedDescription, setEditedDescription] = useState(""); + const [editedWriteup, setEditedWriteup] = useState(""); + const [editedTemplate, setEditedTemplate] = useState(""); + const [parseError, setParseError] = useState(null); + const [publishError, setPublishError] = useState(null); + + const [manualName, setManualName] = useState(""); + const [manualDescription, setManualDescription] = useState(""); + const [manualWriteup, setManualWriteup] = useState(""); + const [manualTemplate, setManualTemplate] = useState(""); + const [manualRepository, setManualRepository] = useState(""); + const [manualPlugins, setManualPlugins] = useState(""); + const [manualSkills, setManualSkills] = useState(""); + + const form = useForm>({ + resolver: zodResolver(autoFormSchema), + defaultValues: { url: "" }, + }); + + const { execute: executeParse, isExecuting: isParsing } = useAction( + parseGitHubBotAction, + { + onSuccess: ({ data }) => { + if (data) { + setParsed(data); + setEditedName(data.name); + setEditedDescription(data.description); + setEditedWriteup(data.writeup); + setEditedTemplate(data.template); + setParseError(null); + } + }, + onError: ({ error }) => { + setParseError(error.serverError ?? "Failed to parse repository"); + setParsed(null); + }, + }, + ); + + const { execute: executeCreate, isExecuting: isCreating } = useAction( + createBotAction, + { + onSuccess: ({ data }) => { + toast.success("Submitted. It will appear on /bots after review."); + router.push(data?.slug ? `/bots/${data.slug}` : "/bots"); + }, + onError: ({ error }) => { + setPublishError( + error.serverError ?? "Failed to submit bot. Please try again.", + ); + }, + }, + ); + + const onParse = (values: z.infer) => { + setParseError(null); + setPublishError(null); + setParsed(null); + executeParse({ url: values.url }); + }; + + const onPublishAuto = () => { + if (!parsed) return; + setPublishError(null); + executeCreate({ + name: editedName || parsed.name, + description: editedDescription || parsed.description, + writeup: editedWriteup || parsed.writeup, + template: editedTemplate || parsed.template, + needs: parsed.needs, + repository: parsed.repository, + homepage: parsed.homepage ?? null, + }); + }; + + const onPublishManual = () => { + setPublishError(null); + executeCreate({ + name: manualName.trim(), + description: manualDescription.trim(), + writeup: manualWriteup.trim(), + template: manualTemplate.trim(), + needs: parseNeedList(manualPlugins, manualSkills), + repository: manualRepository.trim() || null, + }); + }; + + return ( +
+ { + if (v !== "auto" && v !== "manual") return; + setMode(v); + setPublishError(null); + }} + > + + + Auto (GitHub) + + + Manual + + + + +
+ + ( + + +
+
+ + +
+ +
+
+ +
+ )} + /> + + + + {parseError && ( +
+ +

{parseError}

+
+ )} + + {parsed && ( +
+
+ + setEditedName(e.target.value)} + /> +
+
+ + setEditedDescription(e.target.value)} + /> +
+
+ +