diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index 60529ecc4a9..9670a1d83b1 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -2761,106 +2761,27 @@ _PLATFORMS = [ ], }, ] - - -def _load_bundled_platform_plugins_for_enumeration() -> set[str]: - """Force-load bundled platform plugins so they appear in setup menus. - - Platform plugins under ``plugins/platforms/`` are opt-in via - ``plugins.enabled`` like every other plugin, but we want them listed in - ``hermes gateway setup`` even when disabled so users can discover and - enable them inline. ``register()`` on a platform plugin only populates - the registry — no adapters run, no network I/O — so loading it here is - side-effect-free for the short-lived setup process. - - **Contract:** Platform plugin ``register()`` functions MUST NOT register - tools, hooks, or start background threads. They should only call - ``ctx.register_platform()`` to populate the platform registry. Violating - this contract will cause side effects (tool registration, hook firing) - during setup menu rendering even when the plugin is disabled. - - Returns the set of plugin names that were force-loaded (i.e. plugins - not in ``plugins.enabled``), so the caller can display a hint and - auto-enable them on selection. - """ - try: - import yaml as _yaml - except ImportError: - return set() - - from hermes_cli.plugins import ( - get_bundled_plugins_dir, - get_plugin_manager, - PluginManifest, - ) - - manager = get_plugin_manager() - platforms_dir = get_bundled_plugins_dir() / "platforms" - if not platforms_dir.is_dir(): - return set() - - disabled_plugin_names: set[str] = set() - for child in sorted(platforms_dir.iterdir()): - if not child.is_dir(): - continue - manifest_file = child / "plugin.yaml" - if not manifest_file.exists(): - manifest_file = child / "plugin.yml" - if not manifest_file.exists(): - continue - - try: - data = _yaml.safe_load(manifest_file.read_text()) or {} - except Exception as e: - logger.debug("failed to parse %s: %s", manifest_file, e) - continue - plugin_name = data.get("name", child.name) - - existing = manager._plugins.get(plugin_name) - if existing is not None and existing.enabled: - continue # already loaded by normal discovery - - manifest = PluginManifest( - name=plugin_name, - version=str(data.get("version", "")), - description=data.get("description", ""), - author=data.get("author", ""), - requires_env=data.get("requires_env", []), - provides_tools=data.get("provides_tools", []), - provides_hooks=data.get("provides_hooks", []), - source="bundled", - path=str(child), - ) - try: - manager._load_plugin(manifest) - except Exception as e: - logger.debug("failed to force-load %s: %s", plugin_name, e) - continue - disabled_plugin_names.add(plugin_name) - - return disabled_plugin_names - - def _all_platforms() -> list[dict]: """Return the full list of platforms for setup menus. Combines the built-in ``_PLATFORMS`` with plugin platforms registered via - ``platform_registry``. Plugins are discovered on first call so platforms - like IRC appear in ``hermes setup gateway`` without needing the gateway - to be running. Built-ins keep their dict shape; plugin entries are - adapted to the same shape with ``_registry_entry`` holding the source. + ``platform_registry``. Plugins are discovered on first call so bundled + platforms (like IRC, which auto-load via ``kind: platform``) appear in + ``hermes setup gateway`` without needing the gateway to be running. + Built-ins keep their dict shape; plugin entries are adapted to the same + shape with ``_registry_entry`` holding the source. """ # Populate the registry so plugin platforms are visible. Idempotent. + # Bundled platform plugins (``kind: platform``) auto-load unconditionally, + # so every shipped messaging channel appears in the setup menu by default. + # User-installed platform plugins under ~/.hermes/plugins/ still require + # opt-in via ``plugins.enabled`` (untrusted code). try: from hermes_cli.plugins import discover_plugins discover_plugins() except Exception as e: logger.debug("plugin discovery failed during platform enumeration: %s", e) - # Also surface bundled platform plugins that aren't in `plugins.enabled` - # so the setup menu can offer to enable them. - disabled_plugin_names = _load_bundled_platform_plugins_for_enumeration() - platforms = [dict(p) for p in _PLATFORMS] by_key = {p["key"]: p for p in platforms} @@ -2872,7 +2793,6 @@ def _all_platforms() -> list[dict]: for entry in platform_registry.all_entries(): if entry.name in by_key: continue # built-in already covers it - needs_enable = bool(entry.plugin_name) and entry.plugin_name in disabled_plugin_names platforms.append({ "key": entry.name, "label": entry.label, @@ -2880,7 +2800,6 @@ def _all_platforms() -> list[dict]: "token_var": entry.required_env[0] if entry.required_env else "", "install_hint": entry.install_hint, "_registry_entry": entry, - "needs_enable": needs_enable, }) return platforms @@ -2908,8 +2827,6 @@ def _platform_status(platform: dict) -> str: configured = bool(entry.check_fn()) except Exception: configured = False - if platform.get("needs_enable") and not configured: - return "plugin disabled — select to enable" return "configured" if configured else "not configured" token_var = platform.get("token_var", "") @@ -3895,27 +3812,6 @@ def _builtin_setup_fn(key: str): "wecom": _setup_wecom, "qqbot": _setup_qqbot, }.get(key) - - -def _enable_plugin_for_platform(plugin_name: str, platform_label: str) -> None: - """Add *plugin_name* to ``plugins.enabled`` so it loads on next run.""" - try: - from hermes_cli.plugins_cmd import _get_enabled_set, _save_enabled_set - except Exception as e: - logger.debug("cannot enable plugin %s: %s", plugin_name, e) - return - enabled = _get_enabled_set() - if plugin_name in enabled: - return - enabled.add(plugin_name) - _save_enabled_set(enabled) - print() - print_success( - f"Enabled plugin '{plugin_name}' for {platform_label}. " - "Takes effect on next session." - ) - - def _configure_platform(platform: dict) -> None: """Run the interactive setup flow for a single platform. @@ -3925,12 +3821,11 @@ def _configure_platform(platform: dict) -> None: 3. ``_setup_standard_platform`` when the entry has a ``vars`` schema. 4. Env-var hint fallback for plugins that offer no setup helper. - If the platform is owned by a plugin that isn't in ``plugins.enabled``, - the plugin is added to the allow-list before setup runs. + Bundled platform plugins (e.g. IRC) auto-load, so no plugin enable step + is needed here. User-installed platform plugins under ~/.hermes/plugins/ + must already be in ``plugins.enabled`` before they appear in this menu. """ entry = platform.get("_registry_entry") - if platform.get("needs_enable") and entry is not None and entry.plugin_name: - _enable_plugin_for_platform(entry.plugin_name, entry.label) if entry is not None and entry.setup_fn is not None: entry.setup_fn() @@ -4478,4 +4373,4 @@ def _gateway_command_inner(args): if not supports_systemd_services() and not is_macos(): print("Legacy unit migration only applies to systemd-based Linux hosts.") return - remove_legacy_hermes_units(interactive=not yes, dry_run=dry_run) + remove_legacy_hermes_units(interactive=not yes, dry_run=dry_run) \ No newline at end of file diff --git a/hermes_cli/plugins.py b/hermes_cli/plugins.py index f8b9fb6c0dd..d7913eb9b5c 100644 --- a/hermes_cli/plugins.py +++ b/hermes_cli/plugins.py @@ -170,7 +170,7 @@ def _get_enabled_plugins() -> Optional[set]: # Data classes # --------------------------------------------------------------------------- -_VALID_PLUGIN_KINDS: Set[str] = {"standalone", "backend", "exclusive"} +_VALID_PLUGIN_KINDS: Set[str] = {"standalone", "backend", "exclusive", "platform"} @dataclass @@ -196,6 +196,11 @@ class PluginManifest: # Selection via ``.provider`` config key; the # category's own discovery system handles loading and the # general scanner skips these. + # ``platform``: gateway messaging platform adapter (e.g. IRC). Bundled + # platform plugins auto-load so every shipped platform is + # available out of the box; user-installed platform plugins + # in ~/.hermes/plugins/ still gated by ``plugins.enabled`` + # (untrusted code). kind: str = "standalone" # Registry key — path-derived, used by ``plugins.enabled``/``disabled`` # lookups and by ``hermes plugins list``. For a flat plugin at @@ -705,7 +710,11 @@ class PluginManager: # just work. Selection among them (e.g. which image_gen backend # services calls) is driven by ``.provider`` config, # enforced by the tool wrapper. - if manifest.kind == "backend" and manifest.source == "bundled": + # + # Bundled platform plugins (gateway adapters like IRC) auto-load + # for the same reason: every platform Hermes ships must be + # available out of the box without the user having to opt in. + if manifest.source == "bundled" and manifest.kind in ("backend", "platform"): self._load_plugin(manifest) continue diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index 7e8dddf2306..3933ad8494a 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -2638,10 +2638,14 @@ def _get_section_config_summary(config: dict, section_key: str) -> Optional[str] elif section_key == "gateway": from hermes_cli.gateway import _all_platforms, _platform_status + # Count any non-empty status other than the "not configured" sentinel — + # platforms like WhatsApp ("enabled, not paired"), Matrix ("configured + # + E2EE"), and Signal ("partially configured") all indicate the user + # has already started setup and we shouldn't force the section to rerun. configured = [ _gateway_platform_short_label(plat["label"]) for plat in _all_platforms() - if _platform_status(plat) == "configured" + if _platform_status(plat) and _platform_status(plat) != "not configured" ] if configured: return ", ".join(configured) diff --git a/plugins/platforms/irc/plugin.yaml b/plugins/platforms/irc/plugin.yaml index 632d5b17463..1e3d19f48c2 100644 --- a/plugins/platforms/irc/plugin.yaml +++ b/plugins/platforms/irc/plugin.yaml @@ -1,4 +1,5 @@ name: irc-platform +kind: platform version: 1.0.0 description: > IRC gateway adapter for Hermes Agent. diff --git a/tests/hermes_cli/test_setup_irc.py b/tests/hermes_cli/test_setup_irc.py index 8e3d0fd8ba1..1e5baa5cc0f 100644 --- a/tests/hermes_cli/test_setup_irc.py +++ b/tests/hermes_cli/test_setup_irc.py @@ -17,7 +17,6 @@ def _register_irc_platform(**overrides): Tests run outside the normal plugin-discovery path, so we inject the entry directly into the singleton registry and yield its dict shape. """ - needs_enable = overrides.pop("needs_enable", False) defaults = dict( name="irc", label="IRC", @@ -47,7 +46,6 @@ def _register_irc_platform(**overrides): "token_var": entry.required_env[0] if entry.required_env else "", "install_hint": entry.install_hint, "_registry_entry": entry, - "needs_enable": needs_enable, } @@ -126,42 +124,6 @@ class TestIRCFreshInstallDiscovery: _unregister_irc_platform() -# ── Plugin-disabled flow ──────────────────────────────────────────────────── - - -class TestIRCPluginDisabledFlow: - """When the IRC plugin is disabled, setup offers to enable it.""" - - def test_disabled_plugin_shows_enable_prompt(self, monkeypatch): - """A disabled plugin platform surfaces 'plugin disabled — select to enable'.""" - import hermes_cli.gateway as gateway_mod - - plat = _register_irc_platform(needs_enable=True) - try: - for key in ("IRC_SERVER", "IRC_CHANNEL", "IRC_NICKNAME"): - monkeypatch.delenv(key, raising=False) - - status = gateway_mod._platform_status(plat) - assert "plugin disabled" in status.lower() - assert "select to enable" in status.lower() - finally: - _unregister_irc_platform() - - def test_disabled_but_already_configured_shows_configured(self, monkeypatch): - """If the plugin is disabled but env vars are already present, show 'configured'.""" - import hermes_cli.gateway as gateway_mod - - plat = _register_irc_platform(needs_enable=True) - try: - monkeypatch.setenv("IRC_SERVER", "irc.libera.chat") - monkeypatch.setenv("IRC_CHANNEL", "#hermes") - - status = gateway_mod._platform_status(plat) - assert status == "configured" - finally: - _unregister_irc_platform() - - # ── Interactive setup dispatch ────────────────────────────────────────────── @@ -188,32 +150,6 @@ class TestIRCInteractiveSetup: out = capsys.readouterr().out assert "IRC setup complete!" in out - def test_configure_platform_enables_disabled_plugin_first(self, monkeypatch, capsys, tmp_path): - """If the plugin is disabled, _configure_platform enables it before running setup.""" - import hermes_cli.gateway as gateway_mod - from hermes_cli.config import save_config, load_config - - monkeypatch.setenv("HERMES_HOME", str(tmp_path)) - # Ensure plugins.enabled exists but does NOT include irc_platform - cfg = load_config() - cfg.setdefault("plugins", {})["enabled"] = ["some_other_plugin"] - save_config(cfg) - - calls = [] - - def fake_setup(): - calls.append("setup_called") - - plat = _register_irc_platform(setup_fn=fake_setup, needs_enable=True) - try: - gateway_mod._configure_platform(plat) - finally: - _unregister_irc_platform() - - assert "setup_called" in calls - # Plugin should now be enabled - reloaded = load_config() - assert "irc_platform" in reloaded.get("plugins", {}).get("enabled", []) def test_configure_platform_fallback_when_no_setup_fn(self, monkeypatch, capsys): """A plugin with no setup_fn falls back to env-var instructions.""" diff --git a/tests/hermes_cli/test_setup_openclaw_migration.py b/tests/hermes_cli/test_setup_openclaw_migration.py index 7542c1e977d..e627b619630 100644 --- a/tests/hermes_cli/test_setup_openclaw_migration.py +++ b/tests/hermes_cli/test_setup_openclaw_migration.py @@ -419,7 +419,12 @@ class TestGetSectionConfigSummary: return "disc456" return "" - with patch.object(setup_mod, "get_env_value", side_effect=env_side): + # Also patch gateway module's binding since _platform_status() + # reads from hermes_cli.gateway.get_env_value after the setup + # flows were unified via platform_registry. + import hermes_cli.gateway as gateway_mod + with patch.object(setup_mod, "get_env_value", side_effect=env_side), \ + patch.object(gateway_mod, "get_env_value", side_effect=env_side): result = setup_mod._get_section_config_summary({}, "gateway") assert "Telegram" in result assert "Discord" in result @@ -471,7 +476,9 @@ class TestGetSectionConfigSummary: def env_side(key): return "true" if key == "WHATSAPP_ENABLED" else "" - with patch.object(setup_mod, "get_env_value", side_effect=env_side): + import hermes_cli.gateway as gateway_mod + with patch.object(setup_mod, "get_env_value", side_effect=env_side), \ + patch.object(gateway_mod, "get_env_value", side_effect=env_side): result = setup_mod._get_section_config_summary({}, "gateway") assert result is not None assert "WhatsApp" in result @@ -481,7 +488,9 @@ class TestGetSectionConfigSummary: def env_side(key): return "http://signal.local" if key == "SIGNAL_HTTP_URL" else "" - with patch.object(setup_mod, "get_env_value", side_effect=env_side): + import hermes_cli.gateway as gateway_mod + with patch.object(setup_mod, "get_env_value", side_effect=env_side), \ + patch.object(gateway_mod, "get_env_value", side_effect=env_side): result = setup_mod._get_section_config_summary({}, "gateway") assert result is not None assert "Signal" in result @@ -539,9 +548,18 @@ class TestGetSectionConfigSummary: env_var = plat.get("token_var") if not env_var: continue + # Some platforms require a specific value shape (e.g. WhatsApp + # needs the literal "true"). Use a sentinel that satisfies every + # real validator _platform_status() currently checks. def env_side(key, _target=env_var): - return "x" if key == _target else "" - with patch.object(setup_mod, "get_env_value", side_effect=env_side): + if key != _target: + return "" + if _target == "WHATSAPP_ENABLED": + return "true" + return "x" + import hermes_cli.gateway as gateway_mod + with patch.object(setup_mod, "get_env_value", side_effect=env_side), \ + patch.object(gateway_mod, "get_env_value", side_effect=env_side): result = setup_mod._get_section_config_summary({}, "gateway") expected = setup_mod._gateway_platform_short_label(label) assert result is not None, f"{label} ({env_var}) not recognised"