Files
hermes-agent/tests/tui_gateway/test_plugins_manage_install.py
Teknium 248ff2d3e8 feat(desktop): one row per plugin; desktop halves are app-level copies, never profile-scoped
Capabilities → Plugins is now a single table: one row per PACKAGE, with a
Desktop column (this app) and an Agent column (the selected profile). A
package with both halves is one row, never two; the kind badge is inferred
from what it ships (plugin.yaml → agent, plugin.js → desktop).

The desktop half of a unified agent+desktop package no longer loads from the
profile-shaped `plugins/<name>/desktop/` folder. Electron copies that half
into `~/.hermes/desktop-plugins/<name>/` beside a `.hermes-package.json`
marker (package name, source, origin repo/sha) and keeps it in sync: newer
source → re-copy, package uninstalled → copy removed, hand-installed
standalone folder of the same name → never overwritten. The renderer scans
exactly one root, so a pane can never appear, disappear, or re-scope when the
user switches profiles — the same switch reads the same value everywhere.

Why a copy rather than scanning every profile: two profiles can carry the
same package at different SHAs; a scan has to pick one silently. One copy,
one source of truth, stamped with where it came from.

- plugin-packages.ts: pure merge of desktop records + agent rows → rows
- plugins.manage list reports `has_desktop_half` so the pairing is explicit
- Install dialog (local backend): the desktop half is materialised from the
  installed package instead of cloning a second standalone copy; remote
  backends keep the separate clone. Target path now names the real profile
  folder for non-default profiles.
- "Install here": a desktop half whose agent half is missing in the selected
  profile pre-fills the dialog from the marker's origin; disabled with an
  explanation for hand-copied folders with no origin.
- Profile selector moves into the Agent column header; hidden with 1 profile
- Rescan/Update reconcile the copies BEFORE rescanning (ordering bug)
- Drop the dead `agentPluginsRoot` IPC; docs updated (desktop.md, SDK,
  bot-mode.md, hermes-desktop-plugins reference)

Live-dogfooded on a headless Electron with two profiles and a real
file:// git package: install both halves, profile switch ×3, Install here
into the second profile, v2 update via `hermes plugins update` → chip text
changes on Rescan, uninstall from both profiles → copy and row gone, broken
plugin row, cold restart, sash drag/reset, legacy Settings → Plugins
redirect.
2026-09-10 08:19:55 -07:00

148 lines
4.6 KiB
Python

"""Gateway plugins.manage install action."""
from unittest.mock import patch
from tui_gateway import server
def test_plugins_manage_install_success():
payload = {
"ok": True,
"plugin_name": "hello-world",
"warnings": [],
"missing_env": [],
"after_install_path": None,
"enabled": True,
}
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",
"repo": "owner/hello-world",
"force": True,
"enable": False,
},
}
)
assert "result" in resp
assert resp["result"]["plugin_name"] == "hello-world"
mock_install.assert_called_once_with(
"owner/hello-world",
force=True,
enable=False,
catalog_name=None,
ref=None,
)
def test_plugins_manage_install_missing_identifier():
resp = server.handle_request(
{
"id": "1",
"method": "plugins.manage",
"params": {"action": "install"},
}
)
assert "error" in resp
assert "identifier" in resp["error"]["message"]
def test_plugins_manage_install_failure():
with patch(
"hermes_cli.plugins_cmd.dashboard_install_plugin",
return_value={"ok": False, "error": "Git clone failed"},
):
resp = server.handle_request(
{
"id": "1",
"method": "plugins.manage",
"params": {
"action": "install",
"identifier": "bad/repo",
},
}
)
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",
ref=None,
)
def test_plugins_manage_update_requires_catalog_sidecar(tmp_path, monkeypatch):
"""Non-catalog installs are refused — their update flows stay CLI-owned."""
import hermes_cli.plugins_cmd as plugins_cmd
plugins_root = tmp_path / "plugins"
(plugins_root / "plain-git-plugin").mkdir(parents=True)
monkeypatch.setattr(plugins_cmd, "_plugins_dir", lambda: plugins_root)
resp = server.handle_request(
{
"id": "1",
"method": "plugins.manage",
"params": {"action": "update", "name": "plain-git-plugin"},
}
)
assert "error" in resp
assert "not a catalog install" in resp["error"]["message"]
def test_plugins_manage_list_reports_desktop_half(tmp_path):
"""A unified package (plugin.yaml + desktop/plugin.js) is reported with ``has_desktop_half`` so the
desktop app can pair its app-level copy of that half with the agent row — one package, ONE row."""
unified = tmp_path / "media"
(unified / "desktop").mkdir(parents=True)
(unified / "desktop" / "plugin.js").write_text("export default {}")
agent_only = tmp_path / "snap"
agent_only.mkdir()
rows = [
("media", "1.0", "Media", "user", unified, "media"),
("snap", "1.0", "Snap", "user", agent_only, "snap"),
]
with patch("hermes_cli.plugins_cmd._discover_all_plugins", return_value=rows), \
patch("hermes_cli.plugins_cmd._get_enabled_set", return_value=set()), \
patch("hermes_cli.plugins_cmd._get_disabled_set", return_value=set()), \
patch("hermes_cli.plugins_cmd_catalog.catalog_pins", return_value={}):
resp = server.handle_request({"id": "1", "method": "plugins.manage", "params": {"action": "list"}})
by_name = {r["name"]: r for r in resp["result"]["plugins"]}
assert by_name["media"]["has_desktop_half"] is True
assert by_name["snap"]["has_desktop_half"] is False