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/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/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..2d4d4207 --- /dev/null +++ b/apps/cursor/src/app/bots/[slug]/page.tsx @@ -0,0 +1,54 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { BotDetailView } from "@/components/bots/bot-detail"; +import { getBotBySlug, getBots } from "@/data/queries"; +import { SEED_BOT_SLUG } from "@/lib/bots/seed"; + +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() { + try { + const { data: bots } = await getBots({ fetchAll: true }); + const slugs = new Set((bots ?? []).map((bot) => bot.slug)); + slugs.add(SEED_BOT_SLUG); + return [...slugs].map((slug) => ({ slug })); + } catch { + return [{ slug: SEED_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)} + /> +
+
+ +