feat(plugins): one name resolver for every verb; one switch turns a unified package on

- hermes_cli: _find_plugin_entry is the single resolver (key / manifest name / unique leaf /
  catalog name via the installer-owned record). enable/disable/show/capabilities/update/remove,
  the dashboard and plugins.manage settings all go through it, so the catalog name you
  installed with keeps working (17 catalog entries install under another manifest name).
  _catalog_install_on_disk reuses the same catalog lookup.
- Desktop hub: join agent rows to desktop halves by package folder (what Electron stamps),
  not manifest name, so a self-cloned package is one row instead of two.
- Desktop: turning a unified package on (install dialog 'Enable after install' or the Agent
  switch) also turns its opt-in desktop half on, unless the user switched it off. Off stays
  per half. Capability consent is untouched.
- docs: plugin-catalog page.
This commit is contained in:
teknium1 authored and Teknium committed 2026-10-08 04:12:02 -07:00
1 parent 1748b89455
commit 8f463316fd
13 files changed
+277 -40

No files matched your search

@@ -36,6 +36,28 @@ describe('mergePluginPackages', () => {
expect(rows[0].agent?.name).toBe('hermes-media-studio')
})
it('pairs a desktop half with an agent row whose folder differs from its manifest name', () => {
// Electron names the half after the package FOLDER (plugins/<folder>/desktop);
// the agent row is named from its manifest. `hermes plugins install` lands a
// package at plugins/<manifest name>, but a self-cloned one (the README's
// `git clone … ~/.hermes/plugins/hermes-subscription-meter`) keeps the repo
// name. The join key has to be the folder or it splits into two rows.
const rows = mergePluginPackages(
[desktop({ id: 'meter-ui', name: 'Meter', packageName: 'hermes-subscription-meter' })],
[
agent({
name: 'subscription-meter',
has_desktop_half: true,
install_dir: '/home/u/.hermes/plugins/hermes-subscription-meter'
})
]
)
expect(rows).toHaveLength(1)
expect(rows[0]).toMatchObject({ kind: 'both', agentMissingInProfile: false, desktopMissing: false })
expect(rows[0].agent?.name).toBe('subscription-meter')
})
it('a desktop half whose agent half is absent from THIS profile offers the install-here affordance', () => {
const rows = mergePluginPackages([desktop({ id: 'media', packageName: 'hermes-media-studio' })], [])
@@ -49,6 +49,19 @@ export function desktopPackageName(record: PluginRecord): null | string {
const KIND_RANK: Record<PackageKind, number> = { both: 0, agent: 1, desktop: 2 }
const DESKTOP_KIND_RANK: Record<PluginRecord['kind'], number> = { disk: 0, runtime: 1, bundled: 2 }
/** The package folder an agent row lives in: the join key Electron stamps as
* `packageName` (it names the desktop half after the top-level
* `plugins/<folder>/`, and a hand-copied folder need not match its manifest
* name). Nested rows (`image_gen/foo`) keep their unique canonical key; the
* manifest name is the fallback for backends that report no `install_dir`. */
function agentPackageName(row: AgentPluginRow): string {
if (row.key?.includes('/')) {
return row.key
}
return row.install_dir?.split(/[\\/]/).filter(Boolean).pop() ?? row.name
}
export function mergePluginPackages(
desktopRecords: readonly PluginRecord[],
agentRows: readonly AgentPluginRow[]
@@ -56,7 +69,7 @@ export function mergePluginPackages(
const byKey = new Map<string, PluginPackage>()
for (const row of agentRows) {
const key = row.name
const key = agentPackageName(row)
byKey.set(key, {
key,
name: row.name,
@@ -299,6 +299,58 @@ describe('PluginsTab', () => {
)
})
it('turns a unified package on with ONE switch: Agent on also turns its desktop half on', async () => {
publishPlugin(
{ id: 'meter-ui', name: 'Meter', kind: 'disk', status: 'disabled', packageName: 'meter' },
{ activate: () => patchPlugin('meter-ui', { status: 'loaded' }), deactivate: () => undefined }
)
$agentPlugins.set([
{ description: '', key: 'meter', name: 'meter', source: 'git', status: 'not enabled', version: '1' }
])
requestGateway.mockImplementation((async (_method: string, params?: { action?: string }) =>
params?.action === 'toggle'
? { ok: true, plugin: { key: 'meter', name: 'meter', status: 'enabled' } }
: { plugins: [] }) as never)
render(<PluginsTab profile={null} />)
screen.getByRole('switch', { name: 'Agent: Meter' }).click()
await waitFor(() =>
expect(screen.getByRole('switch', { name: 'Desktop: Meter' }).getAttribute('aria-checked')).toBe('true')
)
expect($pluginDecisions.get()['meter-ui']).toBe(true)
requestGateway.mockImplementation(async () => ({ plugins: [] }))
$pluginDecisions.set({})
})
it('leaves a desktop half the user switched off alone when its agent half is turned on', async () => {
$pluginDecisions.set({ 'meter-ui': false })
publishPlugin(
{ id: 'meter-ui', name: 'Meter', kind: 'disk', status: 'disabled', packageName: 'meter' },
{ activate: () => patchPlugin('meter-ui', { status: 'loaded' }), deactivate: () => undefined }
)
$agentPlugins.set([
{ description: '', key: 'meter', name: 'meter', source: 'git', status: 'not enabled', version: '1' }
])
requestGateway.mockImplementation((async (_method: string, params?: { action?: string }) =>
params?.action === 'toggle'
? { ok: true, plugin: { key: 'meter', name: 'meter', status: 'enabled' } }
: { plugins: [] }) as never)
render(<PluginsTab profile={null} />)
screen.getByRole('switch', { name: 'Agent: Meter' }).click()
await waitFor(() =>
expect(requestGateway).toHaveBeenCalledWith('plugins.manage', expect.objectContaining({ action: 'toggle' }))
)
expect($pluginDecisions.get()['meter-ui']).toBe(false)
expect(screen.getByRole('switch', { name: 'Desktop: Meter' }).getAttribute('aria-checked')).toBe('false')
requestGateway.mockImplementation(async () => ({ plugins: [] }))
$pluginDecisions.set({})
})
it('renders keyless rows read-only (no name-addressed toggle RPC)', () => {
// Name-addressed toggles flip every same-named plugin across category
// dirs — pre-contract-v6 rows must never reach the RPC.
@@ -15,7 +15,7 @@ import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Switch } from '@/components/ui/switch'
import { Tip } from '@/components/ui/tooltip'
import { $pluginRecords, type PluginRecord, setPluginEnabled } from '@/contrib/plugins-store'
import { $pluginRecords, enablePackageDesktopHalf, type PluginRecord, setPluginEnabled } from '@/contrib/plugins-store'
import { useContributions } from '@/contrib/react/use-contributions'
import { discoverRuntimePlugins, uninstallDiskPlugin } from '@/contrib/runtime-loader'
import { pluginSettingsRouteHref, SETTINGS_PLUGINS_AREA } from '@/contrib/settings-pages'
@@ -753,7 +753,17 @@ export const PluginsTab = memo(function PluginsTab({
return
}
void toggleAgentPlugin(requestGateway, row.key, enable, p.toggleFailed(row.name), scope)
void toggleAgentPlugin(requestGateway, row.key, enable, p.toggleFailed(row.name), scope).then(ok => {
// Turning a unified package on turns its desktop half on too
// (unless the user switched that half off on purpose).
// Off stays per half: the desktop half is app-wide, and
// another profile may still run the agent half.
const half = pkg.desktop?.packageName
if (ok && enable && half) {
void enablePackageDesktopHalf(half, { keepUserChoice: true })
}
})
}}
onAgentUpdate={row => {
const finish = (outcome: AgentPluginUpdateOutcome) => {
@@ -18,6 +18,7 @@ vi.mock('@/hermes', async importOriginal => ({
getProfiles: async () => ({ profiles: [] })
}))
import { $pluginDecisions, $pluginRecords, dropPlugin, patchPlugin, publishPlugin } from '@/contrib/plugins-store'
import { queryClient } from '@/lib/query-client'
import {
$pluginInstallRequest,
@@ -227,6 +228,36 @@ describe('Unified package desktop half on a local backend', () => {
expect(reconcileDesktopPlugins).not.toHaveBeenCalled()
})
it('turns the desktop half on with the agent half: one "Enable after install" covers the package', async () => {
// The half lands opt-in (marker => defaultEnabled false). Before, the
// dialog enabled only the agent half and the Desktop switch stayed off.
$connection.set({ mode: 'local' } as NonNullable<ReturnType<typeof $connection.get>>)
probePluginRepo.mockResolvedValue({ ok: true, agent: true, agentName: 'pkg', desktop: true, warnings: [] })
requestGateway.mockImplementation(async (method, params) =>
method === 'plugins.manage' && params?.action === 'install'
? { ok: true, plugin_name: 'pkg', enabled: true }
: { plugins: [] }
)
reconcileDesktopPlugins.mockImplementation(async () => {
publishPlugin(
{ id: 'pkg-ui', name: 'Pkg', kind: 'disk', status: 'disabled', packageName: 'pkg' },
{ activate: () => patchPlugin('pkg-ui', { status: 'loaded' }), deactivate: () => undefined }
)
return ['/app/desktop-plugins/pkg']
})
vi.stubGlobal('hermesDesktop', { installDesktopPlugin, probePluginRepo, reconcileDesktopPlugins })
renderFlow()
act(() => openPluginInstallRequest({ repo: 'https://github.com/example/pkg' }))
expect(await screen.findByText('This package includes')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Install' }))
await waitFor(() => expect($pluginRecords.get()['pkg-ui']?.status).toBe('loaded'))
expect($pluginDecisions.get()['pkg-ui']).toBe(true)
dropPlugin('pkg-ui')
$pluginDecisions.set({})
})
it('does not start the desktop half or offer a retry when the agent install outcome is unknown', async () => {
$connection.set({ mode: 'remote' } as NonNullable<ReturnType<typeof $connection.get>>)
requestGateway.mockImplementation(async (method, params) => {
@@ -18,12 +18,19 @@ import {
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
import { enablePackageDesktopHalf } from '@/contrib/plugins-store'
import { discoverRuntimePlugins } from '@/contrib/runtime-loader'
import { useI18n } from '@/i18n'
import { ExternalLink } from '@/lib/external-link'
import { AlertTriangle } from '@/lib/icons'
import { resolvePluginSourceLinks } from '@/lib/plugin-source-urls'
import { type AgentPluginLiveNow, COMMIT_SHA_RE, installAgentPlugin, loadAgentPlugins } from '@/store/agent-plugins'
import {
type AgentPluginInstallResult,
type AgentPluginLiveNow,
COMMIT_SHA_RE,
installAgentPlugin,
loadAgentPlugins
} from '@/store/agent-plugins'
import { notify } from '@/store/notifications'
import {
$pluginInstallRequest,
@@ -51,6 +58,16 @@ function installOutcome(m: InstallModalCopy, live: AgentPluginLiveNow, nextChat:
]
}
/** One "Enable after install" covers both halves of a unified package: its
* desktop half lands opt-in (it matches an inert agent half), so an install
* that enabled the agent half turns the desktop half on too. Before, the
* user had to find and flip the row's Desktop switch afterwards. */
async function enableInstalledDesktopHalf(result?: AgentPluginInstallResult): Promise<void> {
if (result?.enabled && result.pluginName) {
await enablePackageDesktopHalf(result.pluginName)
}
}
export function PluginInstallModal() {
const request = useStore($pluginInstallRequest)
const { t } = useI18n()
@@ -223,6 +240,7 @@ export function PluginInstallModal() {
const errors: string[] = []
const successes: string[] = []
let agentInstalled = false
let agentResult: AgentPluginInstallResult | undefined
let live: AgentPluginLiveNow = { mcpServers: [], skills: [] }
try {
@@ -244,6 +262,7 @@ export function PluginInstallModal() {
].join(' · ')
)
agentInstalled = true
agentResult = result
live = result.live
if (result.missingEnv?.length) {
@@ -314,6 +333,8 @@ export function PluginInstallModal() {
}
}
}
await enableInstalledDesktopHalf(agentResult)
}
await loadAgentPlugins(requestGateway, targetProfile)
+19
View File
@@ -111,6 +111,25 @@ export function dropPlugin(id: string): void {
handles.delete(id)
}
/** Turn on the desktop half of unified package *packageName*. A half ships
* opt-in to match its inert agent half, so turning the package on (the
* install dialog's "Enable after install", the hub's Agent switch) turns the
* half on too instead of leaving a second switch to find. *keepUserChoice*
* leaves a half the user explicitly switched off alone. */
export async function enablePackageDesktopHalf(packageName: string, { keepUserChoice = false } = {}): Promise<void> {
const decisions = $pluginDecisions.get()
for (const record of Object.values($pluginRecords.get())) {
if (record.packageName !== packageName || record.status !== 'disabled') {
continue
}
if (!keepUserChoice || !(record.id in decisions)) {
await setPluginEnabled(record.id, true)
}
}
}
/** Live toggle: deactivate + remember, or forget + reactivate. */
export async function setPluginEnabled(id: string, enabled: boolean): Promise<void> {
saveDecisions({ ...$pluginDecisions.get(), [id]: enabled })
+46 -15
View File
@@ -453,7 +453,9 @@ def _require_installed_plugin(name: str, plugins_dir: Path, console) -> Path:
except ValueError as e:
_fail(console, f"[red]Error:[/red] {e}")
if not target.exists():
_fail(console, _unknown_plugin_message(name, downloaded_only=True))
target = _resolved_user_plugin_dir(name)
if target is None:
_fail(console, _unknown_plugin_message(name, downloaded_only=True))
return target
@@ -645,22 +647,43 @@ def _resolve_plugin_key(name: str) -> Optional[str]:
return resolved[0] if resolved else None
def _catalog_installed_dir(name: str) -> Optional[Path]:
"""Install dir whose installer-owned record names catalog entry *name* (``hermes-memory-wiki`` is
installed as ``plugins/memory-wiki``), else None (also when two dirs claim it). Read through
:func:`catalog_install_record` (``.install-metadata.json``), never the tree, so a URL-installed
repo cannot claim a catalog entry's name."""
from hermes_cli.plugins_cmd_catalog import catalog_install_record
try:
keys = list(_read_install_metadata())
except PluginOperationError:
return None
hits = [target for key in keys if (target := _plugins_dir() / key).is_dir()
and (record := catalog_install_record(target)) and record["catalog_name"] == name]
return hits[0] if len(hits) == 1 else None
def _find_plugin_entry(name: str) -> Optional[tuple]:
"""First discovered ``(name, version, description, source, dir_path, key)`` entry whose
manifest name or canonical key equals *name*."""
return next((entry for entry in _discover_all_plugins() if name in (entry[0], entry[5])), None)
"""THE plugin name resolver for every verb (enable/disable/show/capabilities/remove/update, the
dashboard and the Desktop hub): the discovered ``(name, version, description, source, dir_path, key)``
entry for a canonical key or manifest name; else a bare leaf (``langfuse`` ->
``observability/langfuse``) only when unique; else the catalog name the plugin was installed under.
Never guesses between two candidates."""
entries = _discover_all_plugins()
exact = next((entry for entry in entries if name in (entry[0], entry[5])), None)
if exact is not None:
return exact
leaf_matches = [entry for entry in entries if name == entry[5].split("/")[-1]]
if leaf_matches:
return leaf_matches[0] if len(leaf_matches) == 1 else None
target = _catalog_installed_dir(name)
return next((entry for entry in entries if target is not None and entry[4] and Path(str(entry[4])) == target),
None)
def _resolve_plugin_key_and_source(name: str) -> Optional[tuple]:
"""Resolve *name* to ``(canonical_key, source)`` or ``None``. Exact key/manifest-name match
first; then a bare leaf match (``langfuse`` -> ``observability/langfuse``) only when unique,
so a same-named nested plugin is never picked silently."""
entries = _discover_all_plugins()
for entry in entries:
if name in (entry[0], entry[5]):
return (entry[5], entry[3])
leaf_matches = [(entry[5], entry[3]) for entry in entries if name == entry[5].split("/")[-1]]
return leaf_matches[0] if len(leaf_matches) == 1 else None
"""Resolve *name* to ``(canonical_key, source)`` or ``None`` through :func:`_find_plugin_entry`."""
entry = _find_plugin_entry(name)
return (entry[5], entry[3]) if entry else None
def cmd_enable(name: str, allow_tool_override: Optional[bool] = None) -> None:
@@ -974,12 +997,20 @@ def dashboard_set_agent_plugin_enabled(name: str, *, enabled: bool) -> dict[str,
def _user_installed_plugin_dir(name: str) -> Optional[Path]:
"""Resolved path under ``~/.hermes/plugins/<name>`` if it exists."""
"""Resolved path under ``~/.hermes/plugins/<name>`` if it exists, else the user install
:func:`_find_plugin_entry` resolves *name* to (its manifest or catalog name)."""
try:
target = _sanitize_plugin_name(name, _plugins_dir(), allow_subdir=True)
except ValueError:
return None
return target if target.is_dir() else None
return target if target.is_dir() else _resolved_user_plugin_dir(name)
def _resolved_user_plugin_dir(name: str) -> Optional[Path]:
"""Dir of the user install :func:`_find_plugin_entry` resolves *name* to (manifest or catalog name)."""
entry = _find_plugin_entry(name)
found = Path(str(entry[4])) if entry and entry[3] in ("user", "git") and entry[4] else None
return found if found is not None and found.is_dir() else None
def cmd_plugin_doctor(target: str = ".", *, ci: bool = False) -> None:
+5
View File
@@ -107,6 +107,11 @@ def cmd_capabilities(name: Optional[str] = None) -> None:
plugin_capability_granted,
)
console = _pc()._console()
if name is not None:
hit = _pc()._find_plugin_entry(name)
if hit is None:
_pc()._fail(console, _pc()._unknown_plugin_message(name))
name = hit[5] or hit[0]
rows = []
for entry in _pc()._discover_all_plugins():
key = entry[5] or entry[0]
+8 -12
View File
@@ -722,18 +722,14 @@ def _catalog_install_on_disk(catalog_name: str, ref: Optional[str]) -> Optional[
Like ``hermes plugins enable``, the tree is used at the commit it has; an explicit *ref* only
matches a tree checked out at that commit."""
from hermes_cli.plugins_cmd_catalog import catalog_install_record
enabled = _pc()._get_enabled_set()
for key in _pc()._read_install_metadata():
target = _pc()._plugins_dir() / key
record = catalog_install_record(target) if target.is_dir() else None
if not record or record["catalog_name"] != catalog_name:
continue
if ref and str(record["sha"]).lower() != ref.lower():
return None
manifest = _pc()._read_manifest(target)
installed_name = manifest.get("name") or target.name
return None if {installed_name, target.name} & enabled else (target, manifest, installed_name)
return None
target = _pc()._catalog_installed_dir(catalog_name)
if target is None:
return None
if ref and str(catalog_install_record(target)["sha"]).lower() != ref.lower():
return None
manifest = _pc()._read_manifest(target)
installed_name = manifest.get("name") or target.name
return None if {installed_name, target.name} & _pc()._get_enabled_set() else (target, manifest, installed_name)
def _resolve_source(identifier: str, catalog_name: Optional[str]) -> tuple:
@@ -134,6 +134,32 @@ def test_kill_list_blocks_cli_dashboard_and_tui_paths(world, monkeypatch):
cat.resolved_removed_entries()) == "malware"
def test_the_catalog_name_you_installed_with_works_for_every_later_verb(world, tmp_path):
"""17 catalog entries install under a different manifest name (`hermes-memory-wiki` lands as
`plugins/memory-wiki`). Every later verb must accept the name the user typed at install, resolved
through the installer's own record; a URL install shipping a forged sidecar must not claim it."""
repo = world["repo"]
(repo / "plugin.yaml").write_text("name: renamed-plugin\nversion: 1.0.0\ndescription: d\n")
world["state"]["pin"] = _commit(repo, "manifest name differs from catalog name")
forged = json.dumps({"catalog_name": "cat-plugin", "tier": "official", "sha": "0" * 40, "repo": "x"})
_install_url(tmp_path / "evil", "evil-plugin", {cat.CATALOG_SIDECAR: forged})
assert pc._find_plugin_entry("cat-plugin") is None
pc.cmd_install("cat-plugin", enable=False)
target = world["plugins_dir"] / "renamed-plugin"
assert target.is_dir() and pc._get_enabled_set() == set()
pc.cmd_enable("cat-plugin")
assert pc._get_enabled_set() == {"renamed-plugin"}
pc.cmd_show("cat-plugin")
pc.cmd_capabilities("cat-plugin")
pc.cmd_disable("cat-plugin")
assert pc._get_disabled_set() == {"renamed-plugin"}
assert pc.dashboard_set_agent_plugin_enabled("cat-plugin", enabled=True)["name"] == "renamed-plugin"
assert pc.dashboard_update_user_plugin("cat-plugin")["ok"] is True
assert pc.dashboard_remove_user_plugin("cat-plugin")["ok"] is True
assert not target.exists()
assert "renamed-plugin" not in pc._get_enabled_set()
def test_custom_install_records_an_anonymous_extension_install_and_reinstall_none(world, monkeypatch):
+1 -1
View File
@@ -1941,7 +1941,7 @@ def _plugins_settings(rid, params):
if not key or not isinstance(values, dict):
return _err(rid, 4019, "plugins.settings requires a 'key' and a 'values' mapping")
pc = _tools_mod("hermes_cli.plugins_cmd")
found = next((p for p in pc._discover_all_plugins() if key in (p[5], p[0])), None)
found = pc._find_plugin_entry(key)
if found is None:
return _err(rid, 4020, f"plugin '{key}' not found")
_name, _version, _desc, _source, plugin_dir, canonical = found
@@ -139,20 +139,31 @@ hermes plugins enable <name>
```
The install prompt shows the entry's capability summary — declared tools,
hooks, and required env vars — before anything is cloned.
hooks, and required env vars — before anything is cloned. In a terminal it
then asks "Enable now?", so the two commands above are one step when you
answer yes (`--enable` / `--no-enable` answer it for scripts).
The catalog name and the plugin's own manifest name can differ; `hermes
plugins install` prints the installed name, and `enable` takes that one. For
example the `touchdesigner` entry (a portable Agent Plugins v1 package that
bundles the twozero MCP server with the `touchdesigner-mcp` skill) installs as
`td`, kept short so its generated MCP tool names stay under provider
function-name limits:
The catalog name and the plugin's own manifest name can differ. Every later
command (`enable`, `disable`, `show`, `capabilities`, `update`, `remove`)
accepts either one: the catalog name resolves through the install record
Hermes wrote when it cloned the entry, never through a file in the plugin's
own repo. For example the `touchdesigner` entry (a portable Agent Plugins v1
package that bundles the twozero MCP server with the `touchdesigner-mcp`
skill) installs as `td`, kept short so its generated MCP tool names stay
under provider function-name limits:
```bash
hermes plugins install touchdesigner
hermes plugins enable td
hermes plugins enable touchdesigner # same as: hermes plugins enable td
```
In the Desktop app a package with both an agent half and a Desktop half
(`desktop/plugin.js`) is one row with one switch per half. Turning the
package on (the install dialog's **Enable agent plugin after install**, or
the row's **Agent** switch) turns its Desktop half on as well, unless you
switched that half off yourself. Turning it off stays per half, because the
Desktop half is shared by every profile.
Portable packages can also carry a stdio MCP server. The `snyk` entry pins the
Snyk CLI (`npx -y snyk@<version> mcp`) and bundles the `snyk-security-scan`
skill, so one install gives Hermes code, dependency, container and IaC scanning