diff --git a/cron/scheduler.py b/cron/scheduler.py index a9feb051a9..ce2cbb3721 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -341,28 +341,6 @@ def _deliver_result(job: dict, content: str, adapters=None, loop=None) -> Option from tools.send_message_tool import _send_to_platform from gateway.config import load_gateway_config, Platform - # Accept any platform name — built-in names resolve to their enum - # member, plugin platform names create dynamic members via _missing_(). - try: - platform = Platform(platform_name.lower()) - except (ValueError, KeyError): - msg = f"unknown platform '{platform_name}'" - logger.warning("Job '%s': %s", job["id"], msg) - return msg - - try: - config = load_gateway_config() - except Exception as e: - msg = f"failed to load gateway config: {e}" - logger.error("Job '%s': %s", job["id"], msg) - return msg - - pconfig = config.platforms.get(platform) - if not pconfig or not pconfig.enabled: - msg = f"platform '{platform_name}' not configured/enabled" - logger.warning("Job '%s': %s", job["id"], msg) - return msg - # Optionally wrap the content with a header/footer so the user knows this # is a cron delivery. Wrapping is on by default; set cron.wrap_response: false # in config.yaml for clean output. @@ -419,13 +397,23 @@ def _deliver_result(job: dict, content: str, adapters=None, loop=None) -> Option job["id"], platform_name, chat_id, thread_id, ) - platform = platform_map.get(platform_name.lower()) - if not platform: + # Built-in names resolve to their enum member; plugin platform names + # create dynamic members via Platform._missing_(). + try: + platform = Platform(platform_name.lower()) + except (ValueError, KeyError): msg = f"unknown platform '{platform_name}'" logger.warning("Job '%s': %s", job["id"], msg) delivery_errors.append(msg) continue + pconfig = config.platforms.get(platform) + if not pconfig or not pconfig.enabled: + msg = f"platform '{platform_name}' not configured/enabled" + logger.warning("Job '%s': %s", job["id"], msg) + delivery_errors.append(msg) + continue + # Prefer the live adapter when the gateway is running — this supports E2EE # rooms (e.g. Matrix) where the standalone HTTP path cannot encrypt. runtime_adapter = (adapters or {}).get(platform) diff --git a/gateway/platform_registry.py b/gateway/platform_registry.py index 54babf9107..7610e8b434 100644 --- a/gateway/platform_registry.py +++ b/gateway/platform_registry.py @@ -64,9 +64,20 @@ class PlatformEntry: # Hint shown when check_fn returns False. install_hint: str = "" + # Optional setup function for interactive configuration. + # Signature: () -> None (prompts user, saves env vars). + # If None, falls back to _setup_standard_platform (needs token_var + vars) + # or a generic "set these env vars" display. + setup_fn: Optional[Callable[[], None]] = None + # "builtin" or "plugin" source: str = "plugin" + # Name of the plugin manifest that registered this entry (empty for + # built-ins). Used by ``hermes gateway setup`` to auto-enable the + # owning plugin when the user configures its platform. + plugin_name: str = "" + # ── Auth env var names (for _is_user_authorized integration) ── # E.g. "IRC_ALLOWED_USERS" — checked for comma-separated user IDs. allowed_users_env: str = "" diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index 264fbc702c..473013e736 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -2763,13 +2763,141 @@ _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. + + 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. + """ + # Populate the registry so plugin platforms are visible. Idempotent. + 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} + + try: + from gateway.platform_registry import platform_registry + except Exception: + return platforms + + 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, + "emoji": entry.emoji, + "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 + + def _platform_status(platform: dict) -> str: """Return a plain-text status string for a platform. Returns uncolored text so it can safely be embedded in - simple_term_menu items (ANSI codes break width calculation). + curses menu items (ANSI codes break width calculation). """ - token_var = platform["token_var"] + entry = platform.get("_registry_entry") + if entry is not None: + try: + 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", "") + if not token_var: + return "not configured" val = get_env_value(token_var) if token_var == "WHATSAPP_ENABLED": if val and val.lower() == "true": @@ -3727,6 +3855,93 @@ def _setup_signal(): print_info(f" Groups: {'enabled' if get_env_value('SIGNAL_GROUP_ALLOWED_USERS') else 'disabled'}") +def _builtin_setup_fn(key: str): + """Resolve the interactive setup function for a built-in platform key. + + Late-bound to avoid a circular import with ``hermes_cli.setup`` (which + imports from this module for the remaining bespoke flows). + """ + from hermes_cli import setup as _s + return { + "telegram": _s._setup_telegram, + "discord": _s._setup_discord, + "slack": _s._setup_slack, + "matrix": _s._setup_matrix, + "mattermost": _s._setup_mattermost, + "bluebubbles": _s._setup_bluebubbles, + "webhooks": _s._setup_webhooks, + "signal": _setup_signal, + "whatsapp": _setup_whatsapp, + "weixin": _setup_weixin, + "dingtalk": _setup_dingtalk, + "feishu": _setup_feishu, + "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. + + Dispatch order: + 1. Plugin-provided ``setup_fn`` on the registry entry. + 2. Built-in setup function matched by platform key. + 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. + """ + 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() + return + + fn = _builtin_setup_fn(platform["key"]) + if fn is not None: + fn() + return + + if platform.get("vars"): + _setup_standard_platform(platform) + return + + # Plugin with no setup helper — show env-var instructions. + label = platform.get("label", platform["key"]) + emoji = platform.get("emoji", "🔌") + print() + print(color(f" ─── {emoji} {label} Setup ───", Colors.CYAN)) + required = entry.required_env if entry else [] + if required: + print_info(f" Set these env vars in ~/.hermes/.env: {', '.join(required)}") + else: + print_info(f" Configure {label} in config.yaml under gateway.platforms.{platform['key']}") + if platform.get("install_hint"): + print_info(f" {platform['install_hint']}") + + def gateway_setup(): """Interactive setup for messaging platforms + gateway service.""" if is_managed(): @@ -3779,61 +3994,19 @@ def gateway_setup(): print() print_header("Messaging Platforms") - # Build menu from built-in platforms + plugin platforms - _plugin_entries = [] - try: - from gateway.platform_registry import platform_registry - _plugin_entries = platform_registry.plugin_entries() - except Exception: - pass + platforms = _all_platforms() - menu_items = [] - for plat in _PLATFORMS: - status = _platform_status(plat) - menu_items.append(f"{plat['label']} ({status})") - for pentry in _plugin_entries: - configured = pentry.check_fn() - status_str = "configured" if configured else "not configured" - menu_items.append(f"{pentry.emoji} {pentry.label} ({status_str}) [plugin]") + menu_items = [ + f"{p['emoji']} {p['label']} ({_platform_status(p)})" + for p in platforms + ] menu_items.append("Done") - _total_platforms = len(_PLATFORMS) + len(_plugin_entries) choice = prompt_choice("Select a platform to configure:", menu_items, len(menu_items) - 1) - - if choice == _total_platforms: + if choice == len(platforms): break - if choice < len(_PLATFORMS): - platform = _PLATFORMS[choice] - - if platform["key"] == "whatsapp": - _setup_whatsapp() - elif platform["key"] == "signal": - _setup_signal() - elif platform["key"] == "weixin": - _setup_weixin() - elif platform["key"] == "dingtalk": - _setup_dingtalk() - elif platform["key"] == "feishu": - _setup_feishu() - elif platform["key"] == "qqbot": - _setup_qqbot() - elif platform["key"] == "wecom": - _setup_wecom() - else: - _setup_standard_platform(platform) - else: - # Plugin platform — show env var setup instructions - pentry = _plugin_entries[choice - len(_PLATFORMS)] - print(f"\n {pentry.label} (plugin platform)") - if pentry.required_env: - print(f" Required env vars: {', '.join(pentry.required_env)}") - print(f" Set these in ~/.hermes/.env or config.yaml gateway.platforms.{pentry.name}.extra") - else: - print(f" Configure in config.yaml under gateway.platforms.{pentry.name}") - if pentry.install_hint: - print(f" {pentry.install_hint}") - print() + _configure_platform(platforms[choice]) # ── Post-setup: offer to install/restart gateway ── any_configured = any( diff --git a/hermes_cli/plugins.py b/hermes_cli/plugins.py index 991b97aa96..f8b9fb6c0d 100644 --- a/hermes_cli/plugins.py +++ b/hermes_cli/plugins.py @@ -37,6 +37,7 @@ import importlib import importlib.metadata import importlib.util import logging +import os import sys import types from dataclasses import dataclass, field @@ -47,6 +48,19 @@ from hermes_constants import get_hermes_home from utils import env_var_enabled from hermes_cli.config import cfg_get + +def get_bundled_plugins_dir() -> Path: + """Locate the bundled ``plugins/`` directory. + + Honours ``HERMES_BUNDLED_PLUGINS`` (set by the Nix wrapper / packaged + installs) so read-only store paths are consulted first. Falls back to + the in-repo path used during development. + """ + env_override = os.getenv("HERMES_BUNDLED_PLUGINS") + if env_override: + return Path(env_override) + return Path(__file__).resolve().parent.parent / "plugins" + try: import yaml except ImportError: # pragma: no cover – yaml is optional at import time @@ -456,6 +470,7 @@ class PluginContext: validate_config: Callable | None = None, required_env: list | None = None, install_hint: str = "", + **entry_kwargs: Any, ) -> None: """Register a gateway platform adapter. @@ -463,6 +478,10 @@ class PluginContext: ``BasePlatformAdapter`` subclass instance. The gateway calls ``check_fn()`` before instantiation to verify dependencies. + Extra keyword arguments are forwarded to ``PlatformEntry`` (e.g. + ``setup_fn``, ``emoji``, ``allowed_users_env``, ``platform_hint``). + Unknown keys raise TypeError from the dataclass constructor. + Example:: ctx.register_platform( @@ -470,10 +489,13 @@ class PluginContext: label="IRC", adapter_factory=lambda cfg: IRCAdapter(cfg), check_fn=lambda: True, + emoji="💬", + setup_fn=irc_interactive_setup, ) """ from gateway.platform_registry import platform_registry, PlatformEntry + entry_kwargs.setdefault("plugin_name", self.manifest.name) entry = PlatformEntry( name=name, label=label, @@ -483,6 +505,7 @@ class PluginContext: required_env=required_env or [], install_hint=install_hint, source="plugin", + **entry_kwargs, ) platform_registry.register(entry) self._manager._plugin_platform_names.add(name) @@ -613,16 +636,19 @@ class PluginManager: # - category: ``plugins/image_gen/openai/plugin.yaml`` (backend) # # ``memory/`` and ``context_engine/`` are skipped at the top level — - # they have their own discovery systems. Porting those to the - # category-namespace ``kind: exclusive`` model is a future PR. - repo_plugins = Path(__file__).resolve().parent.parent / "plugins" + # they have their own discovery systems. ``platforms/`` is a category + # holding platform adapters (scanned one level deeper below). + repo_plugins = get_bundled_plugins_dir() manifests.extend( self._scan_directory( repo_plugins, source="bundled", - skip_names={"memory", "context_engine"}, + skip_names={"memory", "context_engine", "platforms"}, ) ) + manifests.extend( + self._scan_directory(repo_plugins / "platforms", source="bundled") + ) # 2. User plugins (~/.hermes/plugins/) user_dir = get_hermes_home() / "plugins" diff --git a/hermes_cli/plugins_cmd.py b/hermes_cli/plugins_cmd.py index a2d1730993..352dadd194 100644 --- a/hermes_cli/plugins_cmd.py +++ b/hermes_cli/plugins_cmd.py @@ -630,10 +630,9 @@ def _plugin_exists(name: str) -> bool: manifest = _read_manifest(child) if manifest.get("name") == name: return True - # Bundled: /plugins// - from pathlib import Path as _P - import hermes_cli - repo_plugins = _P(hermes_cli.__file__).resolve().parent.parent / "plugins" + # Bundled: /plugins// (or HERMES_BUNDLED_PLUGINS on Nix). + from hermes_cli.plugins import get_bundled_plugins_dir + repo_plugins = get_bundled_plugins_dir() if repo_plugins.is_dir(): candidate = repo_plugins / name if candidate.is_dir() and ( @@ -660,8 +659,8 @@ def _discover_all_plugins() -> list: seen: dict = {} # name -> (name, version, description, source, path) # Bundled (/plugins//), excluding memory/ and context_engine/ - import hermes_cli - repo_plugins = Path(hermes_cli.__file__).resolve().parent.parent / "plugins" + from hermes_cli.plugins import get_bundled_plugins_dir + repo_plugins = get_bundled_plugins_dir() for base, source in ((repo_plugins, "bundled"), (_plugins_dir(), "user")): if not base.is_dir(): continue diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index 414bc91d64..ab785a31c4 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -2206,80 +2206,6 @@ def _setup_mattermost(): save_env_value("MATTERMOST_HOME_CHANNEL", home_channel) -def _setup_whatsapp(): - """Configure WhatsApp bridge.""" - print_header("WhatsApp") - existing = get_env_value("WHATSAPP_ENABLED") - if existing: - print_info("WhatsApp: already enabled") - return - - print_info("WhatsApp connects via a built-in bridge (Baileys).") - print_info("Requires Node.js. Run 'hermes whatsapp' for guided setup.") - print() - if prompt_yes_no("Enable WhatsApp now?", True): - save_env_value("WHATSAPP_ENABLED", "true") - print_success("WhatsApp enabled") - print_info("Run 'hermes whatsapp' to choose your mode (separate bot number") - print_info("or personal self-chat) and pair via QR code.") - - -def _setup_weixin(): - """Configure Weixin (personal WeChat) via iLink Bot API QR login.""" - from hermes_cli.gateway import _setup_weixin as _gateway_setup_weixin - _gateway_setup_weixin() - - -def _setup_signal(): - """Configure Signal via gateway setup.""" - from hermes_cli.gateway import _setup_signal as _gateway_setup_signal - _gateway_setup_signal() - - -def _setup_email(): - """Configure Email via gateway setup.""" - from hermes_cli.gateway import _setup_email as _gateway_setup_email - _gateway_setup_email() - - -def _setup_sms(): - """Configure SMS (Twilio) via gateway setup.""" - from hermes_cli.gateway import _setup_sms as _gateway_setup_sms - _gateway_setup_sms() - - -def _setup_dingtalk(): - """Configure DingTalk via gateway setup.""" - from hermes_cli.gateway import _setup_dingtalk as _gateway_setup_dingtalk - _gateway_setup_dingtalk() - - -def _setup_feishu(): - """Configure Feishu / Lark via gateway setup.""" - from hermes_cli.gateway import _setup_feishu as _gateway_setup_feishu - _gateway_setup_feishu() - - -def _setup_yuanbao(): - """Configure Yuanbao via gateway setup.""" - from hermes_cli.gateway import _setup_yuanbao as _gateway_setup_yuanbao - _gateway_setup_yuanbao() - - -def _setup_wecom(): - """Configure WeCom (Enterprise WeChat) via gateway setup.""" - from hermes_cli.gateway import _setup_wecom as _gateway_setup_wecom - _gateway_setup_wecom() - - -def _setup_wecom_callback(): - """Configure WeCom Callback (self-built app) via gateway setup.""" - from hermes_cli.gateway import _setup_wecom_callback as _gw_setup - _gw_setup() - - - - def _setup_bluebubbles(): """Configure BlueBubbles iMessage gateway.""" print_header("BlueBubbles (iMessage)") @@ -2397,47 +2323,26 @@ def _setup_webhooks(): print_info(" Open config in your editor: hermes config edit") -# Platform registry for the gateway checklist -_GATEWAY_PLATFORMS = [ - ("Telegram", "TELEGRAM_BOT_TOKEN", _setup_telegram), - ("Discord", "DISCORD_BOT_TOKEN", _setup_discord), - ("Slack", "SLACK_BOT_TOKEN", _setup_slack), - ("Signal", "SIGNAL_HTTP_URL", _setup_signal), - ("Email", "EMAIL_ADDRESS", _setup_email), - ("SMS (Twilio)", "TWILIO_ACCOUNT_SID", _setup_sms), - ("Matrix", "MATRIX_ACCESS_TOKEN", _setup_matrix), - ("Mattermost", "MATTERMOST_TOKEN", _setup_mattermost), - ("WhatsApp", "WHATSAPP_ENABLED", _setup_whatsapp), - ("DingTalk", "DINGTALK_CLIENT_ID", _setup_dingtalk), - ("Feishu / Lark", "FEISHU_APP_ID", _setup_feishu), - ("Yuanbao", "YUANBAO_APP_ID", _setup_yuanbao), - ("WeCom (Enterprise WeChat)", "WECOM_BOT_ID", _setup_wecom), - ("WeCom Callback (Self-Built App)", "WECOM_CALLBACK_CORP_ID", _setup_wecom_callback), - ("Weixin (WeChat)", "WEIXIN_ACCOUNT_ID", _setup_weixin), - ("BlueBubbles (iMessage)", "BLUEBUBBLES_SERVER_URL", _setup_bluebubbles), - ("QQ Bot", "QQ_APP_ID", _setup_qqbot), - ("Webhooks (GitHub, GitLab, etc.)", "WEBHOOK_ENABLED", _setup_webhooks), -] def setup_gateway(config: dict): """Configure messaging platform integrations.""" + from hermes_cli.gateway import _all_platforms, _platform_status, _configure_platform + print_header("Messaging Platforms") print_info("Connect to messaging platforms to chat with Hermes from anywhere.") print_info("Toggle with Space, confirm with Enter.") print() - # Build checklist items, pre-selecting already-configured platforms + platforms = _all_platforms() + + # Build checklist, pre-selecting already-configured platforms. items = [] pre_selected = [] - for i, (name, env_var, _func) in enumerate(_GATEWAY_PLATFORMS): - # Matrix has two possible env vars - is_configured = bool(get_env_value(env_var)) - if name == "Matrix" and not is_configured: - is_configured = bool(get_env_value("MATRIX_PASSWORD")) - label = f"{name} (configured)" if is_configured else name - items.append(label) - if is_configured: + for i, plat in enumerate(platforms): + status = _platform_status(plat) + items.append(f"{plat['emoji']} {plat['label']} ({status})") + if status == "configured": pre_selected.append(i) selected = prompt_checklist("Select platforms to configure:", items, pre_selected) @@ -2447,8 +2352,7 @@ def setup_gateway(config: dict): return for idx in selected: - name, _env_var, setup_func = _GATEWAY_PLATFORMS[idx] - setup_func() + _configure_platform(platforms[idx]) # ── Gateway Service Setup ── any_messaging = ( @@ -2738,13 +2642,14 @@ def _get_section_config_summary(config: dict, section_key: str) -> Optional[str] return f"max turns: {max_turns}" elif section_key == "gateway": - platforms = [ - _gateway_platform_short_label(label) - for label, env_var, _ in _GATEWAY_PLATFORMS - if get_env_value(env_var) + from hermes_cli.gateway import _all_platforms, _platform_status + configured = [ + _gateway_platform_short_label(plat["label"]) + for plat in _all_platforms() + if _platform_status(plat) == "configured" ] - if platforms: - return ", ".join(platforms) + if configured: + return ", ".join(configured) return None # No platforms configured — section must run elif section_key == "tools": diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 0cfcd28508..569449f188 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -3074,10 +3074,12 @@ def _discover_dashboard_plugins() -> list: plugins = [] seen_names: set = set() + from hermes_cli.plugins import get_bundled_plugins_dir + bundled_root = get_bundled_plugins_dir() search_dirs = [ (get_hermes_home() / "plugins", "user"), - (PROJECT_ROOT / "plugins" / "memory", "bundled"), - (PROJECT_ROOT / "plugins", "bundled"), + (bundled_root / "memory", "bundled"), + (bundled_root, "bundled"), ] if os.environ.get("HERMES_ENABLE_PROJECT_PLUGINS"): search_dirs.append((Path.cwd() / ".hermes" / "plugins", "project")) diff --git a/plugins/platforms/irc/__init__.py b/plugins/platforms/irc/__init__.py new file mode 100644 index 0000000000..d4f1d7bf0e --- /dev/null +++ b/plugins/platforms/irc/__init__.py @@ -0,0 +1,3 @@ +from .adapter import register + +__all__ = ["register"] diff --git a/plugins/platforms/irc/PLUGIN.yaml b/plugins/platforms/irc/plugin.yaml similarity index 100% rename from plugins/platforms/irc/PLUGIN.yaml rename to plugins/platforms/irc/plugin.yaml diff --git a/tests/hermes_cli/test_setup.py b/tests/hermes_cli/test_setup.py index 6ea2c80ab2..a2283880b9 100644 --- a/tests/hermes_cli/test_setup.py +++ b/tests/hermes_cli/test_setup.py @@ -163,12 +163,13 @@ def test_setup_gateway_skips_service_install_when_systemctl_missing(monkeypatch, "WEBHOOK_ENABLED": "", } + import hermes_cli.gateway as gateway_mod + monkeypatch.setattr(setup_mod, "get_env_value", lambda key: env.get(key, "")) + monkeypatch.setattr(gateway_mod, "get_env_value", lambda key: env.get(key, "")) monkeypatch.setattr(setup_mod, "prompt_yes_no", lambda *args, **kwargs: False) monkeypatch.setattr("platform.system", lambda: "Linux") - import hermes_cli.gateway as gateway_mod - monkeypatch.setattr(gateway_mod, "supports_systemd_services", lambda: False) monkeypatch.setattr(gateway_mod, "is_macos", lambda: False) monkeypatch.setattr(gateway_mod, "_is_service_installed", lambda: False) @@ -201,12 +202,13 @@ def test_setup_gateway_in_container_shows_docker_guidance(monkeypatch, capsys): "WEBHOOK_ENABLED": "", } + import hermes_cli.gateway as gateway_mod + monkeypatch.setattr(setup_mod, "get_env_value", lambda key: env.get(key, "")) + monkeypatch.setattr(gateway_mod, "get_env_value", lambda key: env.get(key, "")) monkeypatch.setattr(setup_mod, "prompt_yes_no", lambda *args, **kwargs: False) monkeypatch.setattr("platform.system", lambda: "Linux") - import hermes_cli.gateway as gateway_mod - monkeypatch.setattr(gateway_mod, "supports_systemd_services", lambda: False) monkeypatch.setattr(gateway_mod, "is_macos", lambda: False) monkeypatch.setattr(gateway_mod, "_is_service_installed", lambda: False) diff --git a/tests/hermes_cli/test_setup_openclaw_migration.py b/tests/hermes_cli/test_setup_openclaw_migration.py index a458bd3761..7542c1e977 100644 --- a/tests/hermes_cli/test_setup_openclaw_migration.py +++ b/tests/hermes_cli/test_setup_openclaw_migration.py @@ -529,10 +529,16 @@ class TestGetSectionConfigSummary: assert result == "gpt-5" def test_gateway_matches_platform_registry(self): - """Every platform in _GATEWAY_PLATFORMS should be recognised by its - own env-var sentinel — i.e. the summary must not drift from the + """Every built-in platform should be recognised by its primary + env-var sentinel — i.e. the summary must not drift from the registry used by the setup checklist.""" - for label, env_var, _fn in setup_mod._GATEWAY_PLATFORMS: + from hermes_cli.gateway import _PLATFORMS + + for plat in _PLATFORMS: + label = plat["label"] + env_var = plat.get("token_var") + if not env_var: + continue 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):