diff --git a/dashboard/src/app/extensions/plugins/page.tsx b/dashboard/src/app/extensions/plugins/page.tsx
index 6b1b90f..4a678f9 100644
--- a/dashboard/src/app/extensions/plugins/page.tsx
+++ b/dashboard/src/app/extensions/plugins/page.tsx
@@ -1,13 +1,18 @@
'use client';
import { useState, useMemo, useEffect } from 'react';
+import useSWR from 'swr';
import { toast } from '@/components/toast';
-import { type Plugin } from '@/lib/api';
+import { type Plugin, getInstalledPlugins, updatePlugin, type InstalledPluginInfo } from '@/lib/api';
import {
AlertCircle,
+ ArrowUpCircle,
Check,
+ Download,
+ ExternalLink,
GitBranch,
Loader,
+ Package,
Plus,
RefreshCw,
Search,
@@ -488,6 +493,204 @@ function PluginFormModal({
);
}
+// ─────────────────────────────────────────────────────────────────────────────
+// Installed Plugins Section (discovered from OpenCode config)
+// ─────────────────────────────────────────────────────────────────────────────
+
+function InstalledPluginCard({
+ plugin,
+ onUpdate,
+ updating,
+}: {
+ plugin: InstalledPluginInfo;
+ onUpdate: (packageName: string) => void;
+ updating: string | null;
+}) {
+ const isUpdating = updating === plugin.package;
+
+ return (
+
+
+
+
+
+
{plugin.package}
+
+
+ {plugin.installed_version ?? 'unknown'}
+
+ {plugin.update_available && plugin.latest_version && (
+ <>
+
+
{plugin.latest_version} available
+ >
+ )}
+
+
+
+
+
+
+
+
+ {plugin.update_available && (
+
+ )}
+ {!plugin.update_available && plugin.latest_version && (
+
+
+ Latest
+
+ )}
+
+
+
+ );
+}
+
+function InstalledPluginsSection() {
+ const { data, isLoading, error, mutate } = useSWR(
+ 'installed-plugins',
+ getInstalledPlugins,
+ { revalidateOnFocus: false }
+ );
+
+ const [updating, setUpdating] = useState(null);
+ const [updateProgress, setUpdateProgress] = useState(null);
+
+ const handleUpdate = (packageName: string) => {
+ setUpdating(packageName);
+ setUpdateProgress(null);
+
+ const cleanup = updatePlugin(packageName, (event) => {
+ setUpdateProgress(event.message);
+
+ if (event.event_type === 'complete') {
+ toast.success(event.message);
+ setUpdating(null);
+ setUpdateProgress(null);
+ mutate(); // Refresh the list
+ } else if (event.event_type === 'error') {
+ toast.error(event.message);
+ setUpdating(null);
+ setUpdateProgress(null);
+ }
+ });
+
+ // Cleanup on unmount
+ return cleanup;
+ };
+
+ if (isLoading) {
+ return (
+
+
+
+ Loading installed plugins...
+
+
+ );
+ }
+
+ if (error) {
+ return (
+
+
+
+
Failed to load installed plugins
+
+
+ );
+ }
+
+ const plugins = data?.plugins ?? [];
+
+ if (plugins.length === 0) {
+ return (
+
+
+ No plugins installed in OpenCode. Plugins are added via the plugin array in ~/.config/opencode/opencode.json.
+
+
+ );
+ }
+
+ const hasUpdates = plugins.some((p) => p.update_available);
+
+ return (
+
+
+
+
Installed OpenCode Plugins
+
+ Plugins discovered from your OpenCode config
+
+
+
+ {hasUpdates && (
+
+
+ Updates available
+
+ )}
+
+
+
+
+ {updateProgress && (
+
+ )}
+
+
+ {plugins.map((plugin) => (
+
+ ))}
+
+
+ );
+}
+
export default function PluginsPage() {
const {
status,
@@ -714,10 +917,17 @@ export default function PluginsPage() {
)}
+ {/* Installed OpenCode Plugins - discovered from config */}
+
+
+ {/* Divider */}
+
+
+ {/* Library Plugins */}
-
Plugins
-
Manage OpenCode plugins stored in your library repo.
+
Library Plugins
+
Plugins managed in your library repo (synced to OpenCode).
diff --git a/dashboard/src/lib/api.ts b/dashboard/src/lib/api.ts
index e7331df..8aa64df 100644
--- a/dashboard/src/lib/api.ts
+++ b/dashboard/src/lib/api.ts
@@ -1621,6 +1621,59 @@ export async function saveLibraryPlugins(
await ensureLibraryResponse(res, "Failed to save plugins");
}
+// ─────────────────────────────────────────────────────────────────────────────
+// Installed OpenCode Plugins (discovered from OpenCode config)
+// ─────────────────────────────────────────────────────────────────────────────
+
+export interface InstalledPluginInfo {
+ package: string;
+ spec: string;
+ installed_version: string | null;
+ latest_version: string | null;
+ update_available: boolean;
+}
+
+export interface InstalledPluginsResponse {
+ plugins: InstalledPluginInfo[];
+}
+
+// Get installed plugins from OpenCode config with version info
+export async function getInstalledPlugins(): Promise {
+ const res = await apiFetch("/api/system/plugins/installed");
+ if (!res.ok) {
+ throw new Error("Failed to fetch installed plugins");
+ }
+ return res.json();
+}
+
+// Update a plugin (returns SSE stream)
+export function updatePlugin(
+ packageName: string,
+ onEvent: (event: { event_type: string; message: string; progress?: number }) => void
+): () => void {
+ const url = `${window.location.origin}/api/system/plugins/${encodeURIComponent(packageName)}/update`;
+
+ const eventSource = new EventSource(url, { withCredentials: true });
+
+ eventSource.onmessage = (event) => {
+ try {
+ const data = JSON.parse(event.data);
+ onEvent(data);
+ if (data.event_type === "complete" || data.event_type === "error") {
+ eventSource.close();
+ }
+ } catch (e) {
+ console.error("Failed to parse SSE event:", e);
+ }
+ };
+
+ eventSource.onerror = () => {
+ eventSource.close();
+ };
+
+ return () => eventSource.close();
+}
+
// ─────────────────────────────────────────────────────────────────────────────
// Rules
// ─────────────────────────────────────────────────────────────────────────────