From 2b47d899c6b4d3f37cf8a39fdb70a42ed3fe9bac Mon Sep 17 00:00:00 2001 From: Quat3rnion <81202811+Quat3rnion@users.noreply.github.com> Date: Mon, 25 May 2026 12:20:04 -0400 Subject: [PATCH] feat: plugin settings (#1375) * feat(settings): add opencode plugins page Manage opencode `plugin` array entries (npm, scoped npm, versioned, local paths) and auto-loaded plugin files in `~/.config/opencode/plugins/` and `/.opencode/plugins/`. Mirrors MCP CRUD pattern. - Server: `plugins.js` data layer + `plugin-routes.js` REST routes - UI: PluginsSidebar / PluginsPage / AddPluginDialog - Store: usePluginsStore (cache TTL, in-flight dedup, narrow selectors) - i18n: 41 keys across 7 locales Whitelist /api/config/plugins in JSON body-parser so POST/PATCH bodies parse; opencode plugin specs runtime-resolve OPENCODE_CONFIG dir so parallel test files do not cross-pollute module-frozen consts. * feat(settings/plugins): hook npm registry for update + invalid-version detection Plugins page now consults registry.npmjs.org with a 1h server cache. Sidebar rows show an update badge with the latest version, group headers show how many updates are available, the kebab adds an "Update to latest" action that reuses the existing PATCH+restart flow, and the editor surfaces a banner for update-available / missing-version / missing-package / malformed / missing-path / unreadable-path / offline-registry states. A refresh button in the sidebar header forces a cache bypass. - Server: `npm-registry.js` (cache + in-flight dedup + 5s timeout, 404 cached, network failures NOT cached) + `plugin-spec.js` (parser + exact semver detection) + `GET /api/config/plugins/registry?specs=...&refresh=` - Routes accept up to 100 specs/request, dedup by npm package name before fetching, classify each result by kind, never propagate network failure as 500. - Client: `registryInfo` slice + `loadRegistryInfo` (fire-and-forget after loadPlugins, refreshes on mutations) + `updateToLatest(id)`. - UI: `RegistryBadge` per-row + `RegistryBanner` per-entry editor, both use theme tokens (text-only color, no new bg/border tokens) and the shared Icon sprite. Per-spec subscriptions only. - i18n: 24 new keys (incl. split singular/plural for "N update(s) available" because the runtime does not parse ICU plural format). * fix(settings/plugins): keep registry badge visible for long specs Sidebar entry row used `inline-flex` with `truncate` only on the spec text. With long npm specs the badge could be pushed past the row edge and clipped by the parent overflow. Switch to `flex` with spec `flex-1 min-w-0 truncate` and add `shrink-0` to the badge wrapper so the update indicator stays anchored to the right of the row. * fix(settings/plugins): use code-box icon to distinguish from MCP Plugins nav entry used 'plug' which is visually too close to MCP's 'plug-2' icon. Swap to 'code-box' for clearer differentiation in the Settings nav list. * Update packages/ui/src/components/sections/plugins/PluginsPage.tsx Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> Signed-off-by: Quat3rnion <81202811+Quat3rnion@users.noreply.github.com> * Update packages/ui/src/stores/usePluginsStore.ts Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> Signed-off-by: Quat3rnion <81202811+Quat3rnion@users.noreply.github.com> * fix(settings/plugins): validate registry directory + surface save errors - registry endpoint: return 400 on invalid directory query (was silently falling back to homedir, breaking relative path specs) - save failure toast: prefer result.message over generic 'Reload failed' * fix(settings/plugins): address review follow-ups --------- Signed-off-by: Quat3rnion <81202811+Quat3rnion@users.noreply.github.com> Co-authored-by: Bohdan Triapitsyn --- CHANGELOG.md | 3 + .../sections/plugins/AddPluginDialog.tsx | 304 ++++++++++++ .../sections/plugins/PluginsPage.tsx | 386 +++++++++++++++ .../sections/plugins/PluginsSidebar.tsx | 397 +++++++++++++++ .../sections/plugins/RegistryBadge.tsx | 108 ++++ .../sections/plugins/RegistryBanner.tsx | 127 +++++ .../src/components/sections/plugins/index.ts | 3 + .../ui/src/components/views/SettingsView.tsx | 15 + .../ui/src/lib/i18n/messages/en.settings.ts | 70 +++ .../ui/src/lib/i18n/messages/es.settings.ts | 70 +++ .../ui/src/lib/i18n/messages/ko.settings.ts | 70 +++ .../ui/src/lib/i18n/messages/pl.settings.ts | 70 +++ .../src/lib/i18n/messages/pt-BR.settings.ts | 70 +++ .../ui/src/lib/i18n/messages/uk.settings.ts | 70 +++ .../src/lib/i18n/messages/zh-CN.settings.ts | 70 +++ packages/ui/src/lib/settings/metadata.ts | 8 + .../ui/src/stores/usePluginsStore.test.ts | 381 ++++++++++++++ packages/ui/src/stores/usePluginsStore.ts | 467 ++++++++++++++++++ .../web/server/lib/opencode/core-routes.js | 1 + .../lib/opencode/feature-routes-runtime.js | 35 ++ packages/web/server/lib/opencode/index.js | 19 + .../web/server/lib/opencode/npm-registry.js | 157 ++++++ .../server/lib/opencode/npm-registry.test.js | 179 +++++++ .../web/server/lib/opencode/plugin-routes.js | 373 ++++++++++++++ .../server/lib/opencode/plugin-routes.test.js | 384 ++++++++++++++ .../web/server/lib/opencode/plugin-spec.js | 107 ++++ .../server/lib/opencode/plugin-spec.test.js | 154 ++++++ packages/web/server/lib/opencode/plugins.js | 393 +++++++++++++++ .../web/server/lib/opencode/plugins.test.js | 176 +++++++ 29 files changed, 4667 insertions(+) create mode 100644 packages/ui/src/components/sections/plugins/AddPluginDialog.tsx create mode 100644 packages/ui/src/components/sections/plugins/PluginsPage.tsx create mode 100644 packages/ui/src/components/sections/plugins/PluginsSidebar.tsx create mode 100644 packages/ui/src/components/sections/plugins/RegistryBadge.tsx create mode 100644 packages/ui/src/components/sections/plugins/RegistryBanner.tsx create mode 100644 packages/ui/src/components/sections/plugins/index.ts create mode 100644 packages/ui/src/stores/usePluginsStore.test.ts create mode 100644 packages/ui/src/stores/usePluginsStore.ts create mode 100644 packages/web/server/lib/opencode/npm-registry.js create mode 100644 packages/web/server/lib/opencode/npm-registry.test.js create mode 100644 packages/web/server/lib/opencode/plugin-routes.js create mode 100644 packages/web/server/lib/opencode/plugin-routes.test.js create mode 100644 packages/web/server/lib/opencode/plugin-spec.js create mode 100644 packages/web/server/lib/opencode/plugin-spec.test.js create mode 100644 packages/web/server/lib/opencode/plugins.js create mode 100644 packages/web/server/lib/opencode/plugins.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 0fdaefdf..09d468b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +- Settings: added a Plugins page to view, add, edit, and remove opencode plugins. Supports `plugin` array entries (npm packages, scoped npm, versioned specs, local paths) with optional options JSON, plus auto-loaded plugin files in `~/.config/opencode/plugins/` and `/.opencode/plugins/`. +- Settings/Plugins: the Plugins page now talks to the npm registry. Sidebar rows show an update badge with the latest available version, group headers show how many updates are available, the kebab menu adds an "Update to latest" action, and the editor surfaces a banner for available updates, missing/invalid versions, missing-package, missing local file, and offline-registry states. Results are cached for one hour with a "Check for updates" button in the sidebar header for forced refresh. + ## [1.11.5] - 2026-05-25 - Chat/Input: pending image attachments now show previews, sent image attachments can be cited from assistant messages, and markdown source mode highlights formatting while you type. diff --git a/packages/ui/src/components/sections/plugins/AddPluginDialog.tsx b/packages/ui/src/components/sections/plugins/AddPluginDialog.tsx new file mode 100644 index 00000000..dcff546e --- /dev/null +++ b/packages/ui/src/components/sections/plugins/AddPluginDialog.tsx @@ -0,0 +1,304 @@ +import React from 'react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; +import { Radio } from '@/components/ui/radio'; +import { toast } from '@/components/ui'; +import { Icon } from '@/components/icon/Icon'; +import { SortableTabsStrip, type SortableTabsStripItem } from '@/components/ui/sortable-tabs-strip'; +import { cn } from '@/lib/utils'; +import { useI18n } from '@/lib/i18n'; +import { usePluginsStore, type PluginScope } from '@/stores/usePluginsStore'; + +type TabKey = 'npm' | 'path' | 'file'; + +interface AddPluginDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + defaultScope?: PluginScope; +} + +const FILENAME_PATTERN = /^[a-z0-9][a-z0-9-_.]*\.(js|ts|mjs|cjs)$/; + +function parseOptions(raw: string): { ok: true; value?: Record } | { ok: false } { + const trimmed = raw.trim(); + if (trimmed === '') return { ok: true, value: undefined }; + try { + const parsed = JSON.parse(trimmed) as unknown; + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { + return { ok: false }; + } + return { ok: true, value: parsed as Record }; + } catch { + return { ok: false }; + } +} + +export const AddPluginDialog: React.FC = ({ + open, + onOpenChange, + defaultScope = 'user', +}) => { + const { t } = useI18n(); + const createEntry = usePluginsStore((s) => s.createEntry); + const createFile = usePluginsStore((s) => s.createFile); + + const [tab, setTab] = React.useState('npm'); + const [spec, setSpec] = React.useState(''); + const [optionsJson, setOptionsJson] = React.useState(''); + const [fileName, setFileName] = React.useState(''); + const [content, setContent] = React.useState(''); + const [scope, setScope] = React.useState(defaultScope); + const [submitting, setSubmitting] = React.useState(false); + + const resetForm = React.useCallback(() => { + setSpec(''); + setOptionsJson(''); + setFileName(''); + setContent(''); + setScope(defaultScope); + }, [defaultScope]); + + React.useEffect(() => { + if (open) { + setTab('npm'); + resetForm(); + } + }, [open, resetForm]); + + const handleTabChange = (next: TabKey) => { + if (next === tab) return; + setTab(next); + resetForm(); + }; + + const optionsResult = React.useMemo(() => parseOptions(optionsJson), [optionsJson]); + const optionsInvalid = !optionsResult.ok; + const fileNameInvalid = tab === 'file' && fileName.trim() !== '' && !FILENAME_PATTERN.test(fileName.trim()); + const specEmpty = (tab === 'npm' || tab === 'path') && spec.trim() === ''; + const contentEmpty = tab === 'file' && content.trim() === ''; + const fileNameEmpty = tab === 'file' && fileName.trim() === ''; + + const submitDisabled = + submitting || + optionsInvalid || + (tab === 'npm' && specEmpty) || + (tab === 'path' && specEmpty) || + (tab === 'file' && (fileNameEmpty || fileNameInvalid || contentEmpty)); + + const handleSubmit = async () => { + if (submitDisabled) return; + setSubmitting(true); + try { + let result; + if (tab === 'file') { + result = await createFile({ fileName: fileName.trim(), content, scope }); + } else { + result = await createEntry({ + spec: spec.trim(), + options: optionsResult.ok ? optionsResult.value : undefined, + scope, + }); + } + if (result.ok) { + toast.success(result.message || t('settings.plugins.toast.created')); + if (result.reloadFailed) { + toast.warning(t('settings.plugins.toast.reloadFailed')); + } + onOpenChange(false); + } else { + toast.error(result.message || t('settings.plugins.sidebar.toast.deleteFailed')); + } + } finally { + setSubmitting(false); + } + }; + + const tabs = React.useMemo(() => [ + { id: 'npm', label: t('settings.plugins.dialog.add.tab.npm') }, + { id: 'path', label: t('settings.plugins.dialog.add.tab.path') }, + { id: 'file', label: t('settings.plugins.dialog.add.tab.file') }, + ], [t]); + + return ( + { + if (!next && submitting) return; + onOpenChange(next); + }} + > + + + {t('settings.plugins.dialog.add.title')} + + {t('settings.plugins.sidebar.empty.description')} + + + + handleTabChange(id as TabKey)} + layoutMode="fit" + variant="active-pill" + activePillLowercase={false} + className="h-10" + /> + +
+ {(tab === 'npm' || tab === 'path') && ( + <> +
+ + setSpec(e.target.value)} + placeholder={t('settings.plugins.page.field.spec.placeholder')} + aria-invalid={specEmpty ? false : undefined} + disabled={submitting} + /> + {specEmpty && ( +

+ {t('settings.plugins.validation.specRequired')} +

+ )} +
+ +
+ +