/** * @fileoverview Plugin Manager page — admin UI for discovering, * installing, enabling/disabling, and uninstalling plugins. * * @see PLUGIN_SPEC.md §9 — Plugin Marketplace / Manager */ import { useEffect, useMemo, useState } from "react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import type { PluginRecord } from "@paperclipai/shared"; import { Link } from "@/lib/router"; import { AlertTriangle, FlaskConical, Plus, Power, Puzzle, Settings, Trash } from "lucide-react"; import { useCompany } from "@/context/CompanyContext"; import { useBreadcrumbs } from "@/context/BreadcrumbContext"; import { pluginsApi } from "@/api/plugins"; import { queryKeys } from "@/lib/queryKeys"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Card, CardContent } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; import { useToastActions } from "@/context/ToastContext"; import { cn } from "@/lib/utils"; function firstNonEmptyLine(value: string | null | undefined): string | null { if (!value) return null; const line = value .split(/\r?\n/) .map((entry) => entry.trim()) .find(Boolean); return line ?? null; } function getPluginErrorSummary(plugin: PluginRecord): string { return firstNonEmptyLine(plugin.lastError) ?? "Plugin entered an error state without a stored error message."; } function isExperimentalPluginIdentity(input: { packageName?: string | null; packagePath?: string | null; manifestJson?: PluginRecord["manifestJson"] | null; bundledExperimental?: boolean; }) { if (input.bundledExperimental) return true; const packageName = input.packageName ?? ""; const packagePath = input.packagePath ?? ""; if (packageName.includes("sandbox") || packagePath.includes("sandbox")) return true; return input.manifestJson?.environmentDrivers?.some((driver) => driver.kind === "sandbox_provider") === true; } function ExperimentalBadge() { return ( Experimental ); } /** * PluginManager page component. * * Provides a management UI for the Paperclip plugin system: * - Lists all installed plugins with their status, version, and category badges. * - Allows installing new plugins by npm package name. * - Provides per-plugin actions: enable, disable, navigate to settings. * - Uninstall with a two-step confirmation dialog to prevent accidental removal. * * Data flow: * - Reads from `GET /api/plugins` via `pluginsApi.list()`. * - Mutations (install / uninstall / enable / disable) invalidate * `queryKeys.plugins.all` so the list refreshes automatically. * * @see PluginSettings — linked from the Settings icon on each plugin row. * @see doc/plugins/PLUGIN_SPEC.md §3 — Plugin Lifecycle for status semantics. */ export function PluginManager() { const { selectedCompany } = useCompany(); const { setBreadcrumbs } = useBreadcrumbs(); const queryClient = useQueryClient(); const { pushToast } = useToastActions(); const [installPackage, setInstallPackage] = useState(""); const [installDialogOpen, setInstallDialogOpen] = useState(false); const [uninstallPluginId, setUninstallPluginId] = useState(null); const [uninstallPluginName, setUninstallPluginName] = useState(""); const [errorDetailsPlugin, setErrorDetailsPlugin] = useState(null); useEffect(() => { setBreadcrumbs([ { label: selectedCompany?.name ?? "Company", href: "/dashboard" }, { label: "Settings", href: "/instance/settings/heartbeats" }, { label: "Plugins" }, ]); }, [selectedCompany?.name, setBreadcrumbs]); const { data: plugins, isLoading, error } = useQuery({ queryKey: queryKeys.plugins.all, queryFn: () => pluginsApi.list(), }); const bundledQuery = useQuery({ queryKey: queryKeys.plugins.examples, queryFn: () => pluginsApi.listBundled(), }); const invalidatePluginQueries = () => { queryClient.invalidateQueries({ queryKey: queryKeys.plugins.all }); queryClient.invalidateQueries({ queryKey: queryKeys.plugins.examples }); queryClient.invalidateQueries({ queryKey: queryKeys.plugins.uiContributions }); }; const installMutation = useMutation({ mutationFn: (params: { packageName: string; version?: string; isLocalPath?: boolean }) => pluginsApi.install(params), onSuccess: () => { invalidatePluginQueries(); setInstallDialogOpen(false); setInstallPackage(""); pushToast({ title: "Plugin installed successfully", tone: "success" }); }, onError: (err: Error) => { pushToast({ title: "Failed to install plugin", body: err.message, tone: "error" }); }, }); const uninstallMutation = useMutation({ mutationFn: (pluginId: string) => pluginsApi.uninstall(pluginId), onSuccess: () => { invalidatePluginQueries(); pushToast({ title: "Plugin uninstalled successfully", tone: "success" }); }, onError: (err: Error) => { pushToast({ title: "Failed to uninstall plugin", body: err.message, tone: "error" }); }, }); const enableMutation = useMutation({ mutationFn: (pluginId: string) => pluginsApi.enable(pluginId), onSuccess: () => { invalidatePluginQueries(); pushToast({ title: "Plugin enabled", tone: "success" }); }, onError: (err: Error) => { pushToast({ title: "Failed to enable plugin", body: err.message, tone: "error" }); }, }); const disableMutation = useMutation({ mutationFn: (pluginId: string) => pluginsApi.disable(pluginId), onSuccess: () => { invalidatePluginQueries(); pushToast({ title: "Plugin disabled", tone: "info" }); }, onError: (err: Error) => { pushToast({ title: "Failed to disable plugin", body: err.message, tone: "error" }); }, }); const installedPlugins = plugins ?? []; const bundledPlugins = bundledQuery.data ?? []; const installedByPackageName = new Map(installedPlugins.map((plugin) => [plugin.packageName, plugin])); const bundledByPackageName = new Map(bundledPlugins.map((plugin) => [plugin.packageName, plugin])); const errorSummaryByPluginId = useMemo( () => new Map( installedPlugins.map((plugin) => [plugin.id, getPluginErrorSummary(plugin)]) ), [installedPlugins] ); if (isLoading) return
Loading plugins...
; if (error) return
Failed to load plugins.
; return (

Plugin Manager

Install Plugin Enter the npm package name of the plugin you wish to install.
setInstallPackage(e.target.value)} />

