diff --git a/gateway/channel_directory.py b/gateway/channel_directory.py index 627e525924..402875d4a2 100644 --- a/gateway/channel_directory.py +++ b/gateway/channel_directory.py @@ -1,6 +1,6 @@ """Channel directory -- cached map of reachable channels/contacts per platform. -Built on gateway startup, refreshed every 5 min, saved to ~/.hermes/channel_directory.json. +Built on gateway startup, refreshed every 5 min, saved to ``/channel_directory.json``. send_message reads it for action="list" and to resolve friendly channel names to IDs. """ diff --git a/hermes_cli/send_cmd.py b/hermes_cli/send_cmd.py index 629cd5f74a..87b5fa0e24 100644 --- a/hermes_cli/send_cmd.py +++ b/hermes_cli/send_cmd.py @@ -133,7 +133,7 @@ def _list_targets(platform_filter: Optional[str], *, json_mode: bool) -> int: def _load_hermes_env() -> None: - """Populate the credential environment from ``~/.hermes/.env`` AND bridge top-level ``config.yaml`` + """Populate the credential environment from ``/.env`` AND bridge top-level ``config.yaml`` keys into it so the gateway config loader sees platform credentials and home channels. The target is ``os.environ`` for the standalone CLI. Inside a multi-profile host (dashboard console diff --git a/tests/hermes_cli/test_send_cmd.py b/tests/hermes_cli/test_send_cmd.py index 57d0b56fe8..e5c255a673 100644 --- a/tests/hermes_cli/test_send_cmd.py +++ b/tests/hermes_cli/test_send_cmd.py @@ -376,3 +376,34 @@ def test_load_hermes_env_bom_only_env_is_noop(tmp_path, monkeypatch): added = {k: v for k, v in os.environ.items() if k not in before} assert "\ufeff" not in "".join(added) + + +def test_help_and_empty_list_hint_name_the_resolved_home(tmp_path, monkeypatch, capsys): + """``--help`` and the ``--list`` empty-state hint derive their paths from the resolved home instead of a + hardcoded ``~/.hermes`` (absent on a Windows install or under a profile home).""" + import argparse + import sys + import types + + home = tmp_path / "AppData" / "Local" / "hermes" + home.mkdir(parents=True) + monkeypatch.setenv("HERMES_HOME", str(home)) + + parser = argparse.ArgumentParser(prog="hermes") + send_parser = send_cmd.register_send_subparser(parser.add_subparsers(dest="command")) + help_text = send_parser.format_help() + assert str(home / ".env") in help_text and str(home / "config.yaml") in help_text + assert "~/.hermes" not in help_text + + fake_gw_config = types.ModuleType("gateway.config") + fake_gw_config.load_gateway_config = lambda: types.SimpleNamespace(get_connected_platforms=lambda: []) + monkeypatch.setitem(sys.modules, "gateway.config", fake_gw_config) + fake_dir = types.ModuleType("gateway.channel_directory") + fake_dir.load_directory = lambda: {"updated_at": None, "platforms": {}} + fake_dir.format_directory_for_display = lambda platforms=None: "" + monkeypatch.setitem(sys.modules, "gateway.channel_directory", fake_dir) + + assert send_cmd._list_targets(None, json_mode=False) == 0 + out = capsys.readouterr().out + assert str(home / "channel_directory.json") in out + assert "~/.hermes" not in out diff --git a/tests/tools/test_send_message_tool.py b/tests/tools/test_send_message_tool.py index 6da8895f6e..0c67840db9 100644 --- a/tests/tools/test_send_message_tool.py +++ b/tests/tools/test_send_message_tool.py @@ -1821,3 +1821,24 @@ class TestSendTelegramThreadNotFoundRetry: finally: if media_path and os.path.exists(media_path): os.unlink(media_path) + + +def test_not_configured_error_names_resolved_home_and_consulted_sources(tmp_path, monkeypatch): + """The 'not configured' error names the files this process actually read (resolved home, not a + hardcoded ``~/.hermes``) and what each source held, so a Windows/profile home user can fix the right file.""" + from gateway.config import GatewayConfig + from tools.send_message_tool import _resolve_platform_config + + home = tmp_path / "AppData" / "Local" / "hermes" + home.mkdir(parents=True) + (home / ".env").write_text("FIRECRAWL_API_KEY=x\n", encoding="utf-8") + (home / "config.yaml").write_text("platforms:\n discord:\n enabled: false\n", encoding="utf-8") + monkeypatch.setenv("HERMES_HOME", str(home)) + monkeypatch.delenv("DISCORD_BOT_TOKEN", raising=False) + + _, _, _, err = _resolve_platform_config("discord", GatewayConfig()) + + assert "~/.hermes" not in err + assert f"{home / '.env'} (no DISCORD_BOT_TOKEN)" in err + assert f"{home / 'config.yaml'} (platforms.discord.enabled: false)" in err + assert "environment (DISCORD_BOT_TOKEN unset)" in err diff --git a/tools/send_message_tool.py b/tools/send_message_tool.py index 2b98cdcc05..2d038e831d 100644 --- a/tools/send_message_tool.py +++ b/tools/send_message_tool.py @@ -304,13 +304,53 @@ def _resolve_platform_config(platform_name, config): if not pconfig or not pconfig.enabled: pconfig = _weixin_env_pconfig() if platform_name == "weixin" else None if pconfig is None: - from hermes_constants import get_hermes_home - config_path = get_hermes_home() / "config.yaml" - return None, None, None, (f"Platform '{platform_name}' is not configured. Set up credentials in " - f"{config_path} or environment variables.") + return None, None, None, _not_configured_error(platform_name, platform, entry) return platform, pconfig, entry, None +def _not_configured_error(platform_name, platform, entry): + """Name the resolved home and what each credential source held, so the user edits the file this + process actually read (a hardcoded ``~/.hermes`` does not exist on a Windows or profile home).""" + from agent.secret_scope import load_env_file + from gateway.config import _getenv + from gateway.config_env import _ENV_ENABLE_CREDENTIALS + from hermes_constants import get_hermes_home + home = get_hermes_home() + env_names = list(_ENV_ENABLE_CREDENTIALS.get(platform) or (entry.required_env if entry else ())) + names = "/".join(env_names) or "credentials" + env_path, config_path = home / ".env", home / "config.yaml" + dotenv_keys = load_env_file(env_path) + dotenv_state = (f"{names} present" if any(n in dotenv_keys for n in env_names) else f"no {names}") \ + if env_path.exists() else "missing" + try: + from hermes_cli.config_effective import load_user_config_effective + block = (load_user_config_effective(config_path) or {}).get("platforms", {}).get(platform_name) + except Exception: + block = None + if not config_path.exists(): + config_state = "missing" + elif not isinstance(block, dict): + config_state = f"no platforms.{platform_name} block" + elif block.get("enabled") is False: + config_state = f"platforms.{platform_name}.enabled: false" + else: + config_state = f"platforms.{platform_name} has no token" + env_state = f"{names} set" if any(_getenv(n) for n in env_names) else f"{names} unset" + msg = (f"Platform '{platform_name}' is not configured. Looked in: {env_path} ({dotenv_state}), " + f"{config_path} ({config_state}), environment ({env_state}).") + # The gateway can hold a token only in its own process environment; a fresh CLI cannot see it. + try: + from gateway.status import read_runtime_status, runtime_status_pid_is_live + record = read_runtime_status() + state = ((record or {}).get("platforms") or {}).get(platform_name, {}).get("state") + if state == "connected" and "present" not in dotenv_state and runtime_status_pid_is_live(record): + msg += (f" A gateway (pid {record.get('pid')}) running from {home} has {platform_name} connected, " + f"so its credentials live only in that process's environment; add {names} to {env_path}.") + except Exception: + pass + return msg + + def _home_chat_id(config, platform, platform_name): """``(home chat_id, None)`` or ``(None, actionable error)``; Weixin also honours WEIXIN_HOME_CHANNEL.""" home = config.get_home_channel(platform) diff --git a/website/docs/guides/pipe-script-output.md b/website/docs/guides/pipe-script-output.md index a58f408260..b87bf01440 100644 --- a/website/docs/guides/pipe-script-output.md +++ b/website/docs/guides/pipe-script-output.md @@ -186,9 +186,19 @@ msg_id=$(hermes send --to discord:#ops --json "build started" \ **Usually no.** For any bot-token platform — Telegram, Discord, Slack, Signal, SMS, WhatsApp Cloud API, and most others — `hermes send` calls the platform's REST endpoint directly using credentials from -`~/.hermes/.env` and `~/.hermes/config.yaml`. It's a standalone subprocess +`~/.hermes/.env` and `~/.hermes/config.yaml` (or the equivalent files under +your resolved Hermes home — `%LOCALAPPDATA%\hermes` on Windows, or the profile +directory when `HERMES_HOME` / `-p` is set). It's a standalone subprocess that exits as soon as the message is delivered. +If a platform reports `not configured`, the error lists the exact files it +read and what each one held, e.g. +`Looked in: C:\Users\me\AppData\Local\hermes\.env (no DISCORD_BOT_TOKEN), +C:\Users\me\AppData\Local\hermes\config.yaml (no platforms.discord block), +environment (DISCORD_BOT_TOKEN unset)`. When a gateway started from the same +home has that platform connected, the token only exists in the gateway's +process environment — add it to that home's `.env` so `hermes send` can use it. + A live gateway is only required for **plugin platforms** that rely on a persistent adapter connection (for example, a custom plugin that keeps a long-lived WebSocket open). In that case you'll get a clear error