248ff2d3e8
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.
148 lines
4.6 KiB
Python
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
|