Plugins are alpha.

The plugin runtime and API surface are still changing. Expect breaking changes while this feature settles.

Available Plugins

Bundled
{bundledQuery.isLoading ? (
Loading bundled plugins...
) : bundledQuery.error ? (
Failed to load bundled plugins.
) : bundledPlugins.length === 0 ? (
No bundled plugins were found in this checkout.
) : (
    {bundledPlugins.map((bundledPlugin) => { const installedPlugin = installedByPackageName.get(bundledPlugin.packageName); const installPending = installMutation.isPending && installMutation.variables?.isLocalPath && installMutation.variables.packageName === bundledPlugin.localPath; return (
  • {bundledPlugin.displayName} {bundledPlugin.tag === "first-party" ? "First-party" : "Example"} {isExperimentalPluginIdentity({ packageName: bundledPlugin.packageName, packagePath: bundledPlugin.localPath, bundledExperimental: bundledPlugin.experimental, }) && } {installedPlugin ? ( {installedPlugin.status} ) : ( Not installed )}

    {bundledPlugin.description}

    {bundledPlugin.packageName}

    {installedPlugin ? ( <> {installedPlugin.status !== "ready" && ( )} ) : ( )}
  • ); })}
)}

Installed Plugins

{!installedPlugins.length ? (

No plugins installed

Install a plugin to extend functionality.

) : (
    {installedPlugins.map((plugin) => (
  • {plugin.manifestJson.displayName ?? plugin.packageName} {bundledByPackageName.has(plugin.packageName) && ( {bundledByPackageName.get(plugin.packageName)?.tag === "first-party" ? "First-party" : "Example"} )} {isExperimentalPluginIdentity({ packageName: plugin.packageName, packagePath: plugin.packagePath, manifestJson: plugin.manifestJson, bundledExperimental: bundledByPackageName.get(plugin.packageName)?.experimental, }) && }

    {plugin.packageName} · v{plugin.manifestJson.version ?? plugin.version}

    {plugin.manifestJson.description || "No description provided."}

    {plugin.status === "error" && (
    Plugin error

    {errorSummaryByPluginId.get(plugin.id)}

    )}
    {plugin.status}
  • ))}
)}
{ if (!open) setUninstallPluginId(null); }} > Uninstall Plugin Are you sure you want to uninstall {uninstallPluginName}? This action cannot be undone. { if (!open) setErrorDetailsPlugin(null); }} > Error Details {errorDetailsPlugin?.manifestJson.displayName ?? errorDetailsPlugin?.packageName ?? "Plugin"} hit an error state.

What errored

{errorDetailsPlugin ? getPluginErrorSummary(errorDetailsPlugin) : "No error summary available."}

Full error output

                {errorDetailsPlugin?.lastError ?? "No stored error message."}
              
); }