From 8c72cacaa4e69b14591caf646910726902f575cb Mon Sep 17 00:00:00 2001 From: Ishaan Jaff Date: Mon, 19 Jan 2026 19:08:59 -0800 Subject: [PATCH] [Feat] UI - Allow Adding Claude Code Plugins (#19387) * init schema * init endpoints * fix: claude_code_marketplace_router * refactor * fix: claude_code_marketplace_router * claude_code_marketplace_router * add netwroking methods * add plugin lefnat * add plugin form * add plugin on marketplace / ai hub * fix find mant bug * ui fix --- .../claude_code_marketplace.py | 27 +- litellm/types/proxy/claude_code_endpoints.py | 5 + .../app/(dashboard)/components/Sidebar2.tsx | 9 + .../experimental/claude-code-plugins/page.tsx | 17 + ui/litellm-dashboard/src/app/page.tsx | 3 + .../AIHub/ClaudeCodeMarketplaceTab.tsx | 162 ++++++++ .../src/components/AIHub/ModelHubTable.tsx | 9 +- .../AIHub/marketplace/PluginCard.tsx | 155 ++++++++ .../AIHub/marketplace_table_columns.tsx | 178 +++++++++ .../src/components/claude_code_plugins.tsx | 169 +++++++++ .../claude_code_plugins/add_plugin_form.tsx | 328 ++++++++++++++++ .../components/claude_code_plugins/helpers.ts | 264 +++++++++++++ .../claude_code_plugins/plugin_info.tsx | 350 +++++++++++++++++ .../claude_code_plugins/plugin_table.tsx | 351 ++++++++++++++++++ .../components/claude_code_plugins/types.ts | 113 ++++++ .../src/components/leftnav.tsx | 7 + .../src/components/networking.tsx | 275 ++++++++++++++ 17 files changed, 2414 insertions(+), 8 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/experimental/claude-code-plugins/page.tsx create mode 100644 ui/litellm-dashboard/src/components/AIHub/ClaudeCodeMarketplaceTab.tsx create mode 100644 ui/litellm-dashboard/src/components/AIHub/marketplace/PluginCard.tsx create mode 100644 ui/litellm-dashboard/src/components/AIHub/marketplace_table_columns.tsx create mode 100644 ui/litellm-dashboard/src/components/claude_code_plugins.tsx create mode 100644 ui/litellm-dashboard/src/components/claude_code_plugins/add_plugin_form.tsx create mode 100644 ui/litellm-dashboard/src/components/claude_code_plugins/helpers.ts create mode 100644 ui/litellm-dashboard/src/components/claude_code_plugins/plugin_info.tsx create mode 100644 ui/litellm-dashboard/src/components/claude_code_plugins/plugin_table.tsx create mode 100644 ui/litellm-dashboard/src/components/claude_code_plugins/types.ts diff --git a/litellm/proxy/anthropic_endpoints/claude_code_endpoints/claude_code_marketplace.py b/litellm/proxy/anthropic_endpoints/claude_code_endpoints/claude_code_marketplace.py index 7c212020a3..ab3fa9010e 100644 --- a/litellm/proxy/anthropic_endpoints/claude_code_endpoints/claude_code_marketplace.py +++ b/litellm/proxy/anthropic_endpoints/claude_code_endpoints/claude_code_marketplace.py @@ -310,24 +310,37 @@ async def list_plugins( where = {"enabled": True} if enabled_only else {} plugins = await prisma_client.db.litellm_claudecodeplugintable.find_many( - where=where, - order_by={"created_at": "desc"}, + where=where ) - return ListPluginsResponse( - plugins=[ + plugin_list = [] + for p in plugins: + # Parse manifest to get additional fields + manifest = json.loads(p.manifest_json) if p.manifest_json else {} + + plugin_list.append( PluginListItem( id=p.id, name=p.name, version=p.version, description=p.description, + source=manifest.get("source", {}), + author=manifest.get("author"), + homepage=manifest.get("homepage"), + keywords=manifest.get("keywords"), + category=manifest.get("category"), enabled=p.enabled, created_at=p.created_at.isoformat() if p.created_at else None, updated_at=p.updated_at.isoformat() if p.updated_at else None, ) - for p in plugins - ], - count=len(plugins), + ) + + # Sort by created_at descending (newest first) + plugin_list.sort(key=lambda x: x.created_at or "", reverse=True) + + return ListPluginsResponse( + plugins=plugin_list, + count=len(plugin_list), ) except HTTPException: diff --git a/litellm/types/proxy/claude_code_endpoints.py b/litellm/types/proxy/claude_code_endpoints.py index 663b182b80..033765527b 100644 --- a/litellm/types/proxy/claude_code_endpoints.py +++ b/litellm/types/proxy/claude_code_endpoints.py @@ -76,6 +76,11 @@ class PluginListItem(BaseModel): name: str version: Optional[str] description: Optional[str] + source: Dict[str, str] + author: Optional[PluginAuthor] = None + homepage: Optional[str] = None + keywords: Optional[List[str]] = None + category: Optional[str] = None enabled: bool created_at: Optional[str] updated_at: Optional[str] diff --git a/ui/litellm-dashboard/src/app/(dashboard)/components/Sidebar2.tsx b/ui/litellm-dashboard/src/app/(dashboard)/components/Sidebar2.tsx index 06da61a376..260cac16e0 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/components/Sidebar2.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/components/Sidebar2.tsx @@ -120,6 +120,8 @@ const routeFor = (slug: string): string => { return "experimental/api-playground"; case "tag-management": return "experimental/tag-management"; + case "claude-code-plugins": + return "experimental/claude-code-plugins"; case "usage": // "Old Usage" return "experimental/old-usage"; @@ -257,6 +259,13 @@ const menuItems: MenuItemCfg[] = [ icon: , roles: all_admin_roles, }, + { + key: "27", + page: "claude-code-plugins", + label: "Claude Code Plugins", + icon: , + roles: all_admin_roles, + }, { key: "4", page: "usage", label: "Old Usage", icon: }, ], }, diff --git a/ui/litellm-dashboard/src/app/(dashboard)/experimental/claude-code-plugins/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/experimental/claude-code-plugins/page.tsx new file mode 100644 index 0000000000..c92c39639c --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/experimental/claude-code-plugins/page.tsx @@ -0,0 +1,17 @@ +"use client"; + +import ClaudeCodePluginsPanel from "@/components/claude_code_plugins"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; + +const ClaudeCodePluginsPage = () => { + const { accessToken, userRole } = useAuthorized(); + + return ( + + ); +}; + +export default ClaudeCodePluginsPage; diff --git a/ui/litellm-dashboard/src/app/page.tsx b/ui/litellm-dashboard/src/app/page.tsx index 8a56287e15..8ca25eb9e4 100644 --- a/ui/litellm-dashboard/src/app/page.tsx +++ b/ui/litellm-dashboard/src/app/page.tsx @@ -8,6 +8,7 @@ import AdminPanel from "@/components/admins"; import AgentsPanel from "@/components/agents"; import BudgetPanel from "@/components/budgets/budget_panel"; import CacheDashboard from "@/components/cache_dashboard"; +import ClaudeCodePluginsPanel from "@/components/claude_code_plugins"; import { fetchTeams } from "@/components/common_components/fetch_teams"; import LoadingScreen from "@/components/common_components/LoadingScreen"; import { CostTrackingSettings } from "@/components/CostTrackingSettings"; @@ -530,6 +531,8 @@ export default function CreateKeyPage() { ) : page == "tag-management" ? ( + ) : page == "claude-code-plugins" ? ( + ) : page == "vector-stores" ? ( ) : page == "new_usage" ? ( diff --git a/ui/litellm-dashboard/src/components/AIHub/ClaudeCodeMarketplaceTab.tsx b/ui/litellm-dashboard/src/components/AIHub/ClaudeCodeMarketplaceTab.tsx new file mode 100644 index 0000000000..df022f4a74 --- /dev/null +++ b/ui/litellm-dashboard/src/components/AIHub/ClaudeCodeMarketplaceTab.tsx @@ -0,0 +1,162 @@ +import React, { useState, useEffect, useMemo } from "react"; +import { Input } from "antd"; +import { Card, TabGroup, TabList, Tab, TabPanels, TabPanel, Text } from "@tremor/react"; +import { SearchOutlined } from "@ant-design/icons"; +import { getClaudeCodeMarketplace } from "../networking"; +import { ModelDataTable } from "../model_dashboard/table"; +import { getMarketplaceTableColumns } from "./marketplace_table_columns"; +import NotificationsManager from "../molecules/notifications_manager"; +import { + MarketplaceResponse, + MarketplacePluginEntry, +} from "../claude_code_plugins/types"; +import { + extractCategories, + filterPluginsBySearch, + filterPluginsByCategory, +} from "../claude_code_plugins/helpers"; + +interface ClaudeCodeMarketplaceTabProps { + publicPage?: boolean; +} + +const ClaudeCodeMarketplaceTab: React.FC = ({ + publicPage = false, +}) => { + const [marketplaceData, setMarketplaceData] = + useState(null); + const [isLoading, setIsLoading] = useState(true); + const [searchTerm, setSearchTerm] = useState(""); + const [selectedCategoryIndex, setSelectedCategoryIndex] = useState(0); + + useEffect(() => { + fetchMarketplace(); + }, []); + + const fetchMarketplace = async () => { + setIsLoading(true); + try { + const data: MarketplaceResponse = await getClaudeCodeMarketplace(); + console.log("Claude Code marketplace:", data); + setMarketplaceData(data); + } catch (error) { + console.error("Error fetching marketplace:", error); + } finally { + setIsLoading(false); + } + }; + + const copyToClipboard = (text: string) => { + navigator.clipboard.writeText(text); + NotificationsManager.success("Copied to clipboard!"); + }; + + // Extract unique categories from plugins + const categories = useMemo(() => { + if (!marketplaceData) return ["All"]; + return extractCategories(marketplaceData.plugins); + }, [marketplaceData]); + + // Get selected category name + const selectedCategory = categories[selectedCategoryIndex] || "All"; + + // Filter plugins by search and category + const filteredPlugins = useMemo(() => { + if (!marketplaceData) return []; + + let plugins = marketplaceData.plugins; + + // Apply category filter + plugins = filterPluginsByCategory(plugins, selectedCategory); + + // Apply search filter + plugins = filterPluginsBySearch(plugins, searchTerm); + + return plugins; + }, [marketplaceData, selectedCategory, searchTerm]); + + const columns = useMemo( + () => getMarketplaceTableColumns(copyToClipboard, publicPage), + [publicPage] + ); + + if (!marketplaceData && !isLoading) { + return ( + +
+ + Failed to load marketplace. Please try again later. + +
+
+ ); + } + + return ( +
+ {/* Search Bar */} +
+ } + value={searchTerm} + onChange={(e) => setSearchTerm(e.target.value)} + allowClear + size="large" + /> +
+ + {/* Category Tabs */} + + + {categories.map((category) => { + // Count plugins in this category + const categoryPlugins = filterPluginsByCategory( + marketplaceData?.plugins || [], + category + ); + const count = filterPluginsBySearch( + categoryPlugins, + searchTerm + ).length; + + return ( + + {category} {count > 0 && `(${count})`} + + ); + })} + + + + {categories.map((category) => ( + + + {/* Plugin Table */} + + + + {/* Footer Info */} +
+ + Showing {filteredPlugins.length} of{" "} + {marketplaceData?.plugins.length || 0} plugin + {marketplaceData?.plugins.length !== 1 ? "s" : ""} + {searchTerm && ` matching "${searchTerm}"`} + {selectedCategory !== "All" && ` in ${selectedCategory}`} + +
+
+ ))} +
+
+
+ ); +}; + +export default ClaudeCodeMarketplaceTab; diff --git a/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.tsx b/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.tsx index 7c538b618f..23bfb7d219 100644 --- a/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.tsx +++ b/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.tsx @@ -5,6 +5,7 @@ import MakeModelPublicForm from "@/components/AIHub/forms/MakeModelPublicForm"; import { mcpHubColumns, MCPServerData } from "@/components/mcp_hub_table_columns"; import { modelHubColumns } from "@/components/model_hub_table_columns"; import UsefulLinksManagement from "@/components/AIHub/UsefulLinksManagement"; +import ClaudeCodeMarketplaceTab from "@/components/AIHub/ClaudeCodeMarketplaceTab"; import { ModelDataTable } from "@/components/model_dashboard/table"; import ModelFilters from "@/components/model_filters"; import NotificationsManager from "@/components/molecules/notifications_manager"; @@ -372,12 +373,13 @@ const ModelHubTable: React.FC = ({ accessToken, publicPage, )} - {/* Tab System for Model Hub, Agent Hub, and MCP Hub */} + {/* Tab System for Model Hub, Agent Hub, MCP Hub, and Plugin Marketplace */} Model Hub Agent Hub MCP Hub + Claude Code Plugin Marketplace @@ -462,6 +464,11 @@ const ModelHubTable: React.FC = ({ accessToken, publicPage, + + {/* Plugin Marketplace Tab */} + + + diff --git a/ui/litellm-dashboard/src/components/AIHub/marketplace/PluginCard.tsx b/ui/litellm-dashboard/src/components/AIHub/marketplace/PluginCard.tsx new file mode 100644 index 0000000000..d8111b2da0 --- /dev/null +++ b/ui/litellm-dashboard/src/components/AIHub/marketplace/PluginCard.tsx @@ -0,0 +1,155 @@ +import React from "react"; +import { Card, Badge, Button, Text } from "@tremor/react"; +import { Tooltip } from "antd"; +import { CopyOutlined, ExternalLinkIcon } from "@heroicons/react/outline"; +import { MarketplacePluginEntry } from "@/components/claude_code_plugins/types"; +import { + formatInstallCommand, + getCategoryBadgeColor, + getSourceLink, + truncateText, +} from "@/components/claude_code_plugins/helpers"; +import NotificationsManager from "@/components/molecules/notifications_manager"; + +interface PluginCardProps { + plugin: MarketplacePluginEntry; +} + +const PluginCard: React.FC = ({ plugin }) => { + const installCommand = formatInstallCommand(plugin); + const sourceLink = getSourceLink(plugin.source); + const categoryBadgeColor = getCategoryBadgeColor(plugin.category); + + const copyToClipboard = (text: string) => { + navigator.clipboard.writeText(text); + NotificationsManager.success("Install command copied!"); + }; + + // Limit keywords display to first 5 + const displayKeywords = plugin.keywords?.slice(0, 5) || []; + const remainingKeywords = (plugin.keywords?.length || 0) - 5; + + return ( + + {/* Header */} +
+
+
+

+ {plugin.name} +

+ {plugin.version && ( + + v{plugin.version} + + )} + {plugin.category && ( + + {plugin.category} + + )} +
+
+ {sourceLink && ( + + e.stopPropagation()} + > + + + + )} +
+ + {/* Description */} +
+ {plugin.description ? ( + + {plugin.description} + + ) : ( + + No description available + + )} +
+ + {/* Keywords */} + {displayKeywords.length > 0 && ( +
+ {displayKeywords.map((keyword, index) => ( + + {keyword} + + ))} + {remainingKeywords > 0 && ( + + +{remainingKeywords} more + + )} +
+ )} + + {/* Author */} + {plugin.author && ( +
+ + By {plugin.author.name} + {plugin.author.email && ` (${plugin.author.email})`} + +
+ )} + + {/* Homepage Link */} + {plugin.homepage && ( + + )} + + {/* Install Command */} +
+
+
+ Install command + + + {installCommand} + + +
+ +
+
+
+ ); +}; + +export default PluginCard; diff --git a/ui/litellm-dashboard/src/components/AIHub/marketplace_table_columns.tsx b/ui/litellm-dashboard/src/components/AIHub/marketplace_table_columns.tsx new file mode 100644 index 0000000000..ed17e84c23 --- /dev/null +++ b/ui/litellm-dashboard/src/components/AIHub/marketplace_table_columns.tsx @@ -0,0 +1,178 @@ +import { ColumnDef } from "@tanstack/react-table"; +import { Button, Badge, Text } from "@tremor/react"; +import { Tooltip } from "antd"; +import { CopyOutlined } from "@ant-design/icons"; +import { MarketplacePluginEntry } from "@/components/claude_code_plugins/types"; +import { + formatInstallCommand, + getCategoryBadgeColor, + getSourceDisplayText, +} from "@/components/claude_code_plugins/helpers"; + +export const getMarketplaceTableColumns = ( + copyToClipboard: (text: string) => void, + publicPage: boolean = false, +): ColumnDef[] => { + const allColumns: ColumnDef[] = [ + { + header: "Plugin Name", + accessorKey: "name", + enableSorting: true, + sortingFn: "alphanumeric", + cell: ({ row }) => { + const plugin = row.original; + const installCommand = formatInstallCommand(plugin); + + return ( +
+
+ {plugin.name} + + copyToClipboard(installCommand)} + className="cursor-pointer text-gray-500 hover:text-blue-500 text-xs" + /> + +
+ {/* Show description on mobile */} +
+ + {plugin.description || "No description"} + +
+
+ ); + }, + }, + { + header: "Description", + accessorKey: "description", + enableSorting: true, + sortingFn: "alphanumeric", + cell: ({ row }) => { + const plugin = row.original; + + return ( + + {plugin.description || "-"} + + ); + }, + meta: { + className: "hidden md:table-cell", + }, + }, + { + header: "Version", + accessorKey: "version", + enableSorting: true, + sortingFn: "alphanumeric", + cell: ({ row }) => { + const plugin = row.original; + + return plugin.version ? ( + + v{plugin.version} + + ) : ( + - + ); + }, + meta: { + className: "hidden lg:table-cell", + }, + }, + { + header: "Category", + accessorKey: "category", + enableSorting: true, + sortingFn: "alphanumeric", + cell: ({ row }) => { + const plugin = row.original; + const badgeColor = getCategoryBadgeColor(plugin.category); + + return plugin.category ? ( + + {plugin.category} + + ) : ( + + Uncategorized + + ); + }, + meta: { + className: "hidden lg:table-cell", + }, + }, + { + header: "Source", + accessorKey: "source", + enableSorting: false, + cell: ({ row }) => { + const plugin = row.original; + const sourceText = getSourceDisplayText(plugin.source); + + return {sourceText}; + }, + meta: { + className: "hidden xl:table-cell", + }, + }, + { + header: "Keywords", + accessorKey: "keywords", + enableSorting: false, + cell: ({ row }) => { + const plugin = row.original; + const keywords = plugin.keywords?.slice(0, 3) || []; + const remaining = (plugin.keywords?.length || 0) - 3; + + return ( +
+ {keywords.map((keyword, index) => ( + + {keyword} + + ))} + {remaining > 0 && ( + + +{remaining} + + )} +
+ ); + }, + meta: { + className: "hidden xl:table-cell", + }, + }, + { + header: "Install Command", + id: "install_command", + enableSorting: false, + cell: ({ row }) => { + const plugin = row.original; + const installCommand = formatInstallCommand(plugin); + + return ( +
+ + {installCommand} + + +
+ ); + }, + }, + ]; + + return allColumns; +}; diff --git a/ui/litellm-dashboard/src/components/claude_code_plugins.tsx b/ui/litellm-dashboard/src/components/claude_code_plugins.tsx new file mode 100644 index 0000000000..b2e66349a2 --- /dev/null +++ b/ui/litellm-dashboard/src/components/claude_code_plugins.tsx @@ -0,0 +1,169 @@ +import React, { useState, useEffect } from "react"; +import { Button } from "@tremor/react"; +import { Modal } from "antd"; +import { + getClaudeCodePluginsList, + deleteClaudeCodePlugin, +} from "./networking"; +import AddPluginForm from "./claude_code_plugins/add_plugin_form"; +import PluginTable from "./claude_code_plugins/plugin_table"; +import { isAdminRole } from "@/utils/roles"; +import PluginInfoView from "./claude_code_plugins/plugin_info"; +import NotificationsManager from "./molecules/notifications_manager"; +import { Plugin, ListPluginsResponse } from "./claude_code_plugins/types"; + +interface ClaudeCodePluginsPanelProps { + accessToken: string | null; + userRole?: string; +} + +const ClaudeCodePluginsPanel: React.FC = ({ + accessToken, + userRole, +}) => { + const [pluginsList, setPluginsList] = useState([]); + const [isAddModalVisible, setIsAddModalVisible] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + const [pluginToDelete, setPluginToDelete] = useState<{ + name: string; + displayName: string; + } | null>(null); + const [selectedPluginId, setSelectedPluginId] = useState( + null + ); + + const isAdmin = userRole ? isAdminRole(userRole) : false; + + const fetchPlugins = async () => { + if (!accessToken) { + return; + } + + setIsLoading(true); + try { + const response: ListPluginsResponse = await getClaudeCodePluginsList( + accessToken, + false // Get all plugins (enabled and disabled) + ); + console.log(`Claude Code plugins: ${JSON.stringify(response)}`); + setPluginsList(response.plugins); + } catch (error) { + console.error("Error fetching Claude Code plugins:", error); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + fetchPlugins(); + }, [accessToken]); + + const handleAddPlugin = () => { + if (selectedPluginId) { + setSelectedPluginId(null); + } + setIsAddModalVisible(true); + }; + + const handleCloseModal = () => { + setIsAddModalVisible(false); + }; + + const handleSuccess = () => { + fetchPlugins(); + }; + + const handleDeleteClick = (pluginName: string, displayName: string) => { + setPluginToDelete({ name: pluginName, displayName }); + }; + + const handleDeleteConfirm = async () => { + if (!pluginToDelete || !accessToken) return; + + setIsDeleting(true); + try { + await deleteClaudeCodePlugin(accessToken, pluginToDelete.name); + NotificationsManager.success( + `Plugin "${pluginToDelete.displayName}" deleted successfully` + ); + fetchPlugins(); + } catch (error) { + console.error("Error deleting plugin:", error); + NotificationsManager.error("Failed to delete plugin"); + } finally { + setIsDeleting(false); + setPluginToDelete(null); + } + }; + + const handleDeleteCancel = () => { + setPluginToDelete(null); + }; + + return ( +
+
+

Claude Code Plugins

+

+ Manage Claude Code marketplace plugins. Add, enable, disable, or + delete plugins that will be available in your marketplace catalog. + Enabled plugins will appear in the public marketplace at{" "} + /claude-code/marketplace.json. +

+
+ +
+
+ + {selectedPluginId ? ( + setSelectedPluginId(null)} + accessToken={accessToken} + isAdmin={isAdmin} + onPluginUpdated={fetchPlugins} + /> + ) : ( + setSelectedPluginId(id)} + /> + )} + + + + {pluginToDelete && ( + +

+ Are you sure you want to delete plugin:{" "} + {pluginToDelete.displayName}? +

+

This action cannot be undone.

+
+ )} +
+ ); +}; + +export default ClaudeCodePluginsPanel; diff --git a/ui/litellm-dashboard/src/components/claude_code_plugins/add_plugin_form.tsx b/ui/litellm-dashboard/src/components/claude_code_plugins/add_plugin_form.tsx new file mode 100644 index 0000000000..217851f128 --- /dev/null +++ b/ui/litellm-dashboard/src/components/claude_code_plugins/add_plugin_form.tsx @@ -0,0 +1,328 @@ +import React, { useState } from "react"; +import { Modal, Form, Input, Select, message } from "antd"; +import { Button } from "@tremor/react"; +import { registerClaudeCodePlugin } from "../networking"; +import { + validatePluginName, + isValidSemanticVersion, + isValidEmail, + isValidUrl, + parseKeywords, +} from "./helpers"; + +const { TextArea } = Input; +const { Option } = Select; + +interface AddPluginFormProps { + visible: boolean; + onClose: () => void; + accessToken: string | null; + onSuccess: () => void; +} + +const PREDEFINED_CATEGORIES = [ + "Development", + "Productivity", + "Learning", + "Security", + "Data & Analytics", + "Integration", + "Testing", + "Documentation", +]; + +const AddPluginForm: React.FC = ({ + visible, + onClose, + accessToken, + onSuccess, +}) => { + const [form] = Form.useForm(); + const [isSubmitting, setIsSubmitting] = useState(false); + const [sourceType, setSourceType] = useState<"github" | "url">("github"); + + const handleSubmit = async (values: any) => { + if (!accessToken) { + message.error("No access token available"); + return; + } + + // Validate plugin name + if (!validatePluginName(values.name)) { + message.error( + "Plugin name must be kebab-case (lowercase letters, numbers, and hyphens only)" + ); + return; + } + + // Validate semantic version if provided + if (values.version && !isValidSemanticVersion(values.version)) { + message.error( + "Version must be in semantic versioning format (e.g., 1.0.0)" + ); + return; + } + + // Validate email if provided + if (values.authorEmail && !isValidEmail(values.authorEmail)) { + message.error("Invalid email format"); + return; + } + + // Validate homepage URL if provided + if (values.homepage && !isValidUrl(values.homepage)) { + message.error("Invalid homepage URL format"); + return; + } + + setIsSubmitting(true); + try { + // Build plugin data + const pluginData: any = { + name: values.name.trim(), + source: + sourceType === "github" + ? { + source: "github", + repo: values.repo.trim(), + } + : { + source: "url", + url: values.url.trim(), + }, + }; + + // Add optional fields + if (values.version) { + pluginData.version = values.version.trim(); + } + if (values.description) { + pluginData.description = values.description.trim(); + } + if (values.authorName || values.authorEmail) { + pluginData.author = {}; + if (values.authorName) { + pluginData.author.name = values.authorName.trim(); + } + if (values.authorEmail) { + pluginData.author.email = values.authorEmail.trim(); + } + } + if (values.homepage) { + pluginData.homepage = values.homepage.trim(); + } + if (values.category) { + pluginData.category = values.category; + } + if (values.keywords) { + pluginData.keywords = parseKeywords(values.keywords); + } + + await registerClaudeCodePlugin(accessToken, pluginData); + message.success("Plugin registered successfully"); + form.resetFields(); + setSourceType("github"); + onSuccess(); + onClose(); + } catch (error) { + console.error("Error registering plugin:", error); + message.error("Failed to register plugin"); + } finally { + setIsSubmitting(false); + } + }; + + const handleCancel = () => { + form.resetFields(); + setSourceType("github"); + onClose(); + }; + + const handleSourceTypeChange = (value: "github" | "url") => { + setSourceType(value); + // Clear repo/url fields when switching + form.setFieldsValue({ repo: undefined, url: undefined }); + }; + + return ( + +
+ {/* Plugin Name */} + + + + + {/* Source Type */} + + + + + {/* GitHub Repository */} + {sourceType === "github" && ( + + + + )} + + {/* Git URL */} + {sourceType === "url" && ( + + + + )} + + {/* Version */} + + + + + {/* Description */} + +