Files
ethernet 746d861504 fix(install): don't ask the gateway install questions twice
The setup stage installs the gateway service through
ensure_gateway_service. On Windows that asks the start-now, Scheduled
Task and UAC questions. The gateway stage then ran `hermes gateway
install`, which asked them all again.

`gateway install --if-missing` does nothing when a service is already
installed. Both installers' gateway stages use it, so they ask only
when setup did not install the service.
2026-09-24 23:49:18 -04:00

218 lines
12 KiB
Python

"""``hermes gateway`` and ``hermes proxy`` subcommand parsers."""
from __future__ import annotations
import argparse
from typing import Callable
from hermes_cli.subcommands._shared import add_accept_hooks_flag
# `start`/`restart` on a named profile refuse while the default multiplexer serves it (a second gateway
# would double-bind its platforms); `gateway run` carries its own broader --force text.
_FORCE_SERVED_PROFILE_HELP = (
"Start a separate gateway for this profile even when the default multiplexer already serves it "
"(not recommended: two pollers on one bot token, port conflicts)")
def _flag(parser, *names, help, **kw):
parser.add_argument(*names, action="store_true", help=help, **kw)
def _add_compat_platform_flag(parser: argparse.ArgumentParser) -> None:
"""Accept stale `gateway <verb> --platform X` docs without advertising it.
Lifecycle commands operate on the gateway process, not one adapter; Photon briefly
printed a per-platform start command during setup, so keep it parseable.
"""
parser.add_argument("--platform", dest="platform", help=argparse.SUPPRESS)
def _add_system_flag(parser: argparse.ArgumentParser) -> None:
_flag(parser, "--system", help="Target the Linux system-level gateway service")
def build_gateway_parser(
subparsers, *, cmd_gateway: Callable, cmd_proxy: Callable, cmd_gateway_enroll: Callable
) -> None:
"""Attach the ``gateway`` and ``proxy`` subcommands to ``subparsers``."""
gateway_parser = subparsers.add_parser("gateway", help="Messaging gateway management",
description="Manage the messaging gateway (Telegram, Discord, WhatsApp, Weixin, and more)")
gateway_subparsers = gateway_parser.add_subparsers(dest="gateway_command")
gateway_run = gateway_subparsers.add_parser(
"run", help="Run gateway in foreground (recommended for WSL and Docker)"
)
gateway_run.add_argument(
"-v",
"--verbose",
action="count",
default=0,
help="Increase stderr log verbosity (-v=INFO, -vv=DEBUG)",
)
gateway_run.add_argument(
"-q", "--quiet", action="store_true", help="Suppress all stderr log output"
)
gateway_run.add_argument(
"--replace",
action="store_true",
help="Replace any existing gateway instance (useful for systemd)",
)
gateway_run.add_argument(
"--force",
action="store_true",
help=(
"Start a foreground gateway even when a systemd/launchd/s6 service "
"already supervises this profile. Without --force, the command "
"refuses because a second dispatcher escapes the service and can "
"corrupt shared gateway state."
),
)
gateway_run.add_argument(
"--no-supervise",
action="store_true",
help=(
"Inside the s6-overlay Docker image, normally `gateway run` is "
"automatically redirected to the supervised s6 service (so the "
"gateway gets auto-restart on crash, plus a supervised dashboard "
"if HERMES_DASHBOARD is set). Pass --no-supervise to opt out and "
"get the historical pre-s6 foreground behavior: the gateway is "
"the container's main process and the container exits with the "
"gateway's exit code. No effect outside an s6 container."),
)
_flag(gateway_run, "--external-supervisor",
help="Declare that an external process manager owns this foreground "
"gateway. In-chat restarts and updates exit back to that manager "
"instead of spawning a detached replacement. Use this when a "
"launchd/systemd wrapper strips its native environment markers.")
add_accept_hooks_flag(gateway_run)
add_accept_hooks_flag(gateway_parser)
gateway_start = gateway_subparsers.add_parser(
"start", help="Start the installed systemd/launchd background service")
_add_system_flag(gateway_start)
_flag(gateway_start, "--all",
help="Kill ALL stale gateway processes across all profiles before starting")
_flag(gateway_start, "--force", help=_FORCE_SERVED_PROFILE_HELP)
_add_compat_platform_flag(gateway_start)
gateway_stop = gateway_subparsers.add_parser("stop", help="Stop gateway service")
_add_system_flag(gateway_stop)
_flag(gateway_stop, "--all", help="Stop ALL gateway processes across all profiles")
gateway_restart = gateway_subparsers.add_parser("restart", help="Restart gateway service")
_add_system_flag(gateway_restart)
_flag(gateway_restart, "--all",
help="Kill ALL gateway processes across all profiles before restarting")
_flag(gateway_restart, "--force", help=_FORCE_SERVED_PROFILE_HELP)
_add_compat_platform_flag(gateway_restart)
gateway_status = gateway_subparsers.add_parser("status", help="Show gateway status")
_flag(gateway_status, "--deep", help="Deep status check")
_flag(gateway_status, "-l", "--full",
help="Show full, untruncated service/log output where supported")
_add_system_flag(gateway_status)
_add_compat_platform_flag(gateway_status)
# gateway install
gateway_install = gateway_subparsers.add_parser(
"install", help="Install gateway as a systemd/launchd background service")
_flag(gateway_install, "--force",
help="Force reinstall, and install even when the default multiplexer already serves this profile")
_flag(gateway_install, "--system",
help="Install as a Linux system-level service (starts at boot)")
gateway_install.add_argument("--run-as-user", dest="run_as_user",
help="User account the Linux system service should run as")
_flag(gateway_install, "--start-now", dest="start_now", default=None,
help="Start the gateway service immediately after installing")
gateway_install.add_argument("--no-start-now", dest="start_now", action="store_false",
help="Do not start the gateway service after installing")
_flag(gateway_install, "--start-on-login", dest="start_on_login", default=None,
help="Enable the service to start automatically on login/boot")
gateway_install.add_argument("--no-start-on-login", dest="start_on_login", action="store_false",
help="Do not enable the service to start on login/boot")
_flag(gateway_install, "--if-missing", dest="if_missing",
help="Do nothing when a gateway service is already installed")
_flag(gateway_install, "--elevated-handoff", dest="elevated_handoff", help=argparse.SUPPRESS)
gateway_uninstall = gateway_subparsers.add_parser("uninstall", help="Uninstall gateway service")
_add_system_flag(gateway_uninstall)
gateway_subparsers.add_parser("list", help="List all profiles and their gateway status")
gateway_subparsers.add_parser("setup", help="Configure messaging platforms")
gateway_migrate_legacy = gateway_subparsers.add_parser(
"migrate-legacy", help="Remove legacy hermes.service units from pre-rename installs",
description="Stop, disable, and remove legacy Hermes gateway unit files "
"(e.g. hermes.service) left over from older installs. Profile "
"units (hermes-gateway-<profile>.service) and unrelated "
"third-party services are never touched.")
_flag(gateway_migrate_legacy, "--dry-run", dest="dry_run",
help="List what would be removed without doing it")
_flag(gateway_migrate_legacy, "-y", "--yes", dest="yes", help="Skip the confirmation prompt")
gateway_migrate = gateway_subparsers.add_parser(
"migrate", help="Converge every per-profile gateway onto the ONE host gateway",
description="Converge this host onto the one-gateway-per-host model: stop and uninstall "
"each secondary profile's gateway and its supervisor unit (systemd, launchd, Windows "
"Scheduled Task), then restart the default profile's gateway so it serves every "
"profile. Runs a preflight first (duplicate bot tokens, port-binding platforms "
"without a /p/<profile>/ ingress) and changes nothing when blocked. Safe to re-run: a "
"half-migrated host converges on the next run. Per-profile gateways are not a "
"supported topology any more, so there is no rollback command.")
_flag(gateway_migrate, "--multiplex", dest="multiplex", help="Converge onto the one host gateway (default)")
_flag(gateway_migrate, "--dry-run", dest="dry_run", help="Print the plan and blockers without changing anything")
_flag(gateway_migrate, "-y", "--yes", dest="yes", help="Apply without confirmation")
# enroll: redeem a single-use connector token for the per-gateway secret + per-tenant
# delivery key, written to .env. See website/docs/developer-guide/relay-connector-contract.md. EXPERIMENTAL.
gateway_enroll = gateway_subparsers.add_parser("enroll",
help="Enroll this gateway with a relay connector (writes relay auth creds to .env)",
description="Redeem a single-use enrollment token with a relay connector. "
"Authenticates as your Nous Portal account (the connector derives the "
"authoritative tenant from it), mints this gateway's per-gateway secret "
"and per-tenant delivery key, and writes GATEWAY_RELAY_ID / "
"GATEWAY_RELAY_SECRET / GATEWAY_RELAY_DELIVERY_KEY into ~/.hermes/.env. "
"Requires being logged in (hermes setup). Not available in managed installs.")
gateway_enroll.add_argument("--token", default=None,
help="The single-use enrollment token from the connector (delivered with "
"your gateway config). Also settable via GATEWAY_RELAY_ENROLL_TOKEN.")
gateway_enroll.add_argument("--connector-url", dest="connector_url", default=None,
help="The connector base/relay URL, e.g. wss://connector.example.com/relay "
"or https://connector.example.com. Also settable via GATEWAY_RELAY_URL "
"/ gateway.relay_url in config.yaml.")
gateway_enroll.add_argument("--gateway-id", dest="gateway_id", default=None,
help="A stable id for this gateway instance (kill-switch granularity). "
"Defaults to gw-<hostname>.")
gateway_enroll.add_argument("--wake-url", dest="wake_url", default=None,
help="Phase 5 §5.2 wake URL: a reachable URL the connector pokes "
"(payload-free GET) to wake this gateway when buffered work arrives "
"while it's idle/suspended, so it reconnects and drains. Persisted as "
"GATEWAY_RELAY_WAKE_URL in ~/.hermes/.env and forwarded at provision. "
"Optional — without it the gateway still drains whenever it next "
"reconnects on its own.")
gateway_enroll.set_defaults(func=cmd_gateway_enroll)
# proxy: local OpenAI-compatible proxy attaching the user's OAuth provider credentials,
# so external apps (Open WebUI, Karakeep, ...) ride a logged-in subscription.
proxy_parser = subparsers.add_parser(
"proxy", help="Local OpenAI-compatible proxy to OAuth providers",
description="Run a local HTTP server that forwards OpenAI-compatible requests "
"to an OAuth-authenticated provider (e.g. Nous Portal). External "
"apps can point at the proxy with any bearer token; the proxy "
"attaches your real credentials.")
proxy_subparsers = proxy_parser.add_subparsers(dest="proxy_command")
proxy_start = proxy_subparsers.add_parser("start", help="Run the proxy in the foreground")
proxy_start.add_argument("--provider", default="nous",
help="Upstream provider: nous or xai (default: nous). See `hermes proxy providers`.")
proxy_start.add_argument("--host", default=None,
help="Bind address (default: 127.0.0.1). Use 0.0.0.0 to expose on LAN.")
proxy_start.add_argument("--port", type=int, default=None, help="Bind port (default: 8645)")
proxy_subparsers.add_parser("status", help="Show which proxy upstreams are ready")
proxy_subparsers.add_parser("providers", help="List available proxy upstream providers")
proxy_parser.set_defaults(func=cmd_proxy)
gateway_parser.set_defaults(func=cmd_gateway)