#!/usr/bin/env python3 """ Hermes CLI - Main entry point. Usage: hermes # Interactive chat (default) hermes chat # Interactive chat hermes gateway # Run gateway in foreground hermes gateway start # Start gateway as service hermes gateway stop # Stop gateway service hermes gateway status # Show gateway status hermes gateway install # Install gateway service hermes gateway uninstall # Uninstall gateway service hermes setup # Interactive setup wizard hermes logout # Clear stored authentication hermes status # Show status of all components hermes cron # Manage cron jobs hermes cron list # List cron jobs hermes cron status # Check if cron scheduler is running hermes doctor # Check configuration and dependencies hermes honcho setup # Configure Honcho AI memory integration hermes honcho status # Show Honcho config and connection status hermes honcho sessions # List directory → session name mappings hermes honcho map # Map current directory to a session name hermes honcho peer # Show peer names and dialectic settings hermes honcho peer --user NAME # Set user peer name hermes honcho peer --ai NAME # Set AI peer name hermes honcho peer --reasoning LEVEL # Set dialectic reasoning level hermes honcho mode # Show current memory mode hermes honcho mode [hybrid|honcho|local] # Set memory mode hermes honcho tokens # Show token budget settings hermes honcho tokens --context N # Set session.context() token cap hermes honcho tokens --dialectic N # Set dialectic result char cap hermes honcho identity # Show AI peer identity representation hermes honcho identity # Seed AI peer identity from a file (SOUL.md etc.) hermes honcho migrate # Step-by-step migration guide: OpenClaw native → Hermes + Honcho hermes version Show version hermes update Update to latest version hermes uninstall Uninstall Hermes Agent hermes acp Run as an ACP server for editor integration hermes sessions browse Interactive session picker with search hermes claw migrate --dry-run # Preview migration without changes """ # IMPORTANT: hermes_bootstrap must be the very first import — it sets up # UTF-8 stdio on Windows so print()/subprocess children don't hit # UnicodeEncodeError with non-ASCII characters. No-op on POSIX. # # Guarded against ModuleNotFoundError because ``hermes_bootstrap`` is a # top-level module registered via pyproject.toml's ``py-modules`` list. # When the user upgrades code via ``git pull`` (or ``hermes update`` # crashes between ``git reset --hard`` and ``uv pip install -e .``), the # new code references ``hermes_bootstrap`` but the editable install's # ``.pth`` file still points at the old set of top-level modules. Without # this guard, hermes crashes on import and the user can't run # ``hermes update`` to recover. Missing the bootstrap means UTF-8 stdio # setup is skipped on Windows — degraded, not broken. POSIX is unaffected. try: import hermes_bootstrap # noqa: F401 except ModuleNotFoundError: pass # Windows: neutralize CPython's ``platform._syscmd_ver`` before anything else # imports — it shells out ``cmd /c ver`` (shell=True, no CREATE_NO_WINDOW), so # any dependency touching ``platform.uname()`` at import time flashes a # visible console when this process is windowless (pythonw gateway + every # kanban worker). No-op on POSIX; never raises. from hermes_cli._subprocess_compat import suppress_platform_ver_console suppress_platform_ver_console() import os import sys # ── Startup fast-path bootstrap ───────────────────────────────────────── # Two lines of inline path math so ``python hermes_cli/main.py`` (script # mode — sys.path[0] is hermes_cli/, not the repo root) can import the # canonical helpers; everything else lives in hermes_cli._startup_fast. _bootstrap_root = os.path.realpath(os.path.join(os.path.dirname(__file__), os.pardir)) if _bootstrap_root not in sys.path: sys.path.insert(0, _bootstrap_root) from hermes_cli import _startup_fast # noqa: E402 # Early venv self-heal — MUST run before any third-party import below. When # a prior ``hermes update`` left a recovery marker and a core package's import # files were wiped (#57828 — failed lazy backend refresh), the module-level # ``from hermes_cli.env_loader import ...`` / ``from hermes_cli.config import # ...`` imports further down would crash before ``main()`` ever reaches # ``_recover_from_interrupted_install()``. ``_early_recovery`` is stdlib-only # (safe to import on a corrupted venv), repairs just enough for this module to # finish importing, and leaves the marker lifecycle to the full recovery path. # The module import itself is unguarded on purpose: it lives in this same # package directory, so if IT can't import, nothing else in hermes_cli can # either. It is also the canonical home of the probe/repair tables reused by # the full recovery path below. from hermes_cli import _early_recovery as _early_recovery_mod try: _early_recovery_mod.recover_if_needed() except Exception: pass def _exit_after_oneshot(rc: object) -> None: """Exit one-shot mode without letting late native finalizers change rc. The SIGABRT this guards against (#30387, #43055) fires in a native-extension finalizer during CPython's ``Py_FinalizeEx``, *after* the response has printed. Flush streams, shut down file logging, then ``os._exit`` past interpreter finalization. The ``atexit`` chain is deliberately skipped — several handlers re-enter native code that may be the abort source. Stateful cleanup is handled in ``_run_agent`` and ``_cleanup_oneshot_runtime``. """ for stream in (sys.stdout, sys.stderr): try: stream.flush() except Exception: pass try: logging.shutdown() except Exception: pass if rc is None: exit_code = 0 elif isinstance(rc, int): exit_code = rc else: exit_code = 1 os._exit(exit_code) _oneshot_cleanup_done = False def _cleanup_oneshot_runtime() -> None: """Best-effort process-global cleanup before one-shot hard exit. ``run_oneshot`` owns the agent-local cleanup (memory provider, agent.close, session_db.close — all in ``_run_agent``'s finally block). This mirrors the process-global pieces from ``cli.py:_run_cleanup()`` that would otherwise be skipped by ``os._exit``. """ global _oneshot_cleanup_done if _oneshot_cleanup_done: return _oneshot_cleanup_done = True try: from tools.terminal_tool import cleanup_all_environments cleanup_all_environments() except Exception: pass try: from tools.async_delegation import interrupt_all interrupt_all(reason="oneshot shutdown") except Exception: pass try: from tools.browser_tool import _emergency_cleanup_all_sessions _emergency_cleanup_all_sessions() except Exception: pass try: from tools.mcp_tool import shutdown_mcp_servers shutdown_mcp_servers() except BaseException: pass try: from agent.auxiliary_client import shutdown_cached_clients shutdown_cached_clients() except Exception: pass def _run_and_exit_oneshot( prompt: str, *, model: object = None, provider: object = None, toolsets: object = None, usage_file: object = None, ) -> None: try: from hermes_cli.oneshot import run_oneshot rc = run_oneshot( prompt, model=model, provider=provider, toolsets=toolsets, usage_file=usage_file, ) except KeyboardInterrupt: rc = 130 except SystemExit as exc: if exc.code is not None and not isinstance(exc.code, int): print(exc.code, file=sys.stderr) rc = 1 else: rc = exc.code except BaseException: # Defense-in-depth. ``run_oneshot`` already converts agent failures # into an int return code and only re-raises KeyboardInterrupt / # SystemExit (handled above). Anything still escaping here means # ``run_oneshot`` itself malfunctioned — surface it on stderr but never # fall through to normal interpreter teardown, which is the exact path # that aborts with SIGABRT on AL2023 (the bug this routine fixes). import traceback try: traceback.print_exc() except Exception: pass rc = 1 try: _cleanup_oneshot_runtime() finally: # The hard exit is the safety boundary for #43055. Even an interrupt # during best-effort cleanup must not fall back into interpreter # finalization, where the reported native SIGABRT occurs. _exit_after_oneshot(rc) def _project_root_str_fast() -> str: return _startup_fast.project_root_str() def _ensure_project_root_on_path_fast() -> None: _startup_fast.ensure_project_root_on_path() def _set_process_title() -> None: """Set the process title to 'hermes' so tools like 'ps', 'top', and 'htop' show the app name instead of 'python3.xx'. Purely cosmetic — non-fatal on any platform. Strategy (try in order): 1. ``setproctitle`` (opt-in dep — installed via ``hermes tools`` or ``pip install setproctitle``, or bundled in a future release). 2. ctypes ``prctl(PR_SET_NAME)`` (Linux only, 15-char limit). 3. ctypes ``pthread_setname_np`` (macOS only, kernel thread name — changes lldb/top but not ``ps aux``). 4. No-op on Windows (the .exe name is already ``hermes.exe``). """ # Strategy 1: setproctitle (best — works on macOS, Linux, BSD) try: import setproctitle # type: ignore[import-untyped] setproctitle.setproctitle("hermes") return except ImportError: pass # Strategy 2/3: platform-specific ctypes fallback import ctypes import platform try: system = platform.system() if system == "Linux": libc = ctypes.CDLL("libc.so.6", use_errno=True) libc.prctl(15, b"hermes", 0, 0, 0) # PR_SET_NAME = 15 elif system == "Darwin": libc = ctypes.CDLL("libc.dylib", use_errno=True) libc.pthread_setname_np(b"hermes") # Windows: the .exe name is already ``hermes.exe`` — nothing to do. except Exception: pass # Cheap, dependency-free read of `display.interface` from config.yaml for the # earliest hot-path decisions (mouse-residue suppression, Termux fast launch) # that run *before* hermes_cli.config is importable. Mirrors the explicit # precedence used everywhere else: `--cli` always wins, then `--tui`/env, then # this config value. Cached so the multiple early callers don't re-parse YAML. _EARLY_INTERFACE_CACHE: "list | None" = None def _config_default_interface_early() -> str: """Return the configured default interface ("cli"/"tui") via a minimal YAML read. Best-effort: any error falls back to "cli" (legacy behavior).""" global _EARLY_INTERFACE_CACHE if _EARLY_INTERFACE_CACHE is not None: return _EARLY_INTERFACE_CACHE[0] value = "cli" try: home = os.environ.get("HERMES_HOME") if home: cfg_path = os.path.join(home, "config.yaml") else: cfg_path = os.path.join(os.path.expanduser("~"), ".hermes", "config.yaml") if os.path.exists(cfg_path): import yaml as _yaml_iface with open(cfg_path, encoding="utf-8") as _f: raw = _yaml_iface.load( _f, Loader=getattr(_yaml_iface, "CSafeLoader", None) or _yaml_iface.SafeLoader ) or {} disp = raw.get("display", {}) if isinstance(disp, dict): iface = disp.get("interface") if isinstance(iface, str) and iface.strip().lower() == "tui": value = "tui" except Exception: value = "cli" # best-effort — default to classic REPL on any error _EARLY_INTERFACE_CACHE = [value] return value def _wants_tui_early(argv: "list[str] | None" = None) -> bool: """Earliest TUI decision, usable before argparse/config imports. Precedence: explicit ``--cli`` wins (forces classic REPL), then explicit ``--tui``/``HERMES_TUI=1``, then a real-TTY gate (a non-interactive stdio can't host the Ink UI, so ambient config never boots it there), then ``display.interface`` in config. The TTY gate is load-bearing for headless spawners — kanban workers, cron jobs, pipes run ``hermes … chat -q`` with stdio on a pipe. This is the earliest launch decision (it runs before ``cmd_chat`` / ``_resolve_use_tui``), so a ``display.interface: tui`` default used to boot the TUI here — whose no-TTY bail-out exits 0 without doing the task → "protocol violation" on every attempt. An explicit ``--tui`` still reaches the informative bail-out. """ if argv is None: argv = sys.argv[1:] if "--cli" in argv: return False if os.environ.get("HERMES_TUI") == "1" or "--tui" in argv: return True try: if not (sys.stdin.isatty() and sys.stdout.isatty()): return False except Exception: return False return _config_default_interface_early() == "tui" # Mouse-tracking residue suppression — runs BEFORE every other import on the # TUI hot path so the terminal stops emitting SGR/X10 mouse reports while the # Python launcher is still doing imports (≈100–300ms in cooked + echo mode, # before the Node TUI takes stdin into raw mode). During that window any # incoming bytes are echoed straight back to the user's shell scrollback as # ``^[[<…M`` text. The TUI itself runs `resetTerminalModes()` again in # `entry.tsx`; this is just the earlier cousin. ``HERMES_TUI_NO_EARLY_DISABLE`` # escapes the behaviour for diagnostics. def _suppress_mouse_residue_early() -> None: if os.environ.get("HERMES_TUI_NO_EARLY_DISABLE") == "1": return if not _wants_tui_early(): return try: # Skip when stdout is redirected (`hermes --tui … >log`, CI capture): # the bytes can't reach the terminal anyway and would just pollute # the log with raw CSI. if not os.isatty(1): return # Disable every mouse-tracking variant we know about. Idempotent and # safe to send even when no tracking is currently asserted. os.write( 1, b"\x1b[?1003l\x1b[?1002l\x1b[?1001l\x1b[?1000l\x1b[?9l" b"\x1b[?1006l\x1b[?1005l\x1b[?1015l\x1b[?1016l\x1b[?2029l", ) except OSError: pass _suppress_mouse_residue_early() def _is_termux_startup_environment_fast() -> bool: """Tiny Termux check for pre-import startup shortcuts.""" return _startup_fast.is_termux_env() def _is_termux_fast_version_argv(argv: list[str]) -> bool: return _startup_fast.is_termux_fast_version_argv(argv) def _is_global_fast_version_argv(argv: list[str]) -> bool: return _startup_fast.is_global_fast_version_argv(argv) def _is_container_startup_environment_fast() -> bool: return _startup_fast.is_container_startup_environment() def _active_profile_may_override_home_fast(hermes_root: str) -> bool: return _startup_fast.active_profile_may_override_home(hermes_root) def _container_mode_may_be_active_fast() -> bool: return _startup_fast.container_mode_may_be_active() def _read_openai_version_fast() -> str | None: """Read OpenAI SDK version without importing ``importlib.metadata``.""" return _startup_fast.read_openai_version() def _print_fast_version_info() -> None: _startup_fast.print_fast_version_info() def _try_ultrafast_version() -> bool: """Handle ``hermes --version`` before config/logging imports.""" return _startup_fast.try_fast_version() def _try_termux_ultrafast_version() -> bool: """Backward-compatible test hook for the Termux startup fast path.""" if not _is_termux_startup_environment_fast(): return False return _try_ultrafast_version() _ensure_project_root_on_path_fast() if _try_ultrafast_version(): raise SystemExit(0) import argparse import hashlib import json import re import shlex import shutil import stat import subprocess from pathlib import Path from typing import Optional import functools as _functools from hermes_cli.subcommands._shared import add_accept_hooks_flag as _add_accept_hooks_flag from hermes_cli.subcommands.cron import build_cron_parser from hermes_cli.subcommands.sync import build_sync_parser from hermes_cli.subcommands.gateway import build_gateway_parser from hermes_cli.subcommands.profile import build_profile_parser from hermes_cli.subcommands.model import build_model_parser from hermes_cli.subcommands.setup import build_setup_parser from hermes_cli.subcommands.whatsapp import build_whatsapp_parser from hermes_cli.subcommands.slack import build_slack_parser from hermes_cli.subcommands.login import build_login_parser from hermes_cli.subcommands.logout import build_logout_parser from hermes_cli.subcommands.auth import build_auth_parser from hermes_cli.subcommands.status import build_status_parser from hermes_cli.subcommands.pause import build_pause_parser from hermes_cli.subcommands.webhook import build_webhook_parser from hermes_cli.subcommands.hooks import build_hooks_parser from hermes_cli.subcommands.doctor import build_doctor_parser from hermes_cli.subcommands.verify import build_verify_parser from hermes_cli.subcommands.security import build_security_parser from hermes_cli.subcommands.approvals import build_approvals_parser from hermes_cli.subcommands.dump import build_dump_parser from hermes_cli.subcommands.debug import build_debug_parser from hermes_cli.subcommands.backup import build_backup_parser from hermes_cli.subcommands.import_cmd import build_import_cmd_parser from hermes_cli.subcommands.import_agent import build_import_agent_parser from hermes_cli.subcommands.config import build_config_parser from hermes_cli.subcommands.skin import build_skin_parser from hermes_cli.subcommands.console import build_console_parser from hermes_cli.subcommands.version import build_version_parser from hermes_cli.subcommands.update import build_update_parser from hermes_cli.subcommands.uninstall import build_uninstall_parser from hermes_cli.subcommands.dashboard import build_dashboard_parser from hermes_cli.subcommands.gui import build_gui_parser from hermes_cli.subcommands.logs import build_logs_parser from hermes_cli.subcommands.prompt_size import build_prompt_size_parser from hermes_cli.subcommands.memory import build_memory_parser from hermes_cli.subcommands.acp import build_acp_parser from hermes_cli.subcommands.tools import build_tools_parser from hermes_cli.subcommands.insights import build_insights_parser from hermes_cli.subcommands.monitoring import build_monitoring_parser from hermes_cli.subcommands.skills import build_skills_parser from hermes_cli.subcommands.pairing import build_pairing_parser from hermes_cli.subcommands.plugins import build_plugins_parser from hermes_cli.subcommands.mcp import build_mcp_parser from hermes_cli.subcommands.claw import build_claw_parser def _require_tty(command_name: str) -> None: """Exit with a clear error if stdin is not a terminal. Interactive TUI commands (hermes tools, hermes setup, hermes model) use curses or input() prompts that spin at 100% CPU when stdin is a pipe. This guard prevents accidental non-interactive invocation. """ if not sys.stdin.isatty(): print( f"Error: 'hermes {command_name}' requires an interactive terminal.\n" f"It cannot be run through a pipe or non-interactive subprocess.\n" f"Run it directly in your terminal instead.", file=sys.stderr, ) sys.exit(1) # Add project root to path PROJECT_ROOT = Path(_project_root_str_fast()) _ensure_project_root_on_path_fast() # --------------------------------------------------------------------------- # Profile override — MUST happen before any hermes module import. # # Many modules cache HERMES_HOME at import time (module-level constants). # We intercept --profile/-p from sys.argv here and set the env var so that # every subsequent ``os.getenv("HERMES_HOME", ...)`` resolves correctly. # The flag is stripped from sys.argv so argparse never sees it. # Falls back to ~/.hermes/active_profile for sticky default. # --------------------------------------------------------------------------- def _apply_profile_override() -> None: """Pre-parse --profile/-p and set HERMES_HOME before imports.""" argv = sys.argv[1:] profile_name = None consume = 0 profile_index = None def _inside_mcp_add_args(index: int) -> bool: """True once argv reaches `hermes mcp add ... --args `. ``mcp add --args`` is command-argv passthrough. Flags after that point belong to the child MCP command (for example Docker MCP Toolkit's ``--profile``), not to Hermes' own profile selector. """ try: mcp_index = argv.index("mcp", 0, index) argv.index("add", mcp_index + 1, index) except ValueError: return False return True def _resolve_sudo_user_profile_env(name: str) -> str | None: """Resolve `sudo hermes -p ` against the invoking user's home. `_apply_profile_override()` runs before argparse, so `--run-as-user` is not available yet. For sudo invocations, the best available signal is SUDO_USER: root is only doing the privileged install/start action, while the profile store normally belongs to the user who invoked sudo. """ if name == "default": return None if not hasattr(os, "geteuid") or os.geteuid() != 0: return None sudo_user = os.environ.get("SUDO_USER", "").strip() if not sudo_user or sudo_user == "root": return None try: import pwd home = Path(pwd.getpwnam(sudo_user).pw_dir) except Exception: return None candidate = home / ".hermes" / "profiles" / name try: if candidate.is_dir(): return str(candidate) except OSError: return None return None # 1. Check for explicit -p / --profile flag. Historically this worked even # after the subcommand (`hermes chat -p coder`), so keep scanning broadly. # The exception is command-argv passthrough regions such as `mcp add --args`. value_flags = { "-z", "--oneshot", "-m", "--model", "--provider", "-t", "--toolsets", "-r", "--resume", "-s", "--skills", "--usage-file", "--in", } optional_value_flags = {"-c", "--continue"} i = 0 while i < len(argv): arg = argv[i] if arg == "--": break if arg == "--args" and _inside_mcp_add_args(i): break if arg in {"--profile", "-p"} and i + 1 < len(argv): profile_name = argv[i + 1] consume = 2 profile_index = i break if arg.startswith("--profile="): profile_name = arg.split("=", 1)[1] consume = 1 profile_index = i break if "=" not in arg and arg in value_flags and i + 1 < len(argv): i += 2 elif ( "=" not in arg and arg in optional_value_flags and i + 1 < len(argv) and not argv[i + 1].startswith("-") ): i += 2 else: i += 1 # 1b. Reject values that can't be valid profile names (e.g. pytest's # "-p no:xdist" would be misread as profile "no:xdist" otherwise). # Mirrors hermes_cli.profiles._PROFILE_ID_RE so we never call # resolve_profile_env() with a value it must reject + sys.exit on. if profile_name is not None and consume == 2: import re as _re if not _re.match(r"^[a-z0-9][a-z0-9_-]{0,63}$", profile_name): profile_name = None consume = 0 profile_index = None # 1.5 If HERMES_HOME is already set and no explicit flag was given, trust it # only when it already points to a specific profile directory. The # distinguishing heuristic: a profile path has "profiles" as its immediate # parent directory name (e.g. ~/.hermes/profiles/coder or # /opt/data/profiles/coder). If HERMES_HOME points to the hermes root # instead (e.g. systemd hardcodes HERMES_HOME=/root/.hermes), we must # still read active_profile — the user may have switched profiles via # `hermes profile use` and the gateway should honour that choice. # See issue #22502. hermes_home_env = os.environ.get("HERMES_HOME", "") if profile_name is None and hermes_home_env: if Path(hermes_home_env).parent.name == "profiles": return # 2. If no flag, check active_profile in the hermes root. # # EXCEPTION: a supervised s6 gateway child (exported by the container # run-script as HERMES_S6_SUPERVISED_CHILD=1) must NOT follow the sticky # active_profile. Each supervised slot has a fixed profile identity: named # slots pass ``-p `` explicitly (handled in step 1 above), and the # reserved ``gateway-default`` slot runs bare ``hermes gateway run`` to mean # "the root HERMES_HOME profile". If the reserved default child read # active_profile here, switching the active profile (e.g. via the dashboard) # would silently redirect the default gateway into that profile — yielding a # duplicate gateway for the active profile and no real default gateway. See # the "Docker & Profiles & Dashboard" report. if profile_name is None and not os.environ.get("HERMES_S6_SUPERVISED_CHILD"): try: from hermes_constants import get_default_hermes_root active_path = get_default_hermes_root() / "active_profile" if active_path.exists(): name = active_path.read_text(encoding="utf-8").strip() if name and name != "default": profile_name = name consume = 0 # don't strip anything from argv except (UnicodeDecodeError, OSError): pass # corrupted file, skip # 3. If we found a profile, resolve and set HERMES_HOME if profile_name is not None: try: from hermes_cli.profiles import resolve_profile_env hermes_home = resolve_profile_env(profile_name) except FileNotFoundError as exc: hermes_home = _resolve_sudo_user_profile_env(profile_name) if not hermes_home: print(f"Error: {exc}", file=sys.stderr) sys.exit(1) except ValueError as exc: print(f"Error: {exc}", file=sys.stderr) sys.exit(1) except Exception as exc: # A bug in profiles.py must NEVER prevent hermes from starting print( f"Warning: profile override failed ({exc}), using default", file=sys.stderr, ) return os.environ["HERMES_HOME"] = hermes_home # Strip the flag from argv so argparse doesn't choke if consume > 0 and profile_index is not None: start = profile_index + 1 # +1 because argv is sys.argv[1:] sys.argv = sys.argv[:start] + sys.argv[start + consume :] _apply_profile_override() # Load .env from ~/.hermes/.env first, then project root as dev fallback. # User-managed env files should override stale shell exports on restart. from hermes_cli.config import get_hermes_home from hermes_cli.env_loader import load_hermes_dotenv load_hermes_dotenv(project_env=PROJECT_ROOT / ".env") # Bridge security.redact_secrets from config.yaml → HERMES_REDACT_SECRETS env # var BEFORE hermes_logging imports agent.redact (which snapshots the flag at # module-import time). Without this, config.yaml's toggle is ignored because # the setup_logging() call below imports agent.redact, which reads the env var # exactly once. Env var in .env still wins — this is config.yaml fallback only. # # We also read network.force_ipv4 from the same yaml load to avoid two # separate config.yaml reads (saves ~17ms on every CLI startup — the second # `load_config()` was doing a full deep-merge for one boolean lookup). _FORCE_IPV4_EARLY = False try: # Reuse read_raw_config()'s (mtime, size)-keyed cache instead of a bespoke # yaml.load — the SAME parse then serves hermes_logging's # _read_logging_config and any later raw reads in this process, collapsing # 3-4 config.yaml parses per invocation into one. from hermes_cli.config import read_raw_config as _read_raw_early _cfg_path = get_hermes_home() / "config.yaml" if _cfg_path.exists(): _early_cfg_raw = _read_raw_early() or {} # Managed scope: overlay administrator-pinned values so a managed # security.redact_secrets / network.force_ipv4 wins here too. This early # bridge reads config.yaml directly (before load_config is usable), so # without the overlay a managed redact_secrets toggle would be ignored. # Fail-open via the shared helper. try: from hermes_cli import managed_scope _early_cfg_raw = managed_scope.apply_managed_overlay(_early_cfg_raw) except Exception: pass if "HERMES_REDACT_SECRETS" not in os.environ: _early_sec_cfg = _early_cfg_raw.get("security", {}) if isinstance(_early_sec_cfg, dict): _early_redact = _early_sec_cfg.get("redact_secrets") if _early_redact is not None: os.environ["HERMES_REDACT_SECRETS"] = str(_early_redact).lower() _early_net_cfg = _early_cfg_raw.get("network", {}) if isinstance(_early_net_cfg, dict) and _early_net_cfg.get("force_ipv4"): _FORCE_IPV4_EARLY = True del _early_cfg_raw del _cfg_path except Exception: pass # best-effort — redaction stays at default (enabled) on config errors # Initialize centralized file logging early — all `hermes` subcommands # (chat, setup, gateway, config, etc.) write to agent.log + errors.log. # Dashboard entrypoints bootstrap with GUI mode so gui.log is always present # during GUI testing, including pre-dispatch startup failures. try: from hermes_logging import setup_logging as _setup_logging _setup_logging( mode=( "gui" if next((arg for arg in sys.argv[1:] if not arg.startswith("-")), "") in {"dashboard", "serve", "gui", "desktop"} else "cli" ) ) except Exception: pass # best-effort — don't crash the CLI if logging setup fails # Apply IPv4 preference early, before any HTTP clients are created. # We already determined whether to force IPv4 from the raw yaml read above — # this just calls the toggle without a redundant load_config() round trip. if _FORCE_IPV4_EARLY: try: from hermes_constants import apply_ipv4_preference as _apply_ipv4 _apply_ipv4(force=True) except Exception: pass # best-effort — don't crash if hermes_constants not importable yet import logging import threading import time as _time from datetime import datetime from hermes_cli import __version__, __release_date__ # Provider model-selection wizard flows extracted to hermes_cli/model_setup_flows.py # (god-file decomposition Phase 2). Re-imported here so select_provider_and_model and # existing test monkeypatches (hermes_cli.main._model_flow_*) keep resolving unchanged. from hermes_cli.model_setup_flows import ( _prompt_auth_credentials_choice, _model_flow_openrouter, _model_flow_nous, _model_flow_openai_codex, _model_flow_xai_oauth, _model_flow_qwen_oauth, _model_flow_minimax_oauth, _model_flow_custom, _model_flow_azure_foundry, _model_flow_named_custom, _model_flow_copilot, _model_flow_copilot_acp, _model_flow_kimi, _model_flow_stepfun, _model_flow_bedrock_api_key, _model_flow_bedrock, _model_flow_vertex, _model_flow_api_key_provider, _model_flow_anthropic, _model_flow_moa, _model_flow_ai_gateway, ) logger = logging.getLogger(__name__) def _is_termux_startup_environment(env: dict[str, str] | None = None) -> bool: """Import-safe Termux check for cold-start-sensitive CLI paths.""" check = env or os.environ prefix = str(check.get("PREFIX", "")) return bool( check.get("TERMUX_VERSION") or "com.termux/files/usr" in prefix or prefix.startswith("/data/data/com.termux/") ) def _read_packed_ref(common_dir: Path, ref: str) -> str | None: """Look up a ref in .git/packed-refs without spawning git. packed-refs lines look like `` `` with optional ``^`` peel lines and ``#``-prefixed comments / ``# pack-refs with:`` header. """ try: text = (common_dir / "packed-refs").read_text(encoding="utf-8", errors="replace") except OSError: return None for line in text.splitlines(): if not line or line.startswith("#") or line.startswith("^"): continue parts = line.split(" ", 1) if len(parts) == 2 and parts[1].strip() == ref: return parts[0].strip() return None def _read_git_revision_fingerprint(repo_root: Path) -> str | None: """Return a cheap checkout fingerprint without spawning git.""" git_dir = repo_root / ".git" try: if git_dir.is_file(): for line in git_dir.read_text(encoding="utf-8", errors="replace").splitlines(): key, _, value = line.partition(":") if key.strip() == "gitdir" and value.strip(): git_dir = (repo_root / value.strip()).resolve() break # Worktrees point HEAD at a per-worktree gitdir but pack their refs # in the main repo's gitdir (referenced via ``commondir``). Resolve # that up front so packed-refs lookups hit the right file. common_dir = git_dir commondir_file = git_dir / "commondir" if commondir_file.exists(): try: rel = commondir_file.read_text(encoding="utf-8", errors="replace").strip() if rel: common_dir = (git_dir / rel).resolve() except OSError: pass head_file = git_dir / "HEAD" head = head_file.read_text(encoding="utf-8", errors="replace").strip() if head.startswith("ref:"): ref = head.split(":", 1)[1].strip() # Loose refs may live in the worktree gitdir OR the common dir # (branches created via `git worktree add` typically live in the # common dir's refs/heads/). for candidate in (git_dir, common_dir): ref_file = candidate / ref if ref_file.exists(): return f"git:{ref}:{ref_file.read_text(encoding='utf-8', errors='replace').strip()}" packed_sha = _read_packed_ref(common_dir, ref) if packed_sha: return f"git:{ref}:{packed_sha}" # Ref name is known but unresolved — still stable across launches, # and the version/release fallback in the caller will invalidate # after `hermes update`. return f"git:{ref}:unresolved" return f"git:HEAD:{head}" except OSError: return None def _termux_bundled_skills_fingerprint() -> str: """Cheap invalidation key for Termux bundled-skill startup sync.""" git_fp = _read_git_revision_fingerprint(PROJECT_ROOT) if git_fp: return git_fp skills_dir = PROJECT_ROOT / "skills" try: stat = skills_dir.stat() return f"skills:{__version__}:{__release_date__}:{stat.st_mtime_ns}:{stat.st_size}" except OSError: return f"skills:{__version__}:{__release_date__}:missing" def _termux_bundled_skills_stamp_path() -> Path: return get_hermes_home() / "skills" / ".termux_bundled_sync_stamp" def _termux_bundled_skills_sync_needed() -> bool: if not _is_termux_startup_environment(): return True if os.environ.get("HERMES_TERMUX_FORCE_SKILLS_SYNC") == "1": return True try: stamp = _termux_bundled_skills_stamp_path() return stamp.read_text(encoding="utf-8").strip() != _termux_bundled_skills_fingerprint() except OSError: return True def _mark_termux_bundled_skills_synced() -> None: if not _is_termux_startup_environment(): return try: stamp = _termux_bundled_skills_stamp_path() stamp.parent.mkdir(parents=True, exist_ok=True) stamp.write_text(_termux_bundled_skills_fingerprint() + "\n", encoding="utf-8") except OSError: pass def _sync_bundled_skills_for_startup() -> bool: """Sync bundled skills, but skip unchanged Termux checkouts cheaply. Hashing every bundled skill is safe but expensive on older Android storage. The git/ref stamp keeps post-update correctness: a changed checkout revision forces one real sync, then later starts skip it. """ if _is_termux_startup_environment() and not _termux_bundled_skills_sync_needed(): return False from tools.skills_sync import sync_skills sync_skills(quiet=True) _mark_termux_bundled_skills_synced() return True def _termux_should_prefetch_update_check() -> bool: if not _is_termux_startup_environment(): return True return os.environ.get("HERMES_TERMUX_PREFETCH_UPDATES") == "1" def _relative_time(ts) -> str: """Format a timestamp as relative time (e.g., '2h ago', 'yesterday'). Thin wrapper kept for backward compatibility; the implementation lives in :mod:`hermes_cli.timefmt` so lightweight consumers don't have to import the whole CLI surface. """ from hermes_cli.timefmt import relative_time return relative_time(ts) def _has_any_provider_configured() -> bool: """Check if at least one inference provider is usable.""" from hermes_cli.config import get_env_path, get_hermes_home, load_config from hermes_cli.auth import get_auth_status # Determine whether Hermes itself has been explicitly configured (model # in config that isn't the hardcoded default). Used below to gate external # tool credentials (Claude Code, Codex CLI) that shouldn't silently skip # the setup wizard on a fresh install. from hermes_cli.config import DEFAULT_CONFIG _DEFAULT_MODEL = DEFAULT_CONFIG.get("model", "") cfg = load_config() model_cfg = cfg.get("model") if isinstance(model_cfg, dict): _default = model_cfg.get("default") if isinstance(_default, dict): from hermes_cli.config import split_model_config_default _model_name, _ = split_model_config_default(_default) else: _model_name = (_default or "") _model_name = (str(_model_name) if not isinstance(_model_name, str) else _model_name).strip() elif isinstance(model_cfg, str): _model_name = model_cfg.strip() else: _model_name = "" _has_hermes_config = _model_name and _model_name != _DEFAULT_MODEL # Check env vars (may be set by .env or shell). # OPENAI_BASE_URL alone counts — local models (vLLM, llama.cpp, etc.) # often don't require an API key. from hermes_cli.auth import PROVIDER_REGISTRY # Collect all provider env vars provider_env_vars = { "OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "ANTHROPIC_TOKEN", "OPENAI_BASE_URL", } for pconfig in PROVIDER_REGISTRY.values(): if pconfig.auth_type == "api_key": provider_env_vars.update(pconfig.api_key_env_vars) if any(os.getenv(v) for v in provider_env_vars): return True # Check .env file for keys env_file = get_env_path() if env_file.exists(): try: for line in env_file.read_text(encoding="utf-8").splitlines(): line = line.strip() if line.startswith("#") or "=" not in line: continue if line.startswith("export "): line = line[7:] key, _, val = line.partition("=") val = val.strip().strip("'\"") if key.strip() in provider_env_vars and val: return True except Exception: pass # Cheap local checks first: auth.json and config.yaml are on-disk lookups, # while the PROVIDER_REGISTRY sweep below spawns subprocesses (gh) and can # take 15-20s — long enough that desktop setup.status calls time out. # Check for Nous Portal OAuth credentials auth_file = get_hermes_home() / "auth.json" if auth_file.exists(): try: import json auth = json.loads(auth_file.read_text(encoding="utf-8-sig")) active = auth.get("active_provider") if active: status = get_auth_status(active) if status.get("logged_in"): return True except Exception: pass # Check config.yaml — if model is a dict with an explicit provider set, # the user has gone through setup (fresh installs have model as a plain # string). Also covers custom endpoints that store api_key/base_url in # config rather than .env. if isinstance(model_cfg, dict): cfg_provider = (model_cfg.get("provider") or "").strip() cfg_base_url = (model_cfg.get("base_url") or "").strip() cfg_api_key = (model_cfg.get("api_key") or "").strip() if cfg_provider or cfg_base_url or cfg_api_key: return True # Check provider-specific auth fallbacks (for example, Copilot via gh auth). try: for provider_id, pconfig in PROVIDER_REGISTRY.items(): if pconfig.auth_type != "api_key": continue status = get_auth_status(provider_id) if status.get("logged_in"): return True except Exception: pass # Check for Claude Code OAuth credentials (~/.claude/.credentials.json) # Only count these if Hermes has been explicitly configured — Claude Code # being installed doesn't mean the user wants Hermes to use their tokens. if _has_hermes_config: try: from agent.anthropic_adapter import ( read_claude_code_credentials, is_claude_code_token_valid, ) creds = read_claude_code_credentials() if creds and ( is_claude_code_token_valid(creds) or creds.get("refreshToken") ): return True except Exception: pass return False def _confirm_startup_expensive_model_override(args) -> None: """Guard startup -m/--provider overrides before the first API call.""" explicit_model = (getattr(args, "model", None) or "").strip() explicit_provider = (getattr(args, "provider", None) or "").strip() if not explicit_model and not explicit_provider: return try: from hermes_cli.config import load_config from hermes_cli.model_selection_guards import combined_selection_warning except Exception as exc: logger.warning("startup model cost guard unavailable: %s", exc) return try: model_cfg = (load_config().get("model") or {}) except Exception as exc: logger.warning("startup model cost guard could not load config: %s", exc) model_cfg = {} if not isinstance(model_cfg, dict): model_cfg = {} model = explicit_model or (model_cfg.get("default") or "").strip() if not model: return provider = (explicit_provider or model_cfg.get("provider") or "").strip() try: # Unified registry: cost guard + id-keyed guards (e.g. the # data-training-tier warning) all fire at startup too. warning = combined_selection_warning( model, provider=provider, base_url=(model_cfg.get("base_url") or ""), api_key=(model_cfg.get("api_key") or ""), ) except Exception as exc: logger.warning("startup model cost guard failed for %s/%s: %s", provider, model, exc) return if warning is None: return # Cost and provider-routing confirmation is intentionally independent of # --yolo / --accept-hooks: those flags approve local command/tool risk, not # paid aggregator spend or a surprising provider route. message = warning.message if not sys.stdin.isatty(): sys.stderr.write(message + "\n") sys.stderr.write( "Refusing this startup model override in non-interactive mode. " "Run interactively and confirm if you intend to use it.\n" ) raise SystemExit(1) sys.stderr.write(message + "\n") try: reply = input("Use this model for this invocation? [y/N] ").strip().lower() except (EOFError, KeyboardInterrupt): reply = "" if reply not in {"y", "yes"}: sys.stderr.write("Model override cancelled.\n") raise SystemExit(1) def _session_browse_picker(sessions: list) -> Optional[str]: """Interactive curses-based session browser with live search filtering. Returns the selected session ID, or None if cancelled. """ if not sessions: print("No sessions found.") return None # Try curses-based picker first try: import curses result_holder = [None] def _format_row(s, max_x): """Format a session row for display.""" title = (s.get("title") or "").strip() preview = (s.get("preview") or "").strip() source = s.get("source", "")[:6] last_active = _relative_time(s.get("last_active")) sid = s["id"][:18] # Adaptive column widths based on terminal width # Layout: [arrow 3] [title/preview flexible] [active 12] [src 6] [id 18] fixed_cols = 3 + 12 + 6 + 18 + 6 # arrow + active + src + id + padding name_width = max(20, max_x - fixed_cols) if title: name = title[:name_width] elif preview: name = preview[:name_width] else: name = sid return f"{name:<{name_width}} {last_active:<10} {source:<5} {sid}" def _match(s, query): """Check if a session matches the search query (case-insensitive).""" q = query.lower() return ( q in (s.get("title") or "").lower() or q in (s.get("preview") or "").lower() or q in s.get("id", "").lower() or q in (s.get("source") or "").lower() ) def _curses_browse(stdscr): curses.curs_set(0) if curses.has_colors(): curses.start_color() curses.use_default_colors() curses.init_pair(1, curses.COLOR_GREEN, -1) # selected curses.init_pair(2, curses.COLOR_YELLOW, -1) # header curses.init_pair(3, curses.COLOR_CYAN, -1) # search curses.init_pair(4, 8 if curses.COLORS > 8 else curses.COLOR_WHITE, -1) # dim cursor = 0 scroll_offset = 0 search_text = "" filtered = list(sessions) while True: stdscr.clear() max_y, max_x = stdscr.getmaxyx() if max_y < 5 or max_x < 40: # Terminal too small try: stdscr.addstr(0, 0, "Terminal too small") except curses.error: pass stdscr.refresh() stdscr.getch() return # Header line if search_text: header = f" Browse sessions — filter: {search_text}█" header_attr = curses.A_BOLD if curses.has_colors(): header_attr |= curses.color_pair(3) else: header = " Browse sessions — ↑↓ navigate Enter select Type to filter Esc quit" header_attr = curses.A_BOLD if curses.has_colors(): header_attr |= curses.color_pair(2) try: stdscr.addnstr(0, 0, header, max_x - 1, header_attr) except curses.error: pass # Column header line fixed_cols = 3 + 12 + 6 + 18 + 6 name_width = max(20, max_x - fixed_cols) col_header = f" {'Title / Preview':<{name_width}} {'Active':<10} {'Src':<5} {'ID'}" try: dim_attr = ( curses.color_pair(4) if curses.has_colors() else curses.A_DIM ) stdscr.addnstr(1, 0, col_header, max_x - 1, dim_attr) except curses.error: pass # Compute visible area visible_rows = max_y - 4 # header + col header + blank + footer visible_rows = max(visible_rows, 1) # Clamp cursor and scroll if not filtered: try: msg = " No sessions match the filter." stdscr.addnstr(3, 0, msg, max_x - 1, curses.A_DIM) except curses.error: pass else: if cursor >= len(filtered): cursor = len(filtered) - 1 cursor = max(cursor, 0) if cursor < scroll_offset: scroll_offset = cursor elif cursor >= scroll_offset + visible_rows: scroll_offset = cursor - visible_rows + 1 for draw_i, i in enumerate( range( scroll_offset, min(len(filtered), scroll_offset + visible_rows), ) ): y = draw_i + 3 if y >= max_y - 1: break s = filtered[i] arrow = " → " if i == cursor else " " row = arrow + _format_row(s, max_x - 3) attr = curses.A_NORMAL if i == cursor: attr = curses.A_BOLD if curses.has_colors(): attr |= curses.color_pair(1) try: stdscr.addnstr(y, 0, row, max_x - 1, attr) except curses.error: pass # Footer footer_y = max_y - 1 if filtered: footer = f" {cursor + 1}/{len(filtered)} sessions" if len(filtered) < len(sessions): footer += f" (filtered from {len(sessions)})" else: footer = f" 0/{len(sessions)} sessions" try: stdscr.addnstr( footer_y, 0, footer, max_x - 1, curses.color_pair(4) if curses.has_colors() else curses.A_DIM, ) except curses.error: pass stdscr.refresh() key = stdscr.getch() if key in {curses.KEY_UP,}: if filtered: cursor = (cursor - 1) % len(filtered) elif key in {curses.KEY_DOWN,}: if filtered: cursor = (cursor + 1) % len(filtered) elif key in {curses.KEY_ENTER, 10, 13}: if filtered: result_holder[0] = filtered[cursor]["id"] return elif key == 27: # Esc if search_text: # First Esc clears the search search_text = "" filtered = list(sessions) cursor = 0 scroll_offset = 0 else: # Second Esc exits return elif key in {curses.KEY_BACKSPACE, 127, 8}: if search_text: search_text = search_text[:-1] if search_text: filtered = [s for s in sessions if _match(s, search_text)] else: filtered = list(sessions) cursor = 0 scroll_offset = 0 elif key == ord("q") and not search_text: return elif 32 <= key <= 126: # Printable character → add to search filter search_text += chr(key) filtered = [s for s in sessions if _match(s, search_text)] cursor = 0 scroll_offset = 0 curses.wrapper(_curses_browse) return result_holder[0] except Exception: pass # Fallback: numbered list (Windows without curses, etc.) print("\n Browse sessions (enter number to resume, q to cancel)\n") for i, s in enumerate(sessions): title = (s.get("title") or "").strip() preview = (s.get("preview") or "").strip() label = title or preview or s["id"] if len(label) > 50: label = label[:47] + "..." last_active = _relative_time(s.get("last_active")) src = s.get("source", "")[:6] print(f" {i + 1:>3}. {label:<50} {last_active:<10} {src}") while True: try: val = input(f"\n Select [1-{len(sessions)}]: ").strip() if not val or val.lower() in {"q", "quit", "exit"}: return None idx = int(val) - 1 if 0 <= idx < len(sessions): return sessions[idx]["id"] print(f" Invalid selection. Enter 1-{len(sessions)} or q to cancel.") except ValueError: print(" Invalid input. Enter a number or q to cancel.") except (KeyboardInterrupt, EOFError): print() return None def _resolve_workspace_key() -> Optional[str]: """The current workspace identity for cwd-scoped resume. Git repo root when CWD is inside a repo (so all sessions across its subdirs/worktrees group together), else the CWD itself. Returns None when neither can be determined — callers fall back to the global MRU then. """ try: import subprocess result = subprocess.run( ["git", "rev-parse", "--show-toplevel"], capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=5, ) if result.returncode == 0 and result.stdout.strip(): return os.path.abspath(result.stdout.strip()) except Exception: pass try: return os.getcwd() except Exception: return None def _resolve_last_session(source: str = "cli") -> Optional[str]: """Look up the most recently-used session ID for a source. Scoped to the current workspace first (git repo root, else cwd) so ``hermes -c`` from repo A continues repo A's last session rather than the global MRU. Falls back to the unscoped MRU when no session matches the current workspace, preserving the old behaviour for fresh directories. """ db = None try: from hermes_state import SessionDB db = SessionDB() ws_key = _resolve_workspace_key() if ws_key: sessions = db.search_sessions(source=source, limit=1, workspace_key=ws_key) if sessions: return sessions[0]["id"] # Fallback: global MRU for this source. sessions = db.search_sessions(source=source, limit=1) return sessions[0]["id"] if sessions else None except Exception: pass finally: if db is not None: try: db.close() except Exception: pass return None def _probe_container(cmd: list, backend: str, via_sudo: bool = False): """Run a container inspect probe, returning the CompletedProcess. Catches TimeoutExpired specifically for a human-readable message; all other exceptions propagate naturally. """ try: return subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=15) except subprocess.TimeoutExpired: label = f"sudo {backend}" if via_sudo else backend print( f"Error: timed out waiting for {label} to respond.\n" f"The {backend} daemon may be unresponsive or starting up.", file=sys.stderr, ) sys.exit(1) def _exec_in_container(container_info: dict, cli_args: list): """Replace the current process with a command inside the managed container. Probes whether sudo is needed (rootful containers), then os.execvp into the container. On success the Python process is replaced entirely and the container's exit code becomes the process exit code (OS semantics). On failure, OSError propagates naturally. Args: container_info: dict with backend, container_name, exec_user, hermes_bin cli_args: the original CLI arguments (everything after 'hermes') """ backend = container_info["backend"] container_name = container_info["container_name"] exec_user = container_info["exec_user"] hermes_bin = container_info["hermes_bin"] runtime = shutil.which(backend) if not runtime: print( f"Error: {backend} not found on PATH. Cannot route to container.", file=sys.stderr, ) sys.exit(1) # Rootful containers (NixOS systemd service) are invisible to unprivileged # users — Podman uses per-user namespaces, Docker needs group access. # Probe whether the runtime can see the container; if not, try via sudo. sudo_path = None probe = _probe_container( [runtime, "inspect", "--format", "ok", container_name], backend, ) if probe.returncode != 0: sudo_path = shutil.which("sudo") if sudo_path: probe2 = _probe_container( [sudo_path, "-n", runtime, "inspect", "--format", "ok", container_name], backend, via_sudo=True, ) if probe2.returncode != 0: print( f"Error: container '{container_name}' not found via {backend}.\n" f"\n" f"The container is likely running as root. Your user cannot see it\n" f"because {backend} uses per-user namespaces. Grant passwordless\n" f"sudo for {backend} — the -n (non-interactive) flag is required\n" f"because a password prompt would hang or break piped commands.\n" f"\n" f"On NixOS:\n" f"\n" f" security.sudo.extraRules = [{{\n" f' users = [ "{os.getenv("USER", "your-user")}" ];\n' f' commands = [{{ command = "{runtime}"; options = [ "NOPASSWD" ]; }}];\n' f" }}];\n" f"\n" f"Or run: sudo hermes {' '.join(cli_args)}", file=sys.stderr, ) sys.exit(1) else: print( f"Error: container '{container_name}' not found via {backend}.\n" f"The container may be running under root. Try: sudo hermes {' '.join(cli_args)}", file=sys.stderr, ) sys.exit(1) is_tty = sys.stdin.isatty() tty_flags = ["-it"] if is_tty else ["-i"] env_flags = [] for var in ("TERM", "COLORTERM", "LANG", "LC_ALL"): val = os.environ.get(var) if val: env_flags.extend(["-e", f"{var}={val}"]) cmd_prefix = [sudo_path, "-n", runtime] if sudo_path else [runtime] exec_cmd = ( cmd_prefix + ["exec"] + tty_flags + ["-u", exec_user] + env_flags + [container_name, hermes_bin] + cli_args ) os.execvp(exec_cmd[0], exec_cmd) def _resolve_session_by_name_or_id(name_or_id: str) -> Optional[str]: """Resolve a session name (title) or ID to a session ID. - If it looks like a session ID (contains underscore + hex), try direct lookup first. - Otherwise, treat it as a title and use resolve_session_by_title (auto-latest). - Falls back to the other method if the first doesn't match. - If the resolved session is a compression root, follow the chain forward to the latest continuation. Users who remember the old root ID (e.g. from an exit summary printed before the bug fix, or from notes) get resumed at the live tip instead of a stale parent with no messages. """ db = None try: from hermes_state import SessionDB db = SessionDB() # Try as exact session ID first session = db.get_session(name_or_id) resolved_id: Optional[str] = None if session: resolved_id = session["id"] else: # Try as title (with auto-latest for lineage) resolved_id = db.resolve_session_by_title(name_or_id) if resolved_id: # Project forward through compression chain so resumes land on # the live tip instead of a dead compressed parent. try: resolved_id = db.get_compression_tip(resolved_id) or resolved_id except Exception: pass return resolved_id except Exception: pass finally: if db is not None: try: db.close() except Exception: pass return None def _read_tui_active_session_file(path: Optional[str]) -> Optional[str]: if not path: return None try: data = json.loads(Path(path).read_text(encoding="utf-8")) sid = str(data.get("session_id") or "").strip() return sid or None except Exception: return None def _print_tui_exit_summary( session_id: Optional[str], active_session_file: Optional[str] = None ) -> None: """Print a shell-visible epilogue after TUI exits.""" target = ( _read_tui_active_session_file(active_session_file) or session_id or _resolve_last_session(source="tui") ) if not target: return db = None try: from hermes_state import SessionDB db = SessionDB() session = db.get_session(target) if not session: return title = db.get_session_title(target) message_count = int(session.get("message_count") or 0) if message_count == 0: return # No real conversation — don't show resume info input_tokens = int(session.get("input_tokens") or 0) output_tokens = int(session.get("output_tokens") or 0) cache_read_tokens = int(session.get("cache_read_tokens") or 0) cache_write_tokens = int(session.get("cache_write_tokens") or 0) reasoning_tokens = int(session.get("reasoning_tokens") or 0) total_tokens = ( input_tokens + output_tokens + cache_read_tokens + cache_write_tokens + reasoning_tokens ) except Exception: return finally: if db is not None: db.close() print() print("Resume this session with:") print(f" hermes --tui --resume {target}") if title: print(f' hermes --tui -c "{title}"') print() print(f"Session: {target}") if title: print(f"Title: {title}") print(f"Messages: {message_count}") print( "Tokens: " f"{total_tokens} (in {input_tokens}, out {output_tokens}, " f"cache {cache_read_tokens + cache_write_tokens}, reasoning {reasoning_tokens})" ) _NPM_LOCK_RUNTIME_KEYS = frozenset({"ideallyInert", "peer"}) """Lockfile fields npm writes non-deterministically at install time. ``ideallyInert`` is npm's runtime annotation for packages it skipped installing (per-platform opt-outs). ``peer`` is dropped from the hidden ``.package-lock.json`` on dev-dependencies that are *also* declared as peers — the canonical ``package-lock.json`` records the dual role, but npm 9's actualized tree strips it. Neither key represents a real skew between what was declared and what was installed, so we exclude them from the comparison in :func:`_tui_need_npm_install` to avoid false-positive reinstalls on every launch. """ def _workspace_root(dir: Path) -> Path: """Return the npm workspace root for *dir*. In a workspace checkout the single ``package-lock.json`` and hoisted ``node_modules/`` live at the workspace root (the parent of the sub-package directory). Heuristic: if *dir* has a ``package.json`` but **no** ``package-lock.json``, and its **parent** has a ``package-lock.json``, the parent is the workspace root. Otherwise *dir* itself is the root (standalone project or prebuilt-bundle layout). Used by ``_tui_need_npm_install``, ``_make_tui_argv``, and ``_build_web_ui`` so that lockfile/node_modules resolution and ``npm install`` cwd stay consistent — a single helper prevents the checks from diverging if someone accidentally creates a sub-package lockfile (e.g. running ``npm install`` in the wrong directory). """ if ( (dir / "package.json").is_file() and not (dir / "package-lock.json").is_file() and (dir.parent / "package-lock.json").is_file() ): return dir.parent return dir def _termux_workspace_install_context( dir: Path, *, include_child_workspaces: bool = False ) -> tuple[Path, tuple[str, ...]]: """Return Termux-only ``(cwd, npm_args)`` for installing deps for *dir* only.""" ws_root = _workspace_root(dir) if ws_root == dir: return dir, () try: workspace = dir.relative_to(ws_root).as_posix() except ValueError: return ws_root, () workspace_args: list[str] = ["--workspace", workspace] if include_child_workspaces: packages_dir = dir / "packages" if packages_dir.is_dir(): for child in sorted(packages_dir.iterdir()): if child.is_dir() and (child / "package.json").is_file(): workspace_args.extend( ["--workspace", child.relative_to(ws_root).as_posix()] ) workspace_args.append("--include-workspace-root=false") return ws_root, tuple(workspace_args) def _tui_need_npm_install(root: Path) -> bool: """True when @hermes/ink is missing or node_modules is behind package-lock.json. Prebuilt bundle mode: when ``dist/entry.js`` exists and there is no ``package-lock.json`` (nix install layout only ships ``dist/`` + ``package.json``), skip reinstall entirely — the bundle is self-contained and there is nothing to install. With npm workspaces the single ``package-lock.json`` and the hoisted ``node_modules/`` live at the workspace root (the parent of the ``ui-tui/`` directory). The lockfile / ink / marker checks use that workspace root; only the prebuilt-bundle sentinel stays relative to *root* (``ui-tui/dist/entry.js``). Compares ``package-lock.json`` against ``node_modules/.package-lock.json`` (npm's hidden lockfile) by **content**, not mtime: git checkouts and npm rewrites can bump the root lockfile's timestamp even when installed deps already match, which used to trigger a spurious "Installing TUI dependencies" on every launch. For each entry in the root lock's ``packages`` map: - missing from hidden lock → reinstall (unless the entry is marked ``optional`` or ``peer``, which npm may intentionally skip per platform) - present but with differing fields (excluding npm-written runtime annotations like ``ideallyInert``) → reinstall Extra entries that exist only in the hidden lock are ignored — stale transitives left over from a removed dependency don't break runtime and we'd rather not force a reinstall for them. Falls back to mtime comparison if either lockfile is unparseable. """ # Prebuilt self-contained bundle (nix / packaged release): no lockfile # shipped, dist/entry.js is the single runtime artefact. entry = root / "dist" / "entry.js" # With npm workspaces the lockfile lives at the workspace root. ws_root = _workspace_root(root) lock = ws_root / "package-lock.json" if entry.is_file() and not lock.is_file(): return False ink = ws_root / "node_modules" / "@hermes" / "ink" / "package.json" if not ink.is_file(): return True if not lock.is_file(): return False marker = ws_root / "node_modules" / ".package-lock.json" if not marker.is_file(): return True # Compare lockfile contents, not mtimes: git checkouts and npm rewrites # can bump the root lockfile timestamp even when installed deps already # match. Fall back to mtime when either file is unparseable. try: wanted = json.loads(lock.read_text(encoding="utf-8")).get("packages") or {} installed = json.loads(marker.read_text(encoding="utf-8")).get("packages") or {} except (OSError, UnicodeDecodeError, json.JSONDecodeError): return lock.stat().st_mtime > marker.stat().st_mtime def comparable(pkg: dict) -> dict: return {k: v for k, v in pkg.items() if k not in _NPM_LOCK_RUNTIME_KEYS} for name, pkg in wanted.items(): if not name: continue if not isinstance(pkg, dict): continue if name not in installed: if pkg.get("optional") or pkg.get("peer"): continue return True if isinstance(installed[name], dict) and comparable(pkg) != comparable( installed[name] ): return True return False _TUI_BUILD_INPUT_DIRS = ( "src", "packages/hermes-ink/src", ) _TUI_BUILD_INPUT_FILES = ( "package.json", "package-lock.json", "tsconfig.json", "tsconfig.build.json", "babel.compiler.config.cjs", "scripts/build.mjs", "packages/hermes-ink/package.json", "packages/hermes-ink/index.js", "packages/hermes-ink/text-input.js", ) _TUI_BUILD_INPUT_SUFFIXES = frozenset( {".cjs", ".js", ".jsx", ".json", ".mjs", ".ts", ".tsx"} ) def _iter_tui_build_inputs(root: Path): """Yield source/config files that affect ``ui-tui/dist/entry.js``.""" for rel in _TUI_BUILD_INPUT_FILES: path = root / rel if path.is_file(): yield path for rel in _TUI_BUILD_INPUT_DIRS: base = root / rel if not base.is_dir(): continue for path in base.rglob("*"): if path.is_file() and path.suffix in _TUI_BUILD_INPUT_SUFFIXES: yield path def _tui_need_rebuild(root: Path) -> bool: """True when ``dist/entry.js`` is missing or older than TUI inputs. The TUI bundle is self-contained. Rebuilding it on every launch adds a visible cold-start tax on slow Termux CPUs, while a simple mtime freshness check still rebuilds immediately after source updates, dependency updates, or local edits. Set ``HERMES_TUI_FORCE_BUILD=1`` to force the old behaviour. """ force = (os.environ.get("HERMES_TUI_FORCE_BUILD") or "").strip().lower() if force in {"1", "true", "yes", "on"}: return True entry = root / "dist" / "entry.js" try: output_mtime = entry.stat().st_mtime except OSError: return True for path in _iter_tui_build_inputs(root): try: if path.stat().st_mtime > output_mtime: return True except OSError: return True return False def _ensure_tui_node() -> None: """Make sure `node` + `npm` are on PATH for the TUI. If either is missing and scripts/lib/node-bootstrap.sh is available, source it and call `ensure_node` (fnm/nvm/proto/brew/bundled cascade). After install, capture the resolved node binary path from the bash subprocess and prepend its directory to os.environ["PATH"] so shutil.which finds the new binaries in this Python process — regardless of which version manager was used (nvm, fnm, proto, brew, or the bundled fallback). Idempotent no-op when node+npm are already discoverable. Set ``HERMES_SKIP_NODE_BOOTSTRAP=1`` to disable auto-install. """ if shutil.which("node") and shutil.which("npm"): return if os.environ.get("HERMES_SKIP_NODE_BOOTSTRAP"): return helper = PROJECT_ROOT / "scripts" / "lib" / "node-bootstrap.sh" if not helper.is_file(): return from hermes_constants import get_hermes_home hermes_home = str(get_hermes_home()) try: # Helper writes logs to stderr; we ask bash to print `command -v node` # on stdout once ensure_node succeeds. Subshell PATH edits don't leak # back into Python, so the stdout capture is the bridge. result = subprocess.run( [ "bash", "-c", f'source "{helper}" >&2 && ensure_node >&2 && command -v node', ], env={**os.environ, "HERMES_HOME": hermes_home}, capture_output=True, text=True, encoding="utf-8", errors="replace", check=False, ) except (OSError, subprocess.SubprocessError): return parts = os.environ.get("PATH", "").split(os.pathsep) extras: list[Path] = [] resolved = (result.stdout or "").strip() if resolved: extras.append(Path(resolved).resolve().parent) extras.extend([Path(hermes_home) / "node" / "bin", Path.home() / ".local" / "bin"]) for extra in extras: s = str(extra) if extra.is_dir() and s not in parts: parts.insert(0, s) os.environ["PATH"] = os.pathsep.join(parts) def _find_bundled_tui(hermes_cli_dir: Path | None = None) -> Path | None: """Find a pre-built TUI entry.js bundled in the wheel.""" if hermes_cli_dir is None: hermes_cli_dir = Path(__file__).parent bundled = hermes_cli_dir / "tui_dist" / "entry.js" return bundled if bundled.is_file() else None def _restore_tui_workspace(tui_dir: Path) -> bool: """Try to restore a missing ``ui-tui/`` from git, returning True on success. On Windows an antivirus / NTFS filter driver can leave tracked ``ui-tui/`` files deleted in the working tree after ``hermes update`` (HEAD stays intact; the files just vanish — see issue #49145). Those files are tracked, so ``git restore`` puts them back deterministically. Best-effort: returns False (rather than raising) when git is unavailable, this isn't a checkout, or the restore leaves the directory still missing — the caller then prints the manual-recovery message. """ git = shutil.which("git") if not git or not (tui_dir.parent / ".git").exists(): return False try: subprocess.run( [git, "restore", "--", tui_dir.name], cwd=str(tui_dir.parent), capture_output=True, text=True, encoding="utf-8", errors="replace", check=False, ) except OSError: return False return tui_dir.is_dir() def _ensure_tui_workspace(tui_dir: Path) -> None: """Ensure ``ui-tui/`` exists before any npm/node subprocess uses it as cwd. Without this, a missing workspace falls through to ``subprocess.run(..., cwd=)``, which crashes with ``NotADirectoryError`` (``WinError 267`` on Windows) instead of a usable message (#49145). We first try to self-heal via ``git restore``; only if that can't recover the directory do we abort with concrete manual-recovery steps. """ if tui_dir.is_dir(): return if _restore_tui_workspace(tui_dir): if not os.environ.get("HERMES_QUIET"): print(f"Restored missing TUI workspace: {tui_dir}") return print( "Error: the TUI workspace is missing from this Hermes checkout.\n" f"Expected directory: {tui_dir}\n" "This usually means `hermes update` left tracked ui-tui files deleted.\n" "Recovery:\n" " 1. From the Hermes checkout, run `git restore -- ui-tui`\n" " 2. Run `npm install --silent --no-fund --no-audit --progress=false`\n" " 3. Retry `hermes --tui`\n" "If the checkout is still inconsistent, run `hermes update --force`.", file=sys.stderr, ) sys.exit(1) def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]: """TUI: --dev → tsx src; else node dist (HERMES_TUI_DIR prebuilt or esbuild).""" _ensure_tui_node() def _node_bin(bin: str) -> str: if bin == "node": env_node = os.environ.get("HERMES_NODE") if env_node and os.path.isfile(env_node) and os.access(env_node, os.X_OK): return env_node # find_node_executable() prefers the managed $HERMES_HOME/node tree, # which is not on PATH — a bare which() would declare "node not found" # and exit on an install whose only Node is the one Hermes installed, # and would pick a system Node over the managed one when both exist. from hermes_constants import find_node_executable path = find_node_executable(bin) if not path and bin == "node": try: from hermes_cli.dep_ensure import ensure_dependency if ensure_dependency("node"): path = find_node_executable("node") except Exception: pass if not path: print(f"{bin} not found — install Node.js to use the TUI.") sys.exit(1) return path # Footgun: --dev against a prebuilt bundle that has no source/node_modules. ext_dir = os.environ.get("HERMES_TUI_DIR") if tui_dev and ext_dir: print( f"Error: --dev is incompatible with HERMES_TUI_DIR={ext_dir}\n" f"The prebuilt TUI has no source code to hot-reload.\n" f"Unset HERMES_TUI_DIR (e.g. `unset HERMES_TUI_DIR`) to use --dev from a checkout.", file=sys.stderr, ) sys.exit(1) # 1. Prebuilt bundle (nix / packaged release / Docker image): just run it. # # This must run BEFORE _ensure_tui_workspace() below. A prebuilt install # (Docker image, Nix build, or prior `npm run build`) ships # hermes_cli/tui_dist/entry.js but never ships ui-tui/ at all (that # directory only exists in a git checkout) — so requiring the workspace # to exist first made every prebuilt dashboard Chat tab connection # hard-exit before it ever got a chance to try the bundled entry.js it # already has. See #56665. if not tui_dev: if ext_dir: p = Path(ext_dir) if (p / "dist" / "entry.js").is_file(): node = _node_bin("node") return [node, "--expose-gc", str(p / "dist" / "entry.js")], p # 1b. Bundled prebuilt TUI (Docker image, Nix build, or prior npm build) bundled = _find_bundled_tui() if bundled is not None: node = _node_bin("node") return [node, "--expose-gc", str(bundled)], bundled.parent # No prebuilt bundle available (or --dev, which never uses one) — we're # about to npm install/build from source, so the workspace must exist. if not ext_dir: _ensure_tui_workspace(tui_dir) # 2. Normal flow: npm install if needed, always esbuild, then node dist/entry.js. # --dev flow: npm install if needed, then tsx src/entry.tsx. # Existing desktop behaviour runs npm from the workspace root. Termux # scopes the install to ui-tui so launch does not pull desktop/web # dependencies into the hot path. did_install = False termux_startup = _is_termux_startup_environment() termux_need_rebuild = False if termux_startup and not tui_dev: termux_need_rebuild = _tui_need_rebuild(tui_dir) skip_install_for_fresh_termux_bundle = ( termux_startup and not tui_dev and not termux_need_rebuild ) if ( not skip_install_for_fresh_termux_bundle and _tui_need_npm_install(tui_dir) ): npm = _node_bin("npm") if not os.environ.get("HERMES_QUIET"): print("Installing TUI dependencies…") npm_cwd = _workspace_root(tui_dir) # --workspace ui-tui avoids resolving apps/desktop (Electron + node-pty). # See #38772. # When ui-tui/ has its own package-lock.json (e.g. curl install), # _workspace_root() returns tui_dir itself. Passing --workspace in # that case fails because npm cannot find a workspace named "ui-tui" # inside ui-tui/. See #42973. npm_workspace_args: tuple[str, ...] = () if npm_cwd == tui_dir else ("--workspace", "ui-tui") if termux_startup: npm_cwd, npm_workspace_args = _termux_workspace_install_context( tui_dir, include_child_workspaces=True, ) npm_install_cmd = [ npm, "install", *npm_workspace_args, # --include=dev: ui-tui's build toolchain (esbuild, typescript) # lives in devDependencies. An inherited NODE_ENV=production # (e.g. from a container shell or a parent TUI launch) or an # npm `omit=dev` config would silently skip them and the TUI # build would fail. See _run_npm_install_deterministic. "--include=dev", "--silent", "--no-fund", "--no-audit", "--progress=false", ] def _run_tui_install() -> subprocess.CompletedProcess: from hermes_constants import with_hermes_node_path # Managed tree first on PATH: if the EBADENGINE repair below # provisioned a managed Node, npm's shebang/lifecycle scripts must # resolve that node, not the mismatched system one. return subprocess.run( npm_install_cmd, cwd=str(npm_cwd), stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding="utf-8", errors="replace", env={**with_hermes_node_path(), "CI": "1"}, ) result = _run_tui_install() if result.returncode != 0: # An npm outside the root package.json's `engines.npm` range fails # here before doing any work; repair once (upgrade a Hermes-managed # npm in place, or provision a managed runtime when the npm belongs # to the user) and retry rather than dumping EBADENGINE at the user. from hermes_cli.npm_engine import maybe_repair_npm_engine combined_output = f"{result.stdout or ''}\n{result.stderr or ''}" repaired_npm = maybe_repair_npm_engine(npm, combined_output) if repaired_npm: npm = repaired_npm npm_install_cmd[0] = repaired_npm result = _run_tui_install() if result.returncode != 0: combined = f"{result.stdout or ''}\n{result.stderr or ''}".strip() preview = "\n".join(combined.splitlines()[-30:]) print("npm install failed.") if preview: print(preview) sys.exit(1) did_install = True if tui_dev: # Keep the local @hermes/ink package exports in sync with source. # --dev runs src/entry.tsx directly, but @hermes/ink resolves through # packages/hermes-ink/dist/entry-exports.js. If that dist bundle is # stale after a pull, newer hooks/components can exist in src while # being missing at runtime (e.g. useCursorAdvance). Prebuild it here. npm = _node_bin("npm") ink_dir = tui_dir / "packages" / "hermes-ink" result = subprocess.run( [npm, "run", "build"], cwd=str(ink_dir), capture_output=True, text=True, encoding="utf-8", errors="replace", ) if result.returncode != 0: combined = f"{result.stdout or ''}{result.stderr or ''}".strip() preview = "\n".join(combined.splitlines()[-30:]) print("TUI dev prebuild failed.") if preview: print(preview) sys.exit(1) tsx = tui_dir / "node_modules" / ".bin" / "tsx" if tsx.exists(): return [str(tsx), "src/entry.tsx"], tui_dir return [npm, "start"], tui_dir # Desktop/dev launches retain the historical "always rebuild" behaviour. # Termux cold starts use the freshness check because esbuild startup is # expensive on old mobile CPUs. should_build = True if termux_startup: should_build = did_install or termux_need_rebuild if should_build: npm = _node_bin("npm") result = subprocess.run( [npm, "run", "build"], cwd=str(tui_dir), capture_output=True, text=True, encoding="utf-8", errors="replace", ) if result.returncode != 0: combined = f"{result.stdout or ''}{result.stderr or ''}".strip() preview = "\n".join(combined.splitlines()[-30:]) print("TUI build failed.") if preview: print(preview) sys.exit(1) node = _node_bin("node") return [node, "--expose-gc", str(tui_dir / "dist" / "entry.js")], tui_dir def _normalize_tui_toolsets(toolsets: object) -> list[str]: """Normalize argparse/Fire-style toolset input for the TUI subprocess.""" try: from hermes_cli.oneshot import _normalize_toolsets return _normalize_toolsets(toolsets) or [] except (AttributeError, ImportError): if not toolsets: return [] raw_items = [toolsets] if isinstance(toolsets, str) else toolsets if not isinstance(raw_items, (list, tuple)): raw_items = [raw_items] normalized: list[str] = [] for item in raw_items: if isinstance(item, str): normalized.extend(part.strip() for part in item.split(",")) else: normalized.append(str(item).strip()) return [item for item in normalized if item] def _read_cgroup_memory_limit() -> Optional[int]: """Return the container memory limit in bytes, or None if unconstrained. Node's V8 heap is NOT cgroup-aware: with a flat ``--max-old-space-size=8192`` it happily grows the heap toward 8GB regardless of the container's real memory limit. In a Docker/k8s container capped below ~9-10GB, the cgroup OOM-killer SIGKILLs Node before V8's own heap monitor ever fires — which runs no JS handler, writes no ``[tui-parent]`` breadcrumb, and the user sees only a bare gateway ``stdin EOF``. Reading the real cgroup limit lets us size the heap cap below it so V8 GCs/exits gracefully instead of being reaped silently. Checks cgroup v2 (``/sys/fs/cgroup/memory.max``) then v1 (``/sys/fs/cgroup/memory/memory.limit_in_bytes``). A literal ``max`` (v2) or the v1 "unlimited" sentinel (a huge near-INT64 value) means no limit. """ candidates = ( "/sys/fs/cgroup/memory.max", # cgroup v2 "/sys/fs/cgroup/memory/memory.limit_in_bytes", # cgroup v1 ) for path in candidates: try: with open(path, "r", encoding="utf-8") as f: raw = f.read().strip() except (OSError, ValueError): continue if raw == "max": return None if not raw: # Blank/empty file: no usable value here. Fall through to the next # candidate (don't mistake an empty v2 file for "unlimited"). continue try: limit = int(raw) except ValueError: continue if limit <= 0: continue # cgroup v1 reports "unlimited" as a huge value (often # 0x7FFFFFFFFFFFF000 ≈ 9.2 EB, sometimes PAGE_COUNTER_MAX). Anything # at/above ~1 PB is effectively unconstrained — treat as no limit. if limit >= (1 << 50): return None return limit return None def _resolve_tui_heap_mb(default_mb: int = 8192) -> int: """Pick a V8 ``--max-old-space-size`` (MB) that fits the container. Returns ``default_mb`` (8192) when unconstrained or when the box is large enough that 8GB fits. In a memory-limited container, returns ~75% of the cgroup limit so the heap + non-heap RSS stays under the cgroup ceiling, clamped to a sane floor (1536MB — below this V8 GC-thrashes and the TUI is barely usable). Never exceeds ``default_mb``. """ limit = _read_cgroup_memory_limit() if not limit: return default_mb limit_mb = limit // (1024 * 1024) # Leave headroom for non-heap RSS (Node internals, buffers, the Python # gateway child shares the same cgroup): cap the heap at 75% of the limit. sized = int(limit_mb * 0.75) if sized >= default_mb: return default_mb # Floor so a tiny limit doesn't drive V8 into constant GC. If the container # is smaller than the floor, honor the limit-derived value anyway (better a # graceful V8 exit than a silent cgroup kill). return max(1536, sized) if limit_mb > 2048 else sized def _safe_tui_cwd(env: Optional[dict] = None) -> str: """Return a stable cwd value for the Node TUI child environment.""" try: return os.getcwd() except FileNotFoundError: candidate = ((env or {}).get("PWD") or os.environ.get("PWD") or "").strip() if candidate and Path(candidate).is_dir(): return candidate return str(PROJECT_ROOT) def _apply_tui_python_env(env: dict) -> None: """Seed/repair Python-related env vars shared by CLI and dashboard TUI launches.""" src_root = str(env.get("HERMES_PYTHON_SRC_ROOT") or "").strip() if not src_root or not Path(src_root).is_dir(): env["HERMES_PYTHON_SRC_ROOT"] = str(PROJECT_ROOT) cwd = str(env.get("HERMES_CWD") or "").strip() if not cwd or not Path(cwd).is_dir(): env["HERMES_CWD"] = _safe_tui_cwd(env) python = str(env.get("HERMES_PYTHON") or "").strip() if os.path.dirname(python): python_path = Path(python) if not python_path.is_absolute(): python_path = Path(env["HERMES_CWD"]) / python_path python_is_executable = python_path.is_file() and os.access(python_path, os.X_OK) else: python_is_executable = bool(shutil.which(python, path=env.get("PATH"))) if not python_is_executable: env["HERMES_PYTHON"] = sys.executable def _launch_tui( resume_session_id: Optional[str] = None, tui_dev: bool = False, model: Optional[str] = None, provider: Optional[str] = None, toolsets: object = None, skills: object = None, verbose: Optional[bool] = None, quiet: bool = False, query: Optional[str] = None, image: Optional[str] = None, worktree: bool = False, checkpoints: bool = False, pass_session_id: bool = False, max_turns: Optional[int] = None, accept_hooks: bool = False, ): """Replace current process with the TUI.""" tui_dir = PROJECT_ROOT / "ui-tui" import tempfile # TUI child is a hermes process: propagate the profile-home contract via # the single factory; keep secrets (the TUI/agent needs provider creds). from tools.environments.local import build_subprocess_env env = build_subprocess_env(scrub_secrets=False, inherit_profile_home=True) try: from hermes_cli.config import apply_terminal_config_to_env apply_terminal_config_to_env(env=env) except Exception: logger.debug("Failed to apply terminal config bridge for TUI launch", exc_info=True) active_session_fd, active_session_file = tempfile.mkstemp( prefix="hermes-tui-active-session-", suffix=".json" ) os.close(active_session_fd) env["HERMES_TUI_ACTIVE_SESSION_FILE"] = active_session_file env.setdefault("NODE_ENV", "development" if tui_dev else "production") wt_info = None if worktree: try: from cli import ( _cleanup_worktree, _git_repo_root, _prune_stale_worktrees, _setup_worktree, ) repo = _git_repo_root() if repo: _prune_stale_worktrees(repo) wt_info = _setup_worktree() except Exception as exc: print(f"✗ Failed to create TUI worktree: {exc}", file=sys.stderr) wt_info = None if not wt_info: sys.exit(1) env["HERMES_CWD"] = wt_info["path"] env["TERMINAL_CWD"] = wt_info["path"] _apply_tui_python_env(env) if model: env["HERMES_MODEL"] = model env["HERMES_INFERENCE_MODEL"] = model if provider: env["HERMES_TUI_PROVIDER"] = provider env["HERMES_INFERENCE_PROVIDER"] = provider tui_toolsets = _normalize_tui_toolsets(toolsets) if tui_toolsets: env["HERMES_TUI_TOOLSETS"] = ",".join(tui_toolsets) if skills: if isinstance(skills, (list, tuple)): flattened = [] for item in skills: flattened.extend( part.strip() for part in str(item).split(",") if part.strip() ) if flattened: env["HERMES_TUI_SKILLS"] = ",".join(flattened) else: value = str(skills).strip() if value: env["HERMES_TUI_SKILLS"] = value if query: env["HERMES_TUI_QUERY"] = query if image: env["HERMES_TUI_IMAGE"] = image if checkpoints: env["HERMES_TUI_CHECKPOINTS"] = "1" if pass_session_id: env["HERMES_TUI_PASS_SESSION_ID"] = "1" if max_turns is not None: env["HERMES_TUI_MAX_TURNS"] = str(max_turns) if verbose: env["HERMES_TUI_TOOL_PROGRESS"] = "verbose" elif quiet: env["HERMES_TUI_TOOL_PROGRESS"] = "off" if accept_hooks: env["HERMES_ACCEPT_HOOKS"] = "1" # Guarantee a generous V8 heap for the TUI. Default node cap is ~1.5–4GB # depending on version and can fatal-OOM on long sessions with large # transcripts / reasoning blobs. We target 8GB on an unconstrained host, # but V8 is NOT cgroup-aware: in a memory-limited Docker/k8s container a # flat 8GB heap grows past the container limit and the cgroup OOM-killer # SIGKILLs Node — running no JS handler, writing no breadcrumb, leaving the # user with only a bare gateway `stdin EOF`. _resolve_tui_heap_mb() reads # the real cgroup limit and sizes the cap below it so V8 GCs/exits # gracefully (and the memory monitor's onCritical breadcrumb can fire) # instead of being reaped silently. Token-level merge: respect any # user-supplied --max-old-space-size (they may have set it higher). # --expose-gc is *not* added here: Node rejects it in NODE_OPTIONS # ("--expose-gc is not allowed in NODE_OPTIONS") and refuses to start. # It is passed as a direct argv flag in _make_tui_argv() instead. _tokens = env.get("NODE_OPTIONS", "").split() if not any(t.startswith("--max-old-space-size=") for t in _tokens): _tokens.append(f"--max-old-space-size={_resolve_tui_heap_mb()}") env["NODE_OPTIONS"] = " ".join(_tokens) # HERMES_TUI_RESUME is an internal hand-off from the Python wrapper to the # Ink app. Because we start from a full os.environ snapshot (via # build_subprocess_env), an exported/stale value # in the user's shell would otherwise make a plain `hermes --tui` try to # resume a non-existent session and leave the UI at "error: session not # found" with no live session. Only forward a resume id that argparse # resolved for this invocation; direct `node ui-tui/dist/entry.js` users can # still set HERMES_TUI_RESUME themselves. env.pop("HERMES_TUI_RESUME", None) if resume_session_id: env["HERMES_TUI_RESUME"] = resume_session_id argv, cwd = _make_tui_argv(tui_dir, tui_dev) code: Optional[int] = None try: try: code = subprocess.call(argv, cwd=str(cwd), env=env) except KeyboardInterrupt: code = 130 if code in {0, 130}: _print_tui_exit_summary(resume_session_id, active_session_file) finally: try: os.unlink(active_session_file) except OSError: pass if wt_info: try: _cleanup_worktree(wt_info) except Exception: pass # Exit code 42 = TUI requested an update. Relaunch as `hermes update` so # the user sees update output directly and gets the new version. # preserve_inherited=False ensures --tui and other flags are NOT carried # into the update subcommand. if code == 42: from hermes_cli.relaunch import relaunch print() print("⚕ Launching update...") print() relaunch(["update"], preserve_inherited=False) sys.exit(code) def _pin_kanban_board_env() -> None: """Pin the active kanban board into ``HERMES_KANBAN_BOARD`` for the chat session. Without this, in-process tools (``kanban_*``) and shelled-out CLI calls (``hermes kanban …``) resolve the board on different paths: the env-pin if set, otherwise the global ``/kanban/current`` file. A concurrent ``hermes kanban boards switch`` from another session can flip the file mid-turn, so the same chat sees its tool calls hit board A while its shell calls hit board B (#20074). Pinning at chat boot mirrors what the dispatcher already does for spawned workers. """ if os.environ.get("HERMES_KANBAN_BOARD"): return try: from hermes_cli.kanban_db import get_current_board os.environ["HERMES_KANBAN_BOARD"] = get_current_board() except Exception: pass def _sync_bundled_skills_quietly() -> None: """Seed ``~/.hermes/skills/`` with the bundled skill library on first launch. Called from any CLI entrypoint that the user might use as their first interaction with Hermes — chat, dashboard (the desktop GUI's backend), and gateway. The skills_sync module is manifest-based and idempotent: skipped skills cost ~milliseconds, so calling this repeatedly is fine. Failures are swallowed because skills are an enhancement, not a hard dependency. Hermes still functions without them; the user just sees an empty skills library. """ try: from tools.skills_sync import sync_skills sync_skills(quiet=True) except Exception: pass def _resolve_use_tui(args) -> bool: """Decide whether to launch the TUI for a chat/bare invocation. Precedence (highest first): 1. ``--cli`` flag → always classic REPL 2. ``--tui`` flag → always TUI (explicit ask) 3. no TTY → always classic (ambient prefs don't apply) 4. ``HERMES_TUI=1`` env → TUI 5. ``display.interface`` config value ("cli" | "tui") 6. default → classic REPL Explicit flags always win over config so muscle memory and scripts keep working regardless of the configured default. The TTY gate (3) is load-bearing: ambient TUI preferences (env var or config default) must never hijack a NON-interactive invocation. Kanban workers, cron jobs, and pipelines run ``hermes … chat -q`` with stdout on a pipe; booting the Ink TUI there hits its no-TTY bail-out, which prints a resume hint and exits 0 — a kanban worker then dies with "exited cleanly without calling kanban_complete — protocol violation" on every attempt (found dogfooding the desktop kanban board). A user who *explicitly* passes ``--tui`` still gets the informative bail-out. """ if getattr(args, "cli", False): return False if getattr(args, "tui", False): return True try: if not (sys.stdin.isatty() and sys.stdout.isatty()): return False except Exception: return False if os.environ.get("HERMES_TUI") == "1": return True try: from hermes_cli.config import load_config iface = (load_config().get("display", {}) or {}).get("interface", "cli") return isinstance(iface, str) and iface.strip().lower() == "tui" except Exception: return False def cmd_chat(args): """Run interactive chat CLI.""" use_tui = _resolve_use_tui(args) _apply_safe_mode(args) # --in DIR: run in DIR. Must happen before any session resolution so the # workspace-scoped "latest"/-c lookups key off DIR, and it pins the # session there — an explicit --in wins over a resumed session's # recorded cwd (so the restore step below is skipped). in_dir = getattr(args, "in_dir", None) if in_dir: # Git Bash / MSYS hands the CLI POSIX-style paths (`--in ~` expands to # `/c/Users/x` before Python ever sees it; MSYS2's path conversion is # disabled for native executables). Translate the MSYS/Cygwin/WSL # drive-root spellings to native Windows form first — no-op elsewhere. from tools.environments.local import _msys_to_windows_path _target_dir = os.path.abspath( os.path.expanduser(_msys_to_windows_path(in_dir)) ) if not os.path.isdir(_target_dir): print(f"Error: --in directory not found: {in_dir}") sys.exit(1) try: os.chdir(_target_dir) except OSError as e: print(f"Error: cannot enter --in directory {in_dir}: {e}") sys.exit(1) args.no_restore_cwd = True # --resume latest: keyword for "most recent session" — same resolution # as `-c` with no name (workspace-scoped MRU, then global fallback). # The keyword wins over a session literally titled "latest"; that # session stays reachable via its ID or `-c latest` (title match). _resume_raw = getattr(args, "resume", None) if isinstance(_resume_raw, str) and _resume_raw.strip().lower() == "latest": _source = "tui" if use_tui else "cli" _last_id = _resolve_last_session(source=_source) if not _last_id and _source == "tui": _last_id = _resolve_last_session(source="cli") if _last_id: args.resume = _last_id else: kind = "TUI" if use_tui else "CLI" print(f"No previous {kind} session found to resume.") print("Use 'hermes sessions list' to see available sessions.") sys.exit(1) # Resolve --continue into --resume with the latest session or by name continue_val = getattr(args, "continue_last", None) if continue_val and not getattr(args, "resume", None): if isinstance(continue_val, str): # -c "session name" — resolve by title or ID resolved = _resolve_session_by_name_or_id(continue_val) if resolved: args.resume = resolved else: print(f"No session found matching '{continue_val}'.") print("Use 'hermes sessions list' to see available sessions.") sys.exit(1) else: # -c with no argument — continue the most recent session source = "tui" if use_tui else "cli" last_id = _resolve_last_session(source=source) if not last_id and source == "tui": last_id = _resolve_last_session(source="cli") if last_id: args.resume = last_id else: kind = "TUI" if use_tui else "CLI" print(f"No previous {kind} session found to continue.") sys.exit(1) # Resolve --resume by title if it's not a direct session ID resume_val = getattr(args, "resume", None) if resume_val: resolved = _resolve_session_by_name_or_id(resume_val) if resolved: args.resume = resolved # If resolution fails, keep the original value — _init_agent will # report "Session not found" with the original input # Session<->workspace binding: cd back into a resumed session's recorded cwd # so it resumes in the repo it belonged to. Opt out with --no-restore-cwd; # skipped under --worktree (that path owns its own dir). Best-effort — a # missing dir warns and stays put rather than failing the resume. if ( getattr(args, "resume", None) and not getattr(args, "no_restore_cwd", False) and not getattr(args, "worktree", False) ): _resume_db = None try: from hermes_state import SessionDB _resume_db = SessionDB() _saved_cwd = ((_resume_db.get_session(args.resume) or {}).get("cwd") or "").strip() if _saved_cwd and not os.path.isdir(_saved_cwd): print(f"⚠ session's recorded dir is gone ({_saved_cwd}); staying in {os.getcwd()}") elif _saved_cwd and os.path.realpath(_saved_cwd) != os.path.realpath(os.getcwd()): os.chdir(_saved_cwd) print(f"↪ restored workspace dir: {_saved_cwd}") except Exception: pass # never let cwd-restore break a resume finally: if _resume_db is not None: try: _resume_db.close() except Exception: pass # xAI retirement warning — one-shot, non-blocking, never fails startup try: from hermes_cli.xai_retirement import ( MIGRATION_GUIDE_URL, RETIREMENT_DATE, find_retired_xai_refs, format_issue, ) from hermes_cli.config import load_config as _load_config_for_xai_check _retired_xai_refs = find_retired_xai_refs(_load_config_for_xai_check()) if _retired_xai_refs: sys.stderr.write( f"\033[33m⚠ xAI retires {len(_retired_xai_refs)} model(s) " f"in your config on {RETIREMENT_DATE}:\033[0m\n" ) for _ref in _retired_xai_refs: sys.stderr.write(f" \033[33m⚠\033[0m {format_issue(_ref)}\n") sys.stderr.write(f" \033[2mMigration guide: {MIGRATION_GUIDE_URL}\033[0m\n") sys.stderr.write(" \033[2mRun 'hermes doctor' for details.\033[0m\n\n") except Exception: pass # First-run guard: check if any provider is configured before launching if not _has_any_provider_configured(): print() print( "It looks like Hermes isn't configured yet -- no API keys or providers found." ) print() print(" Run: hermes setup") print() from hermes_cli.setup import ( is_interactive_stdin, print_noninteractive_setup_guidance, ) if not is_interactive_stdin(): print_noninteractive_setup_guidance( "No interactive TTY detected for the first-run setup prompt." ) sys.exit(1) try: reply = input("Run setup now? [Y/n] ").strip().lower() except (EOFError, KeyboardInterrupt): reply = "n" if reply in {"", "y", "yes"}: cmd_setup(args) return print() print("You can run 'hermes setup' at any time to configure.") sys.exit(1) # Start update check in background (runs while other init happens). # On Termux this imports rich/prompt_toolkit in the foreground and then # competes for CPU on single-core devices, so keep it opt-in there. if _termux_should_prefetch_update_check(): try: from hermes_cli.banner import prefetch_banner_data, prefetch_update_check prefetch_update_check() # Warm git banner state + skills index off-thread too — their # subprocess/file-I/O waits overlap the CPU-bound cli import. prefetch_banner_data() except Exception: pass # Sync bundled skills on every CLI launch. Runs in a background daemon # thread: the sync is idempotent, hash-gated (unchanged skills are # skipped), and nothing on the banner path depends on it, yet the scan # alone costs ~120-170ms of rglob/hashing on the startup path. Skill # loading happens at agent init (first message), by which point the # sync has long finished; a same-instant race would only matter in the # rare launch right after `hermes update` changed a bundled skill. def _skills_sync_bg() -> None: try: _sync_bundled_skills_for_startup() except Exception: pass threading.Thread( target=_skills_sync_bg, name="bundled-skills-sync", daemon=True ).start() # --yolo: bypass all dangerous command approvals. # Also set in main() before _prepare_agent_startup() — that is the # authoritative site because it runs before tool imports freeze # _YOLO_MODE_FROZEN. This redundant set is a safety net for callers # that invoke cmd_chat directly (e.g. subcommand dispatch). if getattr(args, "yolo", False): os.environ["HERMES_YOLO_MODE"] = "1" # --ignore-user-config: make load_cli_config() / load_config() skip the # user's ~/.hermes/config.yaml and return built-in defaults. Set BEFORE # importing cli (which runs `CLI_CONFIG = load_cli_config()` at module # import time). Credentials in .env are still loaded — this flag only # ignores behavioral/config settings. if getattr(args, "ignore_user_config", False): os.environ["HERMES_IGNORE_USER_CONFIG"] = "1" # --ignore-rules: skip auto-injection of AGENTS.md/SOUL.md/.cursorrules # (rules), memory entries, and any preloaded skills coming from user config. # Maps to AIAgent(skip_context_files=True, skip_memory=True). if getattr(args, "ignore_rules", False): os.environ["HERMES_IGNORE_RULES"] = "1" # --source: tag session source for filtering (e.g. 'tool' for third-party integrations) if getattr(args, "source", None): os.environ["HERMES_SESSION_SOURCE"] = args.source _pin_kanban_board_env() _confirm_startup_expensive_model_override(args) if use_tui: _launch_tui( getattr(args, "resume", None), tui_dev=getattr(args, "tui_dev", False), model=getattr(args, "model", None), provider=getattr(args, "provider", None), toolsets=getattr(args, "toolsets", None), skills=getattr(args, "skills", None), verbose=getattr(args, "verbose", None), quiet=getattr(args, "quiet", False), query=getattr(args, "query", None), image=getattr(args, "image", None), worktree=getattr(args, "worktree", False), checkpoints=getattr(args, "checkpoints", False), pass_session_id=getattr(args, "pass_session_id", False), max_turns=getattr(args, "max_turns", None), accept_hooks=getattr(args, "accept_hooks", False), ) # Import and run the CLI from cli import main as cli_main # Build kwargs from args kwargs = { "model": args.model, "provider": getattr(args, "provider", None), "reasoning": getattr(args, "reasoning", None), "toolsets": args.toolsets, "skills": getattr(args, "skills", None), "verbose": getattr(args, "verbose", None), "quiet": getattr(args, "quiet", False), "query": args.query, "image": getattr(args, "image", None), "resume": getattr(args, "resume", None), "worktree": getattr(args, "worktree", False), "checkpoints": getattr(args, "checkpoints", False), "pass_session_id": getattr(args, "pass_session_id", False), "max_turns": getattr(args, "max_turns", None), "ignore_rules": getattr(args, "ignore_rules", False) or getattr(args, "safe_mode", False), "ignore_user_config": getattr(args, "ignore_user_config", False) or getattr(args, "safe_mode", False), "compact": getattr(args, "compact", False), } # Filter out None values kwargs = {k: v for k, v in kwargs.items() if v is not None} try: cli_main(**kwargs) except ValueError as e: print(f"Error: {e}") sys.exit(1) def cmd_gateway(args): """Gateway management commands.""" _sync_bundled_skills_quietly() from hermes_cli.gateway import gateway_command gateway_command(args) def cmd_proxy(args): """Local OpenAI-compatible proxy to OAuth providers.""" # Lazy import — pulls in aiohttp, which is gated behind an extras install # for users who don't run the proxy or the messaging gateway. from hermes_cli.proxy.cli import cmd_proxy as _cmd_proxy rc = _cmd_proxy(args) if isinstance(rc, int) and rc != 0: raise SystemExit(rc) def cmd_whatsapp(args): """Set up WhatsApp: choose mode, configure, install bridge, pair via QR.""" _require_tty("whatsapp") from hermes_cli.config import get_env_value, save_env_value from hermes_constants import find_node_executable, with_hermes_node_path print() print("⚕ WhatsApp Setup") print("=" * 50) # ── Step 1: Choose mode ────────────────────────────────────────────── current_mode = get_env_value("WHATSAPP_MODE") or "" if not current_mode: print() print("How will you use WhatsApp with Hermes?") print() print(" 1. Separate bot number (recommended)") print(" People message the bot's number directly — cleanest experience.") print( " Requires a second phone number with WhatsApp installed on a device." ) print() print(" 2. Personal number (self-chat)") print(" You message yourself to talk to the agent.") print(" Quick to set up, but the UX is less intuitive.") print() try: choice = input(" Choose [1/2]: ").strip() except (EOFError, KeyboardInterrupt): print("\nSetup cancelled.") return if choice == "1": save_env_value("WHATSAPP_MODE", "bot") wa_mode = "bot" print(" ✓ Mode: separate bot number") print() print(" ┌─────────────────────────────────────────────────┐") print(" │ Getting a second number for the bot: │") print(" │ │") print(" │ Easiest: Install WhatsApp Business (free app) │") print(" │ on your phone with a second number: │") print(" │ • Dual-SIM: use your 2nd SIM slot │") print(" │ • Google Voice: free US number (voice.google) │") print(" │ • Prepaid SIM: $3-10, verify once │") print(" │ │") print(" │ WhatsApp Business runs alongside your personal │") print(" │ WhatsApp — no second phone needed. │") print(" └─────────────────────────────────────────────────┘") else: save_env_value("WHATSAPP_MODE", "self-chat") wa_mode = "self-chat" print(" ✓ Mode: personal number (self-chat)") else: wa_mode = current_mode mode_label = ( "separate bot number" if wa_mode == "bot" else "personal number (self-chat)" ) print(f"\n✓ Mode: {mode_label}") # ── Step 2: Mode is selected, will enable WhatsApp only after pairing ── # We intentionally don't write WHATSAPP_ENABLED=true here. If the user # aborts the wizard later (Ctrl+C, failed npm install, missed QR scan), # we'd otherwise leave .env claiming WhatsApp is ready when the bridge # has no creds.json. Every subsequent `hermes gateway` then paid a 30s # bridge-bootstrap timeout and queued WhatsApp for indefinite retries. # Now: aborted setup leaves WHATSAPP_ENABLED unset → gateway skips it. # Re-runs that already have WHATSAPP_ENABLED=true (from a prior # successful pairing) stay enabled — we just don't write it pre-emptively. print() if (get_env_value("WHATSAPP_ENABLED") or "").lower() == "true": print("✓ WhatsApp is already enabled") # ── Step 3: Allowed users ──────────────────────────────────────────── current_users = get_env_value("WHATSAPP_ALLOWED_USERS") or "" if current_users: print(f"✓ Allowed users: {current_users}") try: response = input("\n Update allowed users? [y/N] ").strip() except (EOFError, KeyboardInterrupt): response = "n" if response.lower() in {"y", "yes"}: if wa_mode == "bot": phone = input( " Phone numbers that can message the bot (comma-separated): " ).strip() else: phone = input(" Your phone number (e.g. 15551234567): ").strip() if phone: save_env_value("WHATSAPP_ALLOWED_USERS", phone.replace(" ", "")) print(f" ✓ Updated to: {phone}") else: print() if wa_mode == "bot": print(" Who should be allowed to message the bot?") phone = input( " Phone numbers (comma-separated, or * for anyone): " ).strip() else: phone = input(" Your phone number (e.g. 15551234567): ").strip() if phone: save_env_value("WHATSAPP_ALLOWED_USERS", phone.replace(" ", "")) print(f" ✓ Allowed users set: {phone}") else: print(" ⚠ No allowlist — the agent will respond to ALL incoming messages") # ── Step 4: Install bridge dependencies ────────────────────────────── from gateway.platforms.whatsapp_common import resolve_whatsapp_bridge_dir bridge_dir = resolve_whatsapp_bridge_dir() bridge_script = bridge_dir / "bridge.js" if not bridge_script.exists(): print(f"\n✗ Bridge script not found at {bridge_script}") return if not (bridge_dir / "node_modules").exists(): print( "\n→ Installing WhatsApp bridge dependencies (this can take a few minutes)..." ) npm = find_node_executable("npm") if not npm: print(" ✗ npm not found on PATH — install Node.js first") return try: result = subprocess.run( [npm, "install", "--no-fund", "--no-audit", "--progress=false"], cwd=str(bridge_dir), stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True, encoding="utf-8", errors="replace", env=with_hermes_node_path(), ) except KeyboardInterrupt: print("\n ✗ Install cancelled") return if result.returncode != 0: err = (result.stderr or "").strip() preview = "\n".join(err.splitlines()[-30:]) if err else "(no output)" print(" ✗ npm install failed:") print(preview) return print(" ✓ Dependencies installed") else: print("✓ Bridge dependencies already installed") # ── Step 5: Check for existing session ─────────────────────────────── session_dir = get_hermes_home() / "whatsapp" / "session" session_dir.mkdir(parents=True, exist_ok=True) if (session_dir / "creds.json").exists(): print("✓ Existing WhatsApp session found") try: response = input( "\n Re-pair? This will clear the existing session. [y/N] " ).strip() except (EOFError, KeyboardInterrupt): response = "n" if response.lower() in {"y", "yes"}: shutil.rmtree(session_dir, ignore_errors=True) session_dir.mkdir(parents=True, exist_ok=True) print(" ✓ Session cleared") else: # Existing pairing — ensure WHATSAPP_ENABLED reflects that. # (Older installs may have lost the env var; covers re-runs # where the user picked "no, keep my session" but the var # was never set or got removed.) if (get_env_value("WHATSAPP_ENABLED") or "").lower() != "true": save_env_value("WHATSAPP_ENABLED", "true") print("\n✓ WhatsApp is configured and paired!") print(" Start the gateway with: hermes gateway") return # ── Step 6: QR code pairing ────────────────────────────────────────── print() print("─" * 50) if wa_mode == "bot": print("📱 Open WhatsApp (or WhatsApp Business) on the") print(" phone with the BOT's number, then scan:") else: print("📱 Open WhatsApp on your phone, then scan:") print() print(" Settings → Linked Devices → Link a Device") print("─" * 50) print() try: subprocess.run( [ find_node_executable("node") or "node", str(bridge_script), "--pair-only", "--session", str(session_dir), ], cwd=str(bridge_dir), env=with_hermes_node_path(), ) except KeyboardInterrupt: pass # ── Step 7: Post-pairing ───────────────────────────────────────────── print() if (session_dir / "creds.json").exists(): # Only enable WhatsApp now that pairing actually succeeded. If the # user Ctrl+C'd at any earlier step, WHATSAPP_ENABLED stays unset # and `hermes gateway` skips it cleanly instead of paying a 30s # bridge timeout + queueing the platform for indefinite retries. save_env_value("WHATSAPP_ENABLED", "true") print("✓ WhatsApp paired successfully!") print() if wa_mode == "bot": print(" Next steps:") print(" 1. Start the gateway: hermes gateway") print(" 2. Send a message to the bot's WhatsApp number") print(" 3. The agent will reply automatically") print() print(" Tip: Agent responses are prefixed with '⚕ Hermes Agent'") else: print(" Next steps:") print(" 1. Start the gateway: hermes gateway") print(" 2. Open WhatsApp → Message Yourself") print(" 3. Type a message — the agent will reply") print() print(" Tip: Agent responses are prefixed with '⚕ Hermes Agent'") print(" so you can tell them apart from your own messages.") print() print(" Or install as a service: hermes gateway install") else: print("⚠ Pairing may not have completed. Run 'hermes whatsapp' to try again.") def cmd_whatsapp_cloud(args): """Set up WhatsApp Business Cloud API (official Meta integration). Walks the user through the Meta-side credentials (Phone Number ID, Access Token, App Secret, optional App/WABA IDs) plus webhook configuration. Includes field-shape validators that catch the most common setup mistakes (e.g. pasting a phone number into the Phone Number ID field). Distinct from ``hermes whatsapp`` (the Baileys bridge wizard) — the two adapters are complementary, not alternatives. See ``hermes_cli/setup_whatsapp_cloud.py``. """ _require_tty("whatsapp-cloud") from hermes_cli.setup_whatsapp_cloud import run_whatsapp_cloud_setup return run_whatsapp_cloud_setup() def cmd_setup(args): """Interactive setup wizard.""" from hermes_cli.setup import run_setup_wizard run_setup_wizard(args) def cmd_model(args): """Select default model — starts with provider selection, then model picker.""" _require_tty("model") if getattr(args, "refresh", False): try: from hermes_cli.models import clear_provider_models_cache clear_provider_models_cache() print(" Cleared model picker cache.") except Exception: pass select_provider_and_model(args=args) def _is_profile_api_key_provider(provider_id: str) -> bool: """Return True when provider_id maps to a profile with auth_type='api_key'. Used as a catch-all in select_provider_and_model() so that new providers declared in plugins/model-providers// automatically dispatch to _model_flow_api_key_provider without requiring an explicit elif branch here. """ try: from providers import get_provider_profile _p = get_provider_profile(provider_id) return _p is not None and _p.auth_type == "api_key" except Exception: return False def select_provider_and_model(args=None): """Core provider selection + model picking logic. Shared by ``cmd_model`` (``hermes model``) and the setup wizard (``setup_model_provider`` in setup.py). Handles the full flow: provider picker, credential prompting, model selection, and config persistence. """ from hermes_cli.auth import ( resolve_provider, AuthError, format_auth_error, ) from hermes_cli.config import ( get_compatible_custom_providers, load_config, get_env_value, ) from hermes_cli.providers import ( custom_provider_aliases, custom_provider_slug, resolve_provider_full, ) config = load_config() current_model = config.get("model") if isinstance(current_model, dict): current_model = current_model.get("default", "") current_model = current_model or "(not set)" # Read effective provider the same way the CLI does at startup: # config.yaml model.provider > env var > auto-detect config_provider = None model_cfg = config.get("model") if isinstance(model_cfg, dict): config_provider = model_cfg.get("provider") effective_provider = ( config_provider or os.getenv("HERMES_INFERENCE_PROVIDER") or "auto" ) compatible_custom_providers = get_compatible_custom_providers(config) def _named_custom_provider_map(cfg) -> dict[str, dict[str, str]]: from hermes_cli.config import read_raw_config # Build lookups of raw (un-expanded) templates keyed by a # stable identity. We intentionally bypass # ``get_compatible_custom_providers(read_raw_config())`` here because # its ``_normalize_custom_provider_entry`` step calls ``urlparse()`` # on ``base_url`` and drops any entry whose ``base_url`` is itself an # env-ref template (e.g. ``${NEURALWATT_API_BASE}``). Dropping those # entries is exactly how env-ref preservation fails for the user # config that motivated this fix. raw_api_key_refs: dict[tuple, str] = {} raw_base_url_refs: dict[tuple, str] = {} raw_cfg = read_raw_config() def _record_raw( name: str, provider_key: str, model: str, api_key: str, base_url: str, ) -> None: template = str(api_key or "").strip() base_template = str(base_url or "").strip() name = str(name or "").strip() provider_key = str(provider_key or "").strip() model = str(model or "").strip() # Index by every plausible identity the loaded (expanded) config # might present: (name), (name, model), (provider_key), and # (provider_key, model). Case-insensitive on name/provider_key so # the loaded entry matches regardless of display casing. identities = [] if name: identities.extend(((name.lower(),), (name.lower(), model))) if provider_key: identities.extend( ((provider_key.lower(),), (provider_key.lower(), model)) ) if "${" in template: for identity in identities: raw_api_key_refs.setdefault(identity, template) if "${" in base_template: for identity in identities: raw_base_url_refs.setdefault(identity, base_template) raw_list = raw_cfg.get("custom_providers") if isinstance(raw_list, list): for raw_entry in raw_list: if not isinstance(raw_entry, dict): continue _record_raw( raw_entry.get("name", ""), "", raw_entry.get("model", "") or raw_entry.get("default_model", ""), raw_entry.get("api_key", ""), raw_entry.get("base_url", "") or raw_entry.get("url", "") or raw_entry.get("api", ""), ) raw_providers = raw_cfg.get("providers") if isinstance(raw_providers, dict): for raw_key, raw_entry in raw_providers.items(): if not isinstance(raw_entry, dict): continue _record_raw( raw_entry.get("name", "") or raw_key, raw_key, raw_entry.get("model", "") or raw_entry.get("default_model", ""), raw_entry.get("api_key", ""), raw_entry.get("base_url", "") or raw_entry.get("url", "") or raw_entry.get("api", ""), ) def _lookup_ref( refs: dict[tuple, str], name: str, provider_key: str, model: str, ) -> str: name_lc = str(name or "").strip().lower() pkey_lc = str(provider_key or "").strip().lower() model = str(model or "").strip() for identity in ( (pkey_lc, model), (pkey_lc,), (name_lc, model), (name_lc,), ): if identity[0] and identity in refs: return refs[identity] return "" custom_provider_map = {} for entry in get_compatible_custom_providers(cfg): if not isinstance(entry, dict): continue name = (entry.get("name") or "").strip() base_url = (entry.get("base_url") or "").strip() if not name or not base_url: continue provider_key = (entry.get("provider_key") or "").strip() key = custom_provider_slug(name, provider_key) custom_provider_map[key] = { "name": name, "base_url": base_url, "api_key": entry.get("api_key", ""), "key_env": entry.get("key_env", ""), "model": entry.get("model", ""), "models": entry.get("models", {}), "discover_models": entry.get("discover_models", True), "api_mode": entry.get("api_mode", ""), "provider_key": provider_key, "api_key_ref": _lookup_ref( raw_api_key_refs, name, provider_key, entry.get("model", "") ), "base_url_ref": _lookup_ref( raw_base_url_refs, name, provider_key, entry.get("model", "") ), } return custom_provider_map def _norm_base_url(url: str) -> str: return str(url or "").strip().rstrip("/").lower() # Add user-defined custom providers from config.yaml _custom_provider_map = _named_custom_provider_map( config ) # key → {name, base_url, api_key} def _canonical_named_custom_key(provider_id: str) -> str: requested = str(provider_id or "").strip().lower() for key, provider_info in _custom_provider_map.items(): if requested in custom_provider_aliases( provider_info.get("name", ""), provider_info.get("provider_key", ""), ): return key return provider_id def _active_custom_key_from_base_url() -> str: if effective_provider != "custom" or not isinstance(model_cfg, dict): return "" current_base = _norm_base_url(model_cfg.get("base_url", "")) if not current_base: return "" for key, provider_info in _custom_provider_map.items(): if _norm_base_url(provider_info.get("base_url", "")) == current_base: return key return "" active = _active_custom_key_from_base_url() if active is None: active = "" if not active and effective_provider != "auto": active_def = resolve_provider_full( effective_provider, config.get("providers"), compatible_custom_providers, ) if active_def is not None: active = active_def.id if active_def.source == "user-config": active = _canonical_named_custom_key(active) else: warning = ( f"Unknown provider '{effective_provider}'. Check 'hermes model' for " "available providers, or run 'hermes doctor' to diagnose config " "issues." ) print(f"Warning: {warning} Falling back to auto provider detection.") if not active: try: active = resolve_provider("auto") except AuthError as exc: if effective_provider == "auto": warning = format_auth_error(exc) print(f"Warning: {warning} Falling back to auto provider detection.") active = None # no provider yet; default to first in list # Detect custom endpoint if active == "openrouter" and get_env_value("OPENAI_BASE_URL"): active = "custom" from hermes_cli.models import ( CANONICAL_PROVIDERS, _PROVIDER_LABELS, _PROVIDER_ALIASES, group_providers, provider_group_for_slug, ) provider_labels = dict(_PROVIDER_LABELS) # derive from canonical list if active and active in _custom_provider_map: active_label = _custom_provider_map[active]["name"] else: active_label = provider_labels.get(active, active) if active else "none" print() print(f" Current model: {current_model}") print(f" Active provider: {active_label}") print() # Step 1: Provider selection. # # Canonical providers are folded into top-level groups (display only — see # PROVIDER_GROUPS in hermes_cli/models.py). A multi-member group shows one # row ("Kimi / Moonshot ▸"); picking it opens a member sub-picker that # resolves back to a concrete slug, so the dispatch chain below is # unchanged. Custom providers and the trailing actions stay flat. canonical_descs = {p.slug: p.tui_desc for p in CANONICAL_PROVIDERS} # Honor ``model_catalog.excluded_providers`` so the CLI ``hermes model`` # picker hides the same providers the gateway/TUI pickers do. A canonical # provider is hidden if its slug OR any of its aliases appears in the # exclusion list (case-insensitive), matching list_authenticated_providers' # matching against hermes_id / alias / canonical slug. _cli_excluded = { str(p).strip().lower() for p in (config.get("model_catalog", {}) or {}).get("excluded_providers") or [] if p } if _cli_excluded: _alias_to_canon = _PROVIDER_ALIASES _names_for: dict[str, set[str]] = {} for _p in CANONICAL_PROVIDERS: _names_for[_p.slug] = {_p.slug.lower()} for _alias, _canon in _alias_to_canon.items(): _names_for.setdefault(_canon, {_canon.lower()}).add(_alias.lower()) _visible_slugs = [ p.slug for p in CANONICAL_PROVIDERS if not _names_for.get(p.slug, {p.slug.lower()}) & _cli_excluded ] else: _visible_slugs = [p.slug for p in CANONICAL_PROVIDERS] grouped_rows = group_providers(_visible_slugs) # The group/slug that should be pre-selected: the active provider's group # if it's grouped, otherwise the active slug itself. active_group = provider_group_for_slug(active) if active else "" # ordered entries: (key, label, members) # members == [] → leaf row, key is a provider slug / action # members != [] → group row, key is "group:" ordered: list[tuple[str, str, list[str]]] = [] default_idx = 0 for row in grouped_rows: if row["kind"] == "group": gid = row["group_id"] group_desc = row.get("description", "") label = f"{row['label']} ▸ ({group_desc})" if group_desc else f"{row['label']} ▸" key = f"group:{gid}" is_active = bool(active_group) and gid == active_group members = row["members"] else: slug = row["slug"] label = canonical_descs.get(slug, provider_labels.get(slug, slug)) key = slug is_active = bool(active) and slug == active members = [] if is_active: ordered.append((key, f"{label} ← currently active", members)) default_idx = len(ordered) - 1 else: ordered.append((key, label, members)) for key, provider_info in _custom_provider_map.items(): name = provider_info["name"] base_url = provider_info["base_url"] short_url = base_url.replace("https://", "").replace("http://", "").rstrip("/") saved_model = provider_info.get("model", "") model_hint = f" — {saved_model}" if saved_model else "" label = f"{name} ({short_url}){model_hint}" if active and key == active: ordered.append((key, f"{label} ← currently active", [])) default_idx = len(ordered) - 1 else: ordered.append((key, label, [])) ordered.append(("custom", "Custom endpoint (enter URL manually)", [])) _has_saved_custom_list = isinstance(config.get("custom_providers"), list) and bool( config.get("custom_providers") ) if _has_saved_custom_list: ordered.append(("remove-custom", "Remove a saved custom provider", [])) ordered.append(("aux-config", "Configure auxiliary models...", [])) ordered.append(("cancel", "Leave unchanged", [])) provider_idx = _prompt_provider_choice( [label for _, label, _ in ordered], default=default_idx, ) if provider_idx is None or ordered[provider_idx][0] == "cancel": print("No change.") return selected_key = ordered[provider_idx][0] selected_members = ordered[provider_idx][2] # Group row → drill into a member sub-picker. Default to the active member # if the active provider lives in this group. The descriptive text lives on # the group row itself, so member rows show only their short label here. if selected_members: member_default = 0 if active in selected_members: member_default = selected_members.index(active) member_labels = [ provider_labels.get(m, m) for m in selected_members ] group_label = ordered[provider_idx][1].split(" ▸", 1)[0] member_idx = _prompt_provider_choice( member_labels, default=member_default, title=f"Select {group_label} provider:", ) if member_idx is None: print("No change.") return selected_provider = selected_members[member_idx] else: selected_provider = selected_key if selected_provider == "aux-config": _aux_config_menu() return # Step 2: Provider-specific setup + model selection if selected_provider == "openrouter": _model_flow_openrouter(config, current_model) elif selected_provider == "moa": _model_flow_moa(config, current_model) elif selected_provider == "ai-gateway": _model_flow_ai_gateway(config, current_model) elif selected_provider == "nous": _model_flow_nous(config, current_model, args=args) elif selected_provider == "openai-codex": _model_flow_openai_codex(config, current_model) elif selected_provider == "xai-oauth": _model_flow_xai_oauth(config, current_model, args=args) elif selected_provider == "qwen-oauth": _model_flow_qwen_oauth(config, current_model) elif selected_provider == "minimax-oauth": _model_flow_minimax_oauth(config, current_model, args=args) elif selected_provider == "copilot-acp": _model_flow_copilot_acp(config, current_model) elif selected_provider == "copilot": _model_flow_copilot(config, current_model) elif selected_provider == "custom": _model_flow_custom(config) elif ( selected_provider.startswith("custom:") or selected_provider in _custom_provider_map ): provider_info = _named_custom_provider_map(load_config()).get(selected_provider) if provider_info is None: print( "Warning: the selected saved custom provider is no longer available. " "It may have been removed from config.yaml. No change." ) return _model_flow_named_custom(config, provider_info) elif selected_provider == "remove-custom": _remove_custom_provider(config) elif selected_provider == "anthropic": _model_flow_anthropic(config, current_model) elif selected_provider == "kimi-coding": _model_flow_kimi(config, current_model) elif selected_provider == "stepfun": _model_flow_stepfun(config, current_model) elif selected_provider == "bedrock": _model_flow_bedrock(config, current_model) elif selected_provider == "vertex": _model_flow_vertex(config, current_model) elif selected_provider == "azure-foundry": _model_flow_azure_foundry(config, current_model) elif selected_provider in { "openai-api", "gemini", "deepseek", "xai", "zai", "kimi-coding-cn", "minimax", "minimax-cn", "kilocode", "opencode-zen", "opencode-go", "alibaba", "huggingface", "xiaomi", "arcee", "gmi", "nvidia", "ollama-cloud", "tencent-tokenhub", "lmstudio", } or _is_profile_api_key_provider(selected_provider): _model_flow_api_key_provider(config, selected_provider, current_model) # ── Post-switch cleanup: clear stale OPENAI_BASE_URL ────────────── # When the user switches to a named provider (anything except "custom"), # a leftover OPENAI_BASE_URL in ~/.hermes/.env can poison auxiliary # clients that use provider:auto. Clear it proactively. (#5161) if selected_provider not in { "custom", "cancel", "remove-custom", } and not selected_provider.startswith("custom:"): _clear_stale_openai_base_url() def _clear_stale_openai_base_url(): """Remove OPENAI_BASE_URL from ~/.hermes/.env if the active provider is not 'custom'. After a provider switch, a leftover OPENAI_BASE_URL causes auxiliary clients (compression, vision, delegation) with provider:auto to route requests to the old custom endpoint instead of the newly selected provider. See issue #5161. """ from hermes_cli.config import get_env_value, save_env_value, load_config cfg = load_config() model_cfg = cfg.get("model", {}) if isinstance(model_cfg, dict): provider = (model_cfg.get("provider") or "").strip().lower() else: provider = "" if provider == "custom" or not provider: return # custom provider legitimately uses OPENAI_BASE_URL stale_url = get_env_value("OPENAI_BASE_URL") if stale_url: save_env_value("OPENAI_BASE_URL", "") print( f"Cleared stale OPENAI_BASE_URL from .env (was: {stale_url[:40]}...)" if len(stale_url) > 40 else f"Cleared stale OPENAI_BASE_URL from .env (was: {stale_url})" ) # ───────────────────────────────────────────────────────────────────────────── # Auxiliary model configuration # # Hermes uses lightweight "auxiliary" models for side tasks (vision analysis, # context compression, web extraction, session search, etc.). Each task has # its own provider+model pair in config.yaml under `auxiliary.`. # # The UI lives behind "Configure auxiliary models..." at the bottom of the # `hermes model` provider picker. It does NOT re-run credential setup — it # only routes already-authenticated providers to specific aux tasks. Users # configure new providers through the normal `hermes model` flow first. # ───────────────────────────────────────────────────────────────────────────── # (task_key, display_name, short_description) _AUX_TASKS: list[tuple[str, str, str]] = [ ("vision", "Vision", "image/screenshot analysis"), ("compression", "Compression", "context summarization"), ("web_extract", "Web extract", "web page summarization"), ("approval", "Approval", "smart command approval"), ("mcp", "MCP", "MCP tool reasoning"), ("title_generation", "Title generation", "session titles"), ("memory_query_rewrite", "Memory query rewrite", "memory retrieval queries"), ("tts_audio_tags", "TTS audio tags", "Gemini TTS tag insertion"), ("skills_hub", "Skills hub", "skills search/install"), ("triage_specifier", "Triage specifier", "kanban spec fleshing"), ("kanban_decomposer", "Kanban decomposer", "task decomposition"), ("profile_describer", "Profile describer", "auto profile descriptions"), ("curator", "Curator", "skill-usage review pass"), ] def _all_aux_tasks() -> list[tuple[str, str, str]]: """Return built-in + plugin-registered auxiliary tasks for picker/menu use. Built-in tasks come first (preserving order), followed by plugin tasks sorted by key. Used by ``_aux_config_menu``, ``_reset_aux_to_auto``, and display-name lookups so plugin-registered tasks (registered via :meth:`hermes_cli.plugins.PluginContext.register_auxiliary_task`) appear in the same surfaces as built-in ones without core knowing about them. """ tasks = list(_AUX_TASKS) try: from hermes_cli.plugins import get_plugin_auxiliary_tasks for entry in get_plugin_auxiliary_tasks(): tasks.append((entry["key"], entry["display_name"], entry["description"])) except Exception: # Plugin discovery failure must not break the aux config UI. # Built-in tasks remain available. pass return tasks def _format_aux_current(task_cfg: dict) -> str: """Render the current aux config for display in the task menu.""" if not isinstance(task_cfg, dict): return "auto" base_url = str(task_cfg.get("base_url") or "").strip() provider = str(task_cfg.get("provider") or "auto").strip() or "auto" model = str(task_cfg.get("model") or "").strip() if base_url: short = base_url.replace("https://", "").replace("http://", "").rstrip("/") return f"custom ({short})" + (f" · {model}" if model else "") if provider == "auto": return "auto" + (f" · {model}" if model else "") if model: return f"{provider} · {model}" return provider def _save_aux_choice( task: str, *, provider: str, model: str = "", base_url: str = "", api_key: str = "", ) -> None: """Persist an auxiliary task's provider/model to config.yaml. Only writes the four routing fields — timeout, download_timeout, and any other task-specific settings are preserved untouched. The main model config (``model.default``/``model.provider``) is never modified. """ from hermes_cli.config import load_config, save_config cfg = load_config() aux = cfg.setdefault("auxiliary", {}) if not isinstance(aux, dict): aux = {} cfg["auxiliary"] = aux entry = aux.setdefault(task, {}) if not isinstance(entry, dict): entry = {} aux[task] = entry entry["provider"] = provider entry["model"] = model or "" entry["base_url"] = base_url or "" entry["api_key"] = api_key or "" save_config(cfg) def _reset_aux_to_auto() -> int: """Reset every known aux task back to auto/empty. Returns number reset. Includes plugin-registered tasks (via ``_all_aux_tasks``) so a plugin that contributed an auxiliary task gets reset alongside built-ins. """ from hermes_cli.config import load_config, save_config cfg = load_config() aux = cfg.setdefault("auxiliary", {}) if not isinstance(aux, dict): aux = {} cfg["auxiliary"] = aux count = 0 for task, _name, _desc in _all_aux_tasks(): entry = aux.setdefault(task, {}) if not isinstance(entry, dict): entry = {} aux[task] = entry changed = False if entry.get("provider") not in {None, "", "auto"}: entry["provider"] = "auto" changed = True for field in ("model", "base_url", "api_key"): if entry.get(field): entry[field] = "" changed = True # Preserve timeout/download_timeout — those are user-tuned, not routing if changed: count += 1 save_config(cfg) return count def _aux_config_menu() -> None: """Top-level auxiliary-model picker — choose a task to configure. Loops until the user picks "Back" so multiple tasks can be configured without returning to the main provider menu. """ from hermes_cli.config import load_config while True: cfg = load_config() aux = cfg.get("auxiliary", {}) if isinstance(cfg.get("auxiliary"), dict) else {} print() print(" Auxiliary models — side-task routing") print() print(" Side tasks (vision, compression, web extraction, etc.) default") print(' to your main chat model. "auto" means "use my main model" —') print(" Hermes only falls back to a lightweight backend (OpenRouter,") print(" Nous Portal) if the main model is unavailable. Override a") print(" task below if you want it pinned to a specific provider/model.") print() # Build the task menu with current settings inline all_tasks = _all_aux_tasks() name_col = max(len(name) for _, name, _ in all_tasks) + 2 desc_col = max(len(desc) for _, _, desc in all_tasks) + 4 entries: list[tuple[str, str]] = [] for task_key, name, desc in all_tasks: task_cfg = ( aux.get(task_key, {}) if isinstance(aux.get(task_key), dict) else {} ) current = _format_aux_current(task_cfg) label = ( f"{name.ljust(name_col)}{('(' + desc + ')').ljust(desc_col)}{current}" ) entries.append((task_key, label)) entries.append(("__reset__", "Reset all to auto")) entries.append(("__back__", "Back")) idx = _prompt_provider_choice( [label for _, label in entries], default=0, ) if idx is None: return key = entries[idx][0] if key == "__back__": return if key == "__reset__": n = _reset_aux_to_auto() if n: print(f"Reset {n} auxiliary task(s) to auto.") else: print("All auxiliary tasks were already set to auto.") print() continue # Otherwise configure the specific task _aux_select_for_task(key) def _aux_select_for_task(task: str) -> None: """Pick a provider + model for a single auxiliary task and persist it. Provider rows come from ``build_aux_picker_rows()`` — the shared aux-picker substrate — so this surface shows exactly what every other aux picker shows: authenticated built-ins, the user's own ``providers:`` / ``custom_providers:`` endpoints, and providers whose credential pool is temporarily exhausted. Only already-configured providers appear; users set up new ones through the normal ``hermes model`` flow, then route aux tasks to them here. """ from hermes_cli.config import load_config from hermes_cli.inventory import build_aux_picker_rows, format_aux_picker_entries cfg = load_config() aux = cfg.get("auxiliary", {}) if isinstance(cfg.get("auxiliary"), dict) else {} task_cfg = aux.get(task, {}) if isinstance(aux.get(task), dict) else {} current_provider = str(task_cfg.get("provider") or "auto").strip() or "auto" current_model = str(task_cfg.get("model") or "").strip() current_base_url = str(task_cfg.get("base_url") or "").strip() display_name = next((name for key, name, _ in _all_aux_tasks() if key == task), task) # Gather authenticated providers (has credentials + curated model list) try: providers = build_aux_picker_rows( current_provider=current_provider, current_model=current_model, current_base_url=current_base_url, ) except Exception as exc: print(f"Could not detect authenticated providers: {exc}") providers = [] entries: list[tuple[str, str, list[str]]] = [] # (slug, label, models) # "auto" always first auto_marker = ( " ← current" if current_provider == "auto" and not current_base_url else "" ) entries.append(("__auto__", f"auto (recommended){auto_marker}", [])) entries.extend( format_aux_picker_entries( providers, current_provider=current_provider, current_base_url=current_base_url, ) ) # Custom endpoint (raw base_url) custom_marker = " ← current" if current_base_url else "" entries.append(("__custom__", f"Custom endpoint (direct URL){custom_marker}", [])) entries.append(("__back__", "Back", [])) print() print(f" Configure {display_name} — current: {_format_aux_current(task_cfg)}") print() idx = _prompt_provider_choice([label for _, label, _ in entries], default=0) if idx is None: return slug, _label, models = entries[idx] if slug == "__back__": return if slug == "__auto__": _save_aux_choice(task, provider="auto", model="", base_url="", api_key="") print(f"{display_name}: reset to auto.") return if slug == "__custom__": _aux_flow_custom_endpoint(task, task_cfg) return # Regular provider — pick a model from its curated list _aux_flow_provider_model(task, slug, models, current_model) def _aux_flow_provider_model( task: str, provider_slug: str, curated_models: list, current_model: str = "", ) -> None: """Prompt for a model under an already-authenticated provider, save to aux.""" from hermes_cli.auth import _prompt_model_selection from hermes_cli.models import get_pricing_for_provider display_name = next((name for key, name, _ in _all_aux_tasks() if key == task), task) # Fetch live pricing for this provider (non-blocking) pricing: dict = {} try: pricing = get_pricing_for_provider(provider_slug) or {} except Exception: pricing = {} model_list = list(curated_models) # Let the user pick a model. _prompt_model_selection supports "Enter custom # model name" and cancel. When there's no curated list (rare), fall back # to a raw input prompt. if not model_list: print(f"No curated model list for {provider_slug}.") print("Enter a model slug manually (blank = use provider default):") try: val = input("Model: ").strip() except (KeyboardInterrupt, EOFError): print() return selected = val or "" else: selected = _prompt_model_selection( model_list, current_model=current_model, pricing=pricing, confirm_provider=provider_slug, ) if selected is None: print("No change.") return _save_aux_choice( task, provider=provider_slug, model=selected or "", base_url="", api_key="" ) if selected: print(f"{display_name}: {provider_slug} · {selected}") else: print(f"{display_name}: {provider_slug} (provider default model)") def _aux_flow_custom_endpoint(task: str, task_cfg: dict) -> None: """Prompt for a direct OpenAI-compatible base_url + optional api_key/model.""" from hermes_cli.secret_prompt import masked_secret_prompt display_name = next((name for key, name, _ in _all_aux_tasks() if key == task), task) current_base_url = str(task_cfg.get("base_url") or "").strip() current_model = str(task_cfg.get("model") or "").strip() print() print(f" Custom endpoint for {display_name}") print(" Provide an OpenAI-compatible base URL (e.g. http://localhost:11434/v1)") print() try: url_prompt = ( f"Base URL [{current_base_url}]: " if current_base_url else "Base URL: " ) url = input(url_prompt).strip() except (KeyboardInterrupt, EOFError): print() return url = url or current_base_url if not url: print("No URL provided. No change.") return try: model_prompt = ( f"Model slug (optional) [{current_model}]: " if current_model else "Model slug (optional): " ) model = input(model_prompt).strip() except (KeyboardInterrupt, EOFError): print() return model = model or current_model try: api_key = masked_secret_prompt( "API key (optional, blank = use OPENAI_API_KEY): " ).strip() except (KeyboardInterrupt, EOFError): print() return _save_aux_choice( task, provider="custom", model=model, base_url=url, api_key=api_key, ) short_url = url.replace("https://", "").replace("http://", "").rstrip("/") print(f"{display_name}: custom ({short_url})" + (f" · {model}" if model else "")) def _prompt_provider_choice(choices, *, default=0, title="Select provider:"): """Show provider selection menu with curses arrow-key navigation. Falls back to a numbered list when curses is unavailable (e.g. piped stdin, non-TTY environments). Returns the selected index, or None if the user cancels. """ try: from hermes_cli.setup import _curses_prompt_choice idx = _curses_prompt_choice(title, choices, default) if idx >= 0: print() return idx except Exception: pass # Fallback: numbered list print(title) for i, c in enumerate(choices, 1): marker = "→" if i - 1 == default else " " print(f" {marker} {i}. {c}") print() while True: try: val = input(f"Choice [1-{len(choices)}] ({default + 1}): ").strip() if not val: return default idx = int(val) - 1 if 0 <= idx < len(choices): return idx print(f"Please enter 1-{len(choices)}") except ValueError: print("Please enter a number") except (KeyboardInterrupt, EOFError): print() return None _DEFAULT_QWEN_PORTAL_MODELS = [ "qwen3-coder-plus", "qwen3-coder", ] def _prompt_custom_api_mode_selection(base_url: str, current_api_mode: str = "") -> Optional[str]: """Prompt for a custom provider API mode. Returns an explicit mode string, or None to keep auto-detect behavior. """ from hermes_cli.runtime_provider import _detect_api_mode_for_url detected_mode = _detect_api_mode_for_url(base_url) normalized_current = str(current_api_mode or "").strip().lower() default_mode = normalized_current or detected_mode or "" mode_options = [ ( "", "Auto-detect", "Use Hermes URL heuristics; best for standard OpenAI-compatible endpoints.", ), ( "chat_completions", "Chat Completions", "Use /chat/completions for standard OpenAI-compatible servers.", ), ( "codex_responses", "Responses / Codex", "Use /responses for Codex-compatible tool-calling backends.", ), ( "anthropic_messages", "Anthropic Messages", "Use /v1/messages for Anthropic-compatible endpoints.", ), ] print() print("Select API compatibility mode:") for idx, (value, label, description) in enumerate(mode_options, 1): markers = [] if value == detected_mode: markers.append("detected") if value == default_mode: markers.append("current") suffix = f" [{' / '.join(markers)}]" if markers else "" print(f" {idx}. {label}{suffix}") print(f" {description}") try: raw = input( "Choice [1-4, Enter to keep current/detected]: " ).strip().lower() except (KeyboardInterrupt, EOFError): print("\nCancelled.") raise if not raw: return default_mode or None if raw in {"1", "auto", "detect", "auto-detect"}: return None if raw in {"2", "chat", "chat_completions", "completions"}: return "chat_completions" if raw in {"3", "responses", "codex", "codex_responses"}: return "codex_responses" if raw in {"4", "anthropic", "anthropic_messages", "messages"}: return "anthropic_messages" print(f"Invalid API mode choice: {raw}. Falling back to auto-detect.") return None def _auto_provider_name(base_url: str) -> str: """Generate a display name from a custom endpoint URL. Returns a human-friendly label like "Local (localhost:11434)" or "RunPod (xyz.runpod.io)". Used as the default when prompting the user for a display name during custom endpoint setup. """ import re clean = base_url.replace("https://", "").replace("http://", "").rstrip("/") clean = re.sub(r"/v1/?$", "", clean) name = clean.split("/")[0] if "localhost" in name or "127.0.0.1" in name: name = f"Local ({name})" elif "runpod" in name.lower(): name = f"RunPod ({name})" else: name = name.capitalize() return name def _custom_provider_api_key_config_value(provider_info, resolved_api_key=""): """Return the value that should be persisted for a custom provider key.""" api_key_ref = str(provider_info.get("api_key_ref", "") or "").strip() if api_key_ref: return api_key_ref key_env = str(provider_info.get("key_env", "") or "").strip() if key_env and not str(provider_info.get("api_key", "") or "").strip(): return f"${{{key_env}}}" return str(resolved_api_key or "").strip() def _custom_provider_base_url_config_value(provider_info, resolved_base_url=""): """Return the value that should be persisted for a custom provider URL.""" base_url_ref = str(provider_info.get("base_url_ref", "") or "").strip() if base_url_ref: return base_url_ref return str(resolved_base_url or "").strip() def _save_custom_provider( base_url, api_key="", model="", context_length=None, name=None, api_mode=None, key_env="" ): """Save a custom endpoint to custom_providers in config.yaml. Deduplicates by base_url — if the URL already exists, updates the model name, context_length, and api_mode but doesn't add a duplicate entry. Uses *name* when provided, otherwise auto-generates from the URL. When *key_env* is set the caller has already written the key to ``.env``, so the entry references it instead of inlining the secret (#69449). """ from hermes_cli.config import load_config, save_config cfg = load_config() providers = cfg.get("custom_providers") or [] if not isinstance(providers, list): providers = [] # Check if this URL is already saved — update model/context_length if so for entry in providers: if isinstance(entry, dict) and entry.get("base_url", "").rstrip( "/" ) == base_url.rstrip("/"): changed = False if model and entry.get("model") != model: entry["model"] = model changed = True if model and context_length: models_cfg = entry.get("models", {}) if not isinstance(models_cfg, dict): models_cfg = {} models_cfg[model] = {"context_length": context_length} entry["models"] = models_cfg changed = True if api_mode: if entry.get("api_mode") != api_mode: entry["api_mode"] = api_mode changed = True elif "api_mode" in entry: entry.pop("api_mode", None) changed = True if key_env and (entry.get("key_env") != key_env or entry.get("api_key")): entry["key_env"] = key_env entry.pop("api_key", None) changed = True if changed: cfg["custom_providers"] = providers save_config(cfg) return # already saved, updated if needed # Use provided name or auto-generate from URL if not name: name = _auto_provider_name(base_url) entry = {"name": name, "base_url": base_url} if key_env: entry["key_env"] = key_env elif api_key: entry["api_key"] = api_key if model: entry["model"] = model if api_mode: entry["api_mode"] = api_mode if model and context_length: entry["models"] = {model: {"context_length": context_length}} providers.append(entry) cfg["custom_providers"] = providers save_config(cfg) print(f' 💾 Saved to custom providers as "{name}" (edit in config.yaml)') def _remove_custom_provider(config): """Let the user remove a saved custom provider from config.yaml.""" from hermes_cli.config import load_config, save_config cfg = load_config() providers = cfg.get("custom_providers") or [] if not isinstance(providers, list) or not providers: print("No custom providers configured.") return print("Remove a custom provider:\n") choices = [] for entry in providers: if isinstance(entry, dict): name = entry.get("name", "unnamed") url = entry.get("base_url", "") short_url = url.replace("https://", "").replace("http://", "").rstrip("/") choices.append(f"{name} ({short_url})") else: choices.append(str(entry)) choices.append("Cancel") try: from hermes_cli.curses_ui import curses_radiolist idx = curses_radiolist( "Select provider to remove:", list(choices), selected=0, cancel_returns=-1, ) print() if idx < 0: idx = None except (ImportError, NotImplementedError, OSError, subprocess.SubprocessError): for i, c in enumerate(choices, 1): print(f" {i}. {c}") print() try: val = input(f"Choice [1-{len(choices)}]: ").strip() idx = int(val) - 1 if val else None except (ValueError, KeyboardInterrupt, EOFError): idx = None if idx is None or idx >= len(providers): print("No change.") return removed = providers.pop(idx) cfg["custom_providers"] = providers save_config(cfg) removed_name = ( removed.get("name", "unnamed") if isinstance(removed, dict) else str(removed) ) print(f'✅ Removed "{removed_name}" from custom providers.') # Lazy-export the model catalog at module level. Tests and a handful of # downstream call sites read `hermes_cli.main._PROVIDER_MODELS` directly, # so the symbol needs to be reachable as a module attribute. But importing # the catalog eagerly costs ~55ms on every `hermes` invocation — including # fast paths like `hermes --version` and slash-command dispatch that never # touch the catalog. PEP 562 module-level __getattr__ defers the import # until first attribute access, so the cost is only paid by callers that # actually look up the catalog. Termux already defers via the same # mechanism (its model-selection handlers do their own function-local # imports), so the explicit termux branch from before is no longer needed. _LAZY_MODEL_EXPORTS = ("_PROVIDER_MODELS",) # The main.py decomposition moved the sessions/update/dashboard command # implementations into their own modules, but main.py still re-exports their # surface so argparse wiring and test monkeypatches on hermes_cli.main. # keep resolving unchanged. Importing those modules eagerly costs ~50ms on # every `hermes` invocation, including fast paths like `hermes --version` # that never run a subcommand. Resolve the re-exports through the module # __getattr__ below instead, so each module is only imported when one of its # names is actually touched. Monkeypatching keeps working: patch.object sets # a real module attribute, which shadows __getattr__. _LAZY_COMMAND_EXPORTS = { "hermes_cli.sessions_cmd": ( "cmd_sessions", ), "hermes_cli.dashboard_procs": ( "_detect_concurrent_hermes_instances", "_filter_dashboard_respawn_candidates", "_kill_stale_dashboard_processes", "_scan_dashboard_processes", ), "hermes_cli.update_cmd": ( "_add_upstream_remote", "_atomic_replace_dir", "_capture_active_lazy_features", "_capture_active_tool_dependencies", "_capture_head_sha", "_cmd_update_check", "_cmd_update_impl", "_cold_start_windows_gateway_after_update", "_count_commits_between", "_detect_self_loaded_native_modules", "_detect_venv_python_processes", "_defer_update_for_self_lock", "_discard_lockfile_churn", "_discard_stashed_changes", "_ensure_acp_launcher", "_ensure_fhs_path_guard", "_ensure_uv_for_termux", "_finish_dashboard_update_cleanup", "_for_each_systemd_gateway_unit", "_format_concurrent_instances_message", "_format_time_ago", "_format_venv_python_holders_message", "_gateway_prompt", "_get_origin_url", "_has_upstream_remote", "_install_psutil_android_compat", "_invalidate_update_cache", "_is_android_python", "_is_fork", "_leftover_pausable_gateway_pids", "_log_only_write", "_mark_skip_upstream_prompt", "_npm_bin_exists", "_npm_lockfile_changed", "_npm_manifest_paths", "_npm_manifests_digest", "_orphaned_desktop_backend_pids", "_pause_windows_gateways_for_update", "_print_curator_first_run_notice", "_print_curator_recent_run_notice", "_print_fts_optimize_available_notice", "_print_stash_cleanup_guidance", "_print_update_completion", "_record_npm_lockfile_hash", "_refresh_active_lazy_features", "_refresh_active_memory_provider_dependencies", "_refresh_bootstrap_cache_scripts", "_refresh_windows_gateway_launchers", "_reload_updated_runtime_modules", "_resolve_pre_update_backup_mode", "_resolve_stash_selector", "_restart_phase_failure_is_incomplete", "_restore_active_tool_dependencies", "_restore_stashed_changes", "_resume_windows_gateways_after_update", "_run_logged_subprocess", "_run_pre_update_backup", "_should_skip_upstream_prompt", "_stash_apply_failed_only_on_existing_untracked", "_stash_local_changes_if_needed", "_stop_process_trees", "_surviving_gateway_pids_after_failed_restart", "_sync_fork_with_upstream", "_sync_with_upstream_if_needed", "_update_node_dependencies", "_update_via_zip", "_upgrade_pip_before_lazy_refresh", "_validate_critical_files_syntax", "_validate_critical_modules_import", "_venv_core_imports_healthy", "_venv_launcher_ancestors", "_wait_for_windows_update_gateway_exit", "_warn_gateway_restart_phase_aborted", "_warn_incomplete_gateway_fleet_restart", "_web_build_toolchain_ready", "_web_toolchain_roots", "_write_lazy_refresh_incomplete_marker", "_write_marker_file", "_write_update_incomplete_marker", "_write_update_planned_stop_marker", "_UPDATE_RUNTIME_RELOAD_MODULES", "_UPDATE_CRITICAL_FILES", "_UPDATE_CRITICAL_MODULES", "OFFICIAL_REPO_URLS", "OFFICIAL_REPO_URL", "SKIP_UPSTREAM_PROMPT_FILE", "_PRE_UPDATE_SNAPSHOT_KEEP", "_PRE_UPDATE_SNAPSHOT_MAX_FILE_SIZE", ), } _LAZY_COMMAND_ATTR_TO_MODULE = { attr: module for module, attrs in _LAZY_COMMAND_EXPORTS.items() for attr in attrs } # Back-compat alias: some tests and external callers import the old warn-only # name. The kill behaviour replaced it; resolve to the new name lazily. _LAZY_COMMAND_ALIASES = { "_warn_stale_dashboard_processes": ( "hermes_cli.dashboard_procs", "_kill_stale_dashboard_processes", ), } def _self(): """This module, for attribute access at call time. Bare-name global lookups inside this module do not go through the PEP 562 __getattr__ below, so internal callers of the lazily re-exported names use _self(). instead. That resolves the lazy re-export on first use and keeps monkeypatches on hermes_cli.main. working, exactly like a globals lookup did. ``sys`` is imported locally because some tests patch this module's ``sys`` attribute. """ import sys as _sys return _sys.modules[__name__] def __getattr__(name): """Defer the model-catalog and command-module imports until first read.""" if name in _LAZY_MODEL_EXPORTS: from hermes_cli.models import _PROVIDER_MODELS # Cache on the module so subsequent accesses skip the import machinery. globals()[name] = _PROVIDER_MODELS return _PROVIDER_MODELS module = _LAZY_COMMAND_ATTR_TO_MODULE.get(name) if module is not None: import importlib value = getattr(importlib.import_module(module), name) globals()[name] = value return value alias = _LAZY_COMMAND_ALIASES.get(name) if alias is not None: import importlib module_name, attr = alias value = getattr(importlib.import_module(module_name), attr) globals()[name] = value return value raise AttributeError(f"module {__name__!r} has no attribute {name!r}") def _current_reasoning_effort(config) -> str: agent_cfg = config.get("agent") if isinstance(agent_cfg, dict): return str(agent_cfg.get("reasoning_effort") or "").strip().lower() return "" def _set_reasoning_effort(config, effort: str) -> None: agent_cfg = config.get("agent") if not isinstance(agent_cfg, dict): agent_cfg = {} config["agent"] = agent_cfg agent_cfg["reasoning_effort"] = effort def _prompt_reasoning_effort_selection(efforts, current_effort=""): """Prompt for a reasoning effort. Returns effort, 'none', or None to keep current.""" deduped = list( dict.fromkeys( str(effort).strip().lower() for effort in efforts if str(effort).strip() ) ) canonical_order = ("minimal", "low", "medium", "high", "xhigh", "max", "ultra") ordered = [effort for effort in canonical_order if effort in deduped] ordered.extend(effort for effort in deduped if effort not in canonical_order) if not ordered: return None def _label(effort): if effort == current_effort: return f"{effort} ← currently in use" return effort disable_label = "Disable reasoning" skip_label = "Skip (keep current)" if current_effort == "none": default_idx = len(ordered) elif current_effort in ordered: default_idx = ordered.index(current_effort) elif "medium" in ordered: default_idx = ordered.index("medium") else: default_idx = 0 try: from hermes_cli.curses_ui import curses_radiolist choices = [_label(effort) for effort in ordered] choices.append(disable_label) choices.append(skip_label) idx = curses_radiolist( "Select reasoning effort:", choices, selected=default_idx, cancel_returns=-1, ) if idx < 0: return None print() if idx < len(ordered): return ordered[idx] if idx == len(ordered): return "none" return None except (ImportError, NotImplementedError, OSError, subprocess.SubprocessError): pass print("Select reasoning effort:") for i, effort in enumerate(ordered, 1): print(f" {i}. {_label(effort)}") n = len(ordered) print(f" {n + 1}. {disable_label}") print(f" {n + 2}. {skip_label}") print() while True: try: choice = input(f"Choice [1-{n + 2}] (default: keep current): ").strip() if not choice: return None idx = int(choice) if 1 <= idx <= n: return ordered[idx - 1] if idx == n + 1: return "none" if idx == n + 2: return None print(f"Please enter 1-{n + 2}") except ValueError: print("Please enter a number") except (KeyboardInterrupt, EOFError): return None def _prompt_api_key( pconfig, existing_key: str, provider_id: str = "", existing_source: str = "", ) -> tuple: """Shared API-key entry point for ``hermes setup`` / ``hermes model``. Handles both first-time entry and the already-configured case. When a key is already present, offers [K]eep / [R]eplace / [C]lear so the user can recover from a malformed paste without editing ``~/.hermes/.env`` by hand. Returns ``(resolved_key, abort)``. ``abort=True`` means the caller should ``return`` immediately — the user cancelled entry, declined to replace, or cleared the key and is now unconfigured. """ from hermes_cli.auth import LMSTUDIO_NOAUTH_PLACEHOLDER from hermes_cli.config import save_env_value from hermes_cli.secret_prompt import masked_secret_prompt key_env = pconfig.api_key_env_vars[0] if pconfig.api_key_env_vars else "" def _prompt_new_key(*, allow_lmstudio_default: bool) -> str: if provider_id == "lmstudio" and allow_lmstudio_default: prompt = f"{key_env} (Enter for no-auth default {LMSTUDIO_NOAUTH_PLACEHOLDER!r}): " else: prompt = f"{key_env} (or Enter to cancel): " try: entered = masked_secret_prompt(prompt).strip() except (KeyboardInterrupt, EOFError): print() return "" if not entered and provider_id == "lmstudio" and allow_lmstudio_default: return LMSTUDIO_NOAUTH_PLACEHOLDER return entered # First-time entry ──────────────────────────────────────────────────── if not existing_key: print(f"No {pconfig.name} API key configured.") if not key_env: return "", True new_key = _prompt_new_key(allow_lmstudio_default=True) if not new_key: print("Cancelled.") return "", True save_env_value(key_env, new_key) print("API key saved.") print() return new_key, False # Already configured — offer K / R / C ──────────────────────────────── from hermes_cli.env_loader import format_secret_source_suffix source_suffix = format_secret_source_suffix(key_env) if key_env else "" print(f" {pconfig.name} API key: {existing_key[:8]}... ✓{source_suffix}") if not key_env: # Nothing we can rewrite; just acknowledge and move on. print() return existing_key, False pool_backed = existing_source.startswith("credential_pool:") menu = ( " [K]eep / [R]eplace (default K): " if pool_backed else " [K]eep / [R]eplace / [C]lear (default K): " ) try: choice = input(menu).strip().lower() except (KeyboardInterrupt, EOFError): print() choice = "k" if choice.startswith("r"): new_key = _prompt_new_key(allow_lmstudio_default=False) if not new_key: print(" No change.") print() return existing_key, False save_env_value(key_env, new_key) print(" API key updated.") print() return new_key, False if choice.startswith("c") and not pool_backed: save_env_value(key_env, "") print( f" API key cleared. Re-run `hermes setup` to configure {pconfig.name} again." ) return "", True # Keep (default, or any other input) print() return existing_key, False def _infer_stepfun_region(base_url: str) -> str: """Infer the current StepFun region from the configured endpoint.""" normalized = (base_url or "").strip().lower() if "api.stepfun.com" in normalized: return "china" return "international" def _stepfun_base_url_for_region(region: str) -> str: from hermes_cli.auth import ( STEPFUN_STEP_PLAN_CN_BASE_URL, STEPFUN_STEP_PLAN_INTL_BASE_URL, ) return ( STEPFUN_STEP_PLAN_CN_BASE_URL if region == "china" else STEPFUN_STEP_PLAN_INTL_BASE_URL ) def _run_anthropic_oauth_flow(save_env_value): """Run the Claude OAuth setup-token flow. Returns True if credentials were saved.""" from agent.anthropic_adapter import ( run_oauth_setup_token, read_claude_code_credentials, is_claude_code_token_valid, ) from hermes_cli.config import ( save_anthropic_oauth_token, use_anthropic_claude_code_credentials, ) def _activate_claude_code_credentials_if_available() -> bool: try: creds = read_claude_code_credentials() except Exception: creds = None if creds and ( is_claude_code_token_valid(creds) or bool(creds.get("refreshToken")) ): use_anthropic_claude_code_credentials(save_fn=save_env_value) print(" ✓ Claude Code credentials linked.") from hermes_constants import display_hermes_home as _dhh_fn print( f" Hermes will use Claude's credential store directly instead of copying a setup-token into {_dhh_fn()}/.env." ) return True return False try: print() print(" Running 'claude setup-token' — follow the prompts below.") print(" A browser window will open for you to authorize access.") print() token = run_oauth_setup_token() if token: if _activate_claude_code_credentials_if_available(): return True save_anthropic_oauth_token(token, save_fn=save_env_value) print(" ✓ OAuth credentials saved.") return True # Subprocess completed but no token auto-detected — ask user to paste print() print(" If the setup-token was displayed above, paste it here:") print() from hermes_cli.secret_prompt import masked_secret_prompt try: manual_token = masked_secret_prompt( " Paste setup-token (or Enter to cancel): " ).strip() except (KeyboardInterrupt, EOFError): print() return False if manual_token: save_anthropic_oauth_token(manual_token, save_fn=save_env_value) print(" ✓ Setup-token saved.") return True print(" ⚠ Could not detect saved credentials.") return False except FileNotFoundError: # Claude CLI not installed — guide user through manual setup print() print(" The 'claude' CLI is required for OAuth login.") print() print(" To install and authenticate:") print() print(" 1. Install Claude Code: npm install -g @anthropic-ai/claude-code") print(" 2. Run: claude setup-token") print(" 3. Follow the browser prompts to authorize") print(" 4. Re-run: hermes model") print() print(" Or paste an existing setup-token now (sk-ant-oat-...):") print() from hermes_cli.secret_prompt import masked_secret_prompt try: token = masked_secret_prompt(" Setup-token (or Enter to cancel): ").strip() except (KeyboardInterrupt, EOFError): print() return False if token: save_anthropic_oauth_token(token, save_fn=save_env_value) print(" ✓ Setup-token saved.") return True print(" Cancelled — install Claude Code and try again.") return False def cmd_login(args): """Authenticate Hermes CLI with a provider.""" from hermes_cli.auth import login_command login_command(args) def cmd_logout(args): """Clear provider authentication.""" from hermes_cli.auth import logout_command logout_command(args) def cmd_auth(args): """Manage pooled credentials.""" from hermes_cli.auth_commands import auth_command auth_command(args) def cmd_status(args): """Show status of all components.""" from hermes_cli.status import show_status show_status(args) def cmd_cron(args): """Cron job management.""" from hermes_cli.cron import cron_command cron_command(args) def cmd_sync(args): """Skill Sync — personal sync across devices, plus sharing with your org.""" import json as _json sub = getattr(args, "sync_command", None) if sub in {None, ""}: print( "usage: hermes sync " "\n" "\n" "Your skills, across your devices:\n" " status Show what is synced, and from where\n" " pull Pull your synced skills\n" " push Push your opted-in skills\n" " now Reconcile now: pull then push\n" " enable Include a skill in your sync\n" " disable Exclude a skill from your sync\n" " device [--name N] Show or set this device's label\n" "\n" "Shared with your team:\n" " propose Share a skill with your organisation", file=sys.stderr, ) return 1 if sub == "device": from tools import skills_sync_client as ssc name = getattr(args, "device_name", None) if name is not None: try: stored = ssc.set_device_name(name) except ValueError as e: print(f"error: {e}", file=sys.stderr) return 1 print(f"device label set to '{stored}'.") print( "New commits from this device will use this label; existing " "commits keep their previous one.", file=sys.stderr, ) return 0 # No --name: print the current (creating a default on first use). print(ssc.stable_device_id()) return 0 if sub == "propose": from tools import skills_sync_client as ssc name = args.name try: result = ssc.propose_skill(name, message=args.message) except ssc.SyncInertError as e: print(f"cannot share this skill: {e}", file=sys.stderr) return 1 except ssc.SyncError as e: print(f"could not share '{name}': {e}", file=sys.stderr) return 1 if result.get("proposal_pending"): print( f"Shared '{name}' with your organisation — an admin needs to " f"approve it (proposal #{result.get('proposal_id')}). It is " f"not live for the team until then." ) else: print(f"Added '{name}' to your organisation's shared skills.") return 0 if sub in {"enable", "disable"}: from tools.skill_usage import set_sync, is_curation_eligible skill = args.skill if not is_curation_eligible(skill): print( f"'{skill}' is not sync-eligible (bundled, hub-installed, " f"external, or not found). Only agent-created / user-authored " f"skills under ~/.hermes/skills/ can sync.", file=sys.stderr, ) return 1 set_sync(skill, sub == "enable") print(f"sync {'enabled' if sub == 'enable' else 'disabled'} for '{skill}'.") return 0 from tools import skills_sync_client as ssc if sub == "status": status = ssc.sync_status() print(_json.dumps(status, indent=2, ensure_ascii=False)) if status.get("org_available"): n = len(status.get("org_skills") or []) modified = status.get("org_skills_modified") or [] print( f"\nOrg skills: {n} shared skill(s) from your organisation " f"(your role: {status.get('org_role')}). They load alongside " f"your own, labeled by origin, and you can edit them.", file=sys.stderr, ) if modified: print( f" {len(modified)} with local edits not yet shared: " f"{', '.join(modified)}\n" f" Share them back with `hermes sync propose `. " f"Org updates will not overwrite them.", file=sys.stderr, ) elif status.get("logged_in"): print( "\nOrg skills: not applicable — this account isn't a member " "of a shared organisation.", file=sys.stderr, ) if not status.get("logged_in"): print("\nNot logged into Nous Portal — sync is inert.", file=sys.stderr) elif not status.get("nous_admin"): print( "\nSync is not enabled for your account yet.", file=sys.stderr, ) elif not status.get("feature_enabled"): print( "\nSync feature is off for this instance (set HERMES_SYNC_ENABLED=1 " "or config.yaml sync.enabled: true). Sync is inert.", file=sys.stderr, ) elif not status.get("base_url"): print( "\nNo sync base URL configured (config.yaml sync.base_url or " "HERMES_SYNC_BASE_URL). Sync is inert.", file=sys.stderr, ) return 0 # pull / push / now — enforce the gate up front with a clear message. try: identity = ssc.resolve_identity() except ssc.SyncInertError as e: print(f"sync inert: {e}", file=sys.stderr) return 1 if not identity.get("nous_admin"): print( "sync unavailable: not enabled for your account yet.", file=sys.stderr, ) return 1 if not ssc.resolve_sync_base_url(): print( "sync inert: no sync base URL configured (config.yaml sync.base_url " "or HERMES_SYNC_BASE_URL).", file=sys.stderr, ) return 1 try: if sub == "pull": result = ssc.pull_skills(identity=identity) # Refresh the org mirror too when this account belongs to an # organisation (no-op otherwise), so one pull covers both. org_result = ssc.maybe_pull_org_skills() if org_result: n = len(org_result.get("updated") or []) print( f"org: refreshed {n} shared skill(s) from your " f"organisation.", file=sys.stderr, ) clashes = org_result.get("conflicted") or [] if clashes: print( f"org: {len(clashes)} skill(s) have BOTH local edits " f"and org updates, so they were left as-is: " f"{', '.join(clashes)}\n" f" Your local version is intact. Review it, then " f"either propose it or delete the local copy and pull " f"again to take the org version.", file=sys.stderr, ) elif sub == "push": result = ssc.push_skills(identity=identity, message="hermes sync push") elif sub == "now": pull_res = ssc.pull_skills(identity=identity) push_res = ssc.push_skills(identity=identity, message="hermes sync now") result = {"pull": pull_res, "push": push_res} else: print(f"Unknown sync subcommand: {sub}", file=sys.stderr) return 1 except ssc.SyncError as e: print(f"sync failed: {e}", file=sys.stderr) return 1 print(_json.dumps(result, indent=2, ensure_ascii=False)) return 0 def cmd_webhook(args): """Webhook subscription management.""" from hermes_cli.webhook import webhook_command webhook_command(args) def cmd_slack(args): """Slack integration helpers. Dispatches ``hermes slack ``. Currently supports: manifest — print or write a Slack app manifest with every gateway command registered as a first-class slash. """ sub = getattr(args, "slack_command", None) if sub in {None, ""}: # No subcommand — print usage hint. print( "usage: hermes slack \n" "\n" "subcommands:\n" " manifest Generate a Slack app manifest with every gateway\n" " command registered as a native slash\n" "\n" "Run `hermes slack manifest -h` for details.", file=sys.stderr, ) return 1 if sub == "manifest": from hermes_cli.slack_cli import slack_manifest_command status = slack_manifest_command(args) if status: raise SystemExit(status) return status print(f"Unknown slack subcommand: {sub}", file=sys.stderr) return 1 def cmd_kanban(args): """Multi-profile collaboration board.""" from hermes_cli.kanban import kanban_command return kanban_command(args) def cmd_project(args): """Manage projects (named, multi-folder workspaces).""" from hermes_cli.projects_cmd import projects_command return projects_command(args) def cmd_hooks(args): """Shell-hook inspection and management.""" from hermes_cli.hooks import hooks_command hooks_command(args) def cmd_doctor(args): """Check configuration and dependencies.""" from hermes_cli.doctor import run_doctor run_doctor(args) def cmd_verify(args): """Detect a project's run recipe and smoke-test it.""" from hermes_cli.verify_cmd import run_verify_command sys.exit(run_verify_command(args)) def cmd_security(args): """Dispatch `hermes security `.""" sub = getattr(args, "security_command", None) if sub in ("audit", None): from hermes_cli.security_audit import cmd_security_audit # Default subcommand is `audit` when no subcmd is given. code = cmd_security_audit(args) sys.exit(int(code or 0)) print(f"unknown security subcommand: {sub}", file=sys.stderr) sys.exit(2) def cmd_approvals(args): """Dispatch `hermes approvals `.""" from hermes_cli.approvals_suggest import approvals_command status = approvals_command(args) if status: sys.exit(status) return status def cmd_dump(args): """Dump setup summary for support/debugging.""" from hermes_cli.dump import run_dump run_dump(args) def cmd_debug(args): """Debug tools (share report, etc.).""" from hermes_cli.debug import run_debug run_debug(args) def cmd_config(args): """Configuration management.""" from hermes_cli.config import config_command config_command(args) def cmd_skin(args): """Skin management (list / use / set).""" from hermes_cli.skin_cmd import skin_command skin_command(args) def cmd_backup(args): """Back up Hermes home directory to a zip file.""" if getattr(args, "quick", False): from hermes_cli.backup import run_quick_backup run_quick_backup(args) else: from hermes_cli.backup import run_backup run_backup(args) def cmd_import(args): """Restore a Hermes backup from a zip file.""" from hermes_cli.backup import run_import run_import(args) def _print_version_info(*, check_updates: bool = True) -> None: from hermes_cli.config import detect_install_method from hermes_cli.slash_exec import CommandContext, execute_command # Core version line is registry-owned (shared with the gateway /version); # the install/python/SDK detail below is CLI-only decoration. print(execute_command("version", CommandContext(surface="cli")).text) print(f"Install directory: {PROJECT_ROOT}") print(f"Install method: {detect_install_method(PROJECT_ROOT)}") # Show Python version print(f"Python: {sys.version.split()[0]}") # Check for key dependencies. Use importlib.metadata rather than # ``import openai`` — the SDK drags in ~800ms of pydantic-backed type # modules just to expose ``__version__``. Metadata lookup is ~2ms. try: from importlib.metadata import version as _pkg_version, PackageNotFoundError try: print(f"OpenAI SDK: {_pkg_version('openai')}") except PackageNotFoundError: print("OpenAI SDK: Not installed") except ImportError: print("OpenAI SDK: Not installed") if not check_updates: return # Show update status (synchronous — acceptable since user asked for version info) try: from hermes_cli.banner import UPDATE_AVAILABLE_NO_COUNT, check_for_updates from hermes_cli.config import recommended_update_command behind = check_for_updates() if behind == UPDATE_AVAILABLE_NO_COUNT: print( f"Update available — run '{recommended_update_command()}'" ) elif behind and behind > 0: commits_word = "commit" if behind == 1 else "commits" print( f"Update available: {behind} {commits_word} behind — " f"run '{recommended_update_command()}'" ) elif behind == 0: print("Up to date") except Exception: pass def cmd_version(args): """Show version.""" _print_version_info(check_updates=True) def cmd_uninstall(args): """Uninstall Hermes Agent (or just the Chat GUI with --gui).""" # Machine-readable install snapshot for the desktop app's uninstall UI. # Must run before any TTY gate — it's called from a non-interactive child. if getattr(args, "gui_summary", False): from hermes_cli.gui_uninstall import gui_install_summary print(json.dumps(gui_install_summary())) return # GUI-only uninstall. The desktop app shells out to this non-interactively # with --yes, so only gate on a TTY when we actually need to prompt. if getattr(args, "gui", False): if not getattr(args, "yes", False): _require_tty("uninstall --gui") from hermes_cli.uninstall import run_gui_uninstall run_gui_uninstall(args) return # Full/keep-data uninstall. ``--yes`` runs non-interactively (the desktop # app's lite/full modes drive this from a detached cleanup script), so only # gate on a TTY when we actually need to prompt for the option + confirm. if not getattr(args, "yes", False): _require_tty("uninstall") from hermes_cli.uninstall import run_uninstall run_uninstall(args) def _clear_bytecode_cache(root: Path) -> int: """Remove all __pycache__ directories under *root*. Stale .pyc files can cause ImportError after code updates when Python loads a cached bytecode file that references names that no longer exist (or don't yet exist) in the updated source. Clearing them forces Python to recompile from the .py source on next import. Returns the number of directories removed. """ removed = 0 for dirpath, dirnames, _ in os.walk(root): # Skip venv / node_modules / .git entirely dirnames[:] = [ d for d in dirnames if d not in {"venv", ".venv", "node_modules", ".git", ".worktrees"} ] if os.path.basename(dirpath) == "__pycache__": try: shutil.rmtree(dirpath) removed += 1 except OSError: pass dirnames.clear() # nothing left to recurse into return removed # Update pipeline lives in hermes_cli/update_cmd.py (main.py decomposition, # mechanical move). Its names are re-exported lazily through the module-level # __getattr__ above (see _LAZY_COMMAND_EXPORTS) so argparse wiring and test # monkeypatches on hermes_cli.main. keep resolving unchanged without # paying the update_cmd import cost on every CLI invocation. # Stamp file recording the checkout fingerprint the bytecode cache was last # validated against. Lives next to the checkout (NOT in HERMES_HOME) because # __pycache__ is per-checkout state shared by every profile. _BYTECODE_FINGERPRINT_FILE = ".bytecode-fingerprint" def _record_bytecode_fingerprint() -> None: """Persist the current checkout fingerprint after a bytecode sweep. Never raises. A failed write just means the next launch re-sweeps — safe, merely redundant. """ try: fingerprint = _read_git_revision_fingerprint(PROJECT_ROOT) if not fingerprint: return stamp_path = PROJECT_ROOT / _BYTECODE_FINGERPRINT_FILE tmp_path = stamp_path.with_name(stamp_path.name + ".tmp") tmp_path.write_text(fingerprint, encoding="utf-8") tmp_path.replace(stamp_path) except OSError as exc: logger.debug("Could not record bytecode fingerprint: %s", exc) def _sweep_stale_bytecode_if_checkout_changed() -> None: """Clear ``__pycache__`` at launch when the checkout changed underneath us. The stale-bytecode bug class (issues #6207, #60242; Dhruv's WhatsApp ``cannot import name 'parse_model_flags_detailed'`` report) has one shared shape: the checkout's ``.py`` files change (git pull inside ``hermes update``, a manual ``git pull``, a ZIP update, a file-sync restore) while ``__pycache__`` retains bytecode from the previous revision, and a later process trusts the stale ``.pyc`` instead of the fresh source. Update-time clears alone can never close this class: ``hermes update`` always executes the PRE-pull updater code, so any hardening added to it only takes effect one update late, and manual ``git pull`` never runs the updater at all. This launch-time guard closes the loop: every ``hermes`` entry point compares the checkout fingerprint (cheap file reads, no git subprocess) against the last-validated stamp and sweeps the bytecode cache once when they diverge. Never raises — a failure here must not block launch. """ try: fingerprint = _read_git_revision_fingerprint(PROJECT_ROOT) if not fingerprint: return # non-git install — the ZIP update path clears explicitly stamp_path = PROJECT_ROOT / _BYTECODE_FINGERPRINT_FILE try: recorded = stamp_path.read_text(encoding="utf-8").strip() except OSError: recorded = "" if recorded == fingerprint: return removed = _clear_bytecode_cache(PROJECT_ROOT) if removed: logger.info( "Checkout changed since last launch (%s -> %s): cleared %d stale __pycache__ director%s", recorded or "unknown", fingerprint, removed, "y" if removed == 1 else "ies", ) _record_bytecode_fingerprint() except Exception as exc: logger.debug("Stale-bytecode launch sweep failed: %s", exc) def _web_ui_build_needed(web_dir: Path) -> bool: """Return True if the web UI dist is missing or its source content changed. Uses a SHA-256 content hash of the web source tree (the same approach ``_desktop_build_needed()`` already uses for the Electron build), NOT mtime comparison. ``git checkout`` / ``git pull`` / ``hermes update`` rewrite source mtimes without changing content, which made the old mtime check unreliable in both directions: it could skip a rebuild when source had genuinely changed (serving a stale dashboard) and force a rebuild when nothing had. A content hash is stable across mtime churn. The dashboard source lives under ``web/`` but Vite outputs to ``hermes_cli/web_dist/`` (per vite.config.ts outDir), NOT ``web/dist/``, so the dist directory is never part of the hashed source tree. """ project_root = web_dir.parent.parent if web_dir.parent.name == "apps" else web_dir.parent dist_dir = project_root / "hermes_cli" / "web_dist" sentinel = dist_dir / ".vite" / "manifest.json" if not sentinel.exists(): sentinel = dist_dir / "index.html" if not sentinel.exists(): return True stamp_file = _web_ui_stamp_path() if not stamp_file.is_file(): return True try: stamp_data = json.loads(stamp_file.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): return True if not isinstance(stamp_data, dict): return True saved_hash = stamp_data.get("contentHash") if not saved_hash: return True return _compute_web_ui_content_hash(project_root, web_dir) != saved_hash def _compute_web_ui_content_hash(project_root: Path, web_dir: Path) -> str: """Return a SHA-256 hex digest of the web UI source tree. Covers ``web_dir`` (the dashboard frontend source) plus the root ``package.json`` / ``package-lock.json`` (workspace config that determines dependency resolution). Mirrors ``_compute_desktop_content_hash()``: ignored paths (``node_modules/``, ``dist/``, ``*.pyc``, ...) are skipped via the repo-root ``.gitignore`` so build output never feeds back into its own staleness check. """ h = hashlib.sha256() def _hash_file(path: Path) -> None: rel = str(path.relative_to(project_root)) h.update(rel.encode()) h.update(b"\0") try: with open(path, "rb") as f: for chunk in iter(lambda: f.read(65536), b""): h.update(chunk) except OSError: pass h.update(b"\0") from pathspec import PathSpec gitignore = project_root / ".gitignore" lines: list[str] = [] if gitignore.is_file(): lines = gitignore.read_text(encoding="utf-8").splitlines() spec = PathSpec.from_lines("gitignore", lines) # Root workspace config (single package-lock.json covers all workspaces). for name in ("package.json", "package-lock.json"): p = project_root / name if p.is_file(): rel = str(p.relative_to(project_root)) if not spec.match_file(rel): _hash_file(p) # Walk the web source tree, pruning ignored directories in-place so we # never descend into node_modules/ or a stray dist/. Sort filenames for # a deterministic, order-independent digest. for dirpath, dirnames, filenames in os.walk(web_dir, topdown=True): dirnames[:] = [ d for d in dirnames if not spec.match_file(str((Path(dirpath) / d).relative_to(project_root))) ] for fn in sorted(filenames): fp = Path(dirpath) / fn rel = str(fp.relative_to(project_root)) if not spec.match_file(rel): _hash_file(fp) return h.hexdigest() def _web_ui_stamp_path() -> Path: """Return the path to the web UI build stamp file under $HERMES_HOME.""" from hermes_constants import get_hermes_home return get_hermes_home() / "web-ui-build-stamp.json" def _write_web_ui_build_stamp(project_root: Path, web_dir: Path) -> None: """Write the web UI build stamp after a successful build.""" stamp_file = _web_ui_stamp_path() try: stamp_file.parent.mkdir(parents=True, exist_ok=True) from datetime import datetime, timezone stamp_data = { "contentHash": _compute_web_ui_content_hash(project_root, web_dir), "builtAt": datetime.now(timezone.utc).isoformat(), } stamp_file.write_text(json.dumps(stamp_data, indent=2) + "\n", encoding="utf-8") except Exception as exc: # Never let stamp-writing block or fail a build. logger.debug("Failed to write web UI build stamp: %s", exc) def _run_with_idle_timeout( cmd: list[str], cwd: Path, *, idle_timeout_seconds: int = 180, indent: str = " ", env: dict[str, str] | None = None, ) -> subprocess.CompletedProcess: """Run a subprocess that streams output, with an idle-output timeout. Issue #33788: ``npm run build`` (Vite) was invoked with ``capture_output=True`` and no timeout. On low-memory hosts (notably WSL2 with the default 4 GB cap) the build can stall or sit silent for minutes; users see a frozen terminal, assume the update is hung, and reboot — leaving the editable install in a half-state with the ``hermes`` launcher present but ``hermes_cli`` not importable. This helper fixes both halves: stdout is streamed (so the user sees progress), and if no bytes have appeared on stdout/stderr for ``idle_timeout_seconds``, the process is terminated and the call returns with a non-zero ``returncode``. The caller's existing stale-dist fallback (#23817) takes over from there. Returns a ``CompletedProcess`` with merged stdout (text), empty stderr, and an integer returncode. Never raises on idle timeout — propagation of failure is via the returncode. """ merged_chunks: list[str] = [] last_output_ts = _time.monotonic() lock = threading.Lock() try: proc = subprocess.Popen( cmd, cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, encoding="utf-8", errors="replace", bufsize=1, env=env, ) except OSError as exc: # E.g. npm not on PATH between the which() check and now. return subprocess.CompletedProcess(cmd, 127, stdout="", stderr=str(exc)) def _reader() -> None: nonlocal last_output_ts assert proc.stdout is not None for line in proc.stdout: try: print(f"{indent}{line.rstrip()}", flush=True) except UnicodeEncodeError: # Windows cp1252 fallback — same pattern as _say(). enc = getattr(sys.stdout, "encoding", None) or "ascii" safe = line.rstrip().encode(enc, errors="replace").decode(enc, errors="replace") print(f"{indent}{safe}", flush=True) with lock: merged_chunks.append(line) last_output_ts = _time.monotonic() reader_thread = threading.Thread(target=_reader, daemon=True) reader_thread.start() idle_killed = False while True: try: rc = proc.wait(timeout=5) break except subprocess.TimeoutExpired: with lock: idle = _time.monotonic() - last_output_ts if idle > idle_timeout_seconds: idle_killed = True proc.terminate() try: rc = proc.wait(timeout=3) except subprocess.TimeoutExpired: proc.kill() rc = proc.wait() break # Drain reader so we don't leak the stdout file descriptor. reader_thread.join(timeout=2) combined = "".join(merged_chunks) if idle_killed: msg = ( f"\n ⚠ Build produced no output for {idle_timeout_seconds}s — terminated.\n" " Common causes: out-of-memory on a low-RAM host (WSL/container),\n" " a stuck Node process, or an antivirus scan stalling I/O.\n" ) combined += msg # Force a non-zero rc even if terminate() raced with a clean exit. if rc == 0: rc = 124 # GNU `timeout` convention return subprocess.CompletedProcess(cmd, rc, stdout=combined, stderr="") def _nixos_build_env() -> dict[str, str] | None: """Return extra env vars for native module builds on NixOS. On NixOS, python3 is typically not on the system PATH (it lives in the Nix store and only enters PATH inside a nix-shell or when explicitly installed as a system package). node-gyp uses Python to compile native addons like ``node-pty`` and its ``find-python.js`` does a bare ``PATH`` lookup — which fails on NixOS. Two-tier resolution: 1. Fast path — the hermes venv's python3 (present in managed installs) 2. Fallback — resolves the absolute python3 path via ``nix-shell`` Returns an env dict suitable for ``subprocess.run(env=...)`` or ``None`` when we are not on NixOS or python3 is already on PATH. """ import re try: os_release = Path("/etc/os-release").read_text(encoding="utf-8") except OSError: return None if not re.search(r"^ID=nixos$", os_release, re.M): return None # python3 already on PATH — nothing to do if shutil.which("python3"): return None # Tier 1: fast path — hermes venv python3, no nix-shell overhead for venv_name in ("venv", ".venv"): venv_python = PROJECT_ROOT / venv_name / "bin" / "python3" if venv_python.exists(): return {**os.environ, "PYTHON": str(venv_python)} # Tier 2: nix-shell fallback — resolves the absolute python3 path once. # Slower (~2–5 s for the nix-shell eval) but always works, even without # a hermes venv (pip / non-managed / bare-git installs). The resolved # path is a self-contained Nix store binary (all deps via RPATH) so it # stays valid even after the nix-shell exits. try: result = subprocess.run( ["nix-shell", "-p", "python3", "--run", "which python3"], capture_output=True, text=True, encoding="utf-8", errors="replace", check=False, timeout=15, ) if result.returncode == 0: python3_path = result.stdout.strip() if python3_path and Path(python3_path).exists(): return {**os.environ, "PYTHON": python3_path} except Exception: pass # nix-shell not available — caller will get None return None def _run_npm_install_deterministic( npm: str, cwd: Path, *, extra_args: tuple[str, ...] = (), capture_output: bool = True, env: dict[str, str] | None = None, ) -> subprocess.CompletedProcess: """Run a deterministic npm install that does not mutate ``package-lock.json``. Prefers ``npm ci`` (strict, lockfile-preserving) when a lockfile is present; falls back to ``npm install`` only if ``npm ci`` fails (e.g. lockfile out of sync on a WIP checkout). Without this, ``npm install`` on npm ≥ 10 silently rewrites committed lockfiles (stripping ``"peer": true`` etc.), which leaves the working tree dirty and causes the next ``hermes update`` to stash the lockfile — repeatedly. ``--include=dev`` is forced on every invocation: the callers are frontend builds (web UI / TUI / desktop workspaces), and those builds need the dev toolchain (``tsc``, ``vite``, ``electron-builder`` — all ``devDependencies``). If the caller's environment has ``NODE_ENV=production`` (or npm config ``omit=dev``) — which leaks in from a shell profile, a container image, or the bundled TUI launcher that sets ``NODE_ENV=production`` on its subprocess env — npm silently omits devDependencies (exit 0, no error), so the build toolchain never installs and the subsequent build dies with ``tsc: command not found`` (exit 127). The flag overrides both the env var and npm config, unlike scrubbing ``NODE_ENV`` from the environment which only fixes the env-leak case. ``--no-save`` on the ``npm install`` fallback keeps it true to this function's contract: never mutate ``package-lock.json``. Without it, an out-of-sync lockfile gets rewritten by the fallback, which drifts the committed lockfile and makes every future ``npm ci`` fail — a self-reinforcing cycle where web devDeps never install and a stale dist is served on every update (PR #65595). """ # unicode-animations' postinstall animates to /dev/tty (bypasses # --silent/capture_output). It no-ops when CI is set — same as the TUI # install path and nix/lib.nix npm ci hooks. run_env = {**os.environ, **(env or {}), "CI": "1"} def _run(cmd: list[str]) -> subprocess.CompletedProcess: return _run_npm_watching_for_engine_failure( cmd, cwd=cwd, env=run_env, capture_output=capture_output, ) def _attempt(npm_exe: str) -> subprocess.CompletedProcess: lockfile = cwd / "package-lock.json" if lockfile.exists(): ci_result = _run([npm_exe, "ci", "--include=dev", *extra_args]) if ci_result.returncode == 0: return ci_result # Fall through to `npm install` — lockfile may be out of sync on a # WIP fork/branch, or `npm ci` may not be available on very old npm. return _run([npm_exe, "install", "--no-save", "--include=dev", *extra_args]) result = _attempt(npm) if result.returncode == 0: return result # An npm outside the root package.json's `engines.npm` range fails every # command here identically (the `npm install` fallback included), so the # failure is worth exactly one repair attempt. `maybe_repair_npm_engine` # returns the npm to retry with — the same one after an in-place upgrade # of a Hermes-managed install, or a freshly provisioned managed npm when # the failing npm belongs to the user's own toolchain. from hermes_cli.npm_engine import maybe_repair_npm_engine combined = f"{result.stdout or ''}\n{result.stderr or ''}" repaired_npm = maybe_repair_npm_engine(npm, combined) if not repaired_npm: return result # The repaired npm may be a freshly provisioned managed one whose shebang # and lifecycle scripts resolve `node` from PATH — put the managed tree # first so they find the managed Node, not the mismatched system one. from hermes_constants import with_hermes_node_path run_env["PATH"] = with_hermes_node_path(run_env)["PATH"] return _attempt(repaired_npm) def _run_npm_watching_for_engine_failure( cmd: list[str], *, cwd: Path, env: dict[str, str], capture_output: bool, ) -> subprocess.CompletedProcess: """Run *cmd*, always retaining stderr so ``EBADENGINE`` stays detectable. ``capture_output=False`` callers stream npm's progress live and would otherwise hand back a ``CompletedProcess`` with ``stderr=None``, leaving the engine-failure recovery nothing to read. Tee stderr instead: each line is forwarded to this process's stderr as it arrives (so live output is unchanged) and accumulated for the caller. """ if capture_output: return subprocess.run( cmd, cwd=cwd, env=env, capture_output=True, text=True, encoding="utf-8", errors="replace", check=False, ) captured: list[str] = [] with subprocess.Popen( cmd, cwd=cwd, env=env, stderr=subprocess.PIPE, text=True, encoding="utf-8", errors="replace", ) as proc: if proc.stderr is not None: for line in proc.stderr: captured.append(line) sys.stderr.write(line) sys.stderr.flush() returncode = proc.wait() return subprocess.CompletedProcess(cmd, returncode, None, "".join(captured)) def _missing_web_build_tool(output: str) -> str | None: """Return the build tool a failed ``npm run build`` could not resolve. Each shell words this differently: ``sh: 1: tsc: not found`` (dash), ``vite: command not found`` (bash/zsh), and ``'tsc' is not recognized as an internal or external command`` (cmd.exe). """ lowered = output.lower() for tool in ("tsc", "vite"): if any( phrase in lowered for phrase in ( f"{tool}: not found", f"{tool}: command not found", f"'{tool}' is not recognized", ) ): return tool return None def _build_web_ui(web_dir: Path, *, fatal: bool = False) -> bool: """Build the web UI frontend if npm is available, serializing across processes. Concurrent dashboard boots (e.g. the desktop app's retry loop after a readiness timeout) used to each spawn their own ``npm install`` + ``vite build`` over the same tree; the parallel builds starved each other, none finished, the dist sentinel never advanced, and every new boot re-triggered the build. One process builds under an exclusive flock; the rest serve the existing dist (stale is acceptable) or, when no dist exists yet, block until the builder finishes. Staleness is checked once, inside :func:`_do_build_web_ui`, after the lock is held — so a process that queued behind the builder skips the rebuild, and the (os.walk-based) check runs at most once per boot. """ if not (web_dir / "package.json").exists(): return True try: import fcntl except ImportError: # Windows: no flock — fall through to the unserialized build. return _do_build_web_ui(web_dir, fatal=fatal) project_root = web_dir.parent.parent if web_dir.parent.name == "apps" else web_dir.parent dist_index = project_root / "hermes_cli" / "web_dist" / "index.html" try: lock_file = open(project_root / ".web_ui_build.lock", "a", encoding="utf-8") except OSError: return _do_build_web_ui(web_dir, fatal=fatal) try: try: fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) except OSError: if dist_index.exists(): # Another process is already building — serve the current # dist instead of piling a second build onto the same tree. return True # No dist at all (first-ever build): wait for the builder. fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX) return _do_build_web_ui(web_dir, fatal=fatal) finally: lock_file.close() def _do_build_web_ui(web_dir: Path, *, fatal: bool = False) -> bool: """Build the web UI frontend if npm is available. Args: web_dir: Path to the dashboard frontend source directory. fatal: If True, print error guidance and return False on failure instead of a soft warning (used by ``hermes web``). Returns True if the build succeeded or was skipped (no package.json). """ if not (web_dir / "package.json").exists(): return True if not _web_ui_build_needed(web_dir): return True # Console-encoding-safe print: Windows consoles default to cp1252 # (or similar) and will raise UnicodeEncodeError on arrow / check # glyphs unless PYTHONIOENCODING=utf-8 is set. Routing every print # in this function through _say() with errors="replace" keeps the # build path usable on a stock `py -m hermes_cli.main web` invocation. def _say(text: str) -> None: try: print(text) except UnicodeEncodeError: encoding = getattr(sys.stdout, "encoding", None) or "ascii" print(text.encode(encoding, errors="replace").decode(encoding, errors="replace")) from hermes_constants import with_hermes_node_path npm = _resolve_node_runtime_npm() if not npm: if fatal: _say("Web UI frontend not built and npm is not available.") _say("Install Node.js, then run: cd web && npm install && npm run build") return not fatal build_env = with_hermes_node_path() _say("→ Building web UI...") def _relay(result: "subprocess.CompletedProcess") -> None: """Print captured npm output so users can see *why* a step failed. Windows users hitting `rm -rf` / `cp -r` errors (or any other sync-assets / Vite failure) would otherwise see only ``Web UI build failed`` with no hint of the underlying cause, because the npm calls run with ``capture_output=True``. """ for blob in (result.stdout, result.stderr): if not blob: continue text = blob.decode("utf-8", errors="replace").rstrip() if isinstance(blob, bytes) else blob.rstrip() if text: _say(text) npm_cwd = _workspace_root(web_dir) # Scope the install to the web workspace only so that the full workspace # graph (including apps/desktop with its Electron + node-pty deps) is never # resolved here. Without --workspace the root package.json's apps/* glob # would pull in desktop on every web build. See #38772. # When web/ has its own package-lock.json, _workspace_root() returns # web_dir itself and --workspace would fail. See #42973. # # When running from the workspace root, this must name the SAME closure # as `hermes update`'s _update_node_dependencies() (ui-tui + web + # --include-workspace-root): the helper prefers `npm ci`, which deletes # node_modules before reifying the requested tree, so a narrower closure # here silently prunes everything the update step just installed (root # devDependencies and the ui-tui workspace) while still exiting 0 — # and since the manifests digest was already recorded, later no-op # updates skip the repair. See #43564/#64354. npm_workspace_args: tuple[str, ...] if npm_cwd == web_dir: npm_workspace_args = () else: npm_workspace_args = ("--workspace", "web", "--include-workspace-root") # Prebuilt/partial checkouts can lack the ui-tui workspace; naming a # missing workspace makes npm fail hard, so only include it when # present (same guard as _update_node_dependencies()). if (npm_cwd / "ui-tui" / "package.json").exists(): npm_workspace_args = ("--workspace", "ui-tui", *npm_workspace_args) if _is_termux_startup_environment(): npm_cwd, npm_workspace_args = _termux_workspace_install_context(web_dir) def _install_web_deps(*, silent: bool) -> "subprocess.CompletedProcess": return _run_npm_install_deterministic( npm, npm_cwd, extra_args=(*npm_workspace_args, "--silent", "--prefer-offline") if silent else (*npm_workspace_args, "--prefer-offline"), env=build_env, ) r1 = _install_web_deps(silent=True) if r1.returncode != 0: _say( f" {'✗' if fatal else '⚠'} Web UI npm install failed" + ("" if fatal else " (hermes web will not be available)") ) _relay(r1) if fatal: _say(" Run manually: npm install --workspace web && npm run build -w web") return False # First attempt — stream output via idle-timeout helper (issue #33788). # capture_output=True on a long Vite build looks identical to a hang; # users react by rebooting, which leaves the editable install in a # half-state. Streaming + idle-kill makes failures observable AND # recoverable (the stale-dist fallback below handles the kill path). r2 = _run_with_idle_timeout([npm, "run", "build"], cwd=web_dir, env=build_env) if r2.returncode != 0: # The install above can exit 0 while leaving the tree without a build # toolchain — a lockfile-hash skip over a half-installed tree, or an # interrupted link step. The generic retry below just reruns the same # command, so `tsc: not found` survives it and the stale dist is # served forever. Reinstall (non-silent, so the user sees it) first. missing_tool = _missing_web_build_tool((r2.stdout or "") + (r2.stderr or "")) if missing_tool: _say(f" ⚠ Build could not resolve {missing_tool} — reinstalling web dependencies...") _install_web_deps(silent=False) r2 = _run_with_idle_timeout([npm, "run", "build"], cwd=web_dir, env=build_env) if r2.returncode != 0: # Retry once after a short delay — covers boot-time races on Windows # (antivirus scanning Node.js binaries, npm cache not ready, transient # I/O when launched via Scheduled Task at logon). See issue #23817. _time.sleep(3) r2 = _run_with_idle_timeout([npm, "run", "build"], cwd=web_dir, env=build_env) if r2.returncode != 0: # _run_with_idle_timeout merges stderr into stdout; older callers # using subprocess.run kept them split. Pull from whichever has # content so the error surfaces regardless of which path produced # the CompletedProcess. build_output = (r2.stderr or "") + (r2.stdout or "") stderr_preview = build_output.strip() stderr_tail = "\n ".join(stderr_preview.splitlines()[-10:]) if stderr_preview else "" project_root = web_dir.parent.parent if web_dir.parent.name == "apps" else web_dir.parent dist_dir = project_root / "hermes_cli" / "web_dist" dist_index = dist_dir / "index.html" # If a stale dist exists, serve it as a fallback instead of failing. # A stale UI is far better than no UI for non-interactive callers # (Windows Scheduled Tasks, CI) — issue #23817. if dist_index.exists(): _say(" ⚠ Web UI build failed — serving stale dist as fallback") if stderr_tail: _say(f" Build error:\n {stderr_tail}") return True _say( f" {'✗' if fatal else '⚠'} Web UI build failed" + ("" if fatal else " (hermes web will not be available)") ) _relay(r2) if fatal: _say(" Run manually: npm install --workspace web && npm run build -w web") return False _say(" ✓ Web UI built") project_root = web_dir.parent.parent if web_dir.parent.name == "apps" else web_dir.parent _write_web_ui_build_stamp(project_root, web_dir) return True def _desktop_dist_exists(desktop_dir: Path) -> bool: """Return True when a local desktop renderer build is present.""" return (desktop_dir / "dist" / "index.html").exists() # --------------------------------------------------------------------------- # Desktop build stamp — content-hash based skip logic # --------------------------------------------------------------------------- # The desktop Electron build is expensive. # Unlike the web UI (which uses mtime comparison), the desktop uses a # SHA-256 content hash of the source tree so that: # - ``git checkout`` / ``git pull`` that touch mtimes but not content # don't trigger a rebuild # - ``hermes update`` can unconditionally call ``hermes desktop --build-only`` # and it will skip if nothing actually changed # - ``hermes desktop`` (interactive launch) skips the build when the # stamp matches, making repeated launches fast # # Stamp file: $HERMES_HOME/desktop-build-stamp.json # Schema: # { # "contentHash": "", # "sourceMode": true | false, # "builtAt": "" # } def _compute_desktop_content_hash(project_root: Path) -> str: """Return a SHA-256 hex digest of all source files that feed the desktop build. Covers ``apps/desktop/`` (excluding anything matched by .gitignore) plus the root ``package.json`` / ``package-lock.json`` (workspace config that determines dependency resolution for the desktop workspace). Parses the repo-root ``.gitignore`` via *pathspec* so we automatically skip ``node_modules/``, ``dist/``, ``*.pyc``, etc. without maintaining a hardcoded skip-list. """ h = hashlib.sha256() def _hash_file(path: Path) -> None: rel = str(path.relative_to(project_root)) h.update(rel.encode()) h.update(b"\0") try: with open(path, "rb") as f: for chunk in iter(lambda: f.read(65536), b""): h.update(chunk) except (OSError, IOError): pass h.update(b"\0") from pathspec import PathSpec gitignore = project_root / ".gitignore" lines: list[str] = [] if gitignore.is_file(): lines = gitignore.read_text(encoding="utf-8").splitlines() spec = PathSpec.from_lines("gitignore", lines) # Root workspace config for name in ("package.json", "package-lock.json"): p = project_root / name if p.is_file(): rel = str(p.relative_to(project_root)) if not spec.match_file(rel): _hash_file(p) # Walk apps/desktop/ — prune ignored directories in-place desktop_dir = project_root / "apps" / "desktop" for dirpath, dirnames, filenames in os.walk(desktop_dir, topdown=True): # Prune ignored directories so we never descend into them dirnames[:] = [ d for d in dirnames if not spec.match_file(str((Path(dirpath) / d).relative_to(project_root))) ] for fn in sorted(filenames): fp = Path(dirpath) / fn rel = str(fp.relative_to(project_root)) if not spec.match_file(rel): _hash_file(fp) return h.hexdigest() def _desktop_stamp_path() -> Path: """Return the path to the desktop build stamp file under $HERMES_HOME.""" from hermes_constants import get_hermes_home return get_hermes_home() / "desktop-build-stamp.json" def _renderer_bundle_dir(desktop_dir: Path, *, source_mode: bool) -> Optional[Path]: """The renderer ``dist`` directory a launch loads, when it is inspectable. Source mode builds to ``apps/desktop/dist``. A packaged app ships the same bundle twice — inside ``app.asar`` and, because ``asarUnpack`` lists ``dist/**``, beside it in ``app.asar.unpacked``. Only the unpacked copy is a real directory; that is also the one an interrupted replace tears, so checking it catches the failure we care about. """ if source_mode: return desktop_dir / "dist" executable = _desktop_packaged_executable(desktop_dir) if executable is None: return None # macOS: …/Hermes.app/Contents/MacOS/Hermes → …/Contents/Resources resources = ( executable.parent.parent / "Resources" if sys.platform == "darwin" else executable.parent / "resources" ) return resources / "app.asar.unpacked" / "dist" # The module files the renderer fetches before any app code runs: Vite emits # them as `