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 `<project>/.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 <artmore@protonmail.com>
This commit is contained in:
Quat3rnion
2026-05-25 19:20:04 +03:00
committed by GitHub
co-authored by Bohdan Triapitsyn
parent a25e64099c
commit 2b47d899c6
29 changed files with 4667 additions and 0 deletions
@@ -13,6 +13,8 @@ export const settingsDict = {
"settings.view.home.cards.skillsCatalog.description": "Встановлення навичок із каталогів",
"settings.view.home.cards.mcp.title": "MCP",
"settings.view.home.cards.mcp.description": "Налаштування MCP серверів і підключень",
'settings.view.home.cards.plugins.title': 'Plugins',
'settings.view.home.cards.plugins.description': 'Manage opencode plugins',
"settings.view.home.cards.usage.title": "Використання",
"settings.view.home.cards.usage.description": "Квота + видимість витрат",
"settings.view.unavailable.title": "Недоступно",
@@ -33,6 +35,7 @@ export const settingsDict = {
"settings.page.agents.title": "Агенти",
"settings.page.commands.title": "Команди",
"settings.page.mcp.title": "MCP",
'settings.page.plugins.title': 'Plugins',
"settings.page.skills.title": "Навички",
"settings.page.skillsCatalog.title": "Каталог навичок",
"settings.page.git.title": "Git",
@@ -766,6 +769,73 @@ export const settingsDict = {
"settings.mcp.sidebar.toast.deleteFailed": "Не вдалося видалити сервер MCP",
"settings.mcp.sidebar.toast.serverDeleted": "MCP сервер \"{name}\" видалено",
"settings.mcp.sidebar.toast.refreshListIfStale": "Оновити список MCP, якщо інтерфейс користувача виглядає застарілим.",
'settings.plugins.sidebar.title': 'Плагіни',
'settings.plugins.sidebar.total': 'Усього: {count}',
'settings.plugins.sidebar.actions.addTitle': 'Додати плагін',
'settings.plugins.sidebar.actions.deleting': 'Видалення…',
'settings.plugins.sidebar.empty.title': 'Плагіни не налаштовано',
'settings.plugins.sidebar.empty.description': 'Натисніть кнопку + вище, щоб додати плагін',
'settings.plugins.sidebar.group.userEntries': 'Конфігурація користувача',
'settings.plugins.sidebar.group.userFiles': 'Файл плагіна користувача',
'settings.plugins.sidebar.group.projectEntries': 'Конфігурація проєкту',
'settings.plugins.sidebar.group.projectFiles': 'Файл плагіна проєкту',
'settings.plugins.sidebar.kind.npm': 'пакет npm',
'settings.plugins.sidebar.kind.path': 'Локальний шлях',
'settings.plugins.sidebar.kind.file': 'Файл плагіна',
'settings.plugins.sidebar.deleteDialog.title': 'Видалити плагін',
'settings.plugins.sidebar.deleteDialog.description': 'Ви впевнені, що хочете видалити "{name}"?',
'settings.plugins.sidebar.toast.deleted': 'Плагін "{name}" видалено',
'settings.plugins.sidebar.toast.deleteFailed': 'Не вдалося видалити плагін',
'settings.plugins.page.empty.select': 'Виберіть плагін, щоб переглянути або змінити його',
'settings.plugins.page.empty.add': 'Або натисніть +, щоб додати новий плагін',
'settings.plugins.page.header.entry': 'Встановлений плагін',
'settings.plugins.page.header.file': 'Файл плагіна',
'settings.plugins.page.field.spec': 'Специфікація',
'settings.plugins.page.field.spec.placeholder': 'npm-пакет@версія або /абсолютний/шлях',
'settings.plugins.page.field.options': 'Параметри (JSON)',
'settings.plugins.page.field.options.invalidJson': 'Некоректний JSON',
'settings.plugins.page.field.fileName': 'Назва файлу',
'settings.plugins.page.field.content': 'Вміст',
'settings.plugins.page.field.scope': 'Область',
'settings.plugins.scope.user': 'Користувач',
'settings.plugins.scope.project': 'Проєкт',
'settings.plugins.page.action.save': 'Зберегти',
'settings.plugins.page.action.discard': 'Скасувати',
'settings.plugins.dialog.add.title': 'Додати плагін',
'settings.plugins.dialog.add.tab.npm': 'З npm',
'settings.plugins.dialog.add.tab.path': 'З локального шляху',
'settings.plugins.dialog.add.tab.file': 'Новий файл',
'settings.plugins.dialog.add.action.submit': 'Додати',
'settings.plugins.dialog.add.action.cancel': 'Скасувати',
'settings.plugins.toast.created': 'Плагін додано',
'settings.plugins.toast.updated': 'Плагін оновлено',
'settings.plugins.toast.reloadFailed': 'Не вдалося перезавантажити opencode; потрібен перезапуск',
'settings.plugins.validation.fileName': 'Назва файлу має бути в нижньому регістрі й закінчуватися на .js / .ts / .mjs / .cjs',
'settings.plugins.validation.specRequired': 'Специфікація обов’язкова',
'settings.plugins.registry.badge.update.label': '↑ {version}',
'settings.plugins.registry.badge.update.tooltip': 'Доступне оновлення: {current} → {latest}',
'settings.plugins.registry.badge.malformed.tooltip': 'Специфікація некоректна',
'settings.plugins.registry.badge.missingPackage.tooltip': 'Пакет {name} не знайдено в npm',
'settings.plugins.registry.badge.missingVersion.tooltip': 'Версію {version} пакета {name} не опубліковано',
'settings.plugins.registry.badge.pathMissing.tooltip': 'Файл не знайдено: {path}',
'settings.plugins.registry.badge.pathUnreadable.tooltip': 'Файл неможливо прочитати: {path}',
'settings.plugins.registry.badge.network.tooltip': 'Не вдалося підключитися до реєстру npm',
'settings.plugins.registry.banner.updateAvailable.title': 'Доступне оновлення',
'settings.plugins.registry.banner.updateAvailable.description': '{current} → {latest}',
'settings.plugins.registry.banner.updateAvailable.action': 'Оновити до {latest}',
'settings.plugins.registry.banner.invalid.title': 'Некоректний плагін',
'settings.plugins.registry.banner.invalid.malformed': 'Синтаксис специфікації некоректний',
'settings.plugins.registry.banner.invalid.missingPackage': 'Пакет не знайдено в npm',
'settings.plugins.registry.banner.invalid.missingVersion': 'Версію не опубліковано',
'settings.plugins.registry.banner.invalid.pathMissing': 'Файл не існує',
'settings.plugins.registry.banner.invalid.pathUnreadable': 'Файл неможливо прочитати',
'settings.plugins.sidebar.actions.updateToLatest': 'Оновити до останньої версії',
'settings.plugins.sidebar.actions.refresh': 'Перевірити оновлення',
'settings.plugins.sidebar.group.updatesAvailable_one': 'Доступне {count} оновлення',
'settings.plugins.sidebar.group.updatesAvailable_other': 'Доступно оновлень: {count}',
'settings.plugins.toast.updatedToLatest': 'Плагін оновлено до {version}',
'settings.plugins.toast.refreshing': 'Перевірка оновлень у npm…',
'settings.plugins.toast.refreshFailed': 'Не вдалося перевірити реєстр npm',
"settings.openchamber.keyboardShortcuts.title": "Комбінації клавіш",
"settings.openchamber.keyboardShortcuts.actions.resetAll": "Скинути все",
"settings.openchamber.keyboardShortcuts.actions.overwrite": "Перезаписати",