/** * Pi Extensions Manager Component * * Provides UI for managing Pi extension packages, extensions, skills, prompts, and themes * stored in the global pi settings (~/.pi/agent/settings.json). * * Features: * - List configured package sources with type badges (npm/git/local) * - Add new package sources via install form * - Remove package sources from the list * - Manage top-level extension, skill, prompt, and theme path arrays * - Loading and empty states */ import "./PiExtensionsManager.css"; import { useState, useEffect, useCallback } from "react"; import { Package, Puzzle, BookOpen, FileText, Palette, Plus, X, ChevronDown, ChevronRight, RefreshCw, Trash2, } from "lucide-react"; import { fetchPiSettings, updatePiSettings, installPiPackage, reinstallFusionPiPackage, fetchPiExtensions, updatePiExtensions, type PiSettings, type PiExtensionEntry } from "../api"; import type { ToastType } from "../hooks/useToast"; interface PiExtensionsManagerProps { addToast: (message: string, type?: ToastType) => void; projectId?: string; } /** Map source value to CSS class suffix */ function getSourceClass(source: PiExtensionEntry["source"]): string { return source.replace(/-/g, "-"); } /** Get display label for extension source */ function getSourceLabel(source: PiExtensionEntry["source"]): string { const labels: Record = { "fusion-global": "Fusion Global", "pi-global": "Pi Global", "fusion-project": "Fusion Project", "pi-project": "Pi Project", package: "Package", }; return labels[source] ?? source; } /** Determine package source type from the source string */ function getPackageType(source: string): "npm" | "git" | "local" { if (source.startsWith("npm:")) return "npm"; if (source.startsWith("git:")) return "git"; return "local"; } /** Get display label for a package source (strip prefix) */ function getPackageLabel(source: string): string { return source.replace(/^(npm:|git:)/, ""); } export function PiExtensionsManager({ addToast, projectId }: PiExtensionsManagerProps) { const [settings, setSettings] = useState(null); const [loading, setLoading] = useState(true); const [installing, setInstalling] = useState(false); const [reinstallingFusion, setReinstallingFusion] = useState(false); const [newSource, setNewSource] = useState(""); const [expandedPackages, setExpandedPackages] = useState>(new Set()); // Discovered extensions state const [extensions, setExtensions] = useState([]); const [extensionsLoading, setExtensionsLoading] = useState(true); const [updatingExtensions, setUpdatingExtensions] = useState(false); const loadSettings = useCallback(async () => { try { setLoading(true); const data = await fetchPiSettings(); setSettings(data); } catch (err) { addToast(`Failed to load Pi settings: ${err instanceof Error ? err.message : String(err)}`, "error"); } finally { setLoading(false); } }, [addToast]); const loadExtensions = useCallback(async () => { try { setExtensionsLoading(true); const data = await fetchPiExtensions(projectId); setExtensions(data.extensions); } catch (err) { addToast(`Failed to load extensions: ${err instanceof Error ? err.message : String(err)}`, "error"); } finally { setExtensionsLoading(false); } }, [addToast, projectId]); const handleToggleExtension = useCallback(async (ext: PiExtensionEntry) => { try { setUpdatingExtensions(true); const disabledIds = ext.enabled ? [...extensions.filter((e) => e.enabled && e.id !== ext.id).map((e) => e.id), ext.id] : extensions.filter((e) => e.enabled && e.id !== ext.id).map((e) => e.id); await updatePiExtensions(disabledIds, projectId); await loadExtensions(); addToast(ext.enabled ? "Extension disabled" : "Extension enabled", "success"); } catch (err) { addToast(`Failed to update extension: ${err instanceof Error ? err.message : String(err)}`, "error"); } finally { setUpdatingExtensions(false); } }, [extensions, projectId, loadExtensions, addToast]); useEffect(() => { void loadSettings(); }, [loadSettings]); useEffect(() => { void loadExtensions(); }, [loadExtensions]); const toggleExpanded = (index: number) => { setExpandedPackages((prev) => { const next = new Set(prev); if (next.has(index)) { next.delete(index); } else { next.add(index); } return next; }); }; const handleInstall = async () => { if (!newSource.trim()) { addToast("Please enter a package source", "error"); return; } try { setInstalling(true); await installPiPackage(newSource.trim()); addToast("Package installed successfully", "success"); setNewSource(""); await loadSettings(); } catch (err) { addToast(`Failed to install package: ${err instanceof Error ? err.message : String(err)}`, "error"); } finally { setInstalling(false); } }; const handleReinstallFusion = async () => { try { setReinstallingFusion(true); await reinstallFusionPiPackage(projectId); await Promise.all([loadSettings(), loadExtensions()]); addToast("Fusion skill reinstalled successfully", "success"); } catch (err) { addToast(`Failed to reinstall Fusion skill: ${err instanceof Error ? err.message : String(err)}`, "error"); } finally { setReinstallingFusion(false); } }; const handleRemovePackage = async (sourceToRemove: string) => { if (!settings) return; const updatedPackages = settings.packages.filter((pkg) => { const pkgSource = typeof pkg === "string" ? pkg : pkg.source; return pkgSource !== sourceToRemove; }); try { await updatePiSettings({ packages: updatedPackages }); addToast("Package removed", "success"); await loadSettings(); } catch (err) { addToast(`Failed to remove package: ${err instanceof Error ? err.message : String(err)}`, "error"); } }; const handleRemoveResource = async (type: "extensions" | "skills" | "prompts" | "themes", pathToRemove: string) => { if (!settings) return; const updated = settings[type].filter((p) => p !== pathToRemove); try { await updatePiSettings({ [type]: updated }); addToast(`${type.slice(0, -1)} removed`, "success"); await loadSettings(); } catch (err) { addToast(`Failed to update settings: ${err instanceof Error ? err.message : String(err)}`, "error"); } }; const renderResourceSection = ( label: string, Icon: typeof Puzzle, type: "extensions" | "skills" | "prompts" | "themes" ) => { if (!settings || settings[type].length === 0) return null; return (
{label} {settings[type].length}
{settings[type].map((path, index) => ( {path} ))}
); }; return (

Pi Extensions

{loading ? (
Loading Pi settings…
) : !settings ? (

Failed to load Pi settings.

) : ( <> {/* Add package form */}
setNewSource(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); void handleInstall(); } }} disabled={installing} />
{/* Package list */} {settings.packages.length > 0 ? (
{settings.packages.map((pkg, index) => { const source = typeof pkg === "string" ? pkg : pkg.source; const type = getPackageType(source); const label = getPackageLabel(source); const isObject = typeof pkg === "object" && pkg !== null; const hasFilters = isObject && ((pkg as { extensions?: string[] }).extensions?.length ?? 0) > 0 || ((pkg as { skills?: string[] }).skills?.length ?? 0) > 0 || ((pkg as { prompts?: string[] }).prompts?.length ?? 0) > 0 || ((pkg as { themes?: string[] }).themes?.length ?? 0) > 0; const isExpanded = expandedPackages.has(index); return (
{isObject && hasFilters ? ( ) : ( )} {type} {label}
{isObject && hasFilters && ( {(pkg as { extensions?: string[] }).extensions?.length ?? 0} ext,{" "} {(pkg as { skills?: string[] }).skills?.length ?? 0} skill,{" "} {(pkg as { prompts?: string[] }).prompts?.length ?? 0} prompt,{" "} {(pkg as { themes?: string[] }).themes?.length ?? 0} theme )}
{isObject && hasFilters && isExpanded && (
{(pkg as { extensions?: string[] }).extensions?.length ? (
Extensions: {(pkg as { extensions: string[] }).extensions!.map((ext, i) => ( {ext} ))}
) : null} {(pkg as { skills?: string[] }).skills?.length ? (
Skills: {(pkg as { skills: string[] }).skills!.map((skill, i) => ( {skill} ))}
) : null} {(pkg as { prompts?: string[] }).prompts?.length ? (
Prompts: {(pkg as { prompts: string[] }).prompts!.map((prompt, i) => ( {prompt} ))}
) : null} {(pkg as { themes?: string[] }).themes?.length ? (
Themes: {(pkg as { themes: string[] }).themes!.map((theme, i) => ( {theme} ))}
) : null}
)}
); })}
) : (

No packages configured.

Add a package source above to get started.

)} {/* Top-level resource sections */}
{renderResourceSection("Extensions", Puzzle, "extensions")} {renderResourceSection("Skills", BookOpen, "skills")} {renderResourceSection("Prompts", FileText, "prompts")} {renderResourceSection("Themes", Palette, "themes")}
)} {/* Discovered Extensions Section */}

Discovered Extensions

Installed extensions resolved from packages and configured paths.

{extensionsLoading ? (
Loading extensions…
) : extensions.length === 0 ? (

No extensions discovered.

) : (
{extensions.map((ext) => (
{ext.name} {getSourceLabel(ext.source)}
{ext.path}
))}
)}
); }