From 4395faba12ef7133573da25242326f24517fd373 Mon Sep 17 00:00:00 2001 From: Lee Robinson Date: Mon, 8 Jun 2026 13:07:38 -0500 Subject: [PATCH 01/12] Remove dead code left behind by the rules-era UI None of these modules are imported anywhere: the subscribe form/action, rules search, save-rule button, how-to, filter input, search title, the 628-line ui/cursor.tsx animation, the videos dataset, and the pricing/ format/auth-client utils. formatNumber callers move to the existing formatCount in lib/utils so utils/format.ts can go too. Also drops the unused getSupabaseCookies helper, dead state in the mobile menu, and renames update-mcp-listing.tsx -> .ts since it contains no JSX. Co-authored-by: Cursor --- apps/cursor/src/actions/subscribe-action.ts | 23 - ...-mcp-listing.tsx => update-mcp-listing.ts} | 0 apps/cursor/src/components/filter-input.tsx | 38 -- apps/cursor/src/components/hero-title.tsx | 4 +- apps/cursor/src/components/how-to.tsx | 126 ---- .../src/components/members/members-card.tsx | 5 +- apps/cursor/src/components/mobile-menu.tsx | 10 +- .../src/components/profile/profile-header.tsx | 6 +- apps/cursor/src/components/rules-search.tsx | 32 - .../src/components/save-rule-button.tsx | 165 ----- apps/cursor/src/components/search-title.tsx | 17 - apps/cursor/src/components/ui/cursor.tsx | 628 ------------------ .../src/components/ui/subscribe-form.tsx | 97 --- apps/cursor/src/data/videos.ts | 201 ------ apps/cursor/src/utils/format.ts | 13 - apps/cursor/src/utils/pricing.ts | 19 - apps/cursor/src/utils/supabase/auth-client.ts | 21 - .../src/utils/supabase/client-session.ts | 25 +- 18 files changed, 15 insertions(+), 1415 deletions(-) delete mode 100644 apps/cursor/src/actions/subscribe-action.ts rename apps/cursor/src/actions/{update-mcp-listing.tsx => update-mcp-listing.ts} (100%) delete mode 100644 apps/cursor/src/components/filter-input.tsx delete mode 100644 apps/cursor/src/components/how-to.tsx delete mode 100644 apps/cursor/src/components/rules-search.tsx delete mode 100644 apps/cursor/src/components/save-rule-button.tsx delete mode 100644 apps/cursor/src/components/search-title.tsx delete mode 100644 apps/cursor/src/components/ui/cursor.tsx delete mode 100644 apps/cursor/src/components/ui/subscribe-form.tsx delete mode 100644 apps/cursor/src/data/videos.ts delete mode 100644 apps/cursor/src/utils/format.ts delete mode 100644 apps/cursor/src/utils/pricing.ts delete mode 100644 apps/cursor/src/utils/supabase/auth-client.ts diff --git a/apps/cursor/src/actions/subscribe-action.ts b/apps/cursor/src/actions/subscribe-action.ts deleted file mode 100644 index f41807c7..00000000 --- a/apps/cursor/src/actions/subscribe-action.ts +++ /dev/null @@ -1,23 +0,0 @@ -"use server"; - -export async function subscribeAction(formData: FormData, userGroup: string) { - const email = formData.get("email") as string; - - const res = await fetch( - "https://app.loops.so/api/newsletter-form/cm0bd20vj03imyjzv74y1crnb", - { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - email, - userGroup, - }), - }, - ); - - const json = await res.json(); - - return json; -} diff --git a/apps/cursor/src/actions/update-mcp-listing.tsx b/apps/cursor/src/actions/update-mcp-listing.ts similarity index 100% rename from apps/cursor/src/actions/update-mcp-listing.tsx rename to apps/cursor/src/actions/update-mcp-listing.ts diff --git a/apps/cursor/src/components/filter-input.tsx b/apps/cursor/src/components/filter-input.tsx deleted file mode 100644 index 17815b27..00000000 --- a/apps/cursor/src/components/filter-input.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { useState } from "react"; -import { SearchField } from "@/components/ui/search-field"; - -interface FilterInputProps { - onSearch: (term: string) => void; - clearSearch: () => void; -} - -export const FilterInput = ({ onSearch, clearSearch }: FilterInputProps) => { - const [searchTerm, setSearchTerm] = useState(""); - - const handleSearch = (event: React.ChangeEvent) => { - const term = event.target.value.toLowerCase(); - setSearchTerm(term); - onSearch(term); - }; - - const handleClear = () => { - setSearchTerm(""); - onSearch(""); - clearSearch(); - }; - - return ( -
- { - const term = value.toLowerCase(); - setSearchTerm(term); - onSearch(term); - }} - onClear={handleClear} - /> -
- ); -}; diff --git a/apps/cursor/src/components/hero-title.tsx b/apps/cursor/src/components/hero-title.tsx index 26eefca0..7aafc2c4 100644 --- a/apps/cursor/src/components/hero-title.tsx +++ b/apps/cursor/src/components/hero-title.tsx @@ -1,7 +1,7 @@ "use client"; import Link from "next/link"; -import { formatNumber } from "@/utils/format"; +import { formatCount } from "@/lib/utils"; const linkClass = "border-b border-dashed border-input text-foreground"; @@ -15,7 +15,7 @@ export function HeroTitle({ totalUsers }: { totalUsers: number }) {

Discover and install plugins from{" "} - {formatNumber(totalUsers)}+ developers + {formatCount(totalUsers)}+ developers , ranked by what’s trending.

diff --git a/apps/cursor/src/components/how-to.tsx b/apps/cursor/src/components/how-to.tsx deleted file mode 100644 index b4338a30..00000000 --- a/apps/cursor/src/components/how-to.tsx +++ /dev/null @@ -1,126 +0,0 @@ -import React from "react"; - -export const HowTo = () => { - return ( -
-

- How to Use Model Context Protocol (MCP) in Cursor -

- -
-

What is MCP?

-

- Model Context Protocol (MCP) is an open protocol that allows you to - provide custom tools to agentic LLMs (Large Language Models) in - Cursor's Composer feature. -

-
- -
-

Installation Steps

-
    -
  1. - Open Cursor Settings -
      -
    • Navigate to Cursor Settings > Features > MCP
    • -
    • Click the "+ Add New MCP Server" button
    • -
    -
  2. -
  3. - Configure the Server -
      -
    • Name: Give your server a nickname
    • -
    • Type: Select the transport type (stdio or sse)
    • -
    • - Command/URL: Enter either: -
        -
      • For SSE servers: The URL of the SSE endpoint
      • -
      • - For stdio servers: A valid shell command to run the server -
      • -
      -
    • -
    -
  4. -
-
- -
-

Example Configurations

-
-
-

- For stdio Server (Weather Server Example): -

- - Command: node - ~/mcp-quickstart/weather-server-typescript/build/index.js - -
-
-

For SSE Server:

- - URL: http://example.com:8000/sse - -
-
-
- -
-

Using MCP Tools

-
    -
  1. - Tool Availability -
      -
    • - After adding a server, it will appear in your MCP servers list -
    • -
    • - You may need to click the refresh button to populate the tool - list -
    • -
    -
  2. -
  3. - Using Tools in Composer -
      -
    • - The Composer Agent automatically uses MCP tools when relevant -
    • -
    • - You can explicitly prompt tool usage by: -
        -
      • Referring to the tool by name
      • -
      • Describing the tool's function
      • -
      -
    • -
    -
  4. -
  5. - Tool Execution Process -
      -
    • Displays a message in chat requesting approval
    • -
    • Shows tool call arguments (expandable)
    • -
    • Executes the tool upon user approval
    • -
    • Displays the tool's response in the chat
    • -
    -
  6. -
-
- -
-

Important Notes

-
    -
  • MCP tools may not work with all models
  • -
  • MCP tools are only available to the Agent in Composer
  • -
  • - For servers requiring environment variables, create a wrapper script - that sets the variables before running the server -
  • -
-
-
- ); -}; - -export default HowTo; diff --git a/apps/cursor/src/components/members/members-card.tsx b/apps/cursor/src/components/members/members-card.tsx index 330314c9..958f22d9 100644 --- a/apps/cursor/src/components/members/members-card.tsx +++ b/apps/cursor/src/components/members/members-card.tsx @@ -3,8 +3,7 @@ import Link from "next/link"; import { AmbassadorBadge } from "@/components/ambassador-badge"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; -import { cn } from "@/lib/utils"; -import { formatNumber } from "@/utils/format"; +import { cn, formatCount } from "@/lib/utils"; export function MembersCard({ member, @@ -58,7 +57,7 @@ export function MembersCard({ {(member.follower_count ?? 0) > 0 && ( - {formatNumber(member.follower_count!)}{" "} + {formatCount(member.follower_count!)}{" "} {member.follower_count === 1 ? "follower" : "followers"} )} diff --git a/apps/cursor/src/components/mobile-menu.tsx b/apps/cursor/src/components/mobile-menu.tsx index 48a3efda..ab46a1a2 100644 --- a/apps/cursor/src/components/mobile-menu.tsx +++ b/apps/cursor/src/components/mobile-menu.tsx @@ -1,6 +1,5 @@ "use client"; -import { AnimatePresence, motion } from "framer-motion"; import { Menu, X } from "lucide-react"; import Link from "next/link"; import { usePathname } from "next/navigation"; @@ -24,15 +23,12 @@ export function MobileMenu() { const [isOpen, setIsOpen] = useState(false); const supabase = createClient(); const [user, setUser] = useState(null); - const [isLoading, setIsLoading] = useState(true); useEffect(() => { async function getUser() { - setIsLoading(true); const session = await supabase.auth.getSession(); if (!session.data.session) { - setIsLoading(false); return; } @@ -43,7 +39,6 @@ export function MobileMenu() { .single(); setUser(data); - setIsLoading(false); } if (!user) { @@ -116,7 +111,10 @@ export function MobileMenu() { ))} setIsOpen(false)}> - diff --git a/apps/cursor/src/components/profile/profile-header.tsx b/apps/cursor/src/components/profile/profile-header.tsx index ebd1e06f..a2729083 100644 --- a/apps/cursor/src/components/profile/profile-header.tsx +++ b/apps/cursor/src/components/profile/profile-header.tsx @@ -1,7 +1,7 @@ "use client"; import Link from "next/link"; -import { formatNumber } from "@/utils/format"; +import { formatCount } from "@/lib/utils"; import { AmbassadorBadge } from "../ambassador-badge"; import { EditProfileModal } from "../modals/edit-profile-modal"; import { EditableAvatar } from "./editable-avatar"; @@ -60,12 +60,12 @@ export function ProfileHeader({
- {formatNumber(following_count)} Following + {formatCount(following_count)} Following - {formatNumber(followers_count)} Followers + {formatCount(followers_count)} Followers
diff --git a/apps/cursor/src/components/rules-search.tsx b/apps/cursor/src/components/rules-search.tsx deleted file mode 100644 index b34091f3..00000000 --- a/apps/cursor/src/components/rules-search.tsx +++ /dev/null @@ -1,32 +0,0 @@ -"use client"; - -import { useQueryState } from "nuqs"; -import { SearchField } from "./ui/search-field"; - -export function RulesSearch() { - const [search, setSearch] = useQueryState("q", { defaultValue: "" }); - - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); - }; - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Enter" && !e.shiftKey) { - e.preventDefault(); - handleSubmit(e as unknown as React.FormEvent); - } - }; - - return ( -
-
- - -
- ); -} diff --git a/apps/cursor/src/components/save-rule-button.tsx b/apps/cursor/src/components/save-rule-button.tsx deleted file mode 100644 index e623b0f4..00000000 --- a/apps/cursor/src/components/save-rule-button.tsx +++ /dev/null @@ -1,165 +0,0 @@ -"use client"; - -import { zodResolver } from "@hookform/resolvers/zod"; -import { Download } from "lucide-react"; -import { useEffect, useState } from "react"; -import { useForm } from "react-hook-form"; -import { toast } from "sonner"; -import { z } from "zod"; -import { Button } from "@/components/ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@/components/ui/dialog"; -import { - Form, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form"; -import { Input } from "@/components/ui/input"; -import { cn } from "@/lib/utils"; - -const SaveRuleSchema = z.object({ - fileName: z.string().min(1, "File name is required."), -}); - -type SaveRuleFormData = z.infer; - -export function SaveRuleButton({ - slug, - content, - small, -}: { - slug: string; - content: string; - small?: boolean; -}) { - const [open, setOpen] = useState(false); - const [isSupported, setIsSupported] = useState(false); - const form = useForm({ - resolver: zodResolver(SaveRuleSchema), - defaultValues: { - fileName: slug, - }, - }); - - useEffect(() => { - setIsSupported(isFileSystemAccessSupported()); - }, []); - - async function onSubmit({ fileName }: SaveRuleFormData) { - try { - const directoryHandle = await ( - window as unknown as { - showDirectoryPicker: () => Promise; - } - ).showDirectoryPicker(); - - const cursorDirectoryHandle = await directoryHandle.getDirectoryHandle( - ".cursor", - { create: true }, - ); - - const ruleDirectoryHandle = - await cursorDirectoryHandle.getDirectoryHandle("rules", { - create: true, - }); - - const fileHandle = await ruleDirectoryHandle.getFileHandle( - `${fileName}.mdc`, - { create: true }, - ); - - const writableStream = await fileHandle.createWritable(); - - await writableStream.write(content); - - await writableStream.close(); - - toast.success("File was saved successfully!"); - } catch (error) { - if (error instanceof Error && error.name === "AbortError") { - toast.error("Folder selection was canceled."); - } else { - console.error(error); - toast.error("Failed to save file."); - } - } finally { - setOpen(false); - } - } - - if (!isSupported) { - return null; - } - - return ( - - - - - - - Save Rule - - Save a rule on .cursor/rules directory. - - -
- - ( - - File Name - - - - - The file will be saved with a .mdc extension. - - - - )} - /> - - - -
-
- ); -} - -// @see https://web.dev/patterns/files/open-a-directory -function isFileSystemAccessSupported(): boolean { - return ( - typeof window !== "undefined" && - "showDirectoryPicker" in window && - (() => { - try { - return window.self === window.top; - } catch { - return false; - } - })() - ); -} diff --git a/apps/cursor/src/components/search-title.tsx b/apps/cursor/src/components/search-title.tsx deleted file mode 100644 index e8082e8d..00000000 --- a/apps/cursor/src/components/search-title.tsx +++ /dev/null @@ -1,17 +0,0 @@ -export function SearchTitle() { - return ( -
-

Find MCP servers

-

- Discover and search for custom MCP tools to extend the Agent in Cursor's - Composer feature.{" "} - - How to use them in Cursor. - -

-
- ); -} diff --git a/apps/cursor/src/components/ui/cursor.tsx b/apps/cursor/src/components/ui/cursor.tsx deleted file mode 100644 index 21837b94..00000000 --- a/apps/cursor/src/components/ui/cursor.tsx +++ /dev/null @@ -1,628 +0,0 @@ -export function Cursor() { - return ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ); -} diff --git a/apps/cursor/src/components/ui/subscribe-form.tsx b/apps/cursor/src/components/ui/subscribe-form.tsx deleted file mode 100644 index 8297261f..00000000 --- a/apps/cursor/src/components/ui/subscribe-form.tsx +++ /dev/null @@ -1,97 +0,0 @@ -"use client"; - -import { Loader2 } from "lucide-react"; -import { usePathname } from "next/navigation"; -import { useState } from "react"; -import { useFormStatus } from "react-dom"; -import { subscribeAction } from "@/actions/subscribe-action"; -import { cn } from "@/lib/utils"; - -function SubmitButton() { - const { pending } = useFormStatus(); - - if (pending) { - return ( -
- -
- ); - } - - return ( - - ); -} - -type Props = { - group: string; - placeholder: string; - className?: string; -}; - -export function SubscribeForm({ group, placeholder, className }: Props) { - const [isSubmitted, setSubmitted] = useState(false); - const pathname = usePathname(); - - if (pathname === "/generate" || pathname === "/mcp") { - return null; - } - - return ( -
-
- {isSubmitted ? ( -
-

Subscribed

- - - Check - - -
- ) : ( -
{ - setSubmitted(true); - await subscribeAction(formData, group); - - setTimeout(() => { - setSubmitted(false); - }, 5000); - }} - > -
- - -
-
- )} -
-
- ); -} diff --git a/apps/cursor/src/data/videos.ts b/apps/cursor/src/data/videos.ts deleted file mode 100644 index df16ae37..00000000 --- a/apps/cursor/src/data/videos.ts +++ /dev/null @@ -1,201 +0,0 @@ -export const videos = [ - { - title: - "Cursor UPDATE: Code Editor just got EVEN BETTER! (MCP Servers, Codebase Understanding, Fusion Model)", - description: - "In this video, we dive into the latest and most powerful update to Cursor, the agentic AI code editor! This update brings MCP servers, improved codebase understanding, and the game-changing Fusion Model—making Cursor smarter, faster, and more intuitive than ever before.", - url: "https://www.youtube.com/embed/2vJobjx1p6w", - author: { - name: "WorldofAI", - image: - "https://yt3.ggpht.com/Aee59geVCIWJz9y7AzVdnY3I1jPR1S4VFF4kIkNJ46VD6jrEGhH2VszD-vKly0XhHz_sLBN3u4A=s88-c-k-c0x00ffffff-no-rj", - }, - }, - { - title: "I spent 400+ hours in Cursor, here’s what I learned", - description: "I spent 400+ hours in Cursor, here’s what I learned", - url: "https://www.youtube.com/embed/gYLNxUxVomY", - author: { - name: "David Ondrej", - image: - "https://yt3.ggpht.com/Ksq0cOgKhbOuQMtIAjJlmrHSIDNbqmLtwosJNUZUuEZwNR3bc_W8l8Ve_xulQNidyb7dxqffyYI=s88-c-k-c0x00ffffff-no-rj", - }, - }, - { - title: "Cursor AI for Beginners: A Complete Guide", - description: - "Are you new to Cursor AI and wondering how to get started? In this video you'll learn exactly how to use Cursor AI (No Experience Needed).", - url: "https://www.youtube.com/embed/YG459bD8qmw", - author: { - name: "Richardson Dackam", - image: - "https://yt3.ggpht.com/wfdn_1ByKhAo3OaoSF3dXcKUM79Slz8p7m2DSLdk_nkCrDBdRAqFYieWziwmVTfiMf0sM2YhNA=s176-c-k-c0x00ffffff-no-rj", - }, - }, - { - title: "How to Use Cursor’s New Repository Rules (.cursor/rules)", - description: - "Cursor upgraded its rule system! Instead of a single .cursorrules file, you can now write multiple repository-level rules inside .cursor/rules", - url: "https://www.youtube.com/embed/1AxTVGxbkPs", - author: { - name: "Richardson Dackam", - image: - "https://yt3.ggpht.com/wfdn_1ByKhAo3OaoSF3dXcKUM79Slz8p7m2DSLdk_nkCrDBdRAqFYieWziwmVTfiMf0sM2YhNA=s176-c-k-c0x00ffffff-no-rj", - }, - }, - { - title: "Cursor AI tutorial for beginners", - description: - "In this episode, I am joined by Ras Mic, a full stack engineer & YouTuber, where we dive deep into the frameworks and strategies on how to best use Cursor AI. Mic shares his unique insights into how to use and set up Cursor to make the experience of building on top of Cursor as easy and seamless as possible. Learn how to use Cursor like a pro!", - url: "https://www.youtube.com/embed/gqUQbjsYZLQ", - author: { - name: "Greg Isenberg", - image: - "https://yt3.ggpht.com/sRSt1MT1n-FL1JTsZCcW35Vbio2kVTIrvU2TRDPZd0IBxPa8TDLsZtCLPzPaljwEvdy4kBojjw=s88-c-k-c0x00ffffff-no-rj", - }, - }, - { - title: "Using Cursor + Claude 3.5 Sonnet + Tailwind to ship 20x faster", - description: - "Using Cursor + Claude 3.5 Sonnet + Tailwind to ship 20x faster", - url: "https://www.youtube.com/embed/bEU15KXIAVk", - author: { - name: "Sahil Lavingia", - image: - "https://yt3.ggpht.com/7Hvm8iHnumiLr8aWtftr6rNckmhqt7FvhbxmUMD9eB55v_BSDmCPiFtCVRgR2JowNyz6al4Ohg=s88-c-k-c0x00ffffff-no-rj", - }, - }, - { - title: "Introduction to Cursor - AI Code Editor", - description: - "Discover Cursor: The revolutionary AI-powered code editor that’s transforming how developers work. Learn about its key features, natural language coding capabilities, and how it compares to traditional IDEs. Perfect for both beginners and experienced coders looking to boost productivity", - url: "https://www.youtube.com/embed/sKxUEnylsQg", - author: { - name: "Tech•sistence", - image: "https://img.youtube.com/vi/sKxUEnylsQg/maxresdefault.jpg", - }, - }, - { - title: "Coding with Cursor: Session 1", - description: "Coding with Cursor: Session 1", - url: "https://www.youtube.com/embed/1CC88QGQiEA", - author: { - name: "Sahil Lavingia", - image: - "https://yt3.ggpht.com/7Hvm8iHnumiLr8aWtftr6rNckmhqt7FvhbxmUMD9eB55v_BSDmCPiFtCVRgR2JowNyz6al4Ohg=s88-c-k-c0x00ffffff-no-rj", - }, - }, - { - title: "How I code 159% Faster using AI (Cursor + Sonnet 3.5)", - description: - "Cursor is a powerful new AI-powered code editor that makes coding much faster with the help of AI. In this video I will show you the fastest way to code with the best AI coding tools: Cursor and Claude Sonnet 3.5.", - url: "https://www.youtube.com/embed/yk9lXobJ95E", - author: { - name: "Volo", - image: - "https://yt3.ggpht.com/5SaI-Z9lEUBFO0Uv0wZK9olaKiLBmqyDlELKQywZQIdiBh_cuJMJqrvk2np3OFUMNalDrTdO=s88-c-k-c0x00ffffff-no-rj", - }, - }, - { - title: "Cursor Composer: MULTI-FILE AI Coding for engineers that SHIP", - description: - "Cursor Composer gives you INCREDIBLE Multi-File AI Coding Abilities", - url: "https://www.youtube.com/embed/V9_RzjqCXP8", - author: { - name: "IndyDevDan", - image: - "https://yt3.ggpht.com/tRTaWiEPa4eLVJgg3K0gO6orKleaIhxKcQBc4LryL_xczX5leDI5-6NEaD5xKEpwAQ_M7a747g=s88-c-k-c0x00ffffff-no-rj", - }, - }, - { - title: - "How to Build an AI Web App with Claude 3.5 and Cursor | Full Tutorial", - description: - "AI Programming Full Tutorial: YouTube Search App | Cursor - Claude 3.5 ++", - url: "https://www.youtube.com/embed/fv1rkctrEPk", - author: { - name: "All About AI", - image: - "https://yt3.ggpht.com/OLvM9exmm0IyZqyK_PLSNCcKZbkzUneljsQ7B_t6hjBawDy4mCYzLqQX8FxzNlVB8Tc10-VkJA=s88-c-k-c0x00ffffff-no-rj", - }, - }, - { - title: "I Finally Tried The AI-Powered VS Code Killer | Cursor IDE Review", - description: - "I Finally Tried The AI-Powered VS Code Killer | Cursor IDE Review", - url: "https://www.youtube.com/embed/u3wPImWBz7c", - author: { - name: "Your Average Tech Bro", - image: - "https://yt3.ggpht.com/-5pyvUOmvkobQLYDV39VhjNU4Fp4Z178V3_pHuxrokzwinC-CFo1omaY7Ra5-A_N7gLynPS3=s88-c-k-c0x00ffffff-no-rj", - }, - }, - { - title: "Cursor Editor - VS Code with GPT Built-In", - url: "https://www.youtube.com/embed/tjFnifSEEjQ", - description: - "Cursor AI Editor is revolutionizing the way developers write code, offering an AI-powered environment that accelerates the software development process. With features like Ctrl+K, Copilot++, and AI chat, Cursor provides an unparalleled coding experience that’s both efficient and intuitive.", - author: { - name: "Chris Titus Tech", - image: - "https://yt3.ggpht.com/R_rSQnTYQkL-rbtTA7djVbXLjU8Bwgua8GHJz6Ollsbyx_txdu0qVDBudCqvpzaxRQfVp2F4=s88-c-k-c0x00ffffff-no-rj", - }, - }, - { - title: "Coding with Cursor: Session 4 ft. @shaoruu", - url: "https://www.youtube.com/embed/42zmF9ARSWM", - description: - "Coding with Cursor: Session 4 ft. @shaoruu - developer at Cursor and Cursor Composer Creator", - author: { - name: "Sahil Lavingia", - image: - "https://yt3.ggpht.com/7Hvm8iHnumiLr8aWtftr6rNckmhqt7FvhbxmUMD9eB55v_BSDmCPiFtCVRgR2JowNyz6al4Ohg=s88-c-k-c0x00ffffff-no-rj", - }, - }, - { - title: "Let's dig into CursorAI, do's don't, how to use it", - url: "https://www.youtube.com/embed/_SN7fqSNThg?si=UYS8khW30im4bfxz", - description: - "@pierre_vannier walks through CursorAI, what it is, how to use it, and how to get the most out of it with Python and fastapi", - author: { - name: "Pierre Vannier", - image: - "https://yt3.ggpht.com/CmdYMKlESb6P6DoDZ11hbEzzZMbnIWLLn1Bovrcv3AjxRWdbGnUrgG0RtvycO04OLOrFs2emBg=s176-c-k-c0x00ffffff-no-rj-mo", - }, - }, - { - title: - "Cursor Composer Tutorial: Building a directory in 30 minutes from scratch", - url: "https://www.youtube.com/embed/nUTR11D8q08?si=aqh18rsdLbRWAOol", - description: - "@krisbuildsstuff shows how to build web directory using Cursor Composer and V0 Dev. You'll learn to build a tool listing, implement submission features, design individual pages, and organize tools into categories, and deploy with Vercel.", - author: { - name: "Kris Builds Stuff", - image: - "https://yt3.ggpht.com/XKgFFRlHWCIKHRXl1JFMBRW9VpHHVRUIpuTAudnHdPXAlSWINd7rRca8fSeqFf1lwkwmIvHbuA=s48-c-k-c0x00ffffff-no-rj", - }, - }, - { - title: - "Building (and deploying!) with AI-assistance using Cursor, Claude and Cloudflare", - url: "https://www.youtube.com/embed/oBDdcVaRhUk?si=8j-33MdX-1nHdxkR", - description: - "@rickyrobinett walks you through how to build, and deploy, an application using Cursor, Claude and Cloudflare.", - author: { - name: "Cloudflare", - image: - "https://yt3.ggpht.com/F3ahPSZ8266o3g-63hgpAYmLBxR2-Pove0uuE8OSKbcVRmuonb5wKAfCocdVrJ8bh8J315QwKA=s88-c-k-c0x00ffffff-no-rj", - }, - }, - { - title: "Master Cursor Rules and Fix AI Code Mistakes—Here’s How", - description: - "Frustrated with AI messing up your code? Here’s how I use Cursor Rules to make it work for me. ", - url: "https://www.youtube.com/embed/FLdCJe3Fxzw?si=5Z0SzMIY0RE4Mhra", - author: { - name: "Richardson Dackam", - image: - "https://yt3.ggpht.com/wfdn_1ByKhAo3OaoSF3dXcKUM79Slz8p7m2DSLdk_nkCrDBdRAqFYieWziwmVTfiMf0sM2YhNA=s176-c-k-c0x00ffffff-no-rj", - }, - }, -]; diff --git a/apps/cursor/src/utils/format.ts b/apps/cursor/src/utils/format.ts deleted file mode 100644 index dfbf1104..00000000 --- a/apps/cursor/src/utils/format.ts +++ /dev/null @@ -1,13 +0,0 @@ -export const formatNumber = (num: number) => { - if (num >= 1000000) { - let str = (num / 1000000).toFixed(num % 1000000 === 0 ? 0 : 1); - if (str.endsWith(".0")) str = str.slice(0, -2); - return `${str}M`; - } - if (num >= 1000) { - let str = (num / 1000).toFixed(num % 1000 === 0 ? 0 : 1); - if (str.endsWith(".0")) str = str.slice(0, -2); - return `${str}k`; - } - return num.toString(); -}; diff --git a/apps/cursor/src/utils/pricing.ts b/apps/cursor/src/utils/pricing.ts deleted file mode 100644 index 4a1bcc6d..00000000 --- a/apps/cursor/src/utils/pricing.ts +++ /dev/null @@ -1,19 +0,0 @@ -export type JobPlan = "standard" | "featured" | "premium"; - -export const JOB_PRICES = { - standard: 99, - featured: 299, - premium: 999, -} as const; - -export function getJobPlanPrice(plan: JobPlan): number { - return JOB_PRICES[plan]; -} - -export function formatPrice(amount: number): string { - return `$${amount}`; -} - -export function getFormattedJobPlanPrice(plan: JobPlan): string { - return formatPrice(getJobPlanPrice(plan)); -} diff --git a/apps/cursor/src/utils/supabase/auth-client.ts b/apps/cursor/src/utils/supabase/auth-client.ts deleted file mode 100644 index fee2d182..00000000 --- a/apps/cursor/src/utils/supabase/auth-client.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { createClient } from "./client"; - -export async function getSession() { - const supabase = createClient(); - const { - data: { session }, - error, - } = await supabase.auth.getSession(); - - if (error) { - console.error("Error getting session:", error); - return null; - } - - if (!session) { - console.log("No active session found"); - return null; - } - - return session; -} diff --git a/apps/cursor/src/utils/supabase/client-session.ts b/apps/cursor/src/utils/supabase/client-session.ts index 4e808017..fbdab6a3 100644 --- a/apps/cursor/src/utils/supabase/client-session.ts +++ b/apps/cursor/src/utils/supabase/client-session.ts @@ -1,28 +1,11 @@ /** - * Checks if the user is authenticated by looking for Supabase cookies - * @returns boolean indicating if user is authenticated + * Cheap, synchronous "is someone logged in?" check based on the presence of + * Supabase auth cookies. Useful for client components that only need to gate + * UI (e.g. show a login prompt) without waiting for a session round-trip. */ export const isAuthenticated = (): boolean => { - if (typeof document === "undefined") return false; // Check if we're in browser environment + if (typeof document === "undefined") return false; const cookies = document.cookie.split(";"); return cookies.some((cookie) => cookie.trim().startsWith("sb-")); }; - -/** - * Gets all Supabase-related cookies - * @returns Object containing Supabase cookies - */ -export const getSupabaseCookies = (): Record => { - if (typeof document === "undefined") return {}; // Check if we're in browser environment - - const cookies: Record = {}; - for (const cookie of document.cookie.split(";")) { - const [name, value] = cookie.trim().split("="); - if (name.startsWith("sb-")) { - cookies[name] = decodeURIComponent(value); - } - } - - return cookies; -}; From 66e5f9cc59af913e9cef1b483815d101a677de49 Mon Sep 17 00:00:00 2001 From: Lee Robinson Date: Mon, 8 Jun 2026 13:07:46 -0500 Subject: [PATCH 02/12] Remove the sync-ambassadors cron Vercel observability shows the hourly Airtable sync has 500'd on every run (~172/week) since the Airtable credentials went stale, so it only produces noise and wasted invocations. Ambassador flags can be set directly in Supabase; drop the cron, its route, and the AIRTABLE_* env docs. Co-authored-by: Cursor --- apps/cursor/.env.example | 9 -- .../app/api/cron/sync-ambassadors/route.ts | 120 ------------------ apps/cursor/vercel.json | 4 - 3 files changed, 133 deletions(-) delete mode 100644 apps/cursor/src/app/api/cron/sync-ambassadors/route.ts diff --git a/apps/cursor/.env.example b/apps/cursor/.env.example index f895fdf6..7f75066d 100644 --- a/apps/cursor/.env.example +++ b/apps/cursor/.env.example @@ -34,14 +34,5 @@ GITHUB_TOKEN= # Vercel Web Analytics is enabled in the Vercel dashboard (Project → Analytics) # and requires no env vars; the @vercel/analytics client is a no-op in dev. -# Airtable — source for the ambassadors cron sync -# (src/app/api/cron/sync-ambassadors/route.ts). Only required if you run that -# cron locally. -AIRTABLE_API_KEY= -AIRTABLE_BASE_ID= -AIRTABLE_AMBASSADORS_TABLE=Directory -# Comma-separated list of field names to read emails from (case-sensitive). -AIRTABLE_AMBASSADORS_EMAIL_FIELD=Email,Cursor email - # Shared secret guarding /api/cron/* routes against unauthenticated callers. CRON_SECRET= diff --git a/apps/cursor/src/app/api/cron/sync-ambassadors/route.ts b/apps/cursor/src/app/api/cron/sync-ambassadors/route.ts deleted file mode 100644 index d4a37c36..00000000 --- a/apps/cursor/src/app/api/cron/sync-ambassadors/route.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { revalidatePath } from "next/cache"; -import { type NextRequest, NextResponse } from "next/server"; -import { requireCronAuth } from "@/lib/cron-auth"; -import { createClient } from "@/utils/supabase/admin-client"; - -export const dynamic = "force-dynamic"; -export const maxDuration = 60; - -const AIRTABLE_BASE_URL = "https://api.airtable.com/v0"; -const PAGE_SIZE = 100; - -async function fetchAmbassadorEmails(): Promise { - const { - AIRTABLE_API_KEY, - AIRTABLE_BASE_ID, - AIRTABLE_AMBASSADORS_TABLE = "Directory", - AIRTABLE_AMBASSADORS_EMAIL_FIELD = "Email,Cursor email", - } = process.env; - - if (!AIRTABLE_API_KEY || !AIRTABLE_BASE_ID) { - throw new Error( - "Missing AIRTABLE_API_KEY or AIRTABLE_BASE_ID environment variables", - ); - } - - const emailFields = AIRTABLE_AMBASSADORS_EMAIL_FIELD.split(",") - .map((f) => f.trim()) - .filter(Boolean); - - if (emailFields.length === 0) { - throw new Error("AIRTABLE_AMBASSADORS_EMAIL_FIELD is empty"); - } - - const emails = new Set(); - let offset: string | undefined; - - do { - const url = new URL( - `${AIRTABLE_BASE_URL}/${AIRTABLE_BASE_ID}/${encodeURIComponent( - AIRTABLE_AMBASSADORS_TABLE, - )}`, - ); - url.searchParams.set("pageSize", String(PAGE_SIZE)); - for (const field of emailFields) { - url.searchParams.append("fields[]", field); - } - if (offset) url.searchParams.set("offset", offset); - - const res = await fetch(url.toString(), { - headers: { - Authorization: `Bearer ${AIRTABLE_API_KEY}`, - }, - cache: "no-store", - }); - - if (!res.ok) { - const body = await res.text(); - throw new Error( - `Airtable request failed: ${res.status} ${res.statusText} - ${body}`, - ); - } - - const json = (await res.json()) as { - records?: Array<{ fields?: Record }>; - offset?: string; - }; - - for (const record of json.records ?? []) { - for (const field of emailFields) { - const value = record.fields?.[field]; - if (typeof value === "string") { - const trimmed = value.trim().toLowerCase(); - if (trimmed) emails.add(trimmed); - } - } - } - - offset = json.offset; - } while (offset); - - return [...emails]; -} - -export async function GET(request: NextRequest) { - const unauthorized = requireCronAuth(request); - if (unauthorized) return unauthorized; - - try { - const emails = await fetchAmbassadorEmails(); - - const supabase = await createClient(); - - const { data, error } = await supabase.rpc("set_ambassadors_by_emails", { - target_emails: emails, - }); - - if (error) { - throw new Error(`Supabase RPC failed: ${error.message}`); - } - - const row = Array.isArray(data) ? data[0] : data; - const granted = Number(row?.granted ?? 0); - const revoked = Number(row?.revoked ?? 0); - - if (granted > 0 || revoked > 0) { - revalidatePath("/members"); - } - - return NextResponse.json({ - ok: true, - total_airtable: emails.length, - granted, - revoked, - }); - } catch (err) { - const message = err instanceof Error ? err.message : "Unknown error"; - console.error("sync-ambassadors failed:", message); - return NextResponse.json({ ok: false, error: message }, { status: 500 }); - } -} diff --git a/apps/cursor/vercel.json b/apps/cursor/vercel.json index e5fa089b..895ccfdb 100644 --- a/apps/cursor/vercel.json +++ b/apps/cursor/vercel.json @@ -1,10 +1,6 @@ { "regions": ["sfo1", "fra1"], "crons": [ - { - "path": "/api/cron/sync-ambassadors", - "schedule": "0 * * * *" - }, { "path": "/api/cron/recover-stuck-scans", "schedule": "*/15 * * * *" From ee6cec12292e63afb57029ad1c43148b9aa63689 Mon Sep 17 00:00:00 2001 From: Lee Robinson Date: Mon, 8 Jun 2026 13:07:55 -0500 Subject: [PATCH 03/12] Retire the legacy public rules API and install-plugin CLI Bot protection has been serving a browser challenge to non-browser clients since mid-February, so /api, /api/[slug], /api/popular, /api/plugins/[slug], and the npx install-plugin CLI that consumed them have all been hard down for months with zero successful consumers in the logs. Rather than carve out firewall exceptions for endpoints with no known users, delete them: rules-era integrations are superseded by plugin pages and Cursor deeplinks. /api/members stays, as the members page uses it. Co-authored-by: Cursor --- apps/cursor/src/app/api/[slug]/route.ts | 63 -- .../src/app/api/plugins/[slug]/route.ts | 44 -- apps/cursor/src/app/api/popular/route.ts | 51 -- apps/cursor/src/app/api/route.ts | 32 - apps/cursor/src/lib/slug.ts | 6 +- bun.lock | 28 - packages/install-plugin/dist/index.d.ts | 2 - packages/install-plugin/dist/index.js | 389 ------------ packages/install-plugin/package.json | 38 -- packages/install-plugin/src/index.ts | 560 ------------------ packages/install-plugin/tsconfig.json | 14 - 11 files changed, 3 insertions(+), 1224 deletions(-) delete mode 100644 apps/cursor/src/app/api/[slug]/route.ts delete mode 100644 apps/cursor/src/app/api/plugins/[slug]/route.ts delete mode 100644 apps/cursor/src/app/api/popular/route.ts delete mode 100644 apps/cursor/src/app/api/route.ts delete mode 100644 packages/install-plugin/dist/index.d.ts delete mode 100755 packages/install-plugin/dist/index.js delete mode 100644 packages/install-plugin/package.json delete mode 100644 packages/install-plugin/src/index.ts delete mode 100644 packages/install-plugin/tsconfig.json diff --git a/apps/cursor/src/app/api/[slug]/route.ts b/apps/cursor/src/app/api/[slug]/route.ts deleted file mode 100644 index ca8aa807..00000000 --- a/apps/cursor/src/app/api/[slug]/route.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { NextResponse } from "next/server"; -import { getPlugins } from "@/data/queries"; - -export const dynamic = "force-static"; -export const revalidate = 86400; - -export async function generateStaticParams() { - const { data: plugins } = await getPlugins({ fetchAll: true }); - return (plugins ?? []).flatMap((p) => - (p.plugin_components ?? []) - .filter((c) => c.type === "rule") - .map((c) => ({ slug: c.slug })), - ); -} - -type Params = Promise<{ slug: string }>; - -export async function GET(_: Request, segmentData: { params: Params }) { - const { slug } = await segmentData.params; - - if (!slug) { - return NextResponse.json({ error: "No slug provided" }, { status: 400 }); - } - - const { data: plugins } = await getPlugins({ fetchAll: true }); - const allRules = (plugins ?? []).flatMap((p) => - (p.plugin_components ?? []) - .filter((c) => c.type === "rule") - .map((c) => { - const meta = c.metadata as Record; - return { - title: c.name, - slug: c.slug, - tags: (meta?.tags as string[]) ?? [], - libs: (meta?.libs as string[]) ?? [], - content: c.content ?? "", - author: meta?.author_name - ? { - name: meta.author_name as string, - url: (meta.author_url as string) ?? null, - avatar: (meta.author_avatar as string) ?? null, - } - : undefined, - }; - }), - ); - - const rule = allRules.find((r) => r.slug === slug); - - if (!rule) { - return NextResponse.json({ error: "Rule not found" }, { status: 404 }); - } - - return new Response(JSON.stringify({ data: rule }), { - status: 200, - headers: { - "Content-Type": "application/json", - "Cache-Control": "public, s-maxage=86400", - "CDN-Cache-Control": "public, s-maxage=86400", - "Vercel-CDN-Cache-Control": "public, s-maxage=86400", - }, - }); -} diff --git a/apps/cursor/src/app/api/plugins/[slug]/route.ts b/apps/cursor/src/app/api/plugins/[slug]/route.ts deleted file mode 100644 index 659828ff..00000000 --- a/apps/cursor/src/app/api/plugins/[slug]/route.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { NextResponse } from "next/server"; -import { getPluginBySlug } from "@/data/queries"; - -export const dynamic = "force-dynamic"; - -type Params = Promise<{ slug: string }>; - -export async function GET(_: Request, segmentData: { params: Params }) { - const { slug } = await segmentData.params; - - if (!slug) { - return NextResponse.json({ error: "No slug provided" }, { status: 400 }); - } - - const { data: plugin, error } = await getPluginBySlug(slug); - - if (error || !plugin) { - return NextResponse.json({ error: "Plugin not found" }, { status: 404 }); - } - - if (!plugin.active) { - return NextResponse.json({ error: "Plugin not found" }, { status: 404 }); - } - - const components = (plugin.plugin_components ?? []).map((c) => ({ - type: c.type, - name: c.name, - slug: c.slug, - description: c.description, - content: c.content, - metadata: c.metadata, - })); - - return NextResponse.json({ - data: { - name: plugin.name, - slug: plugin.slug, - description: plugin.description, - version: plugin.version, - repository: plugin.repository, - components, - }, - }); -} diff --git a/apps/cursor/src/app/api/popular/route.ts b/apps/cursor/src/app/api/popular/route.ts deleted file mode 100644 index 1ad259a1..00000000 --- a/apps/cursor/src/app/api/popular/route.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { NextResponse } from "next/server"; -import { getPlugins } from "@/data/queries"; - -export const revalidate = 86400; -export const dynamic = "force-static"; - -export async function GET() { - const { data: plugins } = await getPlugins({ fetchAll: true }); - - const allRules = (plugins ?? []) - .flatMap((p) => - (p.plugin_components ?? []) - .filter((c) => c.type === "rule") - .map((c) => { - const meta = c.metadata as Record; - return { - title: c.name, - slug: c.slug, - tags: (meta?.tags as string[]) ?? [], - libs: (meta?.libs as string[]) ?? [], - content: c.content ?? "", - count: p.install_count, - author: meta?.author_name - ? { - name: meta.author_name as string, - url: (meta.author_url as string) ?? null, - avatar: (meta.author_avatar as string) ?? null, - } - : undefined, - }; - }), - ) - .sort((a, b) => b.count - a.count); - - const uniqueSlugs = new Set(); - const uniqueRules = allRules.filter((r) => { - if (uniqueSlugs.has(r.slug)) return false; - uniqueSlugs.add(r.slug); - return true; - }); - - return new NextResponse(JSON.stringify({ data: uniqueRules }), { - status: 200, - headers: { - "Content-Type": "application/json", - "Cache-Control": "public, s-maxage=86400", - "CDN-Cache-Control": "public, s-maxage=86400", - "Vercel-CDN-Cache-Control": "public, s-maxage=86400", - }, - }); -} diff --git a/apps/cursor/src/app/api/route.ts b/apps/cursor/src/app/api/route.ts deleted file mode 100644 index 71db5dda..00000000 --- a/apps/cursor/src/app/api/route.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { NextResponse } from "next/server"; -import { getPlugins } from "@/data/queries"; - -export const dynamic = "force-static"; -export const revalidate = 86400; - -export async function GET() { - const { data: plugins } = await getPlugins({ fetchAll: true }); - const rules = (plugins ?? []).flatMap((p) => - (p.plugin_components ?? []) - .filter((c) => c.type === "rule") - .map((c) => { - const meta = c.metadata as Record; - return { - title: c.name, - slug: c.slug, - tags: (meta?.tags as string[]) ?? [], - libs: (meta?.libs as string[]) ?? [], - content: c.content ?? "", - author: meta?.author_name - ? { - name: meta.author_name as string, - url: (meta.author_url as string) ?? null, - avatar: (meta.author_avatar as string) ?? null, - } - : undefined, - }; - }), - ); - - return NextResponse.json({ data: rules }); -} diff --git a/apps/cursor/src/lib/slug.ts b/apps/cursor/src/lib/slug.ts index a1fe027b..6cbf0347 100644 --- a/apps/cursor/src/lib/slug.ts +++ b/apps/cursor/src/lib/slug.ts @@ -5,9 +5,9 @@ * server actions, the GitHub parser, and seed scripts alike. */ -// Cap slugs at 80 chars. The per-slug API routes (e.g. `/api/[slug]`) are -// `force-static`, so Vercel writes a `.prerender-config.json` file per -// known slug at build time; a longer slug blows past the 255-byte filesystem +// Cap slugs at 80 chars. Per-slug static routes (e.g. the legacy `/[slug]` +// redirects) are prerendered, so Vercel writes a `.prerender-config.json` +// file per known slug at build time; a longer slug blows past the 255-byte filesystem // name limit (ENAMETOOLONG) and breaks the build. It also satisfies the // `plugin_components_slug_length_check` Postgres constraint. 80 chars leaves // ample headroom for disambiguating suffixes. diff --git a/bun.lock b/bun.lock index e8a6e7cb..b5ae83fd 100644 --- a/bun.lock +++ b/bun.lock @@ -64,20 +64,6 @@ "typescript": "^6", }, }, - "packages/install-plugin": { - "name": "install-plugin", - "version": "0.1.0", - "bin": { - "install-plugin": "./dist/index.js", - }, - "dependencies": { - "@clack/prompts": "^1.3.0", - }, - "devDependencies": { - "@types/node": "^25.6.0", - "typescript": "^6.0.3", - }, - }, }, "overrides": { "zod": "4.4.3", @@ -105,10 +91,6 @@ "@bufbuild/protobuf": ["@bufbuild/protobuf@1.10.0", "", {}, "sha512-QDdVFLoN93Zjg36NoQPZfsVH9tZew7wKDKyV5qRdj8ntT4wQCOradQjRaTdwMhWUYsgKsvCINKKm87FdEk96Ag=="], - "@clack/core": ["@clack/core@1.3.0", "", { "dependencies": { "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-xJPHpAmEQUBrXSLx0gF+q5K/IyihXpsHZcha+jB+tyahsKRK3Dxo4D0coZDewHo12NhiuzC3dTtMPbm53GEAAA=="], - - "@clack/prompts": ["@clack/prompts@1.3.0", "", { "dependencies": { "@clack/core": "1.3.0", "fast-string-width": "^3.0.2", "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-GgcWwRCs/xPtaqlMy8qRhPnZf9vlWcWZNHAitnVQ3yk7JmSralSiq5q07yaffYE8SogtDm7zFeKccx1QNVARpw=="], - "@connectrpc/connect": ["@connectrpc/connect@1.7.0", "", { "peerDependencies": { "@bufbuild/protobuf": "^1.10.0" } }, "sha512-iNKdJRi69YP3mq6AePRT8F/HrxWCewrhxnLMNm0vpqXAR8biwzRtO6Hjx80C6UvtKJ5sFmffQT7I4Baecz389w=="], "@connectrpc/connect-node": ["@connectrpc/connect-node@1.7.0", "", { "dependencies": { "undici": "^5.28.4" }, "peerDependencies": { "@bufbuild/protobuf": "^1.10.0", "@connectrpc/connect": "1.7.0" } }, "sha512-6vaPIkG/NyhxlYgytLoR9KYbPhczEboFB2OYWkA9qvUz1K7efXfeGrlRxoLtpa+r8VxyIOw73w5ktNe743nD+A=="], @@ -461,12 +443,6 @@ "expand-template": ["expand-template@2.0.3", "", {}, "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg=="], - "fast-string-truncated-width": ["fast-string-truncated-width@3.0.3", "", {}, "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g=="], - - "fast-string-width": ["fast-string-width@3.0.2", "", { "dependencies": { "fast-string-truncated-width": "^3.0.2" } }, "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg=="], - - "fast-wrap-ansi": ["fast-wrap-ansi@0.2.0", "", { "dependencies": { "fast-string-width": "^3.0.2" } }, "sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w=="], - "file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="], "framer-motion": ["framer-motion@12.38.0", "", { "dependencies": { "motion-dom": "^12.38.0", "motion-utils": "^12.36.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g=="], @@ -517,8 +493,6 @@ "ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], - "install-plugin": ["install-plugin@workspace:packages/install-plugin"], - "ip-address": ["ip-address@10.2.0", "", {}, "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA=="], "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], @@ -673,8 +647,6 @@ "simple-get": ["simple-get@4.0.1", "", { "dependencies": { "decompress-response": "^6.0.0", "once": "^1.3.1", "simple-concat": "^1.0.0" } }, "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA=="], - "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], - "slugify": ["slugify@1.6.9", "", {}, "sha512-vZ7rfeehZui7wQs438JXBckYLkIIdfHOXsaVEUMyS5fHo1483l1bMdo0EDSWYclY0yZKFOipDy4KHuKs6ssvdg=="], "smart-buffer": ["smart-buffer@4.2.0", "", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="], diff --git a/packages/install-plugin/dist/index.d.ts b/packages/install-plugin/dist/index.d.ts deleted file mode 100644 index b7988016..00000000 --- a/packages/install-plugin/dist/index.d.ts +++ /dev/null @@ -1,2 +0,0 @@ -#!/usr/bin/env node -export {}; diff --git a/packages/install-plugin/dist/index.js b/packages/install-plugin/dist/index.js deleted file mode 100755 index 8c274e70..00000000 --- a/packages/install-plugin/dist/index.js +++ /dev/null @@ -1,389 +0,0 @@ -#!/usr/bin/env node -import * as fs from "node:fs"; -import * as path from "node:path"; -import * as clack from "@clack/prompts"; -const API_BASE = process.env.CURSOR_DIRECTORY_API ?? "https://cursor.directory"; -// ── Colors ───────────────────────────────────────────────────────────── -const isColorSupported = process.env.FORCE_COLOR !== "0" && - (process.env.FORCE_COLOR !== undefined || process.stdout.isTTY); -const fmt = (open, close) => isColorSupported - ? (s) => `\x1b[${open}m${s}\x1b[${close}m` - : (s) => s; -const bold = fmt("1", "22"); -const dim = fmt("2", "22"); -const green = fmt("32", "39"); -const red = fmt("31", "39"); -const yellow = fmt("33", "39"); -const cyan = fmt("36", "39"); -function printUsage() { - console.log(` -${bold("install-plugin")} — Install plugins from the Cursor Directory - -${bold("USAGE")} - ${cyan("install-plugin")} ${dim("")} ${dim("[options]")} - -${bold("OPTIONS")} - ${cyan("--force")} Overwrite existing files without warning - ${cyan("--dry-run")} Show what would be installed without writing files - ${cyan("--all")} Install all components without prompting - ${cyan("--only")} ${dim("")} Only install components with these slugs - ${cyan("--exclude")} ${dim("")} Skip components with these slugs - ${cyan("--help")} Show this help message - -${bold("EXAMPLES")} - ${dim("$")} install-plugin nextjs - ${dim("$")} install-plugin nextjs --only nextjs,payload-cms-nextjs-typescript-best-practices - ${dim("$")} install-plugin nextjs --exclude nextjs-react-redux-typescript-cursor-rules - ${dim("$")} install-plugin mcp-supabase --all --force -`); -} -function parseArgs(argv) { - const args = argv.slice(2); - let slug = null; - let force = false; - let dryRun = false; - let all = false; - let only = []; - let exclude = []; - let help = false; - for (let i = 0; i < args.length; i++) { - const arg = args[i]; - if (arg === "--force") - force = true; - else if (arg === "--dry-run") - dryRun = true; - else if (arg === "--all") - all = true; - else if (arg === "--help" || arg === "-h") - help = true; - else if (arg === "--only" && args[i + 1]) { - only = args[++i].split(",").filter(Boolean); - } - else if (arg === "--exclude" && args[i + 1]) { - exclude = args[++i].split(",").filter(Boolean); - } - else if (!arg.startsWith("-")) - slug = arg; - } - return { slug, force, dryRun, all, only, exclude, help }; -} -// ── Fetch ────────────────────────────────────────────────────────────── -async function fetchPlugin(slug) { - const url = `${API_BASE}/api/plugins/${encodeURIComponent(slug)}`; - const res = await fetch(url); - if (res.status === 404) { - throw new Error(`Plugin ${bold(`"${slug}"`)} not found on cursor.directory`); - } - if (!res.ok) { - throw new Error(`Failed to fetch plugin (HTTP ${res.status}): ${res.statusText}`); - } - const json = (await res.json()); - if (json.error || !json.data) { - throw new Error(json.error ?? "Unexpected response from API"); - } - return json.data; -} -// ── File helpers ─────────────────────────────────────────────────────── -function ensureDir(filePath) { - const dir = path.dirname(filePath); - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }); - } -} -function readJsonFile(filePath) { - if (!fs.existsSync(filePath)) - return {}; - try { - return JSON.parse(fs.readFileSync(filePath, "utf-8")); - } - catch { - return {}; - } -} -function writeJsonFile(filePath, data) { - ensureDir(filePath); - fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + "\n", "utf-8"); -} -function writeTextFile(filePath, content) { - ensureDir(filePath); - fs.writeFileSync(filePath, content, "utf-8"); -} -// ── Installers per component type ────────────────────────────────────── -function installRule(cwd, comp, force) { - const filePath = path.join(cwd, ".cursor", "rules", `${comp.slug}.mdc`); - const exists = fs.existsSync(filePath); - if (exists && !force) { - return { component: comp, path: filePath, action: "skipped" }; - } - writeTextFile(filePath, comp.content ?? ""); - return { component: comp, path: filePath, action: exists ? "updated" : "created" }; -} -function installMcpServer(cwd, comp, force) { - const filePath = path.join(cwd, ".cursor", "mcp.json"); - const exists = fs.existsSync(filePath); - const config = readJsonFile(filePath); - if (!config.mcpServers || typeof config.mcpServers !== "object") { - config.mcpServers = {}; - } - const servers = config.mcpServers; - const alreadyExists = comp.name in servers; - if (alreadyExists && !force) { - return { component: comp, path: filePath, action: "skipped" }; - } - const metaConfig = comp.metadata?.config; - if (comp.content) { - try { - servers[comp.name] = JSON.parse(comp.content); - } - catch { - servers[comp.name] = { note: "Could not parse MCP config" }; - } - } - else if (metaConfig?.mcpServers) { - const incoming = metaConfig.mcpServers; - for (const [name, cfg] of Object.entries(incoming)) { - servers[name] = cfg; - } - } - writeJsonFile(filePath, config); - return { - component: comp, - path: filePath, - action: alreadyExists ? "updated" : "created", - }; -} -function installSkill(cwd, comp, force) { - const filePath = path.join(cwd, ".cursor", "skills", comp.slug, "SKILL.md"); - const exists = fs.existsSync(filePath); - if (exists && !force) { - return { component: comp, path: filePath, action: "skipped" }; - } - writeTextFile(filePath, comp.content ?? ""); - return { component: comp, path: filePath, action: exists ? "updated" : "created" }; -} -function installAgent(cwd, comp, force) { - const filePath = path.join(cwd, ".cursor", "agents", `${comp.slug}.md`); - const exists = fs.existsSync(filePath); - if (exists && !force) { - return { component: comp, path: filePath, action: "skipped" }; - } - writeTextFile(filePath, comp.content ?? ""); - return { component: comp, path: filePath, action: exists ? "updated" : "created" }; -} -function installCommand(cwd, comp, force) { - const filePath = path.join(cwd, ".cursor", "commands", `${comp.slug}.md`); - const exists = fs.existsSync(filePath); - if (exists && !force) { - return { component: comp, path: filePath, action: "skipped" }; - } - writeTextFile(filePath, comp.content ?? ""); - return { component: comp, path: filePath, action: exists ? "updated" : "created" }; -} -function installHook(cwd, comp, force) { - const filePath = path.join(cwd, ".cursor", "hooks", "hooks.json"); - const exists = fs.existsSync(filePath); - if (exists && !force) { - return { component: comp, path: filePath, action: "skipped" }; - } - if (comp.content) { - try { - const incoming = JSON.parse(comp.content); - const existing = readJsonFile(filePath); - const merged = { ...existing, ...incoming }; - writeJsonFile(filePath, merged); - } - catch { - writeTextFile(filePath, comp.content); - } - } - return { component: comp, path: filePath, action: exists ? "updated" : "created" }; -} -function installLspServer(cwd, comp, force) { - const filePath = path.join(cwd, ".cursor", "lsp.json"); - const exists = fs.existsSync(filePath); - const config = readJsonFile(filePath); - const alreadyExists = comp.name in config; - if (alreadyExists && !force) { - return { component: comp, path: filePath, action: "skipped" }; - } - if (comp.content) { - try { - config[comp.name] = JSON.parse(comp.content); - } - catch { - config[comp.name] = { note: "Could not parse LSP config" }; - } - } - writeJsonFile(filePath, config); - return { - component: comp, - path: filePath, - action: alreadyExists ? "updated" : "created", - }; -} -function installComponent(cwd, comp, force) { - switch (comp.type) { - case "rule": - return installRule(cwd, comp, force); - case "mcp_server": - return installMcpServer(cwd, comp, force); - case "skill": - return installSkill(cwd, comp, force); - case "agent": - return installAgent(cwd, comp, force); - case "command": - return installCommand(cwd, comp, force); - case "hook": - return installHook(cwd, comp, force); - case "lsp_server": - return installLspServer(cwd, comp, force); - default: - return { component: comp, path: "", action: "skipped" }; - } -} -// ── Labels ───────────────────────────────────────────────────────────── -const TYPE_LABELS = { - rule: "Rule", - mcp_server: "MCP Server", - skill: "Skill", - agent: "Agent", - command: "Command", - hook: "Hook", - lsp_server: "LSP Server", -}; -// ── Component selection ──────────────────────────────────────────────── -function filterComponents(components, only, exclude) { - if (only.length > 0) { - return components.filter((c) => only.includes(c.slug)); - } - if (exclude.length > 0) { - return components.filter((c) => !exclude.includes(c.slug)); - } - return components; -} -async function promptComponentSelection(components) { - if (components.length <= 1) - return components; - const selected = await clack.multiselect({ - message: "Select components to install", - options: components.map((c) => ({ - value: c.slug, - label: c.name, - hint: TYPE_LABELS[c.type] ?? c.type, - })), - initialValues: components.map((c) => c.slug), - required: true, - }); - if (clack.isCancel(selected)) { - clack.cancel("Installation cancelled."); - process.exit(0); - } - const slugSet = new Set(selected); - return components.filter((c) => slugSet.has(c.slug)); -} -// ── Dry run path resolver ────────────────────────────────────────────── -function installComponentDryRun(cwd, comp) { - const pathMap = { - rule: path.join(cwd, ".cursor", "rules", `${comp.slug}.mdc`), - mcp_server: path.join(cwd, ".cursor", "mcp.json"), - skill: path.join(cwd, ".cursor", "skills", comp.slug, "SKILL.md"), - agent: path.join(cwd, ".cursor", "agents", `${comp.slug}.md`), - command: path.join(cwd, ".cursor", "commands", `${comp.slug}.md`), - hook: path.join(cwd, ".cursor", "hooks", "hooks.json"), - lsp_server: path.join(cwd, ".cursor", "lsp.json"), - }; - const filePath = pathMap[comp.type] ?? ""; - return { component: comp, path: filePath, action: "created" }; -} -// ── Main ─────────────────────────────────────────────────────────────── -async function main() { - const { slug, force, dryRun, all, only, exclude, help } = parseArgs(process.argv); - if (help || !slug) { - printUsage(); - process.exit(help ? 0 : 1); - } - const cwd = process.cwd(); - const isInteractive = process.stdout.isTTY && !all && only.length === 0 && exclude.length === 0; - clack.intro(`${bold("cursor.directory")} ${dim("/")} ${cyan(slug)}`); - const s = clack.spinner(); - s.start("Fetching plugin from cursor.directory..."); - let plugin; - try { - plugin = await fetchPlugin(slug); - s.stop(`${bold(plugin.name)} ${dim(`v${plugin.version}`)}`); - } - catch (err) { - s.stop("Failed to fetch plugin"); - clack.log.error(err instanceof Error ? err.message : String(err)); - process.exit(1); - } - if (plugin.description) { - clack.log.info(dim(plugin.description)); - } - let components = plugin.components; - if (components.length === 0) { - clack.log.warn("This plugin has no installable components."); - clack.outro("Nothing to install."); - process.exit(0); - } - // Filter components based on flags or interactive prompt - if (only.length > 0 || exclude.length > 0) { - components = filterComponents(components, only, exclude); - if (components.length === 0) { - clack.log.warn("No components matched the filter."); - clack.outro("Nothing to install."); - process.exit(0); - } - } - else if (isInteractive) { - components = await promptComponentSelection(components); - } - if (dryRun) { - clack.log.info(dim("Dry run — no files will be written.")); - } - const results = []; - for (const comp of components) { - const label = TYPE_LABELS[comp.type] ?? comp.type; - if (dryRun) { - const result = installComponentDryRun(cwd, comp); - const relPath = path.relative(cwd, result.path) || result.path; - clack.log.step(`${label}: ${bold(comp.name)} → ${dim(relPath)}`); - results.push(result); - continue; - } - try { - const result = installComponent(cwd, comp, force); - results.push(result); - const relPath = path.relative(cwd, result.path) || result.path; - if (result.action === "skipped") { - clack.log.warn(`${label}: ${bold(comp.name)} → ${dim(relPath)} ${dim("(exists, use --force)")}`); - } - else if (result.action === "updated") { - clack.log.step(`${label}: ${bold(comp.name)} → ${dim(relPath)} ${yellow("(updated)")}`); - } - else { - clack.log.success(`${label}: ${bold(comp.name)} → ${dim(relPath)}`); - } - } - catch (err) { - clack.log.error(`${label}: ${bold(comp.name)} — ${err instanceof Error ? err.message : String(err)}`); - } - } - const created = results.filter((r) => r.action === "created").length; - const updated = results.filter((r) => r.action === "updated").length; - const skipped = results.filter((r) => r.action === "skipped").length; - if (dryRun) { - clack.outro(dim(`Dry run complete. ${results.length} component${results.length === 1 ? "" : "s"} would be installed.`)); - } - else { - const parts = []; - if (created > 0) - parts.push(green(`${created} created`)); - if (updated > 0) - parts.push(yellow(`${updated} updated`)); - if (skipped > 0) - parts.push(dim(`${skipped} skipped`)); - clack.outro(`${bold("Done!")} ${parts.join(dim(", "))}`); - } -} -main(); diff --git a/packages/install-plugin/package.json b/packages/install-plugin/package.json deleted file mode 100644 index 1656889a..00000000 --- a/packages/install-plugin/package.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "name": "install-plugin", - "version": "0.1.0", - "description": "Install plugins from the Cursor Directory", - "type": "module", - "license": "MIT", - "repository": { - "type": "git", - "url": "https://github.com/cursor/community-plugins" - }, - "keywords": [ - "cursor", - "plugin", - "cli", - "install", - "cursor-directory" - ], - "bin": { - "install-plugin": "./dist/index.js" - }, - "files": [ - "dist" - ], - "engines": { - "node": ">=18" - }, - "scripts": { - "build": "tsc", - "dev": "tsc --watch" - }, - "devDependencies": { - "typescript": "^6.0.3", - "@types/node": "^25.6.0" - }, - "dependencies": { - "@clack/prompts": "^1.3.0" - } -} diff --git a/packages/install-plugin/src/index.ts b/packages/install-plugin/src/index.ts deleted file mode 100644 index eb1579d2..00000000 --- a/packages/install-plugin/src/index.ts +++ /dev/null @@ -1,560 +0,0 @@ -#!/usr/bin/env node - -import * as fs from "node:fs"; -import * as path from "node:path"; -import * as clack from "@clack/prompts"; - -const API_BASE = - process.env.CURSOR_DIRECTORY_API ?? "https://cursor.directory"; - -// ── Colors ───────────────────────────────────────────────────────────── - -const isColorSupported = - process.env.FORCE_COLOR !== "0" && - (process.env.FORCE_COLOR !== undefined || process.stdout.isTTY); - -const fmt = (open: string, close: string) => - isColorSupported - ? (s: string) => `\x1b[${open}m${s}\x1b[${close}m` - : (s: string) => s; - -const bold = fmt("1", "22"); -const dim = fmt("2", "22"); -const green = fmt("32", "39"); -const red = fmt("31", "39"); -const yellow = fmt("33", "39"); -const cyan = fmt("36", "39"); - - -// ── Types ────────────────────────────────────────────────────────────── - -type ComponentType = - | "rule" - | "mcp_server" - | "skill" - | "agent" - | "hook" - | "lsp_server" - | "command"; - -interface PluginComponent { - type: ComponentType; - name: string; - slug: string; - description: string | null; - content: string | null; - metadata: Record; -} - -interface PluginData { - name: string; - slug: string; - description: string | null; - version: string; - repository: string | null; - components: PluginComponent[]; -} - -interface InstallResult { - component: PluginComponent; - path: string; - action: "created" | "updated" | "skipped"; -} - -// ── Args ─────────────────────────────────────────────────────────────── - -interface CliArgs { - slug: string | null; - force: boolean; - dryRun: boolean; - all: boolean; - only: string[]; - exclude: string[]; - help: boolean; -} - -function printUsage(): void { - console.log(` -${bold("install-plugin")} — Install plugins from the Cursor Directory - -${bold("USAGE")} - ${cyan("install-plugin")} ${dim("")} ${dim("[options]")} - -${bold("OPTIONS")} - ${cyan("--force")} Overwrite existing files without warning - ${cyan("--dry-run")} Show what would be installed without writing files - ${cyan("--all")} Install all components without prompting - ${cyan("--only")} ${dim("")} Only install components with these slugs - ${cyan("--exclude")} ${dim("")} Skip components with these slugs - ${cyan("--help")} Show this help message - -${bold("EXAMPLES")} - ${dim("$")} install-plugin nextjs - ${dim("$")} install-plugin nextjs --only nextjs,payload-cms-nextjs-typescript-best-practices - ${dim("$")} install-plugin nextjs --exclude nextjs-react-redux-typescript-cursor-rules - ${dim("$")} install-plugin mcp-supabase --all --force -`); -} - -function parseArgs(argv: string[]): CliArgs { - const args = argv.slice(2); - let slug: string | null = null; - let force = false; - let dryRun = false; - let all = false; - let only: string[] = []; - let exclude: string[] = []; - let help = false; - - for (let i = 0; i < args.length; i++) { - const arg = args[i]; - if (arg === "--force") force = true; - else if (arg === "--dry-run") dryRun = true; - else if (arg === "--all") all = true; - else if (arg === "--help" || arg === "-h") help = true; - else if (arg === "--only" && args[i + 1]) { - only = args[++i].split(",").filter(Boolean); - } else if (arg === "--exclude" && args[i + 1]) { - exclude = args[++i].split(",").filter(Boolean); - } else if (!arg.startsWith("-")) slug = arg; - } - - return { slug, force, dryRun, all, only, exclude, help }; -} - -// ── Fetch ────────────────────────────────────────────────────────────── - -async function fetchPlugin(slug: string): Promise { - const url = `${API_BASE}/api/plugins/${encodeURIComponent(slug)}`; - const res = await fetch(url); - - if (res.status === 404) { - throw new Error( - `Plugin ${bold(`"${slug}"`)} not found on cursor.directory`, - ); - } - - if (!res.ok) { - throw new Error( - `Failed to fetch plugin (HTTP ${res.status}): ${res.statusText}`, - ); - } - - const json = (await res.json()) as { data?: PluginData; error?: string }; - - if (json.error || !json.data) { - throw new Error(json.error ?? "Unexpected response from API"); - } - - return json.data; -} - -// ── File helpers ─────────────────────────────────────────────────────── - -function ensureDir(filePath: string): void { - const dir = path.dirname(filePath); - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }); - } -} - -function readJsonFile(filePath: string): Record { - if (!fs.existsSync(filePath)) return {}; - try { - return JSON.parse(fs.readFileSync(filePath, "utf-8")); - } catch { - return {}; - } -} - -function writeJsonFile( - filePath: string, - data: Record, -): void { - ensureDir(filePath); - fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + "\n", "utf-8"); -} - -function writeTextFile(filePath: string, content: string): void { - ensureDir(filePath); - fs.writeFileSync(filePath, content, "utf-8"); -} - -// ── Installers per component type ────────────────────────────────────── - -function installRule( - cwd: string, - comp: PluginComponent, - force: boolean, -): InstallResult { - const filePath = path.join(cwd, ".cursor", "rules", `${comp.slug}.mdc`); - const exists = fs.existsSync(filePath); - - if (exists && !force) { - return { component: comp, path: filePath, action: "skipped" }; - } - - writeTextFile(filePath, comp.content ?? ""); - return { component: comp, path: filePath, action: exists ? "updated" : "created" }; -} - -function installMcpServer( - cwd: string, - comp: PluginComponent, - force: boolean, -): InstallResult { - const filePath = path.join(cwd, ".cursor", "mcp.json"); - const exists = fs.existsSync(filePath); - const config = readJsonFile(filePath); - - if (!config.mcpServers || typeof config.mcpServers !== "object") { - config.mcpServers = {}; - } - - const servers = config.mcpServers as Record; - const alreadyExists = comp.name in servers; - - if (alreadyExists && !force) { - return { component: comp, path: filePath, action: "skipped" }; - } - - const metaConfig = comp.metadata?.config as - | Record - | undefined; - - if (comp.content) { - try { - servers[comp.name] = JSON.parse(comp.content); - } catch { - servers[comp.name] = { note: "Could not parse MCP config" }; - } - } else if (metaConfig?.mcpServers) { - const incoming = metaConfig.mcpServers as Record; - for (const [name, cfg] of Object.entries(incoming)) { - servers[name] = cfg; - } - } - - writeJsonFile(filePath, config); - return { - component: comp, - path: filePath, - action: alreadyExists ? "updated" : "created", - }; -} - -function installSkill( - cwd: string, - comp: PluginComponent, - force: boolean, -): InstallResult { - const filePath = path.join(cwd, ".cursor", "skills", comp.slug, "SKILL.md"); - const exists = fs.existsSync(filePath); - - if (exists && !force) { - return { component: comp, path: filePath, action: "skipped" }; - } - - writeTextFile(filePath, comp.content ?? ""); - return { component: comp, path: filePath, action: exists ? "updated" : "created" }; -} - -function installAgent( - cwd: string, - comp: PluginComponent, - force: boolean, -): InstallResult { - const filePath = path.join(cwd, ".cursor", "agents", `${comp.slug}.md`); - const exists = fs.existsSync(filePath); - - if (exists && !force) { - return { component: comp, path: filePath, action: "skipped" }; - } - - writeTextFile(filePath, comp.content ?? ""); - return { component: comp, path: filePath, action: exists ? "updated" : "created" }; -} - -function installCommand( - cwd: string, - comp: PluginComponent, - force: boolean, -): InstallResult { - const filePath = path.join(cwd, ".cursor", "commands", `${comp.slug}.md`); - const exists = fs.existsSync(filePath); - - if (exists && !force) { - return { component: comp, path: filePath, action: "skipped" }; - } - - writeTextFile(filePath, comp.content ?? ""); - return { component: comp, path: filePath, action: exists ? "updated" : "created" }; -} - -function installHook( - cwd: string, - comp: PluginComponent, - force: boolean, -): InstallResult { - const filePath = path.join(cwd, ".cursor", "hooks", "hooks.json"); - const exists = fs.existsSync(filePath); - - if (exists && !force) { - return { component: comp, path: filePath, action: "skipped" }; - } - - if (comp.content) { - try { - const incoming = JSON.parse(comp.content); - const existing = readJsonFile(filePath); - const merged = { ...existing, ...incoming }; - writeJsonFile(filePath, merged); - } catch { - writeTextFile(filePath, comp.content); - } - } - - return { component: comp, path: filePath, action: exists ? "updated" : "created" }; -} - -function installLspServer( - cwd: string, - comp: PluginComponent, - force: boolean, -): InstallResult { - const filePath = path.join(cwd, ".cursor", "lsp.json"); - const exists = fs.existsSync(filePath); - const config = readJsonFile(filePath); - - const alreadyExists = comp.name in config; - - if (alreadyExists && !force) { - return { component: comp, path: filePath, action: "skipped" }; - } - - if (comp.content) { - try { - config[comp.name] = JSON.parse(comp.content); - } catch { - config[comp.name] = { note: "Could not parse LSP config" }; - } - } - - writeJsonFile(filePath, config); - return { - component: comp, - path: filePath, - action: alreadyExists ? "updated" : "created", - }; -} - -function installComponent( - cwd: string, - comp: PluginComponent, - force: boolean, -): InstallResult { - switch (comp.type) { - case "rule": - return installRule(cwd, comp, force); - case "mcp_server": - return installMcpServer(cwd, comp, force); - case "skill": - return installSkill(cwd, comp, force); - case "agent": - return installAgent(cwd, comp, force); - case "command": - return installCommand(cwd, comp, force); - case "hook": - return installHook(cwd, comp, force); - case "lsp_server": - return installLspServer(cwd, comp, force); - default: - return { component: comp, path: "", action: "skipped" }; - } -} - -// ── Labels ───────────────────────────────────────────────────────────── - -const TYPE_LABELS: Record = { - rule: "Rule", - mcp_server: "MCP Server", - skill: "Skill", - agent: "Agent", - command: "Command", - hook: "Hook", - lsp_server: "LSP Server", -}; - -// ── Component selection ──────────────────────────────────────────────── - -function filterComponents( - components: PluginComponent[], - only: string[], - exclude: string[], -): PluginComponent[] { - if (only.length > 0) { - return components.filter((c) => only.includes(c.slug)); - } - if (exclude.length > 0) { - return components.filter((c) => !exclude.includes(c.slug)); - } - return components; -} - -async function promptComponentSelection( - components: PluginComponent[], -): Promise { - if (components.length <= 1) return components; - - const selected = await clack.multiselect({ - message: "Select components to install", - options: components.map((c) => ({ - value: c.slug, - label: c.name, - hint: TYPE_LABELS[c.type] ?? c.type, - })), - initialValues: components.map((c) => c.slug), - required: true, - }); - - if (clack.isCancel(selected)) { - clack.cancel("Installation cancelled."); - process.exit(0); - } - - const slugSet = new Set(selected as string[]); - return components.filter((c) => slugSet.has(c.slug)); -} - -// ── Dry run path resolver ────────────────────────────────────────────── - -function installComponentDryRun( - cwd: string, - comp: PluginComponent, -): InstallResult { - const pathMap: Record = { - rule: path.join(cwd, ".cursor", "rules", `${comp.slug}.mdc`), - mcp_server: path.join(cwd, ".cursor", "mcp.json"), - skill: path.join(cwd, ".cursor", "skills", comp.slug, "SKILL.md"), - agent: path.join(cwd, ".cursor", "agents", `${comp.slug}.md`), - command: path.join(cwd, ".cursor", "commands", `${comp.slug}.md`), - hook: path.join(cwd, ".cursor", "hooks", "hooks.json"), - lsp_server: path.join(cwd, ".cursor", "lsp.json"), - }; - - const filePath = pathMap[comp.type] ?? ""; - return { component: comp, path: filePath, action: "created" }; -} - -// ── Main ─────────────────────────────────────────────────────────────── - -async function main(): Promise { - const { slug, force, dryRun, all, only, exclude, help } = parseArgs( - process.argv, - ); - - if (help || !slug) { - printUsage(); - process.exit(help ? 0 : 1); - } - - const cwd = process.cwd(); - const isInteractive = process.stdout.isTTY && !all && only.length === 0 && exclude.length === 0; - - clack.intro(`${bold("cursor.directory")} ${dim("/")} ${cyan(slug)}`); - - const s = clack.spinner(); - s.start("Fetching plugin from cursor.directory..."); - - let plugin: PluginData; - try { - plugin = await fetchPlugin(slug); - s.stop(`${bold(plugin.name)} ${dim(`v${plugin.version}`)}`); - } catch (err) { - s.stop("Failed to fetch plugin"); - clack.log.error(err instanceof Error ? err.message : String(err)); - process.exit(1); - } - - if (plugin.description) { - clack.log.info(dim(plugin.description)); - } - - let components = plugin.components; - - if (components.length === 0) { - clack.log.warn("This plugin has no installable components."); - clack.outro("Nothing to install."); - process.exit(0); - } - - // Filter components based on flags or interactive prompt - if (only.length > 0 || exclude.length > 0) { - components = filterComponents(components, only, exclude); - if (components.length === 0) { - clack.log.warn("No components matched the filter."); - clack.outro("Nothing to install."); - process.exit(0); - } - } else if (isInteractive) { - components = await promptComponentSelection(components); - } - - if (dryRun) { - clack.log.info(dim("Dry run — no files will be written.")); - } - - const results: InstallResult[] = []; - - for (const comp of components) { - const label = TYPE_LABELS[comp.type] ?? comp.type; - - if (dryRun) { - const result = installComponentDryRun(cwd, comp); - const relPath = path.relative(cwd, result.path) || result.path; - clack.log.step(`${label}: ${bold(comp.name)} → ${dim(relPath)}`); - results.push(result); - continue; - } - - try { - const result = installComponent(cwd, comp, force); - results.push(result); - - const relPath = path.relative(cwd, result.path) || result.path; - - if (result.action === "skipped") { - clack.log.warn( - `${label}: ${bold(comp.name)} → ${dim(relPath)} ${dim("(exists, use --force)")}`, - ); - } else if (result.action === "updated") { - clack.log.step(`${label}: ${bold(comp.name)} → ${dim(relPath)} ${yellow("(updated)")}`); - } else { - clack.log.success(`${label}: ${bold(comp.name)} → ${dim(relPath)}`); - } - } catch (err) { - clack.log.error( - `${label}: ${bold(comp.name)} — ${err instanceof Error ? err.message : String(err)}`, - ); - } - } - - const created = results.filter((r) => r.action === "created").length; - const updated = results.filter((r) => r.action === "updated").length; - const skipped = results.filter((r) => r.action === "skipped").length; - - if (dryRun) { - clack.outro( - dim(`Dry run complete. ${results.length} component${results.length === 1 ? "" : "s"} would be installed.`), - ); - } else { - const parts: string[] = []; - if (created > 0) parts.push(green(`${created} created`)); - if (updated > 0) parts.push(yellow(`${updated} updated`)); - if (skipped > 0) parts.push(dim(`${skipped} skipped`)); - - clack.outro(`${bold("Done!")} ${parts.join(dim(", "))}`); - } -} - -main(); diff --git a/packages/install-plugin/tsconfig.json b/packages/install-plugin/tsconfig.json deleted file mode 100644 index bc748576..00000000 --- a/packages/install-plugin/tsconfig.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "Node16", - "moduleResolution": "Node16", - "outDir": "dist", - "rootDir": "src", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "declaration": true - }, - "include": ["src"] -} From 4c3ea8c70fb0e55b3a6e7c906090c0119a3e9412 Mon Sep 17 00:00:00 2001 From: Lee Robinson Date: Mon, 8 Jun 2026 13:08:07 -0500 Subject: [PATCH 04/12] Serve legacy /mcp/[slug] URLs as edge redirects The page was nothing but a redirect() to /plugins/mcp-[slug], yet it rendered ~57K times a week as a serverless function. A permanent redirect in next.config handles it at the edge for free. The regex excludes /mcp/new (the submission form) and can't match two-segment paths like /mcp/x/edit. Co-authored-by: Cursor --- apps/cursor/next.config.mjs | 8 ++++++++ apps/cursor/src/app/mcp/[slug]/page.tsx | 10 ---------- 2 files changed, 8 insertions(+), 10 deletions(-) delete mode 100644 apps/cursor/src/app/mcp/[slug]/page.tsx diff --git a/apps/cursor/next.config.mjs b/apps/cursor/next.config.mjs index 931c48fe..d8c04e52 100644 --- a/apps/cursor/next.config.mjs +++ b/apps/cursor/next.config.mjs @@ -36,6 +36,14 @@ const nextConfig = { destination: "/", permanent: true, }, + { + // Legacy MCP detail URLs map to their plugin page. Excludes `new`, + // which is the MCP submission form route. Edit pages (`/mcp/x/edit`) + // are two segments deep and never match this single-segment source. + source: "/mcp/:slug((?!new$)[^/]+)", + destination: "/plugins/mcp-:slug", + permanent: true, + }, { source: "/official/:path*", destination: "/", diff --git a/apps/cursor/src/app/mcp/[slug]/page.tsx b/apps/cursor/src/app/mcp/[slug]/page.tsx deleted file mode 100644 index 8393a062..00000000 --- a/apps/cursor/src/app/mcp/[slug]/page.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { redirect } from "next/navigation"; - -export default async function Page({ - params, -}: { - params: Promise<{ slug: string }>; -}) { - const { slug } = await params; - redirect(`/plugins/mcp-${slug}`); -} From 2bd9673c9bf379604e4ca01e38c871820c57363a Mon Sep 17 00:00:00 2001 From: Lee Robinson Date: Mon, 8 Jun 2026 13:08:19 -0500 Subject: [PATCH 05/12] Stop logging action payloads and split the privileged Supabase client The safe-action middleware logged every action's input and result to stdout, which lands user PII (emails, profile fields) in Vercel logs. Log only failures, and only the action name plus error. The server client also lost its `admin` flag: privileged access now lives solely in utils/supabase/admin-client, so a cookie-scoped client can never silently escalate to the service role, and the two access paths are documented at their source. Co-authored-by: Cursor --- apps/cursor/src/actions/safe-action.ts | 15 +++++++---- .../cursor/src/utils/supabase/admin-client.ts | 5 ++++ apps/cursor/src/utils/supabase/server.ts | 27 +++++++------------ 3 files changed, 25 insertions(+), 22 deletions(-) diff --git a/apps/cursor/src/actions/safe-action.ts b/apps/cursor/src/actions/safe-action.ts index 346260d0..a2cbd5ff 100644 --- a/apps/cursor/src/actions/safe-action.ts +++ b/apps/cursor/src/actions/safe-action.ts @@ -24,13 +24,18 @@ export const actionClient = createSafeActionClient({ actionName: z.string(), }); }, - // Define logging middleware. -}).use(async ({ next, clientInput, metadata }) => { +}).use(async ({ next, metadata }) => { const result = await next(); - console.log("Result ->", result); - console.log("Client input ->", clientInput); - console.log("Metadata ->", metadata); + // Log failures only — never inputs or results, which can contain user PII + // (emails, profile fields). Server errors are already logged with their + // message in handleServerError; this adds which action failed. + if (result.serverError || result.validationErrors) { + console.error(`[action:${metadata?.actionName}] failed`, { + serverError: result.serverError, + validationErrors: result.validationErrors, + }); + } return result; }); diff --git a/apps/cursor/src/utils/supabase/admin-client.ts b/apps/cursor/src/utils/supabase/admin-client.ts index fee2cf58..87f4e50a 100644 --- a/apps/cursor/src/utils/supabase/admin-client.ts +++ b/apps/cursor/src/utils/supabase/admin-client.ts @@ -1,5 +1,10 @@ import { createServerClient } from "@supabase/ssr"; +/** + * Privileged Supabase client using the secret key — bypasses RLS entirely. + * Server-side only. Callers are responsible for any visibility filtering + * (e.g. `.eq("public", true)`) and ownership checks. + */ export async function createClient() { return createServerClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, diff --git a/apps/cursor/src/utils/supabase/server.ts b/apps/cursor/src/utils/supabase/server.ts index bf1cbd11..522cbade 100644 --- a/apps/cursor/src/utils/supabase/server.ts +++ b/apps/cursor/src/utils/supabase/server.ts @@ -1,28 +1,21 @@ import { createServerClient } from "@supabase/ssr"; import { cookies } from "next/headers"; -export async function createClient({ - admin = false, -}: { - admin?: boolean; -} = {}) { +/** + * Cookie-scoped Supabase client for server components, server actions, and + * route handlers. Queries run as the logged-in user with RLS enforced. + * + * For privileged access that bypasses RLS, use + * `@/utils/supabase/admin-client` instead — never mix the secret key with + * cookie-based session auth. + */ +export async function createClient() { const cookieStore = await cookies(); - const auth = admin - ? { - persistSession: false, - autoRefreshToken: false, - detectSessionInUrl: false, - } - : {}; - return createServerClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, - admin - ? process.env.SUPABASE_SECRET_KEY! - : process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!, + process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!, { - auth, cookies: { getAll() { return cookieStore.getAll(); From 9120ca20868c81004e76434fe8514316403debb8 Mon Sep 17 00:00:00 2001 From: Lee Robinson Date: Mon, 8 Jun 2026 13:08:35 -0500 Subject: [PATCH 06/12] Extract shared plugin types and split the oversized plugin components plugin-detail.tsx had grown past 1,000 lines and plugin-form.tsx past 800, mixing data shaping, deeplink construction, clipboard handling, and per-component-type rendering in single files. - lib/plugins/types.ts is now the single source for plugin domain types (PluginRow, PluginComponent, scan verdicts/flags) and the component input schema; data/queries, the scanner, actions, and admin UI import from it instead of re-declaring drifting copies. - plugin-detail is split into focused modules under plugins/detail/ (rules/MCP/generic sections, deeplinks, scan banner, logo, copy button, add-to-cursor CTA). - The duplicated draft-component editor in plugin-form and edit-plugin-form moves to forms/component-draft-editor.tsx. - The hand-rolled 1,000-row pagination loops in data queries collapse into utils/supabase/pagination.fetchAllPages. - github-plugin/parse exports its auth headers + rate-limited fetch for the scanner instead of keeping private near-duplicates. Co-authored-by: Cursor --- .../app/admin/plugins/admin-plugins-tabs.tsx | 2 +- .../app/admin/plugins/flagged-review-list.tsx | 2 +- .../app/admin/plugins/plugin-review-list.tsx | 2 +- .../src/app/admin/plugins/stuck-scan-list.tsx | 2 +- .../plugins/verification-request-list.tsx | 2 +- .../forms/component-draft-editor.tsx | 199 +++++ .../src/components/forms/edit-plugin-form.tsx | 223 +---- .../src/components/forms/plugin-form.tsx | 430 ++------- .../components/icons/external-link-icon.tsx | 28 + .../src/components/members/members-tabs.tsx | 12 +- .../plugins/detail/add-to-cursor-or-copy.tsx | 52 ++ .../components/plugins/detail/copy-button.tsx | 45 + .../components/plugins/detail/deeplinks.ts | 39 + .../detail/generic-component-section.tsx | 65 ++ .../components/plugins/detail/mcp-section.tsx | 160 ++++ .../components/plugins/detail/plugin-logo.tsx | 47 + .../plugins/detail/rules-section.tsx | 73 ++ .../plugins/detail/scan-status-banner.tsx | 88 ++ .../src/components/plugins/plugin-detail.tsx | 824 +----------------- .../components/plugins/verify-controls.tsx | 2 +- .../components/profile/profile-plugins.tsx | 3 +- .../profile/profile-starred-plugins.tsx | 3 +- apps/cursor/src/data/client-queries.ts | 51 +- apps/cursor/src/lib/github-plugin/parse.ts | 15 +- apps/cursor/src/lib/plugins/scan.ts | 147 +++- apps/cursor/src/lib/plugins/types.ts | 154 ++++ .../cursor/src/scripts/extract-from-github.ts | 7 +- apps/cursor/src/utils/supabase/pagination.ts | 35 + 28 files changed, 1277 insertions(+), 1435 deletions(-) create mode 100644 apps/cursor/src/components/forms/component-draft-editor.tsx create mode 100644 apps/cursor/src/components/icons/external-link-icon.tsx create mode 100644 apps/cursor/src/components/plugins/detail/add-to-cursor-or-copy.tsx create mode 100644 apps/cursor/src/components/plugins/detail/copy-button.tsx create mode 100644 apps/cursor/src/components/plugins/detail/deeplinks.ts create mode 100644 apps/cursor/src/components/plugins/detail/generic-component-section.tsx create mode 100644 apps/cursor/src/components/plugins/detail/mcp-section.tsx create mode 100644 apps/cursor/src/components/plugins/detail/plugin-logo.tsx create mode 100644 apps/cursor/src/components/plugins/detail/rules-section.tsx create mode 100644 apps/cursor/src/components/plugins/detail/scan-status-banner.tsx create mode 100644 apps/cursor/src/lib/plugins/types.ts create mode 100644 apps/cursor/src/utils/supabase/pagination.ts diff --git a/apps/cursor/src/app/admin/plugins/admin-plugins-tabs.tsx b/apps/cursor/src/app/admin/plugins/admin-plugins-tabs.tsx index b8674d88..fa3eda4b 100644 --- a/apps/cursor/src/app/admin/plugins/admin-plugins-tabs.tsx +++ b/apps/cursor/src/app/admin/plugins/admin-plugins-tabs.tsx @@ -1,7 +1,7 @@ "use client"; import { useQueryState } from "nuqs"; -import type { PluginRow } from "@/data/queries"; +import type { PluginRow } from "@/lib/plugins/types"; import { cn } from "@/lib/utils"; import { FlaggedReviewList } from "./flagged-review-list"; import { PluginReviewList } from "./plugin-review-list"; diff --git a/apps/cursor/src/app/admin/plugins/flagged-review-list.tsx b/apps/cursor/src/app/admin/plugins/flagged-review-list.tsx index b66eccd2..93ed9034 100644 --- a/apps/cursor/src/app/admin/plugins/flagged-review-list.tsx +++ b/apps/cursor/src/app/admin/plugins/flagged-review-list.tsx @@ -19,7 +19,7 @@ import { } from "@/actions/review-flagged-plugin"; import { declinePluginAction } from "@/actions/review-plugin"; import { Button } from "@/components/ui/button"; -import type { FlagSeverity, PluginRow } from "@/data/queries"; +import type { FlagSeverity, PluginRow } from "@/lib/plugins/types"; import { cn } from "@/lib/utils"; const severityClass: Record = { diff --git a/apps/cursor/src/app/admin/plugins/plugin-review-list.tsx b/apps/cursor/src/app/admin/plugins/plugin-review-list.tsx index bc2dd2ef..39227962 100644 --- a/apps/cursor/src/app/admin/plugins/plugin-review-list.tsx +++ b/apps/cursor/src/app/admin/plugins/plugin-review-list.tsx @@ -10,7 +10,7 @@ import { declinePluginAction, } from "@/actions/review-plugin"; import { Button } from "@/components/ui/button"; -import type { PluginRow } from "@/data/queries"; +import type { PluginRow } from "@/lib/plugins/types"; function PluginReviewCard({ plugin }: { plugin: PluginRow }) { const [dismissed, setDismissed] = useState(false); diff --git a/apps/cursor/src/app/admin/plugins/stuck-scan-list.tsx b/apps/cursor/src/app/admin/plugins/stuck-scan-list.tsx index 689617b7..f2f01d49 100644 --- a/apps/cursor/src/app/admin/plugins/stuck-scan-list.tsx +++ b/apps/cursor/src/app/admin/plugins/stuck-scan-list.tsx @@ -8,7 +8,7 @@ import { toast } from "sonner"; import { rescanPluginAction } from "@/actions/review-flagged-plugin"; import { declinePluginAction } from "@/actions/review-plugin"; import { Button } from "@/components/ui/button"; -import type { PluginRow } from "@/data/queries"; +import type { PluginRow } from "@/lib/plugins/types"; function StuckCard({ plugin }: { plugin: PluginRow }) { const [dismissed, setDismissed] = useState(false); diff --git a/apps/cursor/src/app/admin/plugins/verification-request-list.tsx b/apps/cursor/src/app/admin/plugins/verification-request-list.tsx index 4f624915..c14b4e35 100644 --- a/apps/cursor/src/app/admin/plugins/verification-request-list.tsx +++ b/apps/cursor/src/app/admin/plugins/verification-request-list.tsx @@ -10,7 +10,7 @@ import { setPluginVerifiedAction, } from "@/actions/verify-plugin"; import { Button } from "@/components/ui/button"; -import type { PluginRow } from "@/data/queries"; +import type { PluginRow } from "@/lib/plugins/types"; function VerificationRequestCard({ plugin }: { plugin: PluginRow }) { const [dismissed, setDismissed] = useState(false); diff --git a/apps/cursor/src/components/forms/component-draft-editor.tsx b/apps/cursor/src/components/forms/component-draft-editor.tsx new file mode 100644 index 00000000..9ec9f41d --- /dev/null +++ b/apps/cursor/src/components/forms/component-draft-editor.tsx @@ -0,0 +1,199 @@ +"use client"; + +import { Plus, Trash2 } from "lucide-react"; +import type { ReactNode } from "react"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Textarea } from "@/components/ui/textarea"; +import { + COMPONENT_TYPE_LABELS, + COMPONENT_TYPES, + type ComponentInput, + type ComponentType, +} from "@/lib/plugins/types"; +import { slugify } from "@/lib/slug"; + +/** + * A plugin component as it exists while being edited in a form, before it is + * mapped to a `ComponentInput` for the create/update actions. + */ +export type ComponentDraft = { + id: string; + type: ComponentType; + name: string; + description: string; + content: string; +}; + +export function newComponentDraft(): ComponentDraft { + return { + id: crypto.randomUUID(), + type: "rule", + name: "", + description: "", + content: "", + }; +} + +/** + * Maps a draft to the payload shape accepted by the create/update plugin + * actions. Callers filter out unnamed drafts first and decide whether to + * attach `metadata` (create sends `{}`; update omits it so the action keeps + * the previously stored metadata). + */ +export function draftToComponentInput(draft: ComponentDraft): ComponentInput { + return { + type: draft.type, + name: draft.name.trim(), + slug: slugify(draft.name), + description: draft.description.trim() || undefined, + content: draft.content.trim() || undefined, + }; +} + +/** + * Editable list of plugin components: type, name, description, and content + * per component, plus add/remove. Used by the create (auto + manual tabs) + * and edit plugin forms. + */ +export function ComponentDraftEditor({ + drafts, + onChange, + header, +}: { + drafts: ComponentDraft[]; + onChange: (drafts: ComponentDraft[]) => void; + /** Rendered to the left of the Add button, e.g. a label or count. */ + header: ReactNode; +}) { + const addDraft = () => onChange([...drafts, newComponentDraft()]); + + const removeDraft = (id: string) => + onChange(drafts.filter((c) => c.id !== id)); + + const updateDraft = ( + id: string, + field: keyof Omit, + value: string, + ) => + onChange(drafts.map((c) => (c.id === id ? { ...c, [field]: value } : c))); + + return ( +
+
+ {header} + +
+ + {drafts.map((comp, index) => ( +
+
+

+ Component {index + 1} +

+ {drafts.length > 1 && ( + + )} +
+
+
+ + +
+
+ + updateDraft(comp.id, "name", e.target.value)} + placeholder="my-rule" + className="border-border placeholder:text-[#878787]" + /> +
+
+
+ + + updateDraft(comp.id, "description", e.target.value) + } + placeholder="What this component does" + className="border-border placeholder:text-[#878787]" + /> +
+
+ +