fix(desktop): clean up Plugins settings — drop Example Plugin, hide bundled built-ins, add profile scoping
- 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).
This commit is contained in:
@@ -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<Record<string, unknown>>()),
|
||||
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(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<PluginsSettings />
|
||||
</QueryClientProvider>
|
||||
)
|
||||
|
||||
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(<PluginsSettings />)
|
||||
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(<PluginsSettings />)
|
||||
renderSettings()
|
||||
|
||||
const toggle = screen.getByRole('switch', { name: 'Enable Legacy plugin' })
|
||||
|
||||
@@ -79,7 +103,7 @@ describe('PluginsSettings', () => {
|
||||
|
||||
$agentPlugins.set([legacyRow, sibling])
|
||||
|
||||
render(<PluginsSettings />)
|
||||
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(<PluginsSettings />)
|
||||
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<string, unknown>) => {
|
||||
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'
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<PluginRecord['kind'], number> = { disk: 0, runtime: 1, bundled: 2 }
|
||||
|
||||
// User-installed plugins first, bundled last — mirrors `hermes plugins list`.
|
||||
const SOURCE_ORDER: Record<string, number> = { user: 0, git: 0, project: 1, entrypoint: 2, bundled: 3 }
|
||||
// User-installed plugins first — mirrors `hermes plugins list --user`.
|
||||
const SOURCE_ORDER: Record<string, number> = { 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 | string>(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}
|
||||
</p>
|
||||
|
||||
{connection?.mode !== 'remote' && (
|
||||
{profiles.length > 1 && (
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<span className="text-[length:var(--conversation-caption-font-size)] font-medium text-(--ui-text-tertiary)">
|
||||
{p.agent.appliesTo}
|
||||
</span>
|
||||
<Select
|
||||
onValueChange={name => setScopeOverride(name === activeProfile ? null : name)}
|
||||
value={scopeProfile ?? ''}
|
||||
>
|
||||
<SelectTrigger className="h-7 w-56 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{profiles.map(profile => (
|
||||
<SelectItem key={profile.name} value={profile.name}>
|
||||
{profile.is_default ? 'Hermes (default)' : profile.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{connection?.mode !== 'remote' && !requestProfile && (
|
||||
<div className="mb-2 flex items-center gap-3">
|
||||
<Button
|
||||
onClick={() => void revealAgentPluginsDir(requestGateway)}
|
||||
@@ -254,7 +315,7 @@ function AgentPluginsSection() {
|
||||
) : (
|
||||
<div>
|
||||
{sorted.map(row => (
|
||||
<AgentPluginRowView key={agentPluginRowKey(row)} row={row} />
|
||||
<AgentPluginRowView key={agentPluginRowKey(row)} profile={requestProfile} row={row} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -381,7 +381,8 @@ export const en: Translations = {
|
||||
agent: {
|
||||
title: 'Agent plugins',
|
||||
blurb:
|
||||
'Run in the Hermes backend — tools, skills, MCP servers, hooks, and slash commands. Portable ones are Agent Plugins packages (skills + MCP bundles that work in other agents too). Toggles apply to new sessions.',
|
||||
'Plugins you installed into the Hermes backend — tools, skills, MCP servers, hooks, and slash commands. Portable ones are Agent Plugins packages (skills + MCP bundles that work in other agents too). Toggles apply to new sessions.',
|
||||
appliesTo: 'Applies to:',
|
||||
empty: 'No agent plugins installed yet.',
|
||||
loadFailed: 'Could not load agent plugins',
|
||||
portable: 'portable',
|
||||
|
||||
@@ -330,6 +330,7 @@ export interface Translations {
|
||||
agent: {
|
||||
title: string
|
||||
blurb: string
|
||||
appliesTo: string
|
||||
empty: string
|
||||
loadFailed: string
|
||||
portable: string
|
||||
|
||||
@@ -371,7 +371,8 @@ export const zh: Translations = {
|
||||
agent: {
|
||||
title: '智能体插件',
|
||||
blurb:
|
||||
'运行在 Hermes 后端——工具、技能、MCP 服务器、钩子和斜杠命令。「便携」插件是 Agent Plugins 标准包(技能 + MCP 组合,也可在其他智能体中使用)。开关在新会话中生效。',
|
||||
'你安装到 Hermes 后端的插件——工具、技能、MCP 服务器、钩子和斜杠命令。「便携」插件是 Agent Plugins 标准包(技能 + MCP 组合,也可在其他智能体中使用)。开关在新会话中生效。',
|
||||
appliesTo: '应用于:',
|
||||
empty: '尚未安装智能体插件。',
|
||||
loadFailed: '无法加载智能体插件',
|
||||
portable: '便携',
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
/**
|
||||
* Example plugin — the authoring + publishing reference. A folder under
|
||||
* `src/plugins/` with a `plugin.tsx` that default-exports a `HermesPlugin` is
|
||||
* all it takes; `discoverBundledPlugins()` finds and registers it (no import,
|
||||
* no registry edit). Delete this folder and everything below is gone.
|
||||
*
|
||||
* The ONLY import surface is `@hermes/plugin-sdk` (lint-enforced) — the
|
||||
* vscode-module model. This one plugin dogfoods the whole authoring kit:
|
||||
* - `render()` contribution — full stateful React in a statusbar slot;
|
||||
* - `ctx.storage` — the count survives reloads (namespaced persistence);
|
||||
* - `host.onEvent('*')` — live gateway stream, counted in the tooltip;
|
||||
* - PALETTE + KEYBINDS contracts — "Example: Reset click counter" in ⌘K
|
||||
* and as a rebindable (default-unbound) action in the keybind panel;
|
||||
* - plugin-local `atom` + `useValue` — module state, leaf subscription;
|
||||
* - `haptic` / `host.notify` / `Tip` / `cn` — the design language.
|
||||
*
|
||||
* Ships OFF by default (`defaultEnabled: false`): it inventories in
|
||||
* Settings ▸ Plugins and registers nothing until the user flips the switch.
|
||||
*/
|
||||
|
||||
import {
|
||||
atom,
|
||||
cn,
|
||||
haptic,
|
||||
type HermesPlugin,
|
||||
host,
|
||||
type KeybindContribution,
|
||||
KEYBINDS_AREA,
|
||||
PALETTE_AREA,
|
||||
type PaletteContribution,
|
||||
STATUSBAR_AREAS,
|
||||
Tip,
|
||||
useValue
|
||||
} from '@hermes/plugin-sdk'
|
||||
|
||||
const $clicks = atom(0)
|
||||
const $events = atom(0)
|
||||
|
||||
function ClickCounter() {
|
||||
const count = useValue($clicks)
|
||||
const events = useValue($events)
|
||||
const busy = useValue(host.state.busy)
|
||||
const gateway = useValue(host.state.gateway)
|
||||
|
||||
return (
|
||||
<Tip label={`Example plugin: gateway ${gateway}, ${busy ? 'working' : 'idle'}, ${events} events heard`}>
|
||||
<button
|
||||
className={cn(
|
||||
'inline-flex h-full items-center gap-1 rounded-none px-1.5 text-[0.6875rem] tabular-nums transition-colors',
|
||||
'text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground',
|
||||
count > 0 && 'text-foreground'
|
||||
)}
|
||||
onClick={() => {
|
||||
haptic('tap')
|
||||
// Imperative read in the handler ($atom.get()), reactive read in the
|
||||
// render (useValue) — never a stale closure.
|
||||
const next = $clicks.get() + 1
|
||||
$clicks.set(next)
|
||||
|
||||
if (next % 10 === 0) {
|
||||
host.notify({ kind: 'success', message: `Example plugin: ${next} clicks!` })
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<span aria-hidden>◉</span>
|
||||
<span>{count === 0 ? 'click me' : `clicked ${count}×`}</span>
|
||||
</button>
|
||||
</Tip>
|
||||
)
|
||||
}
|
||||
|
||||
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: () => <ClickCounter />
|
||||
},
|
||||
{
|
||||
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
|
||||
@@ -40,29 +40,61 @@ export const $agentPluginsError = atom<string | null>(null)
|
||||
export const $agentPluginBusy = atom<string | null>(null)
|
||||
|
||||
let inflight: Promise<void> | 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<void> {
|
||||
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<string, unknown>, 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<void> {
|
||||
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<boolean> {
|
||||
$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
|
||||
|
||||
74
tests/test_plugins_manage_profile_scope.py
Normal file
74
tests/test_plugins_manage_profile_scope.py
Normal file
@@ -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"]
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user