- {mode === 'mcp' ? (
+ {mode === 'plugins' ? (
+ // Agent plugins for the scoped profile: installed list on top,
+ // the live catalog picker underneath (same shape as Skills).
+ // Keyed on scope so a profile/connection switch reloads the list.
+
+ ) : mode === 'mcp' ? (
// The gateway instance backs ONLY the live `reload.mcp` RPC, and
// it is the ACTIVE gateway's socket — for a scope pinned to a
// different backend that RPC would hot-reload the wrong
diff --git a/apps/desktop/src/app/skills/plugins-tab.test.tsx b/apps/desktop/src/app/skills/plugins-tab.test.tsx
new file mode 100644
index 0000000000..04009dfe6b
--- /dev/null
+++ b/apps/desktop/src/app/skills/plugins-tab.test.tsx
@@ -0,0 +1,136 @@
+import { cleanup, render, screen, waitFor } from '@testing-library/react'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+import { $agentPlugins, $agentPluginsStatus } from '@/store/agent-plugins'
+import { $pluginInstallRequest, closePluginInstallRequest } from '@/store/plugin-install-request'
+
+import { PluginsTab } from './plugins-tab'
+
+const requestGateway = vi.fn(async () => ({ plugins: [] }))
+
+vi.mock('@/app/gateway/hooks/use-gateway-request', () => ({
+ useGatewayRequest: () => ({ requestGateway })
+}))
+
+describe('PluginsTab', () => {
+ beforeEach(() => {
+ $agentPlugins.set([])
+ $agentPluginsStatus.set('ready')
+ closePluginInstallRequest()
+ requestGateway.mockClear()
+ })
+
+ afterEach(cleanup)
+
+ it('lists the scoped profile agent plugins with toggles', () => {
+ $agentPlugins.set([
+ {
+ description: 'A test plugin',
+ key: 'demo-plugin',
+ name: 'demo-plugin',
+ source: 'git',
+ status: 'enabled',
+ version: '1.0.0'
+ }
+ ])
+
+ render(
)
+
+ expect(screen.getByText('demo-plugin')).toBeTruthy()
+ expect(screen.getByRole('switch', { name: 'demo-plugin' }).getAttribute('aria-checked')).toBe('true')
+ })
+
+ it('hides bundled plugins (managed from their own surfaces)', () => {
+ $agentPlugins.set([
+ {
+ description: '',
+ key: 'image_gen/fal',
+ name: 'fal',
+ source: 'bundled',
+ status: 'enabled',
+ version: ''
+ }
+ ])
+
+ render(
)
+
+ expect(screen.queryByText('fal')).toBeNull()
+ expect(screen.getByText(/No agent plugins installed/)).toBeTruthy()
+ })
+
+ it('loads the plugin list scoped to the selected profile', () => {
+ render(
)
+
+ expect(requestGateway).toHaveBeenCalledWith(
+ 'plugins.manage',
+ expect.objectContaining({ action: 'list', profile: 'workbot' })
+ )
+ })
+
+ it('opens the dual-target install modal from a catalog pick message', async () => {
+ render(
)
+
+ window.dispatchEvent(
+ new MessageEvent('message', {
+ data: {
+ name: 'weather-plugin',
+ repo: 'https://github.com/example/weather-plugin',
+ sha: 'a'.repeat(40),
+ subdir: '',
+ tier: 'community',
+ type: 'hermes-plugin-pick'
+ },
+ origin: 'https://hermes-agent.nousresearch.com'
+ })
+ )
+
+ await waitFor(() => {
+ const request = $pluginInstallRequest.get()
+
+ expect(request).not.toBeNull()
+ expect(request?.catalogName).toBe('weather-plugin')
+ expect(request?.repo).toBe('https://github.com/example/weather-plugin')
+ expect(request?.profile).toBe('workbot')
+ expect(request?.sha).toBe('a'.repeat(40))
+ })
+ })
+
+ it('ignores pick messages from foreign origins', () => {
+ render(
)
+
+ window.dispatchEvent(
+ new MessageEvent('message', {
+ data: {
+ name: 'evil-plugin',
+ repo: 'https://github.com/evil/evil-plugin',
+ type: 'hermes-plugin-pick'
+ },
+ origin: 'https://evil.example.com'
+ })
+ )
+
+ expect($pluginInstallRequest.get()).toBeNull()
+ })
+
+ it('appends the subdir fragment for multi-plugin repos', async () => {
+ render(
)
+
+ window.dispatchEvent(
+ new MessageEvent('message', {
+ data: {
+ name: 'nested-plugin',
+ repo: 'https://github.com/example/plugins-monorepo',
+ subdir: 'nested-plugin',
+ type: 'hermes-plugin-pick'
+ },
+ origin: 'https://hermes-agent.nousresearch.com'
+ })
+ )
+
+ await waitFor(() => {
+ expect($pluginInstallRequest.get()?.repo).toBe(
+ 'https://github.com/example/plugins-monorepo#nested-plugin'
+ )
+ })
+ })
+})
diff --git a/apps/desktop/src/app/skills/plugins-tab.tsx b/apps/desktop/src/app/skills/plugins-tab.tsx
new file mode 100644
index 0000000000..94da93f140
--- /dev/null
+++ b/apps/desktop/src/app/skills/plugins-tab.tsx
@@ -0,0 +1,254 @@
+import { useStore } from '@nanostores/react'
+import { memo, useEffect, useMemo, useState } from 'react'
+
+import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request'
+import { Button } from '@/components/ui/button'
+import { Switch } from '@/components/ui/switch'
+import { Tip } from '@/components/ui/tooltip'
+import type { ProfileScope } from '@/hermes'
+import { useI18n } from '@/i18n'
+import { Loader2, Package } from '@/lib/icons'
+import { cn } from '@/lib/utils'
+import {
+ $agentPluginBusy,
+ $agentPlugins,
+ $agentPluginsError,
+ $agentPluginsStatus,
+ type AgentPluginRow,
+ isDesktopRelevantPlugin,
+ loadAgentPlugins,
+ toggleAgentPlugin
+} from '@/store/agent-plugins'
+import { $paneHeightOverride, setPaneHeightOverride } from '@/store/panes'
+import { openPluginInstallRequest } from '@/store/plugin-install-request'
+
+import { PanelEmpty } from '../overlays/panel'
+
+// The REAL Plugin Catalog page (docs site) embedded as a one-click picker —
+// the same pattern as the Skills tab's EmbeddedHubPicker. `?embed=picker`
+// hides the docs chrome and adds "+ Add to this Agent" per card, which posts
+// { type: 'hermes-plugin-pick', name, repo, sha, subdir, tier, installCmd }
+// to the parent window. We validate the origin and open the shared
+// dual-target install modal (agent half → catalog-pinned install into the
+// scoped profile; desktop half → local app), so bundled agent+desktop
+// packages install both halves in one flow.
+const CATALOG_ORIGIN = 'https://hermes-agent.nousresearch.com'
+const CATALOG_PICKER_URL = `${CATALOG_ORIGIN}/docs/plugins?embed=picker`
+
+const CATALOG_PANE_ID = 'capabilities-plugin-catalog'
+const CATALOG_DEFAULT_PX = 380
+const CATALOG_COLLAPSED_PX = 4
+
+interface PluginPickMessage {
+ installCmd?: string
+ name?: string
+ repo?: string
+ sha?: string
+ subdir?: string
+ tier?: string
+ type?: string
+}
+
+/** Derive the bare profile name a `plugins.manage` call should target. */
+function profileParam(scope: ProfileScope): null | string {
+ if (!scope) {
+ return null
+ }
+
+ return typeof scope === 'string' ? scope : (scope.profile ?? null)
+}
+
+function PluginRow({
+ row,
+ busy,
+ onToggle
+}: {
+ row: AgentPluginRow
+ busy: boolean
+ onToggle: (enable: boolean) => void
+}) {
+ const { t } = useI18n()
+ const address = row.key ?? ''
+ const canToggle = Boolean(address)
+ const enabled = row.status === 'enabled'
+
+ return (
+
+
+
+
+ {row.name}
+ {row.version && v{row.version}}
+ {row.portable && (
+
+ {t.skills.plugins.portableBadge}
+
+ )}
+
+ {row.description && (
+
+ {row.description}
+
+ )}
+
+
+ {busy && }
+ {canToggle ? (
+
+ ) : (
+
+
+
+
+
+ )}
+
+
+ )
+}
+
+/** Agent plugins for the Capabilities page: the scoped profile's installed
+ * plugins on top (toggleable), the live catalog picker underneath — same
+ * management-plus-discovery shape as the Skills tab. */
+export const PluginsTab = memo(function PluginsTab({ profile }: { profile: ProfileScope }) {
+ const { t } = useI18n()
+ const p = t.skills.plugins
+ const { requestGateway } = useGatewayRequest()
+
+ const rows = useStore($agentPlugins)
+ const status = useStore($agentPluginsStatus)
+ const error = useStore($agentPluginsError)
+ const busyKey = useStore($agentPluginBusy)
+
+ const scope = profileParam(profile)
+
+ useEffect(() => {
+ void loadAgentPlugins(requestGateway, scope)
+ }, [requestGateway, scope])
+
+ const visible = useMemo(() => rows.filter(isDesktopRelevantPlugin), [rows])
+
+ // Catalog picker viewport (persisted height, collapse toggle) — same pane
+ // store contract as EmbeddedHubPicker.
+ const heightOverride = useStore($paneHeightOverride(CATALOG_PANE_ID))
+ const height = heightOverride ?? CATALOG_DEFAULT_PX
+ const open = height > CATALOG_COLLAPSED_PX
+ const [pickerMounted, setPickerMounted] = useState(open)
+
+ if (open && !pickerMounted) {
+ setPickerMounted(true)
+ }
+
+ useEffect(() => {
+ if (!open) {
+ return undefined
+ }
+
+ const onMessage = (event: MessageEvent) => {
+ if (event.origin !== CATALOG_ORIGIN) {
+ return
+ }
+
+ const data = event.data as null | PluginPickMessage
+
+ if (!data || data.type !== 'hermes-plugin-pick' || !data.name || !data.repo) {
+ return
+ }
+
+ // Open the shared dual-target install modal: it probes the repo for
+ // agent/desktop halves, installs the agent half at the catalog pin
+ // into the scoped profile, and offers the desktop half locally.
+ openPluginInstallRequest({
+ catalogName: String(data.name),
+ profile: scope,
+ repo: data.subdir ? `${String(data.repo)}#${String(data.subdir)}` : String(data.repo),
+ sha: data.sha ? String(data.sha) : undefined
+ })
+ }
+
+ window.addEventListener('message', onMessage)
+
+ return () => window.removeEventListener('message', onMessage)
+ }, [open, scope])
+
+ return (
+
+
+ {status === 'error' ? (
+
void loadAgentPlugins(requestGateway, scope)} size="sm">
+ {t.skills.refresh}
+
+ }
+ description={error ?? undefined}
+ icon="error"
+ title={p.loadFailed}
+ />
+ ) : visible.length === 0 && status === 'ready' ? (
+
+ ) : (
+
+ {visible.map(row => (
+
{
+ if (!row.key) {
+ return
+ }
+
+ void toggleAgentPlugin(requestGateway, row.key, enable, p.toggleFailed(row.name), scope)
+ }}
+ row={row}
+ />
+ ))}
+
+ )}
+
+
+
+
+ {p.catalogTitle}
+
+
+ {pickerMounted && (
+
+
+
+
+
{p.catalogHint}
+
+ )}
+
+
+ )
+})
diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts
index 2a9aed67e5..bbd94f1f92 100644
--- a/apps/desktop/src/i18n/en.ts
+++ b/apps/desktop/src/i18n/en.ts
@@ -418,6 +418,9 @@ export const en: Translations = {
failed: 'failed',
empty: 'No desktop plugins installed yet.',
kinds: { bundled: 'bundled', disk: 'on disk', runtime: 'runtime' },
+ agentHalfMissing: 'agent half missing here',
+ agentHalfMissingTip:
+ 'This is the desktop half of a bundled plugin, but its agent half is not installed on the currently connected backend/profile. Install it from Capabilities → Plugins.',
agent: {
title: 'Agent plugins',
blurb:
@@ -441,6 +444,8 @@ export const en: Translations = {
desktopLabel: 'Desktop UI',
agentTargetLocal: profile => `Installs into the ${profile} backend (~/.hermes/plugins/)`,
agentTargetRemote: profile => `Installs into the connected ${profile} backend`,
+ catalogPinned: (name, sha) =>
+ `Hermes catalog entry "${name}" — the agent component installs at the reviewed pin${sha ? ` ${sha}` : ''}, not the branch tip.`,
desktopTarget: "Installs into this app's local desktop-plugins folder",
desktopOnlyNote: 'Desktop-only packages do not install a backend agent plugin.',
insecureWarning: 'This URL uses an insecure or local scheme. Prefer https:// or git@ for production installs.',
@@ -1308,6 +1313,20 @@ export const en: Translations = {
archive: 'Archive',
skillArchivedTitle: 'Skill archived',
skillArchivedMessage: 'Restorable via hermes curator restore.',
+ tabPlugins: 'Plugins',
+ plugins: {
+ empty: 'No agent plugins installed for this profile',
+ emptyHint: 'Browse the catalog below and install a reviewed plugin with one click.',
+ loadFailed: 'Could not load agent plugins',
+ toggleFailed: (name: string) => `Could not toggle ${name}`,
+ legacyBackend: 'This backend predates key-addressed plugin toggles — update Hermes to manage it here.',
+ portableBadge: 'portable',
+ catalogTitle: 'Plugin catalog',
+ catalogBrowse: 'Browse',
+ catalogHide: 'Hide the catalog browser',
+ catalogHint:
+ 'Hit "+ Add to this Agent" on any plugin — reviewed entries install at their pinned commit into the selected profile. Bundled agent+desktop plugins offer both halves.'
+ },
hub: {
searchPlaceholder: 'Search the skill hub',
search: 'Search',
diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts
index 3b4415cb7f..1c7115d2c4 100644
--- a/apps/desktop/src/i18n/types.ts
+++ b/apps/desktop/src/i18n/types.ts
@@ -361,6 +361,8 @@ export interface Translations {
failed: string
empty: string
kinds: { bundled: string; disk: string; runtime: string }
+ agentHalfMissing: string
+ agentHalfMissingTip: string
agent: {
title: string
blurb: string
@@ -383,6 +385,7 @@ export interface Translations {
desktopLabel: string
agentTargetLocal: (profile: string) => string
agentTargetRemote: (profile: string) => string
+ catalogPinned: (name: string, sha: string) => string
desktopTarget: string
desktopOnlyNote: string
insecureWarning: string
@@ -1146,6 +1149,19 @@ export interface Translations {
archive: string
skillArchivedTitle: string
skillArchivedMessage: string
+ tabPlugins: string
+ plugins: {
+ empty: string
+ emptyHint: string
+ loadFailed: string
+ toggleFailed: (name: string) => string
+ legacyBackend: string
+ portableBadge: string
+ catalogTitle: string
+ catalogBrowse: string
+ catalogHide: string
+ catalogHint: string
+ }
hub: {
searchPlaceholder: string
search: string
diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts
index 2f90f09868..46b1a22f42 100644
--- a/apps/desktop/src/i18n/zh.ts
+++ b/apps/desktop/src/i18n/zh.ts
@@ -406,6 +406,9 @@ export const zh: Translations = {
failed: '失败',
empty: '尚未安装桌面插件。',
kinds: { bundled: '内置', disk: '磁盘', runtime: '运行时' },
+ agentHalfMissing: '此处缺少 agent 部分',
+ agentHalfMissingTip:
+ '这是捆绑插件的桌面部分,但其 agent 部分未安装在当前连接的后端/配置上。请在 能力 → 插件 中安装。',
agent: {
title: '智能体插件',
blurb:
@@ -429,6 +432,8 @@ export const zh: Translations = {
desktopLabel: '桌面 UI',
agentTargetLocal: profile => `安装到 ${profile} 后端(~/.hermes/plugins/)`,
agentTargetRemote: profile => `安装到已连接的 ${profile} 后端`,
+ catalogPinned: (name, sha) =>
+ `Hermes 目录条目「${name}」— agent 部分将安装在经过审核的固定提交${sha ? ` ${sha}` : ''},而不是分支最新代码。`,
desktopTarget: '安装到此应用的本地 desktop-plugins 文件夹',
desktopOnlyNote: '仅桌面包不会安装后端智能体插件。',
insecureWarning: '此 URL 使用了不安全的本地 scheme。生产环境请优先使用 https:// 或 git@。',
@@ -1497,6 +1502,20 @@ export const zh: Translations = {
archive: '归档',
skillArchivedTitle: '技能已归档',
skillArchivedMessage: '可通过 hermes curator restore 恢复。',
+ tabPlugins: '插件',
+ plugins: {
+ empty: '此配置尚未安装任何 agent 插件',
+ emptyHint: '在下方目录中浏览,一键安装经过审核的插件。',
+ loadFailed: '无法加载 agent 插件',
+ toggleFailed: (name: string) => `无法切换 ${name}`,
+ legacyBackend: '此后端版本较旧,不支持按键名切换插件 — 请更新 Hermes 后再在此管理。',
+ portableBadge: '便携',
+ catalogTitle: '插件目录',
+ catalogBrowse: '浏览',
+ catalogHide: '隐藏目录浏览器',
+ catalogHint:
+ '点击任意插件上的「+ Add to this Agent」— 经过审核的条目会以其固定提交安装到所选配置。捆绑的 agent+桌面插件会同时提供两部分。'
+ },
hub: {
searchPlaceholder: '搜索技能中心',
search: '搜索',
diff --git a/apps/desktop/src/store/agent-plugins.ts b/apps/desktop/src/store/agent-plugins.ts
index 96b00ee2b7..9f38384aeb 100644
--- a/apps/desktop/src/store/agent-plugins.ts
+++ b/apps/desktop/src/store/agent-plugins.ts
@@ -179,7 +179,16 @@ export interface AgentPluginInstallResult {
export async function installAgentPlugin(
request: GatewayRequest,
- opts: { identifier: string; force?: boolean; enable?: boolean }
+ opts: {
+ identifier: string
+ force?: boolean
+ enable?: boolean
+ /** Curated-catalog install: the backend resolves repo + pinned SHA from
+ * its own plugin-catalog and records provenance in the sidecar. */
+ catalogName?: string
+ /** Target profile's HERMES_HOME (null/undefined = backend launch profile). */
+ profile?: string | null
+ }
): Promise
{
try {
const result = await request<{
@@ -188,12 +197,13 @@ export async function installAgentPlugin(
warnings?: string[]
missing_env?: string[]
error?: string
- }>('plugins.manage', {
+ }>('plugins.manage', withProfile({
action: 'install',
identifier: opts.identifier,
force: Boolean(opts.force),
- enable: opts.enable ?? true
- })
+ enable: opts.enable ?? true,
+ ...(opts.catalogName ? { catalog_name: opts.catalogName } : {})
+ }, opts.profile))
if (!result?.ok) {
return { ok: false, error: result?.error || 'Install failed' }
diff --git a/apps/desktop/src/store/plugin-install-request.ts b/apps/desktop/src/store/plugin-install-request.ts
index ff772b5b2b..8749e28fcc 100644
--- a/apps/desktop/src/store/plugin-install-request.ts
+++ b/apps/desktop/src/store/plugin-install-request.ts
@@ -9,6 +9,14 @@ export interface PluginInstallRequest {
enable?: boolean
force?: boolean
legacyHint?: PluginInstallLegacyHint
+ /** Curated-catalog pick: install the agent half by catalog name so the
+ * backend pins the reviewed SHA and records sidecar provenance. */
+ catalogName?: string
+ /** The catalog pin (display only — the backend resolves it itself). */
+ sha?: string
+ /** Capabilities profile scope the pick was made under; the agent half
+ * installs into THIS profile (null/undefined = active profile). */
+ profile?: string | null
}
export const $pluginInstallRequest = atom(null)
diff --git a/tests/tui_gateway/test_plugins_manage_install.py b/tests/tui_gateway/test_plugins_manage_install.py
index 776dfef6c9..e12bfde9c0 100644
--- a/tests/tui_gateway/test_plugins_manage_install.py
+++ b/tests/tui_gateway/test_plugins_manage_install.py
@@ -37,6 +37,7 @@ def test_plugins_manage_install_success():
"owner/hello-world",
force=True,
enable=False,
+ catalog_name=None,
)
@@ -71,3 +72,31 @@ def test_plugins_manage_install_failure():
assert "error" in resp
assert "Git clone failed" in resp["error"]["message"]
+
+
+def test_plugins_manage_install_catalog_name_only():
+ """A catalog pick needs no identifier — the backend resolves repo + pin."""
+ payload = {"ok": True, "plugin_name": "weather-plugin", "enabled": False}
+ with patch(
+ "hermes_cli.plugins_cmd.dashboard_install_plugin",
+ return_value=payload,
+ ) as mock_install:
+ resp = server.handle_request(
+ {
+ "id": "1",
+ "method": "plugins.manage",
+ "params": {
+ "action": "install",
+ "catalog_name": "weather-plugin",
+ "enable": False,
+ },
+ }
+ )
+
+ assert "result" in resp
+ mock_install.assert_called_once_with(
+ "",
+ force=False,
+ enable=False,
+ catalog_name="weather-plugin",
+ )
diff --git a/tui_gateway/methods_tools.py b/tui_gateway/methods_tools.py
index 80d94deee4..96a4289fe1 100644
--- a/tui_gateway/methods_tools.py
+++ b/tui_gateway/methods_tools.py
@@ -2543,14 +2543,20 @@ def _(rid, params: dict) -> dict:
ident = (
params.get("identifier") or params.get("repo") or ""
).strip()
- if not ident:
+ # Curated-catalog install: resolve repo + pinned SHA server-side
+ # (same contract as the dashboard endpoint). ``catalog_name``
+ # alone is enough — identifier may be empty.
+ catalog_name = str(params.get("catalog_name") or "").strip()
+ if not ident and not catalog_name:
return _err(
- rid, 4019, "plugins.install requires 'identifier' or 'repo'"
+ rid, 4019,
+ "plugins.install requires 'identifier', 'repo', or 'catalog_name'",
)
result = dashboard_install_plugin(
ident,
force=bool(params.get("force")),
enable=params.get("enable", True),
+ catalog_name=catalog_name or None,
)
if not result.get("ok"):
return _err(rid, 5026, result.get("error") or "install failed")
diff --git a/website/scripts/extract-plugins.py b/website/scripts/extract-plugins.py
index 72848de765..506e49d3be 100644
--- a/website/scripts/extract-plugins.py
+++ b/website/scripts/extract-plugins.py
@@ -111,6 +111,7 @@ def load_catalog_entries(catalog_dir: Path) -> list[dict]:
"shaShort": sha[:7],
"tier": tier,
"maintainer": str(raw.get("maintainer") or "").strip(),
+ "subdir": str(raw.get("subdir") or "").strip(),
"requiresHermes": str(raw.get("requires_hermes") or "").strip(),
"platforms": _str_list(raw.get("platforms")),
"capabilities": _normalize_capabilities(raw.get("capabilities")),
diff --git a/website/src/pages/plugins/index.tsx b/website/src/pages/plugins/index.tsx
index f1bfbc80f5..134a0e3409 100644
--- a/website/src/pages/plugins/index.tsx
+++ b/website/src/pages/plugins/index.tsx
@@ -18,6 +18,7 @@ interface CatalogPlugin {
shaShort: string;
tier: string;
maintainer: string;
+ subdir?: string;
requiresHermes?: string;
platforms?: string[];
capabilities?: PluginCapabilities;
@@ -140,12 +141,15 @@ function PluginCard({
query,
expanded,
onToggle,
+ onPick,
style,
}: {
plugin: CatalogPlugin;
query: string;
expanded: boolean;
onToggle: () => void;
+ /** Picker embed mode: render "+ Add to this Agent" and call this. */
+ onPick?: (plugin: CatalogPlugin) => void;
style?: React.CSSProperties;
}) {
const tier = TIER_CONFIG[plugin.tier] || TIER_CONFIG.community;
@@ -213,6 +217,18 @@ function PluginCard({
))}