mirror of
https://github.com/tiennm99/litellm.git
synced 2026-08-21 08:26:34 +00:00
[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:
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user