From 76ff06074817cd4359160443b6ffa3f7e8a3a0b7 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 17 Aug 2026 16:01:39 -0700 Subject: [PATCH] =?UTF-8?q?fix(desktop):=20clean=20up=20Plugins=20settings?= =?UTF-8?q?=20=E2=80=94=20drop=20Example=20Plugin,=20hide=20bundled=20buil?= =?UTF-8?q?t-ins,=20add=20profile=20scoping?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove the bundled Example Plugin from the desktop renderer plugins (apps/desktop/src/plugins/example/). Reference/demo plugins live in the companion hermes-example-plugins repo (already pointed to by src/plugins/README.md); shipping the counter demo in everyone's Settings doubled UI noise for no user value. - Agent plugins section now hides ALL repo-bundled built-ins (source === 'bundled': browser/browserbase, cron_providers/chronos, model-providers/deepinfra, platform adapters, image/video backends, …). The section is the control panel for plugins the user installed (user/git/project/pip/portable); built-ins ship enabled-by-default and are configured from their own surfaces. The HIDDEN_KEY_PREFIXES list stays as a fallback for older backends. Count pill reflects the filtered list. - Add an "Applies to" profile scope selector to the Agent plugins section (same pattern as the Capabilities scope selector): list/toggle any profile's plugins without switching the whole app. Backend: plugins.manage now accepts an optional `profile` param via the same set_hermes_home_override contract as cron.manage / mcp.servers.*; unscoped calls are unchanged, so older backends keep working. - i18n: new settings.plugins.agent.appliesTo key (types + en + zh; other locales fall back through defineLocale). - Tests: plugins-settings.test.tsx covers bundled hiding, prefix fallback, count pill, selector visibility, scoped list/toggle payloads; new tests/test_plugins_manage_profile_scope.py mirrors the cron.manage profile-scope tests (scoped read, unknown-profile 4064, no override leak, unscoped contract unchanged). --- .../app/settings/plugins-settings.test.tsx | 137 +++++++++++++++++- .../src/app/settings/plugins-settings.tsx | 87 +++++++++-- apps/desktop/src/i18n/en.ts | 3 +- apps/desktop/src/i18n/types.ts | 1 + apps/desktop/src/i18n/zh.ts | 3 +- apps/desktop/src/plugins/example/plugin.tsx | 126 ---------------- apps/desktop/src/store/agent-plugins.ts | 65 +++++++-- tests/test_plugins_manage_profile_scope.py | 74 ++++++++++ tui_gateway/methods_tools.py | 9 ++ 9 files changed, 346 insertions(+), 159 deletions(-) delete mode 100644 apps/desktop/src/plugins/example/plugin.tsx create mode 100644 tests/test_plugins_manage_profile_scope.py diff --git a/apps/desktop/src/app/settings/plugins-settings.test.tsx b/apps/desktop/src/app/settings/plugins-settings.test.tsx index d4d1651e01..917e7e093a 100644 --- a/apps/desktop/src/app/settings/plugins-settings.test.tsx +++ b/apps/desktop/src/app/settings/plugins-settings.test.tsx @@ -1,13 +1,25 @@ +import { QueryClientProvider } from '@tanstack/react-query' import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { requestGateway } = vi.hoisted(() => ({ requestGateway: vi.fn() })) +const { requestGateway, getProfiles } = vi.hoisted(() => ({ + requestGateway: vi.fn(), + getProfiles: vi.fn<() => Promise<{ profiles: { name: string; is_default: boolean }[] }>>(async () => ({ + profiles: [] + })) +})) vi.mock('@/app/gateway/hooks/use-gateway-request', () => ({ useGatewayRequest: () => ({ requestGateway }) })) +vi.mock('@/hermes', async importOriginal => ({ + ...(await importOriginal>()), + getProfiles +})) + import { $pluginRecords } from '@/contrib/plugins-store' +import { queryClient } from '@/lib/query-client' import { $agentPluginBusy, $agentPlugins, @@ -15,6 +27,7 @@ import { $agentPluginsStatus, type AgentPluginRow } from '@/store/agent-plugins' +import { $activeGatewayProfile } from '@/store/profile' import { $connection, $gatewayState } from '@/store/session' import { PluginsSettings } from './plugins-settings' @@ -27,8 +40,18 @@ const legacyRow = { status: 'disabled' } satisfies AgentPluginRow +const renderSettings = () => + render( + + + + ) + beforeEach(() => { requestGateway.mockReset() + getProfiles.mockReset() + getProfiles.mockResolvedValue({ profiles: [] }) + queryClient.clear() $pluginRecords.set({}) $agentPlugins.set([legacyRow]) $agentPluginsStatus.set('ready') @@ -36,6 +59,7 @@ beforeEach(() => { $agentPluginBusy.set(null) $gatewayState.set('idle') $connection.set(null) + $activeGatewayProfile.set('default') }) afterEach(() => { @@ -45,7 +69,7 @@ afterEach(() => { describe('PluginsSettings', () => { it('renders and searches plugin rows returned without a canonical key', () => { - render() + renderSettings() expect(screen.getByText('Legacy plugin')).toBeTruthy() @@ -58,7 +82,7 @@ describe('PluginsSettings', () => { // Name-addressed toggles flip every same-named plugin across category // dirs (image_gen/fal vs video_gen/fal) — the reason toggles moved to // canonical keys. A pre-contract-v6 row must never reach the RPC. - render() + renderSettings() const toggle = screen.getByRole('switch', { name: 'Enable Legacy plugin' }) @@ -79,7 +103,7 @@ describe('PluginsSettings', () => { $agentPlugins.set([legacyRow, sibling]) - render() + renderSettings() expect(screen.getAllByRole('switch', { name: 'Enable Legacy plugin' })).toHaveLength(2) expect(screen.getByText(sibling.description)).toBeTruthy() @@ -92,7 +116,7 @@ describe('PluginsSettings', () => { $agentPlugins.set([keyedRow]) requestGateway.mockResolvedValue({ ok: true, plugin: { ...keyedRow, status: 'enabled' } }) - render() + renderSettings() fireEvent.click(screen.getByRole('switch', { name: 'Enable Legacy plugin' })) await waitFor(() => @@ -103,4 +127,107 @@ describe('PluginsSettings', () => { }) ) }) + + it('hides repo-bundled built-ins and keeps the count pill in sync', () => { + // The Agent plugins section is the control panel for plugins the USER + // installed — built-ins (browser backends, cron providers, model + // providers…) ship enabled-by-default and are configured elsewhere. + $agentPlugins.set([ + legacyRow, + { ...legacyRow, name: 'browserbase', key: 'browser/browserbase', source: 'bundled' }, + { ...legacyRow, name: 'chronos', key: 'cron_providers/chronos', source: 'bundled' }, + { ...legacyRow, name: 'deepinfra', key: 'model-providers/deepinfra', source: 'bundled' } + ]) + + renderSettings() + + expect(screen.getByText('Legacy plugin')).toBeTruthy() + expect(screen.queryByText('browserbase')).toBeNull() + expect(screen.queryByText('chronos')).toBeNull() + expect(screen.queryByText('deepinfra')).toBeNull() + // Count pill reflects the filtered list, not the raw RPC row count. + expect(screen.getByText('1 installed', { exact: false })).toBeTruthy() + }) + + it('hides legacy other-surface categories even when the backend omits source', () => { + // Older backends may not report source reliably — the key-prefix + // fallback still hides categories other surfaces own. + $agentPlugins.set([{ ...legacyRow, name: 'deepinfra', key: 'model-providers/deepinfra', source: 'user' }]) + + renderSettings() + + expect(screen.queryByText('deepinfra')).toBeNull() + }) + + it('shows no profile selector with a single profile', async () => { + getProfiles.mockResolvedValue({ profiles: [{ name: 'default', is_default: true }] }) + + renderSettings() + + await waitFor(() => expect(getProfiles).toHaveBeenCalled()) + expect(screen.queryByText('Applies to:')).toBeNull() + }) + + it('lists the active profile scope without a profile param and reloads scoped on change', async () => { + getProfiles.mockResolvedValue({ + profiles: [ + { name: 'default', is_default: true }, + { name: 'work', is_default: false } + ] + }) + requestGateway.mockResolvedValue({ plugins: [legacyRow] }) + $gatewayState.set('open') + + renderSettings() + + // Active profile scope: no profile param — older backends unchanged. + await waitFor(() => expect(requestGateway).toHaveBeenCalledWith('plugins.manage', { action: 'list' })) + await waitFor(() => expect(screen.getByText('Applies to:')).toBeTruthy()) + }) + + it('sends toggles through the selected profile scope', async () => { + // jsdom's scrollIntoView is missing/non-functional; Radix Select calls it + // when the dropdown opens. + Element.prototype.scrollIntoView = vi.fn() + + const keyedRow = { ...legacyRow, key: 'image_gen/legacy' } + + getProfiles.mockResolvedValue({ + profiles: [ + { name: 'default', is_default: true }, + { name: 'work', is_default: false } + ] + }) + requestGateway.mockImplementation(async (method: string, params?: Record) => { + if (params?.action === 'list') { + return { plugins: [keyedRow] } + } + + return { ok: true, plugin: { ...keyedRow, status: 'enabled' } } + }) + $gatewayState.set('open') + + renderSettings() + + await waitFor(() => expect(screen.getByText('Applies to:')).toBeTruthy()) + + // Select the non-active profile scope. + fireEvent.click(screen.getByRole('combobox')) + fireEvent.click(await screen.findByText('work')) + + await waitFor(() => + expect(requestGateway).toHaveBeenCalledWith('plugins.manage', { action: 'list', profile: 'work' }) + ) + + fireEvent.click(screen.getByRole('switch', { name: 'Enable Legacy plugin' })) + + await waitFor(() => + expect(requestGateway).toHaveBeenCalledWith('plugins.manage', { + action: 'toggle', + key: 'image_gen/legacy', + enable: true, + profile: 'work' + }) + ) + }) }) diff --git a/apps/desktop/src/app/settings/plugins-settings.tsx b/apps/desktop/src/app/settings/plugins-settings.tsx index ca31ff3838..1c379c7fbc 100644 --- a/apps/desktop/src/app/settings/plugins-settings.tsx +++ b/apps/desktop/src/app/settings/plugins-settings.tsx @@ -1,13 +1,16 @@ import { useStore } from '@nanostores/react' +import { useQuery } from '@tanstack/react-query' import { type ReactNode, useEffect, useState } from 'react' import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request' import { Button } from '@/components/ui/button' import { Codicon } from '@/components/ui/codicon' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Switch } from '@/components/ui/switch' import { Tip } from '@/components/ui/tooltip' import { $pluginRecords, type PluginRecord, setPluginEnabled } from '@/contrib/plugins-store' import { discoverRuntimePlugins } from '@/contrib/runtime-loader' +import { getProfiles } from '@/hermes' import { useI18n } from '@/i18n' import { triggerHaptic } from '@/lib/haptics' import { FolderOpen, Monitor, Package, RefreshCw } from '@/lib/icons' @@ -23,23 +26,32 @@ import { toggleAgentPlugin } from '@/store/agent-plugins' import { notifyError } from '@/store/notifications' +import { $activeGatewayProfile } from '@/store/profile' import { $connection, $gatewayState } from '@/store/session' import { EmptyState, ListRowSkeleton, Pill, SettingsContent, SettingsSection } from './primitives' const KIND_ORDER: Record = { disk: 0, runtime: 1, bundled: 2 } -// User-installed plugins first, bundled last — mirrors `hermes plugins list`. -const SOURCE_ORDER: Record = { user: 0, git: 0, project: 1, entrypoint: 2, bundled: 3 } +// User-installed plugins first — mirrors `hermes plugins list --user`. +const SOURCE_ORDER: Record = { user: 0, git: 0, project: 1, entrypoint: 2 } -// Plugin categories (by registry key prefix) that other surfaces own — same -// curation stance as desktop-slash-commands.ts. dashboard_auth/* only matters -// to `hermes dashboard`; model-providers/* are configured in Settings → -// Models; platforms/* are managed from Messaging. The plugin switch is not -// the user-facing control for any of them, so listing them here is noise. +// Fallback for older backends whose rows predate a reliable `source` field: +// plugin categories (by registry key prefix) that only exist bundled in the +// repo — same curation stance as desktop-slash-commands.ts. const HIDDEN_KEY_PREFIXES = ['dashboard_auth/', 'model-providers/', 'platforms/'] +// This section is the control panel for plugins the USER installed +// (~/.hermes/plugins, git, project, pip entry points, portable packages). +// Repo-bundled built-ins (browser backends, cron providers, model providers, +// platform adapters, image/video backends…) ship enabled-by-default and are +// configured from their own surfaces (Settings → Models, Messaging, `hermes +// tools`…), so listing dozens of them here is pure noise — hide them. const isDesktopRelevant = (row: AgentPluginRow) => { + if (row.source === 'bundled') { + return false + } + const key = row.key return !key || !HIDDEN_KEY_PREFIXES.some(prefix => key.startsWith(prefix)) @@ -129,7 +141,7 @@ function PluginLine({ ) } -function AgentPluginRowView({ row }: { row: AgentPluginRow }) { +function AgentPluginRowView({ row, profile }: { row: AgentPluginRow; profile: string | null }) { const { t } = useI18n() const p = t.settings.plugins const { requestGateway } = useGatewayRequest() @@ -151,7 +163,7 @@ function AgentPluginRowView({ row }: { row: AgentPluginRow }) { } triggerHaptic('selection') - void toggleAgentPlugin(requestGateway, key, on, p.agent.toggleFailed(row.name)) + void toggleAgentPlugin(requestGateway, key, on, p.agent.toggleFailed(row.name), profile) }} /> ) @@ -182,13 +194,39 @@ function AgentPluginsSection() { const error = useStore($agentPluginsError) const [query, setQuery] = useState('') + // 'Applies to' profile scope: which profile's plugins we list/toggle. + // Defaults to the app-wide active profile; overriding it here lets the user + // manage ANY profile's plugins without switching the whole app (same + // pattern as the Capabilities scope selector in app/skills). null = the + // active profile — the RPC is sent without a profile param so older + // backends keep working unchanged. + const activeProfile = useStore($activeGatewayProfile) + const [scopeOverride, setScopeOverride] = useState(null) + const scopeProfile = scopeOverride ?? activeProfile ?? null + // The param we actually send: omit it for the active profile. + const requestProfile = scopeOverride && scopeOverride !== activeProfile ? scopeOverride : null + + const { data: profilesData } = useQuery({ + queryKey: ['agent-plugins-profiles'], + queryFn: getProfiles, + staleTime: 60_000 + }) + + const profiles = profilesData?.profiles ?? [] + + // An app-wide profile switch retargets the default scope — drop the + // override so the list reloads for the profile the user just switched to. + useEffect(() => { + setScopeOverride(null) + }, [activeProfile]) + useEffect(() => { if (gatewayState !== 'open') { return } - void loadAgentPlugins(requestGateway) - }, [gatewayState, requestGateway]) + void loadAgentPlugins(requestGateway, requestProfile) + }, [gatewayState, requestGateway, requestProfile]) const needle = normalize(query) @@ -213,7 +251,30 @@ function AgentPluginsSection() { {p.agent.blurb}

- {connection?.mode !== 'remote' && ( + {profiles.length > 1 && ( +
+ + {p.agent.appliesTo} + + +
+ )} + + {connection?.mode !== 'remote' && !requestProfile && (
- - ) -} - -const plugin: HermesPlugin = { - id: 'example', - name: 'Example Plugin', - defaultEnabled: false, - register(ctx) { - // Persisted count: hydrate once, write through on every change. - $clicks.set(ctx.storage.get('clicks', 0)) - $clicks.listen(clicks => ctx.storage.set('clicks', clicks)) - - // Hear the live gateway stream (deltas, lifecycle, tools — everything). - host.onEvent('*', () => $events.set($events.get() + 1)) - - const reset = () => { - $clicks.set(0) - host.notify({ kind: 'info', message: 'Example plugin: counter reset' }) - } - - // Provenance (source: 'plugin:example') and the namespaced registry ids - // (example:counter, …) are stamped by ctx — authors write plain - // contributions. The shared `example.reset` ACTION id links the palette - // row's hotkey hint to the keybind panel's live binding. - ctx.registerMany([ - { - id: 'counter', - area: STATUSBAR_AREAS.right, - order: 100, - render: () => - }, - { - id: 'reset', - area: PALETTE_AREA, - data: { - id: 'example.reset', - action: 'example.reset', - label: 'Example: Reset click counter', - keywords: ['example', 'plugin', 'counter'], - run: reset - } satisfies PaletteContribution - }, - { - id: 'reset', - area: KEYBINDS_AREA, - data: { - id: 'example.reset', - label: 'Example: Reset click counter', - defaults: [], - run: reset - } satisfies KeybindContribution - } - ]) - } -} - -export default plugin diff --git a/apps/desktop/src/store/agent-plugins.ts b/apps/desktop/src/store/agent-plugins.ts index 505e6b0b3d..fcec7c2210 100644 --- a/apps/desktop/src/store/agent-plugins.ts +++ b/apps/desktop/src/store/agent-plugins.ts @@ -40,29 +40,61 @@ export const $agentPluginsError = atom(null) export const $agentPluginBusy = atom(null) let inflight: Promise | null = null +let inflightProfile: string | null = null +// Bumped per load so a slow response from a previous profile scope can't +// overwrite the newer scope's list (async results can land out of order). +let loadGeneration = 0 -/** Fetch the backend plugin list. Always refetches (it's a cheap local disk - * scan on the backend); concurrent callers share one in-flight request. */ -export function loadAgentPlugins(request: GatewayRequest): Promise { - if (inflight) { +/** Scope a `plugins.manage` payload to a profile. Omitted (null) = the + * backend's launch profile — older backends ignore the extra param. */ +const withProfile = (params: Record, profile?: string | null) => + profile ? { ...params, profile } : params + +/** Fetch the backend plugin list, optionally scoped to another profile's + * HERMES_HOME. Always refetches (it's a cheap local disk scan on the + * backend); concurrent callers for the SAME profile share one in-flight + * request — a different profile starts fresh so a scope switch can't get a + * stale list. */ +export function loadAgentPlugins(request: GatewayRequest, profile?: string | null): Promise { + const scope = profile ?? null + + if (inflight && inflightProfile === scope) { return inflight } + const generation = ++loadGeneration + + inflightProfile = scope inflight = (async () => { if ($agentPluginsStatus.get() !== 'ready') { $agentPluginsStatus.set('loading') } try { - const result = await request<{ plugins?: AgentPluginRow[] }>('plugins.manage', { action: 'list' }) + const result = await request<{ plugins?: AgentPluginRow[] }>( + 'plugins.manage', + withProfile({ action: 'list' }, scope) + ) + + if (generation !== loadGeneration) { + return + } + $agentPlugins.set(result?.plugins ?? []) $agentPluginsStatus.set('ready') $agentPluginsError.set(null) } catch (e) { + if (generation !== loadGeneration) { + return + } + $agentPluginsError.set(e instanceof Error ? e.message : String(e)) $agentPluginsStatus.set('error') } finally { - inflight = null + if (generation === loadGeneration) { + inflight = null + inflightProfile = null + } } })() @@ -80,16 +112,23 @@ export async function toggleAgentPlugin( request: GatewayRequest, key: string, enable: boolean, - failMessage: string + failMessage: string, + profile?: string | null ): Promise { $agentPluginBusy.set(key) try { - const result = await request<{ ok?: boolean; plugin?: AgentPluginRow | null }>('plugins.manage', { - action: 'toggle', - key, - enable - }) + const result = await request<{ ok?: boolean; plugin?: AgentPluginRow | null }>( + 'plugins.manage', + withProfile( + { + action: 'toggle', + key, + enable + }, + profile + ) + ) if (!result?.ok) { throw new Error(failMessage) @@ -100,7 +139,7 @@ export async function toggleAgentPlugin( if (refreshed) { $agentPlugins.set($agentPlugins.get().map(row => (row.key === key ? { ...row, ...refreshed } : row))) } else { - await loadAgentPlugins(request) + await loadAgentPlugins(request, profile) } return true diff --git a/tests/test_plugins_manage_profile_scope.py b/tests/test_plugins_manage_profile_scope.py new file mode 100644 index 0000000000..fd4551a8af --- /dev/null +++ b/tests/test_plugins_manage_profile_scope.py @@ -0,0 +1,74 @@ +"""plugins.manage optional ``profile`` param — per-profile plugins scoping. + +Mirrors ``cron.manage`` / ``mcp.servers.*``: when a ``profile`` is passed the +handler resolves ``get_profile_dir(profile)`` and wraps the action dispatch in +``set_hermes_home_override`` / ``reset_hermes_home_override``. Because +``_plugins_dir()`` keys off ``get_hermes_home()``, the list action must then +scan THAT profile's ``plugins/`` dir, not the launch profile's. +""" + +from tui_gateway import server + + +def test_plugins_manage_profile_reads_that_profiles_dir(tmp_path, monkeypatch): + # A temp profile home with one user plugin in its plugins dir. + profile_home = tmp_path / "profiles" / "botA" + plugin_dir = profile_home / "plugins" / "bota-only-plugin" + plugin_dir.mkdir(parents=True) + (plugin_dir / "plugin.yaml").write_text( + "name: bota-only-plugin\nversion: '1.0'\ndescription: BotA-only plugin\n", + encoding="utf-8", + ) + + # Route the profile name the handler resolves to our temp home. + import hermes_cli.profiles as profiles + + monkeypatch.setattr(profiles, "get_profile_dir", lambda name: profile_home) + + resp = server.handle_request( + { + "id": "1", + "method": "plugins.manage", + "params": {"action": "list", "profile": "botA"}, + } + ) + + assert "result" in resp, resp + user_rows = [ + p for p in resp["result"]["plugins"] if p.get("source") == "user" + ] + assert "bota-only-plugin" in [p["name"] for p in user_rows] + + # The override must not leak: an unscoped call after this one resolves the + # launch profile again. + from hermes_constants import get_hermes_home_override + + assert get_hermes_home_override() is None + + +def test_plugins_manage_unknown_profile_errors(tmp_path, monkeypatch): + import hermes_cli.profiles as profiles + + missing = tmp_path / "profiles" / "ghost" + monkeypatch.setattr(profiles, "get_profile_dir", lambda name: missing) + + resp = server.handle_request( + { + "id": "2", + "method": "plugins.manage", + "params": {"action": "list", "profile": "ghost"}, + } + ) + + assert "error" in resp, resp + assert resp["error"]["code"] == 4064 + + +def test_plugins_manage_unscoped_still_lists(monkeypatch): + # No profile param — the pre-existing contract is unchanged. + resp = server.handle_request( + {"id": "3", "method": "plugins.manage", "params": {"action": "list"}} + ) + + assert "result" in resp, resp + assert "plugins" in resp["result"] diff --git a/tui_gateway/methods_tools.py b/tui_gateway/methods_tools.py index 1e85bba2b3..7eff80ca10 100644 --- a/tui_gateway/methods_tools.py +++ b/tui_gateway/methods_tools.py @@ -2388,8 +2388,15 @@ def _(rid, params: dict) -> dict: status, portable}], "user_count": N, "bundled_count": M} - ``toggle`` → flip ``key`` (or ``name``) based on ``enable`` (bool). Returns the refreshed row plus {"ok", "unchanged"}. + + Accepts an optional ``profile`` param (same contract as mcp.servers.*): + plugins live under each profile's HERMES_HOME, so a client can list or + toggle another profile's plugins without switching the whole app. """ action = params.get("action", "list") + token, err = _mcp_resolve_profile(rid, params) + if err: + return err try: from hermes_cli.plugins_cmd import ( _bundled_default_on, @@ -2476,6 +2483,8 @@ def _(rid, params: dict) -> dict: return _err(rid, 4017, f"unknown plugins action: {action}") except Exception as e: return _err(rid, 5026, str(e)) + finally: + _mcp_reset_profile(token) @method("shell.exec")