[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
This commit is contained in:
Ishaan Jaff
2026-01-19 19:08:59 -08:00
committed by GitHub
parent d48df6c17d
commit 8c72cacaa4
17 changed files with 2414 additions and 8 deletions
@@ -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:
@@ -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]
@@ -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: <TagsOutlined style={{ fontSize: 18 }} />,
roles: all_admin_roles,
},
{
key: "27",
page: "claude-code-plugins",
label: "Claude Code Plugins",
icon: <ToolOutlined style={{ fontSize: 18 }} />,
roles: all_admin_roles,
},
{ key: "4", page: "usage", label: "Old Usage", icon: <BarChartOutlined style={{ fontSize: 18 }} /> },
],
},
@@ -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 (
<ClaudeCodePluginsPanel
accessToken={accessToken}
userRole={userRole}
/>
);
};
export default ClaudeCodePluginsPage;
+3
View File
@@ -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() {
<SearchTools accessToken={accessToken} userRole={userRole} userID={userID} />
) : page == "tag-management" ? (
<TagManagement accessToken={accessToken} userRole={userRole} userID={userID} />
) : page == "claude-code-plugins" ? (
<ClaudeCodePluginsPanel accessToken={accessToken} userRole={userRole} />
) : page == "vector-stores" ? (
<VectorStoreManagement accessToken={accessToken} userRole={userRole} userID={userID} />
) : page == "new_usage" ? (
@@ -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<ClaudeCodeMarketplaceTabProps> = ({
publicPage = false,
}) => {
const [marketplaceData, setMarketplaceData] =
useState<MarketplaceResponse | null>(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 (
<Card>
<div className="text-center p-12">
<Text className="text-gray-500">
Failed to load marketplace. Please try again later.
</Text>
</div>
</Card>
);
}
return (
<div className="space-y-4">
{/* Search Bar */}
<div className="max-w-md">
<Input
placeholder="Search plugins by name, description, or keywords..."
prefix={<SearchOutlined className="text-gray-400" />}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
allowClear
size="large"
/>
</div>
{/* Category Tabs */}
<TabGroup index={selectedCategoryIndex} onIndexChange={setSelectedCategoryIndex}>
<TabList className="mb-4">
{categories.map((category) => {
// Count plugins in this category
const categoryPlugins = filterPluginsByCategory(
marketplaceData?.plugins || [],
category
);
const count = filterPluginsBySearch(
categoryPlugins,
searchTerm
).length;
return (
<Tab key={category}>
{category} {count > 0 && `(${count})`}
</Tab>
);
})}
</TabList>
<TabPanels>
{categories.map((category) => (
<TabPanel key={category}>
<Card>
{/* Plugin Table */}
<ModelDataTable
columns={columns}
data={filteredPlugins}
isLoading={isLoading}
defaultSorting={[{ id: "name", desc: false }]}
/>
</Card>
{/* Footer Info */}
<div className="mt-4 text-center space-y-2">
<Text className="text-sm text-gray-600">
Showing {filteredPlugins.length} of{" "}
{marketplaceData?.plugins.length || 0} plugin
{marketplaceData?.plugins.length !== 1 ? "s" : ""}
{searchTerm && ` matching "${searchTerm}"`}
{selectedCategory !== "All" && ` in ${selectedCategory}`}
</Text>
</div>
</TabPanel>
))}
</TabPanels>
</TabGroup>
</div>
);
};
export default ClaudeCodeMarketplaceTab;
@@ -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<ModelHubTableProps> = ({ accessToken, publicPage,
</div>
)}
{/* Tab System for Model Hub, Agent Hub, and MCP Hub */}
{/* Tab System for Model Hub, Agent Hub, MCP Hub, and Plugin Marketplace */}
<TabGroup>
<TabList className="mb-4">
<Tab>Model Hub</Tab>
<Tab>Agent Hub</Tab>
<Tab>MCP Hub</Tab>
<Tab>Claude Code Plugin Marketplace</Tab>
</TabList>
<TabPanels>
@@ -462,6 +464,11 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({ accessToken, publicPage,
</Text>
</div>
</TabPanel>
{/* Plugin Marketplace Tab */}
<TabPanel>
<ClaudeCodeMarketplaceTab publicPage={publicPage} />
</TabPanel>
</TabPanels>
</TabGroup>
</div>
@@ -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<PluginCardProps> = ({ 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 (
<Card
className="hover:shadow-lg transition-shadow duration-200 h-full flex flex-col"
decoration="top"
decorationColor={categoryBadgeColor}
>
{/* Header */}
<div className="flex items-start justify-between mb-3">
<div className="flex-1">
<div className="flex items-center gap-2 flex-wrap">
<h3 className="text-lg font-semibold text-gray-900">
{plugin.name}
</h3>
{plugin.version && (
<Badge color="blue" size="xs">
v{plugin.version}
</Badge>
)}
{plugin.category && (
<Badge color={categoryBadgeColor} size="xs">
{plugin.category}
</Badge>
)}
</div>
</div>
{sourceLink && (
<Tooltip title="View source repository">
<a
href={sourceLink}
target="_blank"
rel="noopener noreferrer"
className="text-gray-500 hover:text-blue-500"
onClick={(e) => e.stopPropagation()}
>
<ExternalLinkIcon className="h-5 w-5" />
</a>
</Tooltip>
)}
</div>
{/* Description */}
<div className="mb-4 flex-1">
{plugin.description ? (
<Text className="text-sm text-gray-600 line-clamp-3">
{plugin.description}
</Text>
) : (
<Text className="text-sm text-gray-400 italic">
No description available
</Text>
)}
</div>
{/* Keywords */}
{displayKeywords.length > 0 && (
<div className="flex flex-wrap gap-1 mb-4">
{displayKeywords.map((keyword, index) => (
<Badge key={index} color="gray" size="xs" className="text-xs">
{keyword}
</Badge>
))}
{remainingKeywords > 0 && (
<Badge color="gray" size="xs" className="text-xs">
+{remainingKeywords} more
</Badge>
)}
</div>
)}
{/* Author */}
{plugin.author && (
<div className="mb-4">
<Text className="text-xs text-gray-500">
By {plugin.author.name}
{plugin.author.email && ` (${plugin.author.email})`}
</Text>
</div>
)}
{/* Homepage Link */}
{plugin.homepage && (
<div className="mb-4">
<a
href={plugin.homepage}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-blue-500 hover:text-blue-700 flex items-center gap-1"
onClick={(e) => e.stopPropagation()}
>
Visit homepage
<ExternalLinkIcon className="h-3 w-3" />
</a>
</div>
)}
{/* Install Command */}
<div className="mt-auto pt-4 border-t border-gray-100">
<div className="flex items-center justify-between gap-2">
<div className="flex-1 overflow-hidden">
<Text className="text-xs text-gray-500 mb-1">Install command</Text>
<Tooltip title={installCommand}>
<code className="block text-xs bg-gray-50 px-2 py-1 rounded font-mono text-gray-700 truncate">
{installCommand}
</code>
</Tooltip>
</div>
<Tooltip title="Copy install command">
<Button
size="xs"
variant="secondary"
icon={CopyOutlined}
onClick={(e) => {
e.stopPropagation();
copyToClipboard(installCommand);
}}
/>
</Tooltip>
</div>
</div>
</Card>
);
};
export default PluginCard;
@@ -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<MarketplacePluginEntry>[] => {
const allColumns: ColumnDef<MarketplacePluginEntry>[] = [
{
header: "Plugin Name",
accessorKey: "name",
enableSorting: true,
sortingFn: "alphanumeric",
cell: ({ row }) => {
const plugin = row.original;
const installCommand = formatInstallCommand(plugin);
return (
<div className="space-y-1">
<div className="flex items-center space-x-2">
<Text className="font-medium text-sm">{plugin.name}</Text>
<Tooltip title="Copy install command">
<CopyOutlined
onClick={() => copyToClipboard(installCommand)}
className="cursor-pointer text-gray-500 hover:text-blue-500 text-xs"
/>
</Tooltip>
</div>
{/* Show description on mobile */}
<div className="md:hidden">
<Text className="text-xs text-gray-600">
{plugin.description || "No description"}
</Text>
</div>
</div>
);
},
},
{
header: "Description",
accessorKey: "description",
enableSorting: true,
sortingFn: "alphanumeric",
cell: ({ row }) => {
const plugin = row.original;
return (
<Text className="text-xs line-clamp-2">
{plugin.description || "-"}
</Text>
);
},
meta: {
className: "hidden md:table-cell",
},
},
{
header: "Version",
accessorKey: "version",
enableSorting: true,
sortingFn: "alphanumeric",
cell: ({ row }) => {
const plugin = row.original;
return plugin.version ? (
<Badge color="blue" size="sm">
v{plugin.version}
</Badge>
) : (
<Text className="text-xs text-gray-400">-</Text>
);
},
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 ? (
<Badge color={badgeColor} size="sm">
{plugin.category}
</Badge>
) : (
<Badge color="gray" size="sm">
Uncategorized
</Badge>
);
},
meta: {
className: "hidden lg:table-cell",
},
},
{
header: "Source",
accessorKey: "source",
enableSorting: false,
cell: ({ row }) => {
const plugin = row.original;
const sourceText = getSourceDisplayText(plugin.source);
return <Text className="text-xs text-gray-600">{sourceText}</Text>;
},
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 (
<div className="flex flex-wrap gap-1">
{keywords.map((keyword, index) => (
<Badge key={index} color="gray" size="xs">
{keyword}
</Badge>
))}
{remaining > 0 && (
<Badge color="gray" size="xs">
+{remaining}
</Badge>
)}
</div>
);
},
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 (
<div className="flex items-center space-x-2">
<code className="text-xs bg-gray-100 px-2 py-1 rounded font-mono truncate max-w-[200px]">
{installCommand}
</code>
<Tooltip title="Copy command">
<Button
size="xs"
variant="secondary"
icon={CopyOutlined}
onClick={() => copyToClipboard(installCommand)}
/>
</Tooltip>
</div>
);
},
},
];
return allColumns;
};
@@ -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<ClaudeCodePluginsPanelProps> = ({
accessToken,
userRole,
}) => {
const [pluginsList, setPluginsList] = useState<Plugin[]>([]);
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<string | null>(
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 (
<div className="w-full mx-auto flex-auto overflow-y-auto m-8 p-2">
<div className="flex flex-col gap-2 mb-4">
<h1 className="text-2xl font-bold">Claude Code Plugins</h1>
<p className="text-sm text-gray-600">
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{" "}
<code className="bg-gray-100 px-1 rounded">/claude-code/marketplace.json</code>.
</p>
<div className="mt-2">
<Button onClick={handleAddPlugin} disabled={!accessToken || !isAdmin}>
+ Add New Plugin
</Button>
</div>
</div>
{selectedPluginId ? (
<PluginInfoView
pluginId={selectedPluginId}
onClose={() => setSelectedPluginId(null)}
accessToken={accessToken}
isAdmin={isAdmin}
onPluginUpdated={fetchPlugins}
/>
) : (
<PluginTable
pluginsList={pluginsList}
isLoading={isLoading}
onDeleteClick={handleDeleteClick}
accessToken={accessToken}
onPluginUpdated={fetchPlugins}
isAdmin={isAdmin}
onPluginClick={(id) => setSelectedPluginId(id)}
/>
)}
<AddPluginForm
visible={isAddModalVisible}
onClose={handleCloseModal}
accessToken={accessToken}
onSuccess={handleSuccess}
/>
{pluginToDelete && (
<Modal
title="Delete Plugin"
open={pluginToDelete !== null}
onOk={handleDeleteConfirm}
onCancel={handleDeleteCancel}
confirmLoading={isDeleting}
okText="Delete"
okButtonProps={{ danger: true }}
>
<p>
Are you sure you want to delete plugin:{" "}
<strong>{pluginToDelete.displayName}</strong>?
</p>
<p>This action cannot be undone.</p>
</Modal>
)}
</div>
);
};
export default ClaudeCodePluginsPanel;
@@ -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<AddPluginFormProps> = ({
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 (
<Modal
title="Add New Claude Code Plugin"
open={visible}
onCancel={handleCancel}
footer={null}
width={700}
className="top-8"
>
<Form
form={form}
layout="vertical"
onFinish={handleSubmit}
className="mt-4"
>
{/* Plugin Name */}
<Form.Item
label="Plugin Name"
name="name"
rules={[
{ required: true, message: "Please enter plugin name" },
{
pattern: /^[a-z0-9-]+$/,
message:
"Name must be kebab-case (lowercase, numbers, hyphens only)",
},
]}
tooltip="Unique identifier in kebab-case format (e.g., my-awesome-plugin)"
>
<Input placeholder="my-awesome-plugin" className="rounded-lg" />
</Form.Item>
{/* Source Type */}
<Form.Item
label="Source Type"
name="sourceType"
initialValue="github"
rules={[{ required: true, message: "Please select source type" }]}
>
<Select onChange={handleSourceTypeChange} className="rounded-lg">
<Option value="github">GitHub</Option>
<Option value="url">URL</Option>
</Select>
</Form.Item>
{/* GitHub Repository */}
{sourceType === "github" && (
<Form.Item
label="GitHub Repository"
name="repo"
rules={[
{ required: true, message: "Please enter repository" },
{
pattern: /^[a-zA-Z0-9_-]+\/[a-zA-Z0-9_-]+$/,
message: "Repository must be in format: org/repo",
},
]}
tooltip="Format: organization/repository (e.g., anthropics/claude-code)"
>
<Input placeholder="anthropics/claude-code" className="rounded-lg" />
</Form.Item>
)}
{/* Git URL */}
{sourceType === "url" && (
<Form.Item
label="Git URL"
name="url"
rules={[{ required: true, message: "Please enter git URL" }]}
tooltip="Full git URL to the repository"
>
<Input
type="url"
placeholder="https://github.com/org/repo.git"
className="rounded-lg"
/>
</Form.Item>
)}
{/* Version */}
<Form.Item
label="Version (Optional)"
name="version"
tooltip="Semantic version (e.g., 1.0.0)"
>
<Input placeholder="1.0.0" className="rounded-lg" />
</Form.Item>
{/* Description */}
<Form.Item
label="Description (Optional)"
name="description"
tooltip="Brief description of what the plugin does"
>
<TextArea
rows={3}
placeholder="A plugin that helps with..."
maxLength={500}
className="rounded-lg"
/>
</Form.Item>
{/* Category */}
<Form.Item
label="Category (Optional)"
name="category"
tooltip="Select a category or enter a custom one"
>
<Select
placeholder="Select or type a category"
allowClear
showSearch
optionFilterProp="children"
className="rounded-lg"
>
{PREDEFINED_CATEGORIES.map((cat) => (
<Option key={cat} value={cat}>
{cat}
</Option>
))}
</Select>
</Form.Item>
{/* Keywords */}
<Form.Item
label="Keywords (Optional)"
name="keywords"
tooltip="Comma-separated list of keywords for search"
>
<Input placeholder="search, web, api" className="rounded-lg" />
</Form.Item>
{/* Author Name */}
<Form.Item
label="Author Name (Optional)"
name="authorName"
tooltip="Name of the plugin author or organization"
>
<Input placeholder="Your Name or Organization" className="rounded-lg" />
</Form.Item>
{/* Author Email */}
<Form.Item
label="Author Email (Optional)"
name="authorEmail"
rules={[{ type: "email", message: "Please enter a valid email" }]}
tooltip="Contact email for the plugin author"
>
<Input type="email" placeholder="author@example.com" className="rounded-lg" />
</Form.Item>
{/* Homepage */}
<Form.Item
label="Homepage (Optional)"
name="homepage"
rules={[{ type: "url", message: "Please enter a valid URL" }]}
tooltip="URL to the plugin's homepage or documentation"
>
<Input type="url" placeholder="https://example.com" className="rounded-lg" />
</Form.Item>
{/* Submit Buttons */}
<Form.Item className="mb-0 mt-6">
<div className="flex justify-end gap-2">
<Button
variant="secondary"
onClick={handleCancel}
disabled={isSubmitting}
>
Cancel
</Button>
<Button type="submit" loading={isSubmitting}>
{isSubmitting ? "Registering..." : "Register Plugin"}
</Button>
</div>
</Form.Item>
</Form>
</Modal>
);
};
export default AddPluginForm;
@@ -0,0 +1,264 @@
/**
* Helper utilities for Claude Code Marketplace
*/
import { PluginSource, MarketplacePluginEntry } from "./types";
/**
* Generate install command for Claude Code CLI
* Format: /plugin marketplace add org/repo OR /plugin marketplace add url
*/
export const formatInstallCommand = (plugin: {
name: string;
source: PluginSource;
}): string => {
if (plugin.source.source === "github" && plugin.source.repo) {
return `/plugin marketplace add ${plugin.source.repo}`;
} else if (plugin.source.source === "url" && plugin.source.url) {
return `/plugin marketplace add ${plugin.source.url}`;
}
// Fallback to plugin name
return `/plugin marketplace add ${plugin.name}`;
};
/**
* Extract unique categories from plugins list
* Returns array with "All" first, then sorted categories, then "Other"
*/
export const extractCategories = (
plugins: Array<{ category?: string }>
): string[] => {
const categories = new Set<string>();
plugins.forEach((p) => {
if (p.category && p.category.trim() !== "") {
categories.add(p.category);
}
});
const sortedCategories = Array.from(categories).sort();
// Return: All, sorted categories, Other
return ["All", ...sortedCategories, "Other"];
};
/**
* Validate plugin name format (kebab-case)
* Must be lowercase letters, numbers, and hyphens only
*/
export const validatePluginName = (name: string): boolean => {
if (!name || name.trim() === "") {
return false;
}
// Regex: lowercase letters, numbers, hyphens
return /^[a-z0-9-]+$/.test(name);
};
/**
* Get human-readable source display text
*/
export const getSourceDisplayText = (source: PluginSource): string => {
if (source.source === "github" && source.repo) {
return `GitHub: ${source.repo}`;
} else if (source.source === "url" && source.url) {
return source.url;
}
return "Unknown source";
};
/**
* Get clickable link for plugin source
*/
export const getSourceLink = (source: PluginSource): string | null => {
if (source.source === "github" && source.repo) {
return `https://github.com/${source.repo}`;
} else if (source.source === "url" && source.url) {
return source.url;
}
return null;
};
/**
* Get badge color based on category
*/
export const getCategoryBadgeColor = (
category?: string
): "blue" | "green" | "purple" | "red" | "orange" | "yellow" | "gray" => {
if (!category) {
return "gray";
}
const categoryLower = category.toLowerCase();
if (categoryLower.includes("development") || categoryLower.includes("dev")) {
return "blue";
} else if (
categoryLower.includes("productivity") ||
categoryLower.includes("workflow")
) {
return "green";
} else if (
categoryLower.includes("learning") ||
categoryLower.includes("education")
) {
return "purple";
} else if (
categoryLower.includes("security") ||
categoryLower.includes("safety")
) {
return "red";
} else if (
categoryLower.includes("data") ||
categoryLower.includes("analytics")
) {
return "orange";
} else if (
categoryLower.includes("integration") ||
categoryLower.includes("api")
) {
return "yellow";
}
return "gray";
};
/**
* Format date to readable string
*/
export const formatDateString = (dateString?: string): string => {
if (!dateString) {
return "N/A";
}
try {
const date = new Date(dateString);
return date.toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
});
} catch (error) {
return "Invalid date";
}
};
/**
* Truncate text with ellipsis
*/
export const truncateText = (text: string, maxLength: number): string => {
if (!text || text.length <= maxLength) {
return text;
}
return text.substring(0, maxLength) + "...";
};
/**
* Filter plugins by search term
* Searches in: name, description, keywords
*/
export const filterPluginsBySearch = (
plugins: MarketplacePluginEntry[],
searchTerm: string
): MarketplacePluginEntry[] => {
if (!searchTerm || searchTerm.trim() === "") {
return plugins;
}
const term = searchTerm.toLowerCase().trim();
return plugins.filter((plugin) => {
const nameMatch = plugin.name.toLowerCase().includes(term);
const descriptionMatch =
plugin.description?.toLowerCase().includes(term) || false;
const keywordsMatch =
plugin.keywords?.some((keyword) =>
keyword.toLowerCase().includes(term)
) || false;
return nameMatch || descriptionMatch || keywordsMatch;
});
};
/**
* Filter plugins by category
*/
export const filterPluginsByCategory = (
plugins: MarketplacePluginEntry[],
category: string
): MarketplacePluginEntry[] => {
if (category === "All") {
return plugins;
}
if (category === "Other") {
return plugins.filter((p) => !p.category || p.category.trim() === "");
}
return plugins.filter((p) => p.category === category);
};
/**
* Validate semantic version format (basic check)
*/
export const isValidSemanticVersion = (version?: string): boolean => {
if (!version) {
return true; // Version is optional
}
// Basic semver check: X.Y.Z
const semverRegex = /^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$/;
return semverRegex.test(version);
};
/**
* Validate email format
*/
export const isValidEmail = (email?: string): boolean => {
if (!email) {
return true; // Email is optional
}
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
};
/**
* Validate URL format
*/
export const isValidUrl = (url?: string): boolean => {
if (!url) {
return true; // URL is optional
}
try {
new URL(url);
return true;
} catch {
return false;
}
};
/**
* Parse keywords from comma-separated string
*/
export const parseKeywords = (keywordsString: string): string[] => {
if (!keywordsString || keywordsString.trim() === "") {
return [];
}
return keywordsString
.split(",")
.map((kw) => kw.trim())
.filter((kw) => kw !== "");
};
/**
* Format keywords array to comma-separated string
*/
export const formatKeywords = (keywords?: string[]): string => {
if (!keywords || keywords.length === 0) {
return "";
}
return keywords.join(", ");
};
@@ -0,0 +1,350 @@
import React, { useState, useEffect } from "react";
import {
Card,
Title,
Text,
Button,
Badge,
Grid,
} from "@tremor/react";
import { Spin, Switch, Tooltip, Descriptions } from "antd";
import { ArrowLeftIcon, ExternalLinkIcon } from "@heroicons/react/outline";
import { CopyOutlined } from "@ant-design/icons";
import {
getClaudeCodePluginDetails,
enableClaudeCodePlugin,
disableClaudeCodePlugin,
} from "../networking";
import NotificationsManager from "../molecules/notifications_manager";
import { Plugin } from "./types";
import {
formatInstallCommand,
getSourceDisplayText,
getSourceLink,
getCategoryBadgeColor,
formatDateString,
formatKeywords,
} from "./helpers";
interface PluginInfoViewProps {
pluginId: string;
onClose: () => void;
accessToken: string | null;
isAdmin: boolean;
onPluginUpdated: () => void;
}
const PluginInfoView: React.FC<PluginInfoViewProps> = ({
pluginId,
onClose,
accessToken,
isAdmin,
onPluginUpdated,
}) => {
const [plugin, setPlugin] = useState<Plugin | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isToggling, setIsToggling] = useState(false);
useEffect(() => {
fetchPluginInfo();
}, [pluginId, accessToken]);
const fetchPluginInfo = async () => {
if (!accessToken) return;
setIsLoading(true);
try {
// The backend expects plugin name, not ID
// We'll need to find the plugin by ID from the list
// For now, assume pluginId is actually the plugin name
const data = await getClaudeCodePluginDetails(
accessToken,
pluginId as string
);
setPlugin(data.plugin);
} catch (error) {
console.error("Error fetching plugin info:", error);
NotificationsManager.error("Failed to load plugin information");
} finally {
setIsLoading(false);
}
};
const handleToggleEnabled = async () => {
if (!accessToken || !plugin) return;
setIsToggling(true);
try {
if (plugin.enabled) {
await disableClaudeCodePlugin(accessToken, plugin.name);
NotificationsManager.success(`Plugin "${plugin.name}" disabled`);
} else {
await enableClaudeCodePlugin(accessToken, plugin.name);
NotificationsManager.success(`Plugin "${plugin.name}" enabled`);
}
onPluginUpdated();
fetchPluginInfo();
} catch (error) {
NotificationsManager.error("Failed to toggle plugin status");
} finally {
setIsToggling(false);
}
};
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
NotificationsManager.success("Copied to clipboard!");
};
if (isLoading) {
return (
<div className="flex items-center justify-center p-8">
<Spin size="large" />
</div>
);
}
if (!plugin) {
return (
<div className="p-8 text-center text-gray-500">
<p>Plugin not found</p>
<Button className="mt-4" onClick={onClose}>
Go Back
</Button>
</div>
);
}
const installCommand = formatInstallCommand(plugin);
const sourceLink = getSourceLink(plugin.source);
const categoryBadgeColor = getCategoryBadgeColor(plugin.category);
return (
<div className="space-y-4">
{/* Header with Back Button */}
<div className="flex items-center gap-3 mb-6">
<ArrowLeftIcon
className="h-5 w-5 cursor-pointer text-gray-500 hover:text-gray-700"
onClick={onClose}
/>
<h2 className="text-2xl font-bold">{plugin.name}</h2>
{plugin.version && (
<Badge color="blue" size="xs">
v{plugin.version}
</Badge>
)}
{plugin.category && (
<Badge color={categoryBadgeColor} size="xs">
{plugin.category}
</Badge>
)}
<Badge color={plugin.enabled ? "green" : "gray"} size="xs">
{plugin.enabled ? "Enabled" : "Disabled"}
</Badge>
</div>
{/* Install Command */}
<Card>
<div className="flex items-center justify-between">
<div className="flex-1">
<Text className="text-gray-600 text-xs mb-2">Install Command</Text>
<div className="font-mono bg-gray-100 px-3 py-2 rounded text-sm">
{installCommand}
</div>
</div>
<Tooltip title="Copy install command">
<Button
size="xs"
variant="secondary"
icon={CopyOutlined}
onClick={() => copyToClipboard(installCommand)}
className="ml-4"
>
Copy
</Button>
</Tooltip>
</div>
</Card>
{/* Plugin Details */}
<Card>
<Title>Plugin Details</Title>
<Grid numColsSm={2} numColsLg={3} className="gap-6 mt-4">
{/* Plugin ID */}
<div>
<Text className="text-gray-600 text-xs">Plugin ID</Text>
<div className="flex items-center gap-2 mt-1">
<Text className="font-mono text-xs">{plugin.id}</Text>
<CopyOutlined
className="cursor-pointer text-gray-500 hover:text-blue-500 text-xs"
onClick={() => copyToClipboard(plugin.id)}
/>
</div>
</div>
{/* Name */}
<div>
<Text className="text-gray-600 text-xs">Name</Text>
<Text className="font-semibold mt-1">{plugin.name}</Text>
</div>
{/* Version */}
<div>
<Text className="text-gray-600 text-xs">Version</Text>
<Text className="font-semibold mt-1">
{plugin.version || "N/A"}
</Text>
</div>
{/* Source */}
<div className="col-span-2">
<Text className="text-gray-600 text-xs">Source</Text>
<div className="flex items-center gap-2 mt-1">
<Text className="font-semibold">
{getSourceDisplayText(plugin.source)}
</Text>
{sourceLink && (
<a
href={sourceLink}
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 hover:text-blue-700"
>
<ExternalLinkIcon className="h-4 w-4" />
</a>
)}
</div>
</div>
{/* Category */}
<div>
<Text className="text-gray-600 text-xs">Category</Text>
<div className="mt-1">
{plugin.category ? (
<Badge color={categoryBadgeColor} size="xs">
{plugin.category}
</Badge>
) : (
<Text className="text-gray-400">Uncategorized</Text>
)}
</div>
</div>
{/* Enabled Status */}
{isAdmin && (
<div className="col-span-3">
<Text className="text-gray-600 text-xs">Status</Text>
<div className="flex items-center gap-3 mt-2">
<Switch
checked={plugin.enabled}
loading={isToggling}
onChange={handleToggleEnabled}
/>
<Text className="text-sm">
{plugin.enabled
? "Plugin is enabled and visible in marketplace"
: "Plugin is disabled and hidden from marketplace"}
</Text>
</div>
</div>
)}
</Grid>
</Card>
{/* Description */}
{plugin.description && (
<Card>
<Title>Description</Title>
<Text className="mt-2">{plugin.description}</Text>
</Card>
)}
{/* Keywords */}
{plugin.keywords && plugin.keywords.length > 0 && (
<Card>
<Title>Keywords</Title>
<div className="flex flex-wrap gap-2 mt-2">
{plugin.keywords.map((keyword, index) => (
<Badge key={index} color="gray" size="xs">
{keyword}
</Badge>
))}
</div>
</Card>
)}
{/* Author Information */}
{plugin.author && (
<Card>
<Title>Author Information</Title>
<Grid numColsSm={2} className="gap-4 mt-4">
{plugin.author.name && (
<div>
<Text className="text-gray-600 text-xs">Name</Text>
<Text className="font-semibold mt-1">
{plugin.author.name}
</Text>
</div>
)}
{plugin.author.email && (
<div>
<Text className="text-gray-600 text-xs">Email</Text>
<Text className="font-semibold mt-1">
<a
href={`mailto:${plugin.author.email}`}
className="text-blue-500 hover:text-blue-700"
>
{plugin.author.email}
</a>
</Text>
</div>
)}
</Grid>
</Card>
)}
{/* Additional Links */}
{plugin.homepage && (
<Card>
<Title>Homepage</Title>
<a
href={plugin.homepage}
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 hover:text-blue-700 flex items-center gap-2 mt-2"
>
{plugin.homepage}
<ExternalLinkIcon className="h-4 w-4" />
</a>
</Card>
)}
{/* Timestamps */}
<Card>
<Title>Metadata</Title>
<Grid numColsSm={2} className="gap-4 mt-4">
<div>
<Text className="text-gray-600 text-xs">Created At</Text>
<Text className="font-semibold mt-1">
{formatDateString(plugin.created_at)}
</Text>
</div>
<div>
<Text className="text-gray-600 text-xs">Updated At</Text>
<Text className="font-semibold mt-1">
{formatDateString(plugin.updated_at)}
</Text>
</div>
{plugin.created_by && (
<div className="col-span-2">
<Text className="text-gray-600 text-xs">Created By</Text>
<Text className="font-semibold mt-1">{plugin.created_by}</Text>
</div>
)}
</Grid>
</Card>
</div>
);
};
export default PluginInfoView;
@@ -0,0 +1,351 @@
import React, { useState } from "react";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeaderCell,
TableRow,
Button,
Badge,
} from "@tremor/react";
import {
SwitchVerticalIcon,
ChevronUpIcon,
ChevronDownIcon,
TrashIcon,
} from "@heroicons/react/outline";
import { Tooltip, Switch } from "antd";
import { CopyOutlined } from "@ant-design/icons";
import { Plugin } from "./types";
import {
getCategoryBadgeColor,
formatDateString,
} from "./helpers";
import {
enableClaudeCodePlugin,
disableClaudeCodePlugin,
} from "../networking";
import NotificationsManager from "../molecules/notifications_manager";
import {
ColumnDef,
flexRender,
getCoreRowModel,
getSortedRowModel,
SortingState,
useReactTable,
} from "@tanstack/react-table";
interface PluginTableProps {
pluginsList: Plugin[];
isLoading: boolean;
onDeleteClick: (pluginName: string, displayName: string) => void;
accessToken: string | null;
onPluginUpdated: () => void;
isAdmin: boolean;
onPluginClick: (pluginId: string) => void;
}
const PluginTable: React.FC<PluginTableProps> = ({
pluginsList,
isLoading,
onDeleteClick,
accessToken,
onPluginUpdated,
isAdmin,
onPluginClick,
}) => {
const [sorting, setSorting] = useState<SortingState>([
{ id: "created_at", desc: true },
]);
const [togglingPlugin, setTogglingPlugin] = useState<string | null>(null);
const formatDate = (dateString?: string) => {
if (!dateString) return "-";
const date = new Date(dateString);
return date.toLocaleString();
};
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
NotificationsManager.success("Copied to clipboard!");
};
const handleToggleEnabled = async (plugin: Plugin) => {
if (!accessToken) return;
setTogglingPlugin(plugin.id);
try {
if (plugin.enabled) {
await disableClaudeCodePlugin(accessToken, plugin.name);
NotificationsManager.success(`Plugin "${plugin.name}" disabled`);
} else {
await enableClaudeCodePlugin(accessToken, plugin.name);
NotificationsManager.success(`Plugin "${plugin.name}" enabled`);
}
onPluginUpdated();
} catch (error) {
NotificationsManager.error("Failed to toggle plugin status");
} finally {
setTogglingPlugin(null);
}
};
const columns: ColumnDef<Plugin>[] = [
{
header: "Plugin Name",
accessorKey: "name",
cell: ({ row }) => {
const plugin = row.original;
const name = plugin.name || "";
return (
<div className="flex items-center gap-2">
<Tooltip title={name}>
<Button
size="xs"
variant="light"
className="font-mono text-blue-500 bg-blue-50 hover:bg-blue-100 text-xs font-normal px-2 py-0.5 text-left overflow-hidden truncate min-w-[150px] justify-start"
onClick={() => onPluginClick(plugin.id)}
>
{name}
</Button>
</Tooltip>
<Tooltip title="Copy Plugin ID">
<CopyOutlined
onClick={(e) => {
e.stopPropagation();
copyToClipboard(plugin.id);
}}
className="cursor-pointer text-gray-500 hover:text-blue-500 text-xs"
/>
</Tooltip>
</div>
);
},
},
{
header: "Version",
accessorKey: "version",
cell: ({ row }) => {
const version = row.original.version || "N/A";
return <span className="text-xs text-gray-600">{version}</span>;
},
},
{
header: "Description",
accessorKey: "description",
cell: ({ row }) => {
const description = row.original.description || "No description";
return (
<Tooltip title={description}>
<span className="text-xs text-gray-600 block max-w-[300px] truncate">
{description}
</span>
</Tooltip>
);
},
},
{
header: "Category",
accessorKey: "category",
cell: ({ row }) => {
const category = row.original.category;
if (!category) {
return (
<Badge color="gray" className="text-xs font-normal" size="xs">
Uncategorized
</Badge>
);
}
const badgeColor = getCategoryBadgeColor(category);
return (
<Badge color={badgeColor} className="text-xs font-normal" size="xs">
{category}
</Badge>
);
},
},
{
header: "Enabled",
accessorKey: "enabled",
cell: ({ row }) => {
const plugin = row.original;
return (
<div className="flex items-center gap-2">
<Badge
color={plugin.enabled ? "green" : "gray"}
className="text-xs font-normal"
size="xs"
>
{plugin.enabled ? "Yes" : "No"}
</Badge>
{isAdmin && (
<Tooltip
title={plugin.enabled ? "Disable plugin" : "Enable plugin"}
>
<Switch
size="small"
checked={plugin.enabled}
loading={togglingPlugin === plugin.id}
onChange={() => handleToggleEnabled(plugin)}
/>
</Tooltip>
)}
</div>
);
},
},
{
header: "Created At",
accessorKey: "created_at",
cell: ({ row }) => {
const plugin = row.original;
return (
<Tooltip title={plugin.created_at}>
<span className="text-xs">{formatDate(plugin.created_at)}</span>
</Tooltip>
);
},
},
...(isAdmin
? [
{
header: "Actions",
id: "actions",
enableSorting: false,
cell: ({ row }: any) => {
const plugin = row.original;
return (
<div className="flex items-center gap-1">
<Tooltip title="Delete plugin">
<Button
size="xs"
variant="light"
color="red"
onClick={(e) => {
e.stopPropagation();
onDeleteClick(plugin.name, plugin.name);
}}
icon={TrashIcon}
className="text-red-500 hover:text-red-700 hover:bg-red-50"
/>
</Tooltip>
</div>
);
},
},
]
: []),
];
const table = useReactTable({
data: pluginsList,
columns,
state: {
sorting,
},
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
enableSorting: true,
});
return (
<div className="rounded-lg custom-border relative">
<div className="overflow-x-auto">
<Table className="[&_td]:py-0.5 [&_th]:py-1">
<TableHead>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHeaderCell
key={header.id}
className={`py-1 h-8 ${
header.id === "actions"
? "sticky right-0 bg-white shadow-[-4px_0_8px_-6px_rgba(0,0,0,0.1)]"
: ""
}`}
onClick={
header.column.getCanSort()
? header.column.getToggleSortingHandler()
: undefined
}
>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center">
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</div>
{header.column.getCanSort() && (
<div className="w-4">
{header.column.getIsSorted() ? (
{
asc: (
<ChevronUpIcon className="h-4 w-4 text-blue-500" />
),
desc: (
<ChevronDownIcon className="h-4 w-4 text-blue-500" />
),
}[header.column.getIsSorted() as string]
) : (
<SwitchVerticalIcon className="h-4 w-4 text-gray-400" />
)}
</div>
)}
</div>
</TableHeaderCell>
))}
</TableRow>
))}
</TableHead>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={columns.length} className="h-8 text-center">
<div className="text-center text-gray-500">
<p>Loading...</p>
</div>
</TableCell>
</TableRow>
) : pluginsList && pluginsList.length > 0 ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id} className="h-8">
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
className={`py-0.5 max-h-8 overflow-hidden text-ellipsis whitespace-nowrap ${
cell.column.id === "actions"
? "sticky right-0 bg-white shadow-[-4px_0_8px_-6px_rgba(0,0,0,0.1)]"
: ""
}`}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-8 text-center">
<div className="text-center text-gray-500">
<p>No plugins found. Add one to get started.</p>
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
);
};
export default PluginTable;
@@ -0,0 +1,113 @@
/**
* TypeScript types for Claude Code Marketplace
* Matches backend API types from /litellm/types/proxy/claude_code_endpoints.py
*/
export interface PluginSource {
source: "github" | "url";
repo?: string; // Format: "org/repo" for GitHub
url?: string; // Full URL for other sources
}
export interface PluginAuthor {
name: string;
email?: string;
}
export interface Plugin {
id: string;
name: string; // kebab-case
version?: string; // semantic version
description?: string;
source: PluginSource;
author?: PluginAuthor;
homepage?: string;
keywords?: string[];
category?: string;
enabled: boolean;
created_at?: string;
updated_at?: string;
created_by?: string;
}
export interface PluginListItem {
id: string;
name: string;
version?: string;
description?: string;
source: PluginSource;
author?: PluginAuthor;
homepage?: string;
keywords?: string[];
category?: string;
enabled: boolean;
created_at?: string;
updated_at?: string;
created_by?: string;
}
export interface ListPluginsResponse {
plugins: PluginListItem[];
count: number;
}
export interface RegisterPluginRequest {
name: string;
source: PluginSource;
version?: string;
description?: string;
author?: PluginAuthor;
homepage?: string;
keywords?: string[];
category?: string;
}
export interface RegisterPluginResponse {
plugin: Plugin;
action: "created" | "updated";
message: string;
}
// Public marketplace types
export interface MarketplacePluginEntry {
name: string;
source: PluginSource;
version?: string;
description?: string;
author?: PluginAuthor;
homepage?: string;
keywords?: string[];
category?: string;
}
export interface MarketplaceOwner {
name: string;
email?: string;
}
export interface MarketplaceResponse {
name: string; // Marketplace name (e.g., "litellm")
owner: MarketplaceOwner;
plugins: MarketplacePluginEntry[];
}
// UI-specific types
export interface CategoryTab {
key: string;
label: string;
count: number;
}
export interface PluginFormData {
name: string;
sourceType: "github" | "url";
repo: string;
url: string;
version: string;
description: string;
authorName: string;
authorEmail: string;
homepage: string;
category: string;
keywords: string; // Comma-separated string, will be split into array
}
@@ -256,6 +256,13 @@ const Sidebar: React.FC<SidebarProps> = ({ setPage, defaultSelectedKey, collapse
icon: <TagsOutlined />,
roles: all_admin_roles,
},
{
key: "claude-code-plugins",
page: "claude-code-plugins",
label: "Claude Code Plugins",
icon: <ToolOutlined />,
roles: all_admin_roles,
},
{
key: "4",
page: "usage",
@@ -8280,3 +8280,278 @@ export const updateUiSettings = async (accessToken: string, settings: Record<str
const data = await response.json();
return data;
};
// ============================================================
// Claude Code Marketplace Networking Functions
// ============================================================
/**
* Get public marketplace catalog (no authentication required)
* Returns marketplace.json for Claude Code CLI discovery
*/
export const getClaudeCodeMarketplace = async () => {
try {
const proxyBaseUrl = getProxyBaseUrl();
const url = proxyBaseUrl
? `${proxyBaseUrl}/claude-code/marketplace.json`
: `/claude-code/marketplace.json`;
const response = await fetch(url, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
if (!response.ok) {
const errorData = await response.text();
const errorMessage = deriveErrorMessage(JSON.parse(errorData));
handleError(errorMessage);
throw new Error(errorMessage);
}
const data = await response.json();
return data;
} catch (error) {
console.error("Failed to fetch Claude Code marketplace:", error);
throw error;
}
};
/**
* List all Claude Code plugins (admin only)
* @param accessToken - Admin access token
* @param enabledOnly - If true, only return enabled plugins (default: false)
*/
export const getClaudeCodePluginsList = async (
accessToken: string,
enabledOnly: boolean = false
) => {
try {
const proxyBaseUrl = getProxyBaseUrl();
const url = proxyBaseUrl
? `${proxyBaseUrl}/claude-code/plugins?enabled_only=${enabledOnly}`
: `/claude-code/plugins?enabled_only=${enabledOnly}`;
const response = await fetch(url, {
method: "GET",
headers: {
[globalLitellmHeaderName]: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
});
if (!response.ok) {
const errorData = await response.text();
const errorMessage = deriveErrorMessage(JSON.parse(errorData));
handleError(errorMessage);
throw new Error(errorMessage);
}
const data = await response.json();
return data;
} catch (error) {
console.error("Failed to fetch Claude Code plugins list:", error);
throw error;
}
};
/**
* Get details for a specific Claude Code plugin (admin only)
* @param accessToken - Admin access token
* @param pluginName - Name of the plugin
*/
export const getClaudeCodePluginDetails = async (
accessToken: string,
pluginName: string
) => {
try {
const proxyBaseUrl = getProxyBaseUrl();
const url = proxyBaseUrl
? `${proxyBaseUrl}/claude-code/plugins/${pluginName}`
: `/claude-code/plugins/${pluginName}`;
const response = await fetch(url, {
method: "GET",
headers: {
[globalLitellmHeaderName]: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
});
if (!response.ok) {
const errorData = await response.text();
const errorMessage = deriveErrorMessage(JSON.parse(errorData));
handleError(errorMessage);
throw new Error(errorMessage);
}
const data = await response.json();
return data;
} catch (error) {
console.error(`Failed to fetch plugin "${pluginName}":`, error);
throw error;
}
};
/**
* Register or update a Claude Code plugin (admin only)
* @param accessToken - Admin access token
* @param pluginData - Plugin registration data
*/
export const registerClaudeCodePlugin = async (
accessToken: string,
pluginData: {
name: string;
source: { source: string; repo?: string; url?: string };
version?: string;
description?: string;
author?: { name: string; email?: string };
homepage?: string;
keywords?: string[];
category?: string;
}
) => {
try {
const proxyBaseUrl = getProxyBaseUrl();
const url = proxyBaseUrl
? `${proxyBaseUrl}/claude-code/plugins`
: `/claude-code/plugins`;
const response = await fetch(url, {
method: "POST",
headers: {
[globalLitellmHeaderName]: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify(pluginData),
});
if (!response.ok) {
const errorData = await response.text();
const errorMessage = deriveErrorMessage(JSON.parse(errorData));
handleError(errorMessage);
throw new Error(errorMessage);
}
const data = await response.json();
return data;
} catch (error) {
console.error("Failed to register Claude Code plugin:", error);
throw error;
}
};
/**
* Enable a Claude Code plugin (admin only)
* @param accessToken - Admin access token
* @param pluginName - Name of the plugin to enable
*/
export const enableClaudeCodePlugin = async (
accessToken: string,
pluginName: string
) => {
try {
const proxyBaseUrl = getProxyBaseUrl();
const url = proxyBaseUrl
? `${proxyBaseUrl}/claude-code/plugins/${pluginName}/enable`
: `/claude-code/plugins/${pluginName}/enable`;
const response = await fetch(url, {
method: "POST",
headers: {
[globalLitellmHeaderName]: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
});
if (!response.ok) {
const errorData = await response.text();
const errorMessage = deriveErrorMessage(JSON.parse(errorData));
handleError(errorMessage);
throw new Error(errorMessage);
}
const data = await response.json();
return data;
} catch (error) {
console.error(`Failed to enable plugin "${pluginName}":`, error);
throw error;
}
};
/**
* Disable a Claude Code plugin (admin only)
* @param accessToken - Admin access token
* @param pluginName - Name of the plugin to disable
*/
export const disableClaudeCodePlugin = async (
accessToken: string,
pluginName: string
) => {
try {
const proxyBaseUrl = getProxyBaseUrl();
const url = proxyBaseUrl
? `${proxyBaseUrl}/claude-code/plugins/${pluginName}/disable`
: `/claude-code/plugins/${pluginName}/disable`;
const response = await fetch(url, {
method: "POST",
headers: {
[globalLitellmHeaderName]: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
});
if (!response.ok) {
const errorData = await response.text();
const errorMessage = deriveErrorMessage(JSON.parse(errorData));
handleError(errorMessage);
throw new Error(errorMessage);
}
const data = await response.json();
return data;
} catch (error) {
console.error(`Failed to disable plugin "${pluginName}":`, error);
throw error;
}
};
/**
* Delete a Claude Code plugin (admin only)
* @param accessToken - Admin access token
* @param pluginName - Name of the plugin to delete
*/
export const deleteClaudeCodePlugin = async (
accessToken: string,
pluginName: string
) => {
try {
const proxyBaseUrl = getProxyBaseUrl();
const url = proxyBaseUrl
? `${proxyBaseUrl}/claude-code/plugins/${pluginName}`
: `/claude-code/plugins/${pluginName}`;
const response = await fetch(url, {
method: "DELETE",
headers: {
[globalLitellmHeaderName]: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
});
if (!response.ok) {
const errorData = await response.text();
const errorMessage = deriveErrorMessage(JSON.parse(errorData));
handleError(errorMessage);
throw new Error(errorMessage);
}
const data = await response.json();
return data;
} catch (error) {
console.error(`Failed to delete plugin "${pluginName}":`, error);
throw error;
}
};