/** * @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 { useToast } 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."; } /** * 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 } = useToast(); 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 examplesQuery = useQuery({ queryKey: queryKeys.plugins.examples, queryFn: () => pluginsApi.listExamples(), }); 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 examples = examplesQuery.data ?? []; const installedByPackageName = new Map(installedPlugins.map((plugin) => [plugin.packageName, plugin])); const examplePackageNames = new Set(examples.map((example) => example.packageName)); 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

Examples
{examplesQuery.isLoading ? (
Loading bundled examples...
) : examplesQuery.error ? (
Failed to load bundled examples.
) : examples.length === 0 ? (
No bundled example plugins were found in this checkout.
) : (
    {examples.map((example) => { const installedPlugin = installedByPackageName.get(example.packageName); const installPending = installMutation.isPending && installMutation.variables?.isLocalPath && installMutation.variables.packageName === example.localPath; return (
  • {example.displayName} Example {installedPlugin ? ( {installedPlugin.status} ) : ( Not installed )}

    {example.description}

    {example.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} {examplePackageNames.has(plugin.packageName) && ( Example )}

    {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."}
              
); }