/** * 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 { useState, useEffect, useCallback } from "react"; import { Package, Puzzle, BookOpen, FileText, Palette, Plus, X, ChevronDown, ChevronRight, RefreshCw, Trash2, } from "lucide-react"; import { fetchPiSettings, updatePiSettings, installPiPackage, type PiSettings } from "../api"; import type { ToastType } from "../hooks/useToast"; interface PiExtensionsManagerProps { addToast: (message: string, type?: ToastType) => void; projectId?: string; } /** 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 }: PiExtensionsManagerProps) { const [settings, setSettings] = useState(null); const [loading, setLoading] = useState(true); const [installing, setInstalling] = useState(false); const [newSource, setNewSource] = useState(""); const [expandedPackages, setExpandedPackages] = useState>(new Set()); 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]); useEffect(() => { void loadSettings(); }, [loadSettings]); 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 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")}
)}
); }