hermes-agent/hermes_cli/main.py

12183 lines
478 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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 <name> # 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 <file> # 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
# 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 _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 (≈100300ms 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."""
prefix = os.environ.get("PREFIX", "")
return bool(
os.environ.get("TERMUX_VERSION")
or "com.termux/files/usr" in prefix
or prefix.startswith("/data/data/com.termux/")
)
def _is_termux_fast_version_argv(argv: list[str]) -> bool:
return argv in (["--version"], ["-V"], ["version"])
def _read_openai_version_fast() -> str | None:
"""Read OpenAI SDK version without importing ``importlib.metadata``."""
for base in sys.path:
if not base:
base = os.getcwd()
version_file = os.path.join(base, "openai", "_version.py")
try:
with open(version_file, encoding="utf-8") as handle:
for line in handle:
stripped = line.strip()
if not stripped.startswith("__version__"):
continue
_key, _sep, value = stripped.partition("=")
value = value.split("#", 1)[0].strip().strip("\"'")
return value or None
except OSError:
continue
return None
def _print_fast_version_info() -> None:
from hermes_cli import __release_date__, __version__
print(f"Hermes Agent v{__version__} ({__release_date__})")
print(f"Install directory: {PROJECT_ROOT}")
print(f"Python: {sys.version.split()[0]}")
openai_version = _read_openai_version_fast()
print(f"OpenAI SDK: {openai_version}" if openai_version else "OpenAI SDK: Not installed")
def _try_termux_ultrafast_version() -> bool:
"""Handle ``hermes --version`` before config/logging imports on Termux."""
if os.environ.get("HERMES_TERMUX_DISABLE_FAST_CLI") == "1":
return False
if not _is_termux_startup_environment_fast():
return False
if not _is_termux_fast_version_argv(sys.argv[1:]):
return False
_print_fast_version_info()
return True
if _try_termux_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.sessions_cmd import cmd_sessions # noqa: F401
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.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.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.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(__file__).parent.parent.resolve()
sys.path.insert(0, str(PROJECT_ROOT))
# ---------------------------------------------------------------------------
# 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 <command argv>`.
``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 <name>` 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",
}
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 <name>`` 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,
)
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 ``<sha> <ref>`` with optional ``^<sha>``
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')."""
if not ts:
return "?"
delta = _time.time() - ts
if delta < 60:
return "just now"
if delta < 3600:
return f"{int(delta / 60)}m ago"
if delta < 86400:
return f"{int(delta / 3600)}h ago"
if delta < 172800:
return "yesterday"
if delta < 604800:
return f"{int(delta / 86400)}d ago"
return datetime.fromtimestamp(ts).strftime("%Y-%m-%d")
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):
_model_name = (model_cfg.get("default") or "").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
# 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 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"))
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 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 _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.
Uses curses (not simple_term_menu) to avoid the ghost-duplication rendering
bug in tmux/iTerm when arrow keys are used.
"""
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.
"""
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
db.close()
return resolved_id
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=<missing ui-tui>)``, 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
path = shutil.which(bin)
if not path and bin == "node":
try:
from hermes_cli.dep_ensure import ensure_dependency
if ensure_dependency("node"):
path = shutil.which("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,
)
result = subprocess.run(
[
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",
],
cwd=str(npm_cwd),
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
encoding="utf-8",
errors="replace",
env={**os.environ, "CI": "1"},
)
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.54GB
# 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 ``<root>/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)
# 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)
):
try:
from hermes_state import SessionDB
_saved_cwd = ((SessionDB().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
# 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_update_check
prefetch_update_check()
except Exception:
pass
# Sync bundled skills on every CLI launch (fast -- skips unchanged skills)
try:
_sync_bundled_skills_for_startup()
except Exception:
pass
# --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()
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),
"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/<name>/ 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 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
key = "custom:" + name.lower().replace(" ", "-")
provider_key = (entry.get("provider_key") or "").strip()
if provider_key:
try:
resolve_provider(provider_key)
except AuthError:
key = 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 _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
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:<gid>"
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 == "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.<task>`.
#
# 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",)
def __getattr__(name):
"""Defer the model-catalog import until something actually reads it."""
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
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_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 <subcommand>``. 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 <subcommand>\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_security(args):
"""Dispatch `hermes security <subcmd>`."""
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 <subcmd>`."""
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 check_for_updates
from hermes_cli.config import recommended_update_command
behind = check_for_updates()
if 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 extracted to hermes_cli/update_cmd.py (main.py decomposition,
# mechanical move). Every moved name is re-exported here so the argparse wiring
# and existing test monkeypatches (hermes_cli.main._cmd_update_impl,
# hermes_cli.main._run_pre_update_backup, ...) keep resolving unchanged.
from hermes_cli.update_cmd import ( # noqa: F401
_add_upstream_remote,
_atomic_replace_dir,
_capture_head_sha,
_cmd_update_check,
_cmd_update_impl,
_cold_start_windows_gateway_after_update,
_count_commits_between,
_detect_venv_python_processes,
_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,
_log_only_write,
_mark_skip_upstream_prompt,
_npm_bin_exists,
_npm_lockfile_changed,
_npm_manifest_paths,
_npm_manifests_digest,
_pause_windows_gateways_for_update,
_print_curator_first_run_notice,
_print_curator_recent_run_notice,
_print_fts_optimize_available_notice,
_print_stash_cleanup_guidance,
_record_npm_lockfile_hash,
_refresh_active_lazy_features,
_refresh_active_memory_provider_dependencies,
_refresh_windows_gateway_launchers,
_reload_updated_runtime_modules,
_resolve_pre_update_backup_mode,
_resolve_stash_selector,
_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,
_sync_fork_with_upstream,
_sync_with_upstream_if_needed,
_update_node_dependencies,
_update_via_zip,
_upgrade_pip_before_lazy_refresh,
_validate_critical_files_syntax,
_venv_core_imports_healthy,
_wait_for_windows_update_gateway_exit,
_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,
OFFICIAL_REPO_URLS,
OFFICIAL_REPO_URL,
SKIP_UPSTREAM_PROMPT_FILE,
_PRE_UPDATE_SNAPSHOT_KEEP,
_PRE_UPDATE_SNAPSHOT_MAX_FILE_SIZE,
)
# 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 (~25 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"}
lockfile = cwd / "package-lock.json"
if lockfile.exists():
ci_cmd = [npm, "ci", "--include=dev", *extra_args]
ci_result = subprocess.run(
ci_cmd,
cwd=cwd,
env=run_env,
capture_output=capture_output,
text=True,
encoding="utf-8",
errors="replace",
check=False,
)
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.
install_cmd = [npm, "install", "--no-save", "--include=dev", *extra_args]
return subprocess.run(
install_cmd,
cwd=cwd,
env=run_env,
capture_output=capture_output,
text=True,
encoding="utf-8",
errors="replace",
check=False,
)
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.
npm_workspace_args: tuple[str, ...] = () if npm_cwd == web_dir else ("--workspace", "web")
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") if silent else npm_workspace_args,
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": "<sha256 hex of source files>",
# "sourceMode": true | false,
# "builtAt": "<ISO 8601>"
# }
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 _desktop_build_needed(desktop_dir: Path, project_root: Path, *, source_mode: bool) -> bool:
"""Return True when the desktop build output is stale or missing.
Compares the current content hash against the saved stamp. Also returns
True if the expected build artifact doesn't exist (e.g. first run after
``hermes update`` that pulled new source but hasn't built yet).
"""
# If there's no build output at all, we definitely need to build
if source_mode:
if not _desktop_dist_exists(desktop_dir):
return True
else:
if _desktop_packaged_executable(desktop_dir) is None:
return True
stamp_file = _desktop_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, KeyError):
return True
# If the mode changed (source vs packaged), force a rebuild
if stamp_data.get("sourceMode") != source_mode:
return True
saved_hash = stamp_data.get("contentHash")
if not saved_hash:
return True
current_hash = _compute_desktop_content_hash(project_root)
return current_hash != saved_hash
def _write_desktop_build_stamp(project_root: Path, *, source_mode: bool) -> None:
"""Write the desktop build stamp after a successful build."""
stamp_file = _desktop_stamp_path()
try:
stamp_file.parent.mkdir(parents=True, exist_ok=True)
content_hash = _compute_desktop_content_hash(project_root)
from datetime import datetime, timezone
stamp_data = {
"contentHash": content_hash,
"sourceMode": source_mode,
"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 desktop build stamp: %s", exc)
def _desktop_packaged_executable(desktop_dir: Path) -> Optional[Path]:
"""Return the current platform's unpacked Electron app executable."""
release_dir = desktop_dir / "release"
if sys.platform == "darwin":
candidates = list(release_dir.glob("mac*/Hermes.app/Contents/MacOS/Hermes"))
elif sys.platform == "win32":
candidates = [
release_dir / "win-unpacked" / "Hermes.exe",
release_dir / "win-ia32-unpacked" / "Hermes.exe",
release_dir / "win-arm64-unpacked" / "Hermes.exe",
]
else:
candidates = [
release_dir / "linux-unpacked" / "hermes",
release_dir / "linux-unpacked" / "Hermes",
release_dir / "linux-arm64-unpacked" / "hermes",
release_dir / "linux-arm64-unpacked" / "Hermes",
]
existing = [p for p in candidates if p.exists()]
if not existing:
return None
if sys.platform == "win32" and len(existing) > 1:
# Multiple unpacked trees can coexist (e.g. a stale win-arm64-unpacked
# left behind by a cross-arch experiment next to the real win-unpacked).
# Picking purely by mtime can then hand a wrong-architecture Hermes.exe
# to the launcher, which Windows rejects with "This app can't run on
# your computer" (#69179). Prefer candidates whose PE machine field
# matches the host; fall back to mtime when none can be parsed.
expected = _expected_windows_pe_machines()
matching = [p for p in existing if _pe_machine_or_none(p) in expected]
if matching:
existing = matching
return max(existing, key=lambda p: p.stat().st_mtime)
# ─── Desktop exe integrity gate (#69179) ────────────────────────────────────
#
# The desktop self-update chain (Desktop → hermes-setup --update →
# `hermes update` → `hermes desktop --build-only` → relaunch) rebuilds
# Hermes.exe on the end user's machine and used to verify only that the file
# EXISTS before declaring success. A corrupt cached Electron zip whose
# extraction produced a truncated electron.exe, an interrupted rcedit resource
# rewrite, a disk-full pack, or a wrong-arch unpacked tree therefore shipped a
# broken binary that Windows refuses to load ("This app can't run on your
# computer" / 此应用无法在你的电脑上运行). These helpers parse the PE header —
# no signature infrastructure required — so a structurally broken or
# wrong-architecture Hermes.exe is caught BEFORE the updater replaces the
# working app, and the previous build can be restored from the .bak tree that
# apps/desktop/scripts/before-pack.mjs now preserves.
_PE_MACHINE_I386 = 0x014C
_PE_MACHINE_AMD64 = 0x8664
_PE_MACHINE_ARM64 = 0xAA64
_PE_MACHINE_NAMES = {
_PE_MACHINE_I386: "x86 (32-bit)",
_PE_MACHINE_AMD64: "x64 (AMD64)",
_PE_MACHINE_ARM64: "ARM64",
}
_PE_MACHINE_TO_NAME = {
_PE_MACHINE_ARM64: "ARM64",
_PE_MACHINE_AMD64: "AMD64",
_PE_MACHINE_I386: "X86",
}
# MACHINE_ATTRIBUTES bits (processthreadsapi.h). UserEnabled means the host
# can run user-mode code of that machine type — natively or under emulation.
_MACHINE_ATTRIBUTE_USER_ENABLED = 0x00000001
def _windows_native_machine_from_iswow64() -> Optional[str]:
"""Ask IsWow64Process2 for the OS-native machine (None if unavailable/fail).
ctypes defaults ``GetCurrentProcess``'s restype to ``c_int``, so the
current-process pseudo-handle ``(HANDLE)-1`` is truncated to
``0xFFFFFFFF`` and zero-extended into a 64-bit invalid handle. On Win64
that makes ``IsWow64Process2`` fail with ``ERROR_INVALID_HANDLE`` (6),
which is exactly the residual Windows-on-ARM failure after #71218: the
gate fell through to ``PROCESSOR_ARCHITECTURE=AMD64`` (the emulated
process arch) and rejected a correctly-built ARM64 ``Hermes.exe``.
Binding ``restype``/``argtypes`` to ``wintypes.HANDLE`` keeps the full
``0xFFFFFFFFFFFFFFFF`` pseudo-handle.
"""
import ctypes
from ctypes import wintypes
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
kernel32.GetCurrentProcess.restype = wintypes.HANDLE
kernel32.GetCurrentProcess.argtypes = []
kernel32.IsWow64Process2.argtypes = [
wintypes.HANDLE,
ctypes.POINTER(wintypes.USHORT),
ctypes.POINTER(wintypes.USHORT),
]
kernel32.IsWow64Process2.restype = wintypes.BOOL
process_machine = wintypes.USHORT(0)
native_machine = wintypes.USHORT(0)
if not kernel32.IsWow64Process2(
kernel32.GetCurrentProcess(),
ctypes.byref(process_machine),
ctypes.byref(native_machine),
):
return None
return _PE_MACHINE_TO_NAME.get(native_machine.value)
def _windows_user_runnable_pe_machines() -> Optional[set]:
"""PE machines this host can run in user mode, via GetMachineTypeAttributes.
This asks the question the integrity gate actually cares about — "can this
Windows host load a PE of machine X?" — instead of inferring it from a
host-architecture name. It is also the only documented API that reports
AMD64-on-ARM64 emulation support; ``IsWow64GuestMachineSupported`` only
answers for 32-bit guests.
Returns None when the API is unavailable (pre-Windows-11 build 22000) or
reports nothing runnable, so callers fall back to name-based detection.
"""
import ctypes
from ctypes import wintypes
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
kernel32.GetMachineTypeAttributes.argtypes = [
wintypes.USHORT,
ctypes.POINTER(ctypes.c_int),
]
kernel32.GetMachineTypeAttributes.restype = ctypes.c_long
runnable = set()
for machine in (_PE_MACHINE_ARM64, _PE_MACHINE_AMD64, _PE_MACHINE_I386):
attributes = ctypes.c_int(0)
# HRESULT: zero is success, any nonzero value is a failure.
if kernel32.GetMachineTypeAttributes(machine, ctypes.byref(attributes)):
continue
if attributes.value & _MACHINE_ATTRIBUTE_USER_ENABLED:
runnable.add(machine)
return runnable or None
def _windows_native_machine() -> str:
"""The Windows host OS's NATIVE machine architecture, normalized upper.
``platform.machine()`` reports the PROCESS architecture, which lies under
emulation: the desktop update chain runs an x64 hermes-setup.exe (and thus
x64 Python) on Windows-on-ARM devices, where ``platform.machine()``
returns ``AMD64`` even though the OS is ARM64. The #71119 integrity gate
then rejected the CORRECT ARM64 rebuild as an "architecture mismatch"
(#69179 follow-up report). Probe order:
1. ``IsWow64Process2`` with a correctly-typed current-process HANDLE
(#71218 + HANDLE-truncation fix). This is the only API that tells the
truth from an x64 process emulated on ARM64.
2. ``PROCESSOR_ARCHITEW6432`` / ``PROCESSOR_ARCHITECTURE`` — WOW64
(32-bit) hosts and pre-1511 Windows 10 without the newer API.
3. ``platform.machine()``.
Note ``GetNativeSystemInfo`` is deliberately NOT used: Microsoft documents
that it "also returns emulated processor details when run from an app
under emulation", so on the very WoA hosts this function exists to serve
it reports AMD64 — no better than the env-var rung below it.
"""
if sys.platform == "win32":
try:
name = _windows_native_machine_from_iswow64()
except (OSError, AttributeError, TypeError, ValueError):
# API missing (pre-1511), DLL load failure in tests, or a
# mistyped ctypes binding — fall through to the env vars.
name = None
if name:
return name
env_arch = os.environ.get("PROCESSOR_ARCHITEW6432") or os.environ.get(
"PROCESSOR_ARCHITECTURE"
)
if env_arch:
return env_arch.upper()
import platform as _platform
return (_platform.machine() or "").upper()
def _expected_windows_pe_machines() -> set:
"""PE machine values the current Windows host can natively load.
Preferred source is ``GetMachineTypeAttributes``, which answers this
question directly (including AMD64-on-ARM64 emulation) instead of
inferring it from an architecture name.
Fallback is name-based: AMD64 hosts run x64 and (via WOW64) x86. ARM64
hosts run ARM64 and (Windows 11 emulation) x64. 32-bit x86 hosts run only
x86. Unknown machines return the permissive full set so the integrity gate
can never brick launch on exotic hosts. Host detection uses the OS-native
machine (see ``_windows_native_machine``), not the process architecture.
"""
if sys.platform == "win32":
try:
runnable = _windows_user_runnable_pe_machines()
except (OSError, AttributeError, TypeError, ValueError):
runnable = None
if runnable:
return runnable
machine = _windows_native_machine().upper()
if machine in ("AMD64", "X86_64", "X64"):
return {_PE_MACHINE_AMD64, _PE_MACHINE_I386}
if machine in ("ARM64", "AARCH64"):
return {_PE_MACHINE_ARM64, _PE_MACHINE_AMD64}
if machine in ("X86", "I386", "I486", "I586", "I686"):
return {_PE_MACHINE_I386}
return {_PE_MACHINE_AMD64, _PE_MACHINE_ARM64, _PE_MACHINE_I386}
def _parse_pe_machine(path: Path) -> int:
"""Parse ``path`` as a PE executable and return its COFF machine field.
Raises ``ValueError`` with a human-readable reason when the file is not a
structurally complete PE: missing MZ/PE magic (an HTML error page or JSON
body saved as .exe), header truncation, or raw section data extending past
the end of the file (the truncated-download / interrupted-extraction
shape). Purely a header walk — cheap even on a 200 MB Electron exe.
"""
import struct
try:
file_size = path.stat().st_size
except OSError as exc:
raise ValueError(f"unreadable: {exc}")
if file_size < 512:
raise ValueError(
f"file is only {file_size} bytes — far too small to be a Windows executable"
)
with path.open("rb") as fh:
head = fh.read(64)
if len(head) < 64 or head[:2] != b"MZ":
raise ValueError(
"missing MZ header — not a Windows executable "
"(a truncated or non-binary file saved as .exe?)"
)
e_lfanew = struct.unpack_from("<I", head, 0x3C)[0]
if e_lfanew <= 0 or e_lfanew + 24 > file_size:
raise ValueError("corrupt DOS header: PE header offset points past end of file")
fh.seek(e_lfanew)
pe_head = fh.read(24)
if len(pe_head) < 24 or pe_head[:4] != b"PE\x00\x00":
raise ValueError("missing PE signature — corrupt executable header")
machine, n_sections = struct.unpack_from("<HH", pe_head, 4)
size_of_optional = struct.unpack_from("<H", pe_head, 20)[0]
fh.seek(e_lfanew + 24 + size_of_optional)
max_section_end = 0
for _ in range(n_sections):
section = fh.read(40)
if len(section) < 40:
raise ValueError("truncated PE section table")
size_of_raw, pointer_to_raw = struct.unpack_from("<II", section, 16)
max_section_end = max(max_section_end, pointer_to_raw + size_of_raw)
if file_size < max_section_end:
raise ValueError(
f"truncated executable: file is {file_size} bytes but its PE "
f"sections extend to {max_section_end} bytes"
)
return machine
def _pe_machine_or_none(path: Path) -> Optional[int]:
try:
return _parse_pe_machine(path)
except ValueError:
return None
def _desktop_exe_integrity_error(path: Path) -> Optional[str]:
"""Return a human-readable reason ``path`` cannot run on this Windows host,
or ``None`` when the exe parses as a complete PE of a loadable architecture.
"""
try:
machine = _parse_pe_machine(path)
except ValueError as exc:
return str(exc)
expected = _expected_windows_pe_machines()
if machine not in expected:
got = _PE_MACHINE_NAMES.get(machine, f"unknown machine 0x{machine:04X}")
return (
f"architecture mismatch: built a {got} executable but this is a "
f"{_windows_native_machine()} Windows host"
)
return None
def _desktop_backup_unpacked_dir(packaged_executable: Path) -> Path:
"""The rollback tree before-pack.mjs preserves: ``<unpacked-dir>.bak``."""
unpacked = packaged_executable.parent
return unpacked.parent / (unpacked.name + ".bak")
def _rollback_desktop_from_backup(packaged_executable: Path) -> Optional[Path]:
"""Restore the previous unpacked desktop app from its ``.bak`` tree.
Returns the restored executable path, or ``None`` when no usable backup
exists (missing, or its exe fails the same integrity probe). The corrupt
tree is kept alongside as ``<unpacked-dir>.corrupt`` for diagnostics.
Best-effort: never raises.
"""
unpacked = packaged_executable.parent
backup_dir = _desktop_backup_unpacked_dir(packaged_executable)
backup_exe = backup_dir / packaged_executable.name
if not backup_exe.exists():
return None
if _desktop_exe_integrity_error(backup_exe) is not None:
return None
corrupt_dir = unpacked.parent / (unpacked.name + ".corrupt")
try:
shutil.rmtree(corrupt_dir, ignore_errors=True)
try:
unpacked.rename(corrupt_dir)
except OSError:
shutil.rmtree(unpacked, ignore_errors=True)
backup_dir.rename(unpacked)
except OSError:
return None
restored = unpacked / packaged_executable.name
return restored if restored.exists() else None
def _ensure_desktop_exe_launchable(
desktop_dir: Path, packaged_executable: Optional[Path]
) -> tuple:
"""Windows post-build integrity gate for the self-update rebuild (#69179).
Returns ``(verified_exe_or_None, rolled_back)``:
- exe passed the probe → ``(exe, False)``
- exe corrupt/wrong-arch, previous build restored → ``(old_exe, True)``
- exe corrupt and nothing restorable → ``(None, False)``
On any integrity failure the corrupt cached Electron zip is purged and the
desktop build stamp invalidated, so the updater's retry-once rebuild pulls
a fresh, SHASUM-verified Electron download instead of re-staging the same
corrupt bytes. No-op off Windows and when there is no executable to check.
"""
if packaged_executable is None or sys.platform != "win32":
return packaged_executable, False
error = _desktop_exe_integrity_error(packaged_executable)
if error is None:
return packaged_executable, False
print(f"✗ The built Hermes.exe failed its integrity check: {error}")
print(f" at: {packaged_executable}")
# Self-heal setup for the retry: drop the (likely corrupt) cached Electron
# zip and the content stamp so the next rebuild is a genuine re-download +
# re-stage rather than a replay of the same broken extraction.
_purge_electron_build_cache(desktop_dir)
try:
_desktop_stamp_path().unlink()
except OSError:
pass
restored = _rollback_desktop_from_backup(packaged_executable)
if restored is not None:
print(" ↩ Update aborted — restored the previous working Hermes.exe from backup.")
print(" Your existing version was kept and still works. Run `hermes desktop`")
print(" (or the in-app update) again to retry with a fresh Electron download.")
return restored, True
print(" ✗ No usable backup was found to restore.")
print(" Run `hermes desktop --force-build` to rebuild, or re-run the Hermes")
print(" installer to repair the install.")
return None, False
def _electron_download_cache_dirs() -> list[Path]:
"""Return the per-user Electron download cache directories for this OS.
electron-builder's ``app-builder unpack-electron`` extracts the Electron
distribution from a zip stored in this cache (NOT from node_modules), so a
corrupt zip here — not a bad workspace install — is what poisons the build.
Honors the ``electron_config_cache`` / ``ELECTRON_CACHE`` overrides that
``@electron/get`` respects, then falls back to the platform defaults.
"""
home = Path.home()
candidates: list[Path] = []
override = os.environ.get("electron_config_cache") or os.environ.get("ELECTRON_CACHE")
if override:
candidates.append(Path(override))
if sys.platform == "darwin":
candidates.append(home / "Library" / "Caches" / "electron")
elif sys.platform == "win32":
local = os.environ.get("LOCALAPPDATA")
if local:
candidates.append(Path(local) / "electron" / "Cache")
candidates.append(home / "AppData" / "Local" / "electron" / "Cache")
else:
xdg = os.environ.get("XDG_CACHE_HOME")
if xdg:
candidates.append(Path(xdg) / "electron")
candidates.append(home / ".cache" / "electron")
seen: set[Path] = set()
out: list[Path] = []
for c in candidates:
rc = c.expanduser()
if rc not in seen:
seen.add(rc)
out.append(rc)
return out
def _purge_electron_build_cache(desktop_dir: Path) -> list[Path]:
"""Clear the cached Electron download + half-written unpacked dir so the
next ``pack`` re-downloads and re-stages from scratch.
Root cause of the ``ENOENT … rename '…/linux-unpacked/electron' ->
'…/linux-unpacked/Hermes'`` desktop build failure: a corrupt zip in the
per-user Electron download cache (a partial download resumed into the same
file leaves prepended/concatenated junk, or an interrupted write truncates
it). electron-builder's ``app-builder unpack-electron`` extracts the
distribution from that cached zip (NOT from node_modules); a bad zip yields
a partial tree MISSING the 193 MB ``electron`` binary, so the final rename
dies. Re-running repeats the same broken extraction forever.
We deliberately do NOT try to detect corruption ourselves. stdlib
``zipfile`` silently tolerates the prepended/concatenated junk that is the
most common corruption here — it reads from the end-of-central-directory
backward, so ``testzip()`` returns clean on exactly the zips ``unzip -t``
and ``@electron/get`` reject. Gating the purge on a self-rolled validator
would therefore skip the real-world case and never self-heal. Instead, on a
packaged-build failure we unconditionally remove the version's cached zips
and the stale unpacked dir, then let the caller retry once: ``@electron/get``
re-downloads with its own SHASUM verification (the real source of truth),
and ``before-pack.cjs`` re-wipes the unpacked dir. If the failure was
unrelated, a clean re-download is harmless and the retry fails the same way.
Best-effort: never raises. Returns the paths removed so the caller can log
them and decide whether a retry is worthwhile (empty list ⇒ nothing to
clear, so no point retrying).
"""
removed: list[Path] = []
for cache_dir in _electron_download_cache_dirs():
if not cache_dir.is_dir():
continue
for zip_path in sorted(cache_dir.rglob("electron-*.zip")):
try:
zip_path.unlink()
removed.append(zip_path)
except OSError:
# Locked/permission-denied entry is out of our hands; let the
# build report its own error rather than masking it.
pass
# Drop the half-written unpacked dir too: an interrupted prior pack leaves
# a partial tree that poisons the rename even after the zip is fixed.
# (before-pack.cjs also handles this, but clearing it here makes the retry
# robust even if the hook is somehow skipped.)
release_dir = desktop_dir / "release"
if release_dir.is_dir():
for unpacked in release_dir.glob("*-unpacked"):
try:
shutil.rmtree(unpacked, ignore_errors=True)
removed.append(unpacked)
except OSError:
pass
return removed
# Last-resort Electron mirror after GitHub download fails (#47266). Only used
# when the user hasn't pinned ELECTRON_MIRROR.
_ELECTRON_FALLBACK_MIRROR = "https://npmmirror.com/mirrors/electron/"
def _electron_dir(project_root: Path) -> Path:
"""Return the Electron package directory the desktop workspace installs.
npm may keep workspace-only dev dependencies under
``apps/desktop/node_modules`` instead of hoisting them to the repo root.
Which layout you get depends on the npm version and what else is installed,
so a build path that assumes one or the other breaks intermittently across
machines. ``apps/desktop/package.json`` points electron-builder's
``electronDist`` at ``node_modules/electron/dist`` relative to the desktop
project, so prefer the workspace-local package and fall back to the root
hoist when that's where npm landed it.
"""
desktop_local = project_root / "apps" / "desktop" / "node_modules" / "electron"
if desktop_local.exists():
return desktop_local
return project_root / "node_modules" / "electron"
def _electron_dist_binary(project_root: Path) -> Path:
"""Return the path to the Electron main binary inside the installed package.
electron-builder reads the binary from ``build.electronDist`` since #38673,
so this is the exact file whose absence makes a pack fail with "The
specified electronDist does not exist". The basename differs per OS (the
platform Electron is named for the host the build runs on).
"""
dist = _electron_dir(project_root) / "dist"
if sys.platform == "darwin":
return dist / "Electron.app" / "Contents" / "MacOS" / "Electron"
if sys.platform == "win32":
return dist / "electron.exe"
return dist / "electron"
def _electron_dist_ok(project_root: Path) -> bool:
"""True when ``node_modules/electron/dist`` holds a usable Electron binary.
A directory that exists but is missing the binary (a partial extraction from
a corrupt cached zip, or an interrupted postinstall) counts as NOT ok, since
that is exactly the shape that makes electron-builder throw on the pinned
electronDist.
"""
try:
return _electron_dist_binary(project_root).exists()
except OSError:
return False
def _electron_pkg_staged_missing_dist(project_root: Path) -> bool:
"""electron staged (package.json + install.js) but dist missing — blocked postinstall."""
electron_dir = _electron_dir(project_root)
return (
(electron_dir / "package.json").is_file()
and (electron_dir / "install.js").is_file()
and not _electron_dist_ok(project_root)
)
def _redownload_electron_dist(
project_root: Path,
env: dict,
*,
mirror: Optional[str] = None,
) -> bool:
"""Best-effort: run electron's install.js to populate dist/ (optional mirror)."""
if _electron_dist_ok(project_root):
return True
electron_dir = _electron_dir(project_root)
installer = electron_dir / "install.js"
if not installer.is_file():
return False
from hermes_constants import find_node_executable, with_hermes_node_path
node = find_node_executable("node")
if not node:
return False
dist_dir = electron_dir / "dist"
shutil.rmtree(dist_dir, ignore_errors=True)
try:
(electron_dir / "path.txt").unlink()
except OSError:
pass
dl_env = with_hermes_node_path(env)
if mirror:
dl_env["ELECTRON_MIRROR"] = mirror
try:
subprocess.run([node, str(installer)], cwd=str(electron_dir), env=dl_env, check=False)
except OSError:
return False
return _electron_dist_ok(project_root)
def _try_redownload_electron_dist(project_root: Path, env: dict) -> bool:
"""Canonical download, then fallback mirror unless the user pinned one."""
if _redownload_electron_dist(project_root, env):
return True
if env.get("ELECTRON_MIRROR"):
return False
return _redownload_electron_dist(project_root, env, mirror=_ELECTRON_FALLBACK_MIRROR)
def _stop_desktop_processes_locking_build(desktop_dir: Path) -> list[int]:
"""Terminate any running desktop app executing from this build's ``release``
dir so a rebuild can replace its (otherwise locked) executable.
On Windows a running ``Hermes.exe`` keeps an exclusive lock on
``release/win-unpacked/Hermes.exe``. electron-builder's pack then can't
delete the stale binary and dies with ``remove …\\Hermes.exe: Access is
denied`` / ``ERR_ELECTRON_BUILDER_CANNOT_EXECUTE`` (before-pack hits the same
EPERM cleaning the dir). The retry path repeats the failure because the lock
is still held. POSIX lets you unlink a running binary, so this is a no-op
off-Windows.
Scope is deliberately narrow: only processes whose executable lives *inside*
this desktop's ``release`` tree are stopped — a packaged install elsewhere or
an unrelated "Hermes" process is never touched. Best-effort: never raises.
Returns the PIDs we asked to stop.
"""
if sys.platform != "win32":
return []
try:
import psutil
except Exception:
return []
try:
release_dir = (desktop_dir / "release").resolve()
except OSError:
return []
if not release_dir.is_dir():
return []
me = os.getpid()
victims = []
try:
proc_iter = psutil.process_iter(["pid", "exe"])
except Exception:
return []
for proc in proc_iter:
try:
info = proc.info
except Exception:
continue
pid = info.get("pid")
exe = info.get("exe")
if not exe or pid is None or pid == me:
continue
try:
exe_path = Path(exe).resolve()
except (OSError, ValueError):
continue
if release_dir in exe_path.parents:
victims.append(proc)
stopped: list[int] = []
for proc in victims:
try:
proc.terminate()
stopped.append(int(proc.pid))
except Exception:
continue
if stopped:
# Wait for the handles (and thus the file locks) to actually release.
try:
_, alive = psutil.wait_procs(victims, timeout=5)
for proc in alive:
try:
proc.kill()
except Exception:
continue
except Exception:
pass
return stopped
def _desktop_macos_bundle_id(bundle: Path) -> Optional[str]:
"""Return a bundle/framework CFBundleIdentifier for local macOS signing."""
import plistlib
info = bundle / "Contents" / "Info.plist"
if not info.exists() and bundle.suffix == ".framework":
candidates = list(bundle.glob("Versions/*/Resources/Info.plist")) + list(
bundle.glob("Resources/Info.plist")
)
if candidates:
info = candidates[0]
if not info.exists():
return None
try:
data = plistlib.loads(info.read_bytes())
except Exception:
return None
ident = data.get("CFBundleIdentifier")
return str(ident) if ident else None
def _desktop_macos_local_signing_identity() -> Optional[str]:
"""Return the opt-in keychain identity for local macOS desktop signing.
``desktop.macos_signing_identity`` in config.yaml names a persistent
code-signing certificate in the user's login keychain (a self-signed
"Code Signing" cert made in Keychain Access is enough — no Apple Developer
account needed). Signing with any identity gives the app a
certificate-anchored Designated Requirement, which is the strongest way to
keep macOS TCC grants (Full Disk Access, Accessibility, Automation, Files
and Folders) stable across local rebuilds. Empty/unset keeps the default
identifier-pinned ad-hoc signing.
"""
if sys.platform != "darwin":
return None
try:
from hermes_cli.config import load_config
desktop = load_config().get("desktop", {})
if not isinstance(desktop, dict):
return None
identity = desktop.get("macos_signing_identity")
if not isinstance(identity, str):
return None
return identity.strip() or None
except Exception as exc:
print(
" (warning: could not load desktop.macos_signing_identity: "
f"{exc}; falling back to ad-hoc signing)"
)
return None
def _desktop_macos_has_valid_real_signature(app: Path) -> bool:
"""True when the bundle carries an intact non-ad-hoc (Team ID) signature.
Used to make the relaunch fixup a no-op on properly signed/notarized
builds even when CSC_LINK / APPLE_SIGNING_IDENTITY aren't in the
environment (e.g. a release DMG install being repaired) — clobbering a
Developer ID signature with an ad-hoc one would reset TCC grants and can
break the hardened runtime. A *stale* real signature (in-place rebuild
tampered with the bundle) fails --verify and returns False so the fixup
can repair it.
"""
codesign = shutil.which("codesign")
if not codesign:
return False
try:
info = subprocess.run(
[codesign, "-dv", str(app)], check=False, capture_output=True, text=True
)
output = f"{info.stdout}\n{info.stderr}"
if info.returncode != 0 or "TeamIdentifier=" not in output \
or "TeamIdentifier=not set" in output:
return False
verify = subprocess.run(
[codesign, "--verify", "--deep", "--strict", str(app)],
check=False, capture_output=True,
)
return verify.returncode == 0
except Exception:
return False
def _desktop_macos_local_codesign(
app: Path, *, desktop_dir: Path, identity: str = "-"
) -> bool:
"""Re-sign a local Desktop build so macOS TCC grants survive rebuilds.
A plain ``codesign --deep --sign -`` leaves the bundle with a cdhash-only
Designated Requirement and strips electron-builder's entitlements. Every
rebuild changes the cdhash, so TCC (Full Disk Access, Accessibility,
Automation, Files and Folders: Desktop/Downloads/Documents, microphone)
treats the rebuilt app as different code and the user must re-grant
everything — and the lost entitlements break microphone/JIT under the
hardened runtime.
Instead, sign inside-out (standalone Mach-O binaries, then nested
frameworks/helper apps, then the main bundle), preserving the repo's
entitlement plists, and pin an explicit identifier-based Designated
Requirement when signing ad-hoc. With a real ``identity`` the certificate
anchors the DR, so no explicit requirement is needed. Raises on signing
failure; returns True after strict verification passes.
"""
codesign = shutil.which("codesign")
if not codesign:
return False
ent_main = desktop_dir / "electron" / "entitlements.mac.plist"
ent_inherit = desktop_dir / "electron" / "entitlements.mac.inherit.plist"
if not (ent_main.exists() and ent_inherit.exists()):
# Hardened-runtime restrictions are enforced even for ad-hoc
# signatures. Signing with --options runtime but WITHOUT the allow-jit
# entitlements would leave Electron/V8 crashing on launch — strictly
# worse than the legacy plain ad-hoc sign. Bail out so the caller
# falls back to that legacy path instead.
raise FileNotFoundError(
f"desktop entitlement plists missing under {desktop_dir / 'electron'}"
)
def sign_path(
path: Path,
*,
entitlements: Optional[Path] = None,
identifier: Optional[str] = None,
runtime: bool = True,
) -> None:
args = [codesign, "--force", "--sign", identity, "--timestamp=none"]
if runtime:
args += ["--options", "runtime"]
if entitlements is not None and entitlements.exists():
args += ["--entitlements", str(entitlements)]
if identifier and identity == "-":
# Ad-hoc signatures get a cdhash-only DR by default; pin an
# identifier-based DR so TCC has something stable to persist.
args += ["--requirements", f'=designated => identifier "{identifier}"']
args.append(str(path))
subprocess.run(args, check=True, capture_output=True)
# 1) Standalone Mach-O files (native modules, dylibs, crashpad handler).
# Compare paths relative to the app root — the absolute path always
# contains the outer Hermes.app component, so an absolute-parts check
# would skip every file.
contents = app / "Contents"
standalone: list[Path] = []
for root, _dirs, files in os.walk(contents):
root_path = Path(root)
rel_parts = root_path.relative_to(app).parts
if any(part.endswith(".app") for part in rel_parts):
continue # nested helper apps are signed as bundles below
for name in files:
fp = root_path / name
if name in {"chrome_crashpad_handler", "spawn-helper"} or fp.suffix in {
".node",
".dylib",
}:
standalone.append(fp)
for fp in sorted(standalone, key=lambda p: len(p.parts), reverse=True):
sign_path(fp, runtime=False)
# 2) Nested frameworks and helper apps, deepest first.
bundles: list[Path] = []
frameworks_dir = contents / "Frameworks"
if frameworks_dir.exists():
for root, _dirs, _files in os.walk(frameworks_dir):
p = Path(root)
if p.suffix in {".framework", ".app"}:
bundles.append(p)
for bundle in sorted(set(bundles), key=lambda p: len(p.parts), reverse=True):
ent = ent_inherit if bundle.suffix == ".app" and "Helper" in bundle.name else None
sign_path(bundle, entitlements=ent, identifier=_desktop_macos_bundle_id(bundle))
# 3) The main bundle, with the app's own entitlements.
sign_path(app, entitlements=ent_main, identifier=_desktop_macos_bundle_id(app))
subprocess.run(
[codesign, "--verify", "--deep", "--strict", str(app)],
check=True, capture_output=True,
)
return True
def _desktop_macos_relaunchable_fixup(
desktop_dir: Path,
*,
publisher_signing_configured: Optional[bool] = None,
) -> bool:
"""Make a locally-built macOS desktop app survive in-place self-update
without resetting the user's TCC permission grants.
An ad-hoc-signed .app has no stable Designated Requirement, so when the
self-updater rebuilds the bundle in place (new cdhash) Gatekeeper reports
"Hermes is damaged and can't be opened" — and macOS TCC forgets every
permission the user granted (Full Disk Access, Desktop/Downloads/Documents,
Accessibility, Automation, microphone), re-prompting on every launch after
every update.
Clear the quarantine xattrs, then re-sign with a stable identity:
``desktop.macos_signing_identity`` (a persistent keychain cert — strongest)
when configured, else ad-hoc with identifier-pinned Designated Requirements,
preserving the repo's entitlement plists either way. No-op when a real
publisher identity is configured (CSC_LINK / APPLE_SIGNING_IDENTITY) or the
bundle already carries an intact Developer ID signature, so a properly
signed/notarized build is never clobbered. Callers that already made the
publisher-signing decision may pass it explicitly so a later dotenv load
can't reverse it. Falls back to the legacy deep ad-hoc sign if the
entitlement-preserving path fails. Best-effort: never raises. Returns True
when no work was needed or signing + strict verification succeeded.
"""
if sys.platform != "darwin":
return True
if publisher_signing_configured is None:
publisher_signing_configured = bool(
os.environ.get("CSC_LINK") or os.environ.get("APPLE_SIGNING_IDENTITY")
)
if publisher_signing_configured:
return True
exe = _desktop_packaged_executable(desktop_dir)
if exe is None:
return True
# exe = .../Hermes.app/Contents/MacOS/Hermes -> app bundle = .../Hermes.app
app = exe.parents[2]
if not str(app).endswith(".app") or not app.is_dir():
return True
codesign = shutil.which("codesign")
if not codesign:
return False
if _desktop_macos_has_valid_real_signature(app):
return True
subprocess.run(["xattr", "-cr", str(app)], check=False)
identity = _desktop_macos_local_signing_identity() or "-"
try:
if _desktop_macos_local_codesign(app, desktop_dir=desktop_dir, identity=identity):
label = "keychain identity" if identity != "-" else "stable ad-hoc identity"
print(f" → macOS desktop signed with {label}; TCC grants persist across rebuilds")
return True
except Exception as exc:
if identity != "-":
print(
f" (warning: configured macOS signing identity failed: {identity!r}; "
"falling back to ad-hoc — TCC grants may need to be re-granted)"
)
print(f" (warning: stable macOS signing failed ({exc}); using legacy ad-hoc sign)")
try:
subprocess.run([codesign, "--force", "--deep", "--sign", "-", str(app)], check=False)
except Exception as exc:
print(f" (warning: macOS relaunch fixup skipped: {exc})")
return False
def _force_adhoc_macos_signing(env: dict, *, source_mode: bool) -> bool:
"""Stop electron-builder grabbing a random keychain identity on self-update.
The desktop self-updater rebuilds *and re-signs the .app on the end user's
machine* (``hermes desktop --build-only`` → electron-builder ``--dir``).
With ``CSC_IDENTITY_AUTO_DISCOVERY`` on (its default), electron-builder
signs the ``type=distribution``, hardened-runtime bundle with whatever it
finds in that user's keychain — typically a personal "Apple Development"
cert. That stalls/fails the sign step (no Developer ID + no provisioning
profile) or clobbers your real notarized signature with an unusable one, so
every post-update launch trips Gatekeeper.
Force ad-hoc signing for the local packaged rebuild instead: deterministic,
and exactly what ``_desktop_macos_relaunchable_fixup`` already finishes off.
No-op for source runs, off-macOS, when a real identity is configured
(``CSC_LINK`` / ``APPLE_SIGNING_IDENTITY``), or when the caller already
pinned the flag. Mutates ``env``; returns True when it set the flag.
"""
if sys.platform != "darwin" or source_mode:
return False
if env.get("CSC_LINK") or env.get("APPLE_SIGNING_IDENTITY"):
return False
if "CSC_IDENTITY_AUTO_DISCOVERY" in env:
return False
env["CSC_IDENTITY_AUTO_DISCOVERY"] = "false"
return True
def _desktop_linux_needs_no_sandbox() -> bool:
"""Return True when Chromium/Electron should bypass the Linux sandbox.
Ubuntu 23.10+ can enable AppArmor's
``apparmor_restrict_unprivileged_userns`` hardening, which breaks
Chromium/Electron's user-namespace sandbox for normal users unless the app
ships a working root-owned 4755 ``chrome-sandbox`` helper. In headless or
non-interactive CLI contexts we may be unable to ``sudo chown/chmod`` that
helper, so detect the host restriction and fall back to ``--no-sandbox``
rather than hard-failing the launcher.
We intentionally do NOT return True for root users here: running Electron as
root without a sandbox is a qualitatively riskier path than launching as an
unprivileged desktop user on an AppArmor-restricted host. The root case
should remain an explicit user choice.
"""
if sys.platform != "linux":
return False
if hasattr(os, "geteuid") and os.geteuid() == 0:
return False
try:
with open("/proc/sys/kernel/apparmor_restrict_unprivileged_userns", encoding="utf-8") as f:
return f.read().strip() == "1"
except OSError:
return False
def _desktop_linux_sandbox_helper_is_regular_file(packaged_executable: Path) -> bool:
"""Return True when ``chrome-sandbox`` exists as a regular file."""
if sys.platform != "linux":
return False
sandbox = packaged_executable.parent / "chrome-sandbox"
try:
sandbox_lstat = sandbox.lstat()
except OSError:
return False
return stat.S_ISREG(sandbox_lstat.st_mode)
def _desktop_linux_sandbox_fixup(packaged_executable: Path) -> bool:
"""Configure Electron's Linux SUID sandbox helper when required."""
if sys.platform != "linux":
return True
sandbox = packaged_executable.parent / "chrome-sandbox"
if not sandbox.exists():
print(f"✗ Hermes Desktop is missing Electron's Linux sandbox helper: {sandbox}")
return False
# Reject symlinks — chown/chmod must not follow an attacker-controlled
# link to an arbitrary path. Use lstat() so we inspect the link itself
# rather than the target, and require a regular file.
try:
sandbox_lstat = sandbox.lstat()
except OSError:
print(f"✗ Cannot stat Electron's Linux sandbox helper: {sandbox}")
return False
if not stat.S_ISREG(sandbox_lstat.st_mode):
print(f"✗ Electron's Linux sandbox helper is not a regular file: {sandbox}")
return False
if sandbox_lstat.st_uid == 0 and stat.S_IMODE(sandbox_lstat.st_mode) == 0o4755:
return True
sudo = shutil.which("sudo")
if not sudo:
print("✗ Hermes Desktop requires sudo to configure Electron's Linux sandbox helper.")
return False
print("→ Configuring Electron Linux sandbox helper (sudo required)...")
for command in ([sudo, "chown", "root:root", str(sandbox)], [sudo, "chmod", "4755", str(sandbox)]):
if subprocess.run(command, check=False).returncode != 0:
print(f"✗ Failed to configure Electron's Linux sandbox helper: {sandbox}")
return False
return True
def _desktop_launch_options() -> tuple[list[str], str]:
"""Read `desktop.*` launch options from config.yaml.
Returns ``(electron_flags, disable_gpu)`` where ``electron_flags`` is a list
of extra Electron CLI flags and ``disable_gpu`` is one of "auto"/"1"/"0"
(normalized for the HERMES_DESKTOP_DISABLE_GPU env var the Electron app
reads). Best-effort: any config error yields the safe defaults
``([], "auto")`` so a malformed config never blocks the launch.
"""
flags: list[str] = []
disable_gpu = "auto"
try:
from hermes_cli.config import load_config
desktop_cfg = (load_config() or {}).get("desktop") or {}
except Exception:
return flags, disable_gpu
raw_flags = desktop_cfg.get("electron_flags")
if isinstance(raw_flags, str):
flags = shlex.split(raw_flags, posix=(os.name != "nt"))
elif isinstance(raw_flags, (list, tuple)):
flags = [str(f) for f in raw_flags if str(f).strip()]
raw_gpu = desktop_cfg.get("disable_gpu", "auto")
if isinstance(raw_gpu, bool):
disable_gpu = "1" if raw_gpu else "0"
elif isinstance(raw_gpu, str):
low = raw_gpu.strip().lower()
if low in ("1", "true", "yes", "on"):
disable_gpu = "1"
elif low in ("0", "false", "no", "off"):
disable_gpu = "0"
else:
disable_gpu = "auto"
return flags, disable_gpu
def cmd_gui(args: argparse.Namespace):
"""Build and launch the native Electron desktop GUI."""
desktop_dir = PROJECT_ROOT / "apps" / "desktop"
if not (desktop_dir / "package.json").exists():
print(f"Desktop GUI source not found at: {desktop_dir}")
sys.exit(1)
try:
from hermes_logging import setup_logging as _setup_logging_gui
_setup_logging_gui(mode="gui")
except Exception:
pass
from hermes_constants import with_hermes_node_path
# with_hermes_node_path() copies os.environ when called with no arg.
env = with_hermes_node_path()
if getattr(args, "fake_boot", False):
env["HERMES_DESKTOP_BOOT_FAKE"] = "1"
if getattr(args, "ignore_existing", False):
env["HERMES_DESKTOP_IGNORE_EXISTING"] = "1"
if getattr(args, "hermes_root", None):
env["HERMES_DESKTOP_HERMES_ROOT"] = str(Path(args.hermes_root).expanduser().resolve())
if getattr(args, "cwd", None):
env["HERMES_DESKTOP_CWD"] = str(Path(args.cwd).expanduser().resolve())
else:
env["HERMES_DESKTOP_CWD"] = os.getcwd()
# Desktop launch options from config.yaml (`desktop.electron_flags`,
# `desktop.disable_gpu`). The GPU policy is bridged to the env var the
# Electron app already reads; an explicit env var still wins over config so
# `HERMES_DESKTOP_DISABLE_GPU=... hermes desktop` keeps working.
config_electron_flags, config_disable_gpu = _desktop_launch_options()
if config_disable_gpu != "auto" and "HERMES_DESKTOP_DISABLE_GPU" not in os.environ:
env["HERMES_DESKTOP_DISABLE_GPU"] = config_disable_gpu
source_mode = getattr(args, "source", False)
skip_build = getattr(args, "skip_build", False)
force_build = getattr(args, "force_build", False)
packaged_executable = _desktop_packaged_executable(desktop_dir)
if source_mode or not skip_build:
npm = _resolve_node_runtime_npm()
if not npm:
print("Desktop GUI requires Node.js/npm, but npm was not found on PATH.")
print("Install Node.js, then run: hermes gui")
sys.exit(1)
else:
npm = None
if skip_build:
if source_mode:
if not _desktop_dist_exists(desktop_dir):
print(f"✗ --skip-build --source was passed but no desktop dist found at: {desktop_dir / 'dist'}")
print(" Pre-build first: cd apps/desktop && npm run build")
print(" Or drop --skip-build to install dependencies and build automatically.")
sys.exit(1)
if not (_electron_dir(PROJECT_ROOT) / "package.json").exists():
print("✗ --skip-build --source requires existing desktop workspace dependencies.")
print(f" Install first: cd {PROJECT_ROOT} && npm ci")
print(" Or drop --skip-build to install dependencies and build automatically.")
sys.exit(1)
print(f"→ Skipping desktop source build (--skip-build --source); using dist at {desktop_dir / 'dist'}")
elif packaged_executable is None:
print(f"✗ --skip-build was passed but no packaged desktop app was found at: {desktop_dir / 'release'}")
print(" Pre-build first: cd apps/desktop && npm run pack")
print(" Or drop --skip-build to package automatically.")
sys.exit(1)
else:
print(f"→ Skipping desktop package build (--skip-build); using {packaged_executable}")
else:
# Check the content-hash stamp before doing any build work.
# If the source tree hasn't changed since the last successful build,
# skip the npm install + build entirely (saves a ton of useless work).
# --force-build overrides the stamp and always rebuilds.
build_needed = force_build or _desktop_build_needed(
desktop_dir, PROJECT_ROOT, source_mode=source_mode
)
if not build_needed:
build_label = "source build" if source_mode else "packaged app"
print(f"✓ Desktop {build_label} is up to date (content stamp matches)")
else:
print("→ Installing desktop workspace dependencies...")
# Put the Hermes-managed Node on PATH so npm's child scripts (which
# shell out to bare `node`, e.g. electron-winstaller's
# select-7z-arch.js) resolve it even when the parent PATH is
# stripped — the desktop updater chain (Desktop → hermes-setup →
# hermes update) loses shell PATH customizations. Wrapping the
# NixOS build env keeps its PYTHON hint while restoring managed Node
# ahead of a bare PATH (same idiom as the `hermes update` path).
nixos_env = with_hermes_node_path(_nixos_build_env())
install_result = _run_npm_install_deterministic(npm, PROJECT_ROOT, capture_output=False, env=nixos_env)
if install_result.returncode != 0:
if not _electron_pkg_staged_missing_dist(PROJECT_ROOT):
print("✗ Desktop dependency install failed")
print(f" Run manually: cd {PROJECT_ROOT} && npm ci")
sys.exit(install_result.returncode or 1)
repaired = _try_redownload_electron_dist(PROJECT_ROOT, env)
if repaired:
print(" ⚠ Dependency install failed with a missing Electron dist; "
"repopulated it and continuing.")
else:
print(" ⚠ Dependency install failed with a missing Electron dist; "
"continuing to the build so electron-builder can attempt "
"the Electron fetch itself.")
build_label = "source build" if source_mode else "packaged app"
print(f"→ Building desktop {build_label}...")
build_script = "build" if source_mode else "pack"
if _force_adhoc_macos_signing(env, source_mode=source_mode):
print(" → No Developer ID configured; ad-hoc signing this local rebuild "
"(CSC_IDENTITY_AUTO_DISCOVERY=false)")
if not source_mode:
# A running desktop instance launched from release/win-unpacked
# holds Hermes.exe locked on Windows, so the pack can't replace
# it ("Access is denied" / ERR_ELECTRON_BUILDER_CANNOT_EXECUTE).
# Stop it first so the rebuild — including the installer's
# headless --update rebuild — succeeds instead of failing cryptically.
stopped = _stop_desktop_processes_locking_build(desktop_dir)
if stopped:
print(f" ⚠ Stopped running desktop app to free the build output (pid {', '.join(map(str, stopped))})")
build_result = subprocess.run([npm, "run", build_script], cwd=desktop_dir, env=env, check=False)
if (
build_result.returncode != 0
and not source_mode
and _desktop_packaged_executable(desktop_dir) is None
):
# Corrupt cached Electron zip → partial unpack → ENOENT on rename.
# stdlib zipfile won't catch the common concat-junk case, so purge
# and retry once; @electron/get SHASUM is the real gate.
#
# Gate on a MISSING packaged executable: that is the signature of
# the corrupt-download class this recovery exists for. A late
# failure such as macOS code signing leaves the executable in
# place — redownloading Electron can't repair it, so the purge +
# retry would only add another slow, identical failure (#40187).
purged: list[Path] = []
restored = False
if not _electron_dist_ok(PROJECT_ROOT):
purged = _purge_electron_build_cache(desktop_dir)
restored = _redownload_electron_dist(PROJECT_ROOT, env)
if restored:
print(" ⚠ Desktop build failed; refreshed the Electron download and retrying once...")
for p in purged:
print(f" - {p}")
# The purge can't remove a win-unpacked tree whose Hermes.exe
# is still locked by a running instance; stop it before retry.
_stop_desktop_processes_locking_build(desktop_dir)
build_result = subprocess.run([npm, "run", build_script], cwd=desktop_dir, env=env, check=False)
if (
build_result.returncode != 0
and not source_mode
and not env.get("ELECTRON_MIRROR")
and _desktop_packaged_executable(desktop_dir) is None
):
print(" ⚠ Desktop build still failing; the Electron download from "
"GitHub looks blocked. Re-downloading via a public mirror "
"(npmmirror.com)... (set ELECTRON_MIRROR to use another mirror)")
mirror = _ELECTRON_FALLBACK_MIRROR
mirror_env = dict(env)
mirror_env["ELECTRON_MIRROR"] = mirror
if not _electron_dist_ok(PROJECT_ROOT):
_redownload_electron_dist(PROJECT_ROOT, env, mirror=mirror)
_stop_desktop_processes_locking_build(desktop_dir)
build_result = subprocess.run([npm, "run", build_script], cwd=desktop_dir, env=mirror_env, check=False)
if build_result.returncode != 0:
print("✗ Desktop GUI build failed")
print(f" Run manually: cd apps/desktop && npm run {build_script}")
if sys.platform == "win32":
print(" If this says \"Access is denied\" on Hermes.exe, close any")
print(" running Hermes desktop window and retry.")
print(" If the log shows Electron download retries, rebuild via a mirror:")
print(" ELECTRON_MIRROR=<mirror-base-url> hermes desktop --force-build")
sys.exit(build_result.returncode or 1)
packaged_executable = _desktop_packaged_executable(desktop_dir)
if not source_mode:
# Locally-built apps are ad-hoc signed; make them relaunchable after
# an in-place self-update (otherwise macOS reports "Hermes is
# damaged"). No-op on non-macOS and on real-identity builds.
_desktop_macos_relaunchable_fixup(desktop_dir)
# Windows integrity gate (#69179): never declare the rebuild a
# success on a Hermes.exe Windows cannot load (truncated PE from
# a corrupt cached Electron zip, wrong-arch tree, interrupted
# rcedit rewrite). Roll back to the .bak tree preserved by
# before-pack.mjs when possible, then fail loudly so the
# updater's retry-once rebuilds from a fresh Electron download
# instead of silently shipping the broken exe.
verified_executable, rolled_back = _ensure_desktop_exe_launchable(
desktop_dir, packaged_executable
)
if packaged_executable is not None and (
rolled_back or verified_executable is None
):
sys.exit(1)
packaged_executable = verified_executable
# Build succeeded — write the stamp so next run can skip
_write_desktop_build_stamp(PROJECT_ROOT, source_mode=source_mode)
# --build-only: produce the artifact but do NOT launch. The installer's
# --update flow drives the rebuild headlessly and then launches the desktop
# itself (detached, after the old exe has exited), so the launch must NOT
# happen here — it would block the installer and, on Windows, the old exe
# is still being replaced. Verify the expected artifact exists so a silent
# "built nothing" can't slip past, then return success.
if getattr(args, "build_only", False):
if source_mode:
if not _desktop_dist_exists(desktop_dir):
print(f"✗ --build-only --source produced no dist at: {desktop_dir / 'dist'}")
sys.exit(1)
print(f"✓ Desktop source build ready at {desktop_dir / 'dist'} (not launching; --build-only)")
elif packaged_executable is None:
print(f"✗ --build-only produced no launchable app at: {desktop_dir / 'release'}")
print(" Expected an unpacked Electron app for the current OS.")
sys.exit(1)
else:
print(f"✓ Desktop packaged app ready: {packaged_executable} (not launching; --build-only)")
return
if source_mode:
print("→ Launching Hermes Desktop from source build...")
launch_result = subprocess.run([npm, "exec", "--", "electron", "."], cwd=desktop_dir, env=env, check=False)
sys.exit(launch_result.returncode)
if packaged_executable is None:
print(f"✗ Desktop package build completed but no launchable app was found at: {desktop_dir / 'release'}")
print(" Expected an unpacked Electron app for the current OS.")
sys.exit(1)
launch_command = [str(packaged_executable)]
if not _desktop_linux_sandbox_fixup(packaged_executable):
if _desktop_linux_needs_no_sandbox() and _desktop_linux_sandbox_helper_is_regular_file(packaged_executable):
print("⚠ Falling back to --no-sandbox because this Linux host restricts unprivileged user namespaces and the Electron sandbox helper could not be configured.")
launch_command.append("--no-sandbox")
else:
sys.exit(1)
launch_command.extend(config_electron_flags)
print(f"→ Launching packaged Hermes Desktop: {' '.join(launch_command)}")
launch_result = subprocess.run(launch_command, cwd=desktop_dir, env=env, check=False)
sys.exit(launch_result.returncode)
# Dashboard process-hygiene helpers extracted to hermes_cli/dashboard_procs.py
# (main.py decomposition, mechanical move). Re-exported so callers and test
# monkeypatches on hermes_cli.main.<name> keep resolving unchanged.
from hermes_cli.dashboard_procs import ( # noqa: F401
_detect_concurrent_hermes_instances,
_kill_stale_dashboard_processes,
_scan_dashboard_processes,
)
def _find_stale_dashboard_pids(
*,
exclude_pids: set[int] | None = None,
) -> list[int]:
"""Return PIDs of stale ``dashboard``/``serve`` processes for update cleanup."""
return [pid for pid, _cmd in _scan_dashboard_processes(exclude_pids=exclude_pids)]
def _parse_dashboard_runtime(command: str) -> tuple[str, str, int] | None:
"""Best-effort parse of a dashboard/server cmdline into mode, host, and port."""
mode = None
if any(
pattern in command
for pattern in (
"hermes dashboard",
"hermes_cli.main dashboard",
"hermes_cli/main.py dashboard",
)
):
mode = "dashboard"
elif any(
pattern in command
for pattern in (
"hermes serve",
"hermes_cli.main serve",
"hermes_cli/main.py serve",
)
):
mode = "serve"
if mode is None:
return None
port = 9119
host = "127.0.0.1"
port_match = re.search(r"(?:^|\s)--port(?:=|\s+)(\d+)", command)
if port_match:
try:
port = int(port_match.group(1))
except ValueError:
return None
host_match = re.search(r"(?:^|\s)--host(?:=|\s+)(\"[^\"]+\"|'[^']+'|\S+)", command)
if host_match:
host = host_match.group(1).strip("\"'") or "127.0.0.1"
return mode, host, port
def _dashboard_probe_host(host: str | None) -> str:
"""Map wildcard binds to a loopback address suitable for local probing."""
normalized = (host or "127.0.0.1").strip().strip("[]")
if normalized in {"", "0.0.0.0", "::"}:
return "127.0.0.1"
return normalized
_DASHBOARD_SYSTEMD_UNIT = "hermes-dashboard.service"
def _restart_managed_dashboard_service(
reason: str,
unit: str = _DASHBOARD_SYSTEMD_UNIT,
) -> bool:
"""Restart a systemd-managed dashboard instead of raw-killing its PID.
Returns True when a dashboard unit was found and handled (successfully or
with a printed actionable failure). Returning True deliberately prevents
the caller from falling back to ``os.kill``: systemd treats a direct
SIGTERM of the service's main PID as a clean stop, so ``Restart=on-failure``
will not bring the dashboard back.
"""
if sys.platform == "win32":
return False
def _systemctl(*args: str, timeout: int = 10) -> subprocess.CompletedProcess:
return subprocess.run(
["systemctl", *args],
capture_output=True,
text=True, encoding="utf-8", errors="replace",
timeout=timeout,
)
# Probe the user manager first: Hermes installs Linux services in the
# user's systemd scope by default. Only fall back to the system manager
# when the unit is not present there, preserving root/system deployments.
# Crucially, keep the selected scope for *all* probes and the restart — a
# user unit must never be restarted through the system manager (or raw-killed).
scope: tuple[str, ...] | None = None
listed: subprocess.CompletedProcess | None = None
for candidate in (("--user",), ()):
try:
result = _systemctl(
*candidate, "list-unit-files", unit, "--no-legend", "--no-pager"
)
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
continue
if result.returncode != 0:
continue
unit_rows = (result.stdout or "").splitlines()
if any(row.split()[0:1] == [unit] for row in unit_rows if row.split()):
scope = candidate
listed = result
break
if scope is None or listed is None:
return False
try:
active = _systemctl(*scope, "is-active", unit)
enabled = _systemctl(*scope, "is-enabled", unit)
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
return False
active_state = (active.stdout or "").strip()
enabled_state = (enabled.stdout or "").strip()
if active_state != "active" and enabled_state not in {
"enabled",
"enabled-runtime",
"linked",
"linked-runtime",
"static",
"generated",
}:
return False
print()
print(f"⟲ Restarting managed dashboard service ({reason})")
scope_label = "systemctl --user" if scope else "sudo systemctl"
restart = ("systemctl", *scope, "restart", unit)
commands = [restart]
if not scope:
# System units may require privilege escalation; user units must use
# the user manager directly and never prompt for sudo.
commands.append(("sudo", "-n", "systemctl", "restart", unit))
errors: list[str] = []
for command in commands:
try:
result = subprocess.run(
list(command),
capture_output=True,
text=True, encoding="utf-8", errors="replace",
timeout=60,
)
except (FileNotFoundError, subprocess.TimeoutExpired, OSError) as e:
errors.append(f"{' '.join(command)}: {e}")
continue
if result.returncode == 0:
print(f" ✓ restarted {unit}")
return True
errors.append(
f"{' '.join(command)}: {(result.stderr or result.stdout or '').strip()}"
)
print(f" ✗ failed to restart {unit}")
for err in errors:
if err.strip():
print(f" {err}")
print(
" Dashboard is managed by systemd; not raw-killing its PID because "
"systemd would treat that as a clean stop."
)
print(f" Restart manually: {scope_label} restart {unit}")
return True
def _get_systemd_service_for_pid(pid: int) -> str | None:
"""If *pid* belongs to a systemd service unit, return the unit name.
Reads ``/proc/<pid>/cgroup`` and extracts the service name (e.g.
``hermes-serve.service``). Returns ``None`` when the PID is not
part of a systemd service, when the file is unreadable, or on
non-Linux platforms.
"""
try:
cgroup_path = Path(f"/proc/{pid}/cgroup")
if not cgroup_path.is_file():
return None
text = cgroup_path.read_text(encoding="utf-8", errors="replace")
for line in text.splitlines():
line = line.strip()
# Format: 0::/system.slice/hermes-serve.service
# 0::/user.slice/user-1000.slice/session-42.scope
parts = line.split("::", 1)
if len(parts) != 2:
continue
cg_path = parts[1]
if cg_path.endswith(".service"):
svc_name = cg_path.rsplit("/", 1)[-1]
if svc_name:
return svc_name
except (OSError, PermissionError):
pass
return None
def _extract_scope_from_cgroup(cgroup_entry: str) -> str | None:
"""Extract the systemd scope (``user`` or ``system``) from a cgroup path.
The cgroup path format is ``/system.slice/<name>.service`` for system
services and ``/user.slice/user-<uid>.slice/<name>.service`` for user
services. Returns ``None`` when the scope cannot be determined.
"""
if "/system.slice/" in cgroup_entry:
return "system"
if "/user.slice/" in cgroup_entry:
return "user"
return None
def _get_pid_cgroup_path(pid: int) -> str | None:
"""Return the cgroup path from ``/proc/<pid>/cgroup``, or ``None``.
Only the unified (``0::``) hierarchy cgroup entry is examined.
"""
try:
cgroup_path = Path(f"/proc/{pid}/cgroup")
if not cgroup_path.is_file():
return None
text = cgroup_path.read_text(encoding="utf-8", errors="replace")
for line in text.splitlines():
line = line.strip()
parts = line.split("::", 1)
if len(parts) == 2:
return parts[1]
except (OSError, PermissionError):
pass
return None
def _try_restart_systemd_service(svc_name: str, cgroup_path: str | None = None) -> bool:
"""Attempt to restart *svc_name* via systemctl.
Uses ``systemctl --user`` for user-scope services and ``systemctl``
for system-scope services. Returns ``True`` on success.
"""
scope = _extract_scope_from_cgroup(cgroup_path) if cgroup_path else None
if scope == "user":
cmd = ["systemctl", "--user", "restart", svc_name]
elif scope == "system":
cmd = ["systemctl", "restart", svc_name]
else:
# Unknown scope — try system first, then user
cmd = None
for candidate in (
["systemctl", "restart", svc_name],
["systemctl", "--user", "restart", svc_name],
):
try:
r = subprocess.run(
candidate,
capture_output=True,
text=True, encoding="utf-8", errors="replace",
timeout=15,
)
if r.returncode == 0:
return True
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
continue
return False
try:
r = subprocess.run(
cmd,
capture_output=True,
text=True, encoding="utf-8", errors="replace",
timeout=15,
)
return r.returncode == 0
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
return False
def _dashboard_cmdline_for_pid(pid: int) -> list[str] | None:
"""Return the exact argv of a running process, when recoverable.
Linux: reads ``/proc/<pid>/cmdline`` (NUL-separated, lossless).
macOS: falls back to ``ps -o command=`` + shlex (best effort — quoting
is reconstructed, but hermes launch commands don't embed exotic args).
Windows: returns ``None``; taskkill /F gives no graceful window and the
desktop app manages its own backend there.
"""
if sys.platform == "win32":
return None
try:
cmdline_path = f"/proc/{pid}/cmdline"
if os.path.exists(cmdline_path):
with open(cmdline_path, "rb") as f:
raw = f.read()
argv = [
part.decode("utf-8", errors="replace")
for part in raw.split(b"\x00")
if part
]
return argv or None
# macOS (no /proc): best-effort via ps.
result = subprocess.run(
["ps", "-p", str(pid), "-o", "command="],
capture_output=True,
text=True, encoding="utf-8", errors="replace",
timeout=10,
)
if result.returncode != 0:
return None
command = (result.stdout or "").strip()
if not command:
return None
try:
argv = shlex.split(command)
except ValueError:
argv = command.split()
return argv or None
except (OSError, ValueError, subprocess.TimeoutExpired):
return None
def _respawn_dashboard_processes(commands: list[list[str]]) -> list[list[str]]:
"""Best-effort respawn of manually-started dashboards after ``hermes update``.
Spawns each recovered argv detached (new session, output to the profile's
``logs/dashboard-restart.log``). Returns the commands that failed to
spawn; the caller prints the manual hint for those.
"""
from hermes_constants import get_hermes_home
respawned: list[list[str]] = []
failed: list[tuple[list[str], str]] = []
log_path = get_hermes_home() / "logs" / "dashboard-restart.log"
try:
log_path.parent.mkdir(parents=True, exist_ok=True)
except OSError:
pass
for command in commands:
try:
# Keep restarted dashboards headless; reopening a browser after a
# background update is noisy and fails in SSH/headless sessions.
if "dashboard" in command and "--no-open" not in command:
command = [*command, "--no-open"]
with open(log_path, "ab") as log_f:
subprocess.Popen(
command,
stdin=subprocess.DEVNULL,
stdout=log_f,
stderr=subprocess.STDOUT,
start_new_session=True,
close_fds=True,
)
respawned.append(command)
except (OSError, ValueError) as exc:
failed.append((command, str(exc)))
for command in respawned:
print(f" ✓ restarted: {shlex.join(command)}")
for command, err_msg in failed:
print(f" ✗ failed to restart ({shlex.join(command)}): {err_msg}")
return [command for command, _ in failed]
# Back-compat alias: some tests and any external callers may import the old
# warn-only name. The new behaviour (kill stale processes) replaces it.
_warn_stale_dashboard_processes = _kill_stale_dashboard_processes
# =========================================================================
# Fork detection and upstream management for `hermes update`
# =========================================================================
def _load_installable_optional_extras(group: str = "all") -> list[str]:
"""Return optional extras referenced by a dependency group.
``group`` is usually ``all`` (desktop/server broad install) or
``termux-all`` (Termux-compatible broad install).
"""
try:
import tomllib
with (PROJECT_ROOT / "pyproject.toml").open("rb") as handle:
project = tomllib.load(handle).get("project", {})
except Exception:
return []
optional_deps = project.get("optional-dependencies", {})
if not isinstance(optional_deps, dict):
return []
refs = optional_deps.get(group, [])
referenced: list[str] = []
for ref in refs:
if "[" in ref and "]" in ref:
name = ref.split("[", 1)[1].split("]", 1)[0]
if name in optional_deps:
referenced.append(name)
return referenced
# Install-scoped breadcrumbs live next to the venv (not under $HERMES_HOME)
# because the venv is shared across profiles.
#
# ``.update-incomplete`` — generic core ``.[all]`` install was interrupted.
# Cleared only after a confirmed full dependency reinstall/recovery.
#
# ``.lazy-refresh-incomplete`` — lazy-backend refresh phase may have corrupted
# packages. Cleared only after import-probe repair confirms healthy (not when
# probes are unavailable/indeterminate). Narrow lazy probes must NEVER clear
# the generic core marker (#58004 review).
def _update_marker_path() -> Path:
return PROJECT_ROOT / ".update-incomplete"
def _lazy_refresh_marker_path() -> Path:
return PROJECT_ROOT / ".lazy-refresh-incomplete"
def _clear_marker_file(path: Path, *, label: str) -> None:
"""Remove an update-recovery breadcrumb. Never raises."""
try:
path.unlink()
except FileNotFoundError:
pass
except OSError as exc:
logger.debug("Could not clear %s marker: %s", label, exc)
def _clear_update_incomplete_marker() -> None:
"""Remove the interrupted core-install breadcrumb. Never raises."""
_clear_marker_file(_update_marker_path(), label="update-incomplete")
def _clear_lazy_refresh_incomplete_marker() -> None:
"""Remove the interrupted lazy-refresh breadcrumb. Never raises."""
_clear_marker_file(_lazy_refresh_marker_path(), label="lazy-refresh-incomplete")
def _recover_from_interrupted_install() -> None:
"""Finish update work left half-done by a prior ``hermes update``.
Handles two independent breadcrumbs:
- ``.update-incomplete`` — core ``.[all]`` install interrupted. Recovers
via full quarantined reinstall. Never cleared by the narrow lazy-refresh
import probes alone.
- ``.lazy-refresh-incomplete`` — lazy-backend refresh may have corrupted
packages. Recovers via package-only import probes; cleared only when
probes confirm healthy/repaired (indeterminate keeps the marker).
Never raises: a recovery failure must not block launch. If it can't
self-heal it prints the manual command and leaves the relevant marker so
the next launch tries again.
Concurrency: markers live next to the shared venv, so a gateway start
plus a CLI launch (or two profiles starting at once) can both see them.
An ``O_EXCL`` lockfile ensures only one process runs recovery; the
others skip and let the winner clear markers.
Output: everything — our status lines AND the streamed pip/uv install
(which inherits fd 1) — is routed to stderr. Launches whose stdout is a
protocol stream (``hermes acp`` speaks JSON-RPC on stdout) must never get
install noise on stdout.
"""
core_marker = _update_marker_path().exists()
lazy_marker = _lazy_refresh_marker_path().exists()
if not core_marker and not lazy_marker:
return
# Skip in managed/Docker installs and on PyPI installs with no git checkout:
# those don't run the source-tree update path, so a stray marker is not ours
# to act on. Just clear it.
if not (PROJECT_ROOT / "pyproject.toml").is_file():
_clear_update_incomplete_marker()
_clear_lazy_refresh_incomplete_marker()
return
# Single-flight guard: atomically claim the recovery lock. If another
# process holds it, skip — it is running the same reinstall into the same
# shared venv right now. A crashed holder leaves a stale lock; break it
# after an hour (well past any realistic install) so recovery can't be
# wedged forever.
lock_path = PROJECT_ROOT / ".update-incomplete.lock"
try:
fd = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
os.write(fd, f"{os.getpid()}\n".encode())
os.close(fd)
except FileExistsError:
try:
if _time.time() - lock_path.stat().st_mtime > 3600:
lock_path.unlink()
except OSError:
pass
return
except OSError as exc:
# Couldn't create the lock (read-only fs, perms). Proceed unlocked —
# the install itself will surface the real problem.
logger.debug("Could not create install-recovery lock: %s", exc)
saved_stdout_fd = None
saved_sys_stdout = sys.stdout
try:
# Route Python-level prints AND subprocess-inherited fd 1 to stderr
# for the duration of recovery (see docstring: ACP stdout safety).
try:
saved_stdout_fd = os.dup(1)
os.dup2(2, 1)
except OSError:
saved_stdout_fd = None
sys.stdout = sys.stderr
if lazy_marker:
_recover_lazy_refresh_marker_locked()
if _update_marker_path().exists():
_recover_core_update_marker_locked()
finally:
sys.stdout = saved_sys_stdout
if saved_stdout_fd is not None:
try:
os.dup2(saved_stdout_fd, 1)
os.close(saved_stdout_fd)
except OSError:
pass
try:
lock_path.unlink()
except OSError:
pass
def _recover_lazy_refresh_marker_locked() -> None:
"""Heal ``.lazy-refresh-incomplete`` via confirmed import-probe repair."""
print(
"⚠ A previous lazy-backend refresh may have left the venv unhealthy — "
"running import-based package repair..."
)
install_prefix, install_env = _default_venv_install_target()
status = _repair_venv_via_import_probes(install_prefix, env=install_env)
if status in ("healthy", "repaired"):
_clear_lazy_refresh_incomplete_marker()
print("✓ Lazy-refresh venv recovery confirmed — install is healthy again.")
return
if status == "indeterminate":
print(
" ⚠ Import probes unavailable — cannot confirm venv health. "
"Leaving `.lazy-refresh-incomplete` for the next launch."
)
else:
print(
" ⚠ Lazy-refresh package repair incomplete. "
"Leaving `.lazy-refresh-incomplete` for the next launch."
)
print(" Recover manually with:")
all_specs = _lazy_refresh_repair_specs(
sorted(set(_LAZY_REFRESH_REPAIR_PACKAGES.values()))
)
print(
f" {' '.join(install_prefix)} install --force-reinstall "
+ " ".join(shlex.quote(s) for s in all_specs)
)
def _recover_core_update_marker_locked() -> None:
"""Heal ``.update-incomplete`` via full ``.[all]`` reinstall only.
Narrow lazy-refresh import probes are not sufficient proof that a generic
interrupted core install finished — a missing dep outside that probe set
would otherwise look healthy and clear the breadcrumb too early.
"""
print(
"⚠ A previous `hermes update` was interrupted mid-install — "
"finishing dependency installation now..."
)
# Windows: a normal ``hermes.exe`` launch always has the launcher as an
# ancestor. Full editable reinstall uses quarantine so the live shim can
# still be replaced. Package-only import repair may help as first aid but
# must NEVER clear this core marker on its own (#58004 review).
self_locked = _windows_running_hermes_launcher_locked()
if self_locked:
install_prefix, install_env = _default_venv_install_target()
print(
" → Running from hermes.exe; applying package-only first aid, "
"then quarantined full reinstall (core marker stays until that "
"succeeds)..."
)
_repair_venv_via_import_probes(install_prefix, env=install_env)
try:
from hermes_cli.managed_uv import ensure_uv
# Always bootstrap pip first: a killed install can leave the venv with
# no pip module at all, and uv may also be gone. ensurepip restores a
# known-good pip so at least the plain-pip path below can proceed.
try:
subprocess.run(
[sys.executable, "-m", "ensurepip", "--upgrade", "--default-pip"],
cwd=PROJECT_ROOT,
capture_output=True,
)
except Exception as exc:
logger.debug("ensurepip during install recovery failed: %s", exc)
uv_bin = ensure_uv()
if uv_bin:
uv_env = {**os.environ, "VIRTUAL_ENV": str(PROJECT_ROOT / "venv")}
if _is_termux_env(uv_env):
uv_env.pop("PYTHONPATH", None)
uv_env.pop("PYTHONHOME", None)
_install_python_dependencies_with_optional_fallback(
[uv_bin, "pip"],
env=uv_env,
group="termux-all" if _is_termux_env(uv_env) else "all",
)
else:
_install_python_dependencies_with_optional_fallback(
[sys.executable, "-m", "pip"],
group="termux-all" if _is_termux_env() else "all",
)
_clear_update_incomplete_marker()
print("✓ Dependency installation recovered — your install is healthy again.")
except Exception as exc:
# Leave the marker in place so the next launch retries. Give the user
# the exact manual recovery command in the meantime.
logger.debug("Interrupted-install recovery failed: %s", exc)
print("✗ Could not auto-recover the interrupted install.")
if self_locked:
print(
" Hermes is still running from the launcher that needs "
"replacing. Close other Hermes windows, restart from a "
"different terminal, then run:"
)
print(f' cd /d "{PROJECT_ROOT}"')
print(
f' "{sys.executable}" -m pip install -e ".[all]"'
)
else:
print(" Recover manually with:")
print(f" cd {PROJECT_ROOT}")
print(f" {sys.executable} -m ensurepip --upgrade")
print(f" {sys.executable} -m pip install -e '.[all]'")
def _windows_running_hermes_launcher_locked() -> bool:
"""True when a venv ``hermes*.exe`` shim is this process or an ancestor.
Best-effort: returns False when psutil is unavailable or inspection fails.
"""
if not _is_windows():
return False
scripts_dir = _venv_scripts_dir()
if scripts_dir is None:
return False
shims = _hermes_exe_shims(scripts_dir)
if not shims:
return False
shim_set: set[str] = set()
for shim in shims:
try:
shim_set.add(str(shim.resolve()).lower())
except OSError:
shim_set.add(str(shim).lower())
try:
import psutil
me = psutil.Process()
for proc in [me] + list(me.parents()):
try:
exe_norm = str(Path(proc.exe()).resolve()).lower()
except Exception:
continue
if exe_norm in shim_set:
return True
except Exception:
return False
return False
def _default_venv_install_target() -> tuple[list[str], dict[str, str] | None]:
"""Return ``(install_cmd_prefix, env)`` for the project venv when possible."""
try:
from hermes_cli.managed_uv import ensure_uv
uv_bin = ensure_uv()
except Exception:
uv_bin = None
if uv_bin:
env = {**os.environ, "VIRTUAL_ENV": str(PROJECT_ROOT / "venv")}
if _is_termux_env(env):
env.pop("PYTHONPATH", None)
env.pop("PYTHONHOME", None)
return [uv_bin, "pip"], env
return [sys.executable, "-m", "pip"], None
def _run_install_with_heartbeat(
cmd: list[str],
*,
env: dict[str, str] | None = None,
heartbeat_interval_seconds: int = 30,
) -> None:
"""Run dependency install command with periodic heartbeat output.
Some resolvers/build backends (especially when compiling Rust/C extensions)
can stay quiet for minutes. Emit a simple elapsed-time heartbeat so users
know ``hermes update`` is still progressing even if pip/uv itself is silent.
"""
done = threading.Event()
start = _time.time()
def _heartbeat() -> None:
# Wait first, then print, so short installs don't emit noise.
while not done.wait(heartbeat_interval_seconds):
elapsed = int(_time.time() - start)
print(
f" … still installing dependencies ({elapsed}s elapsed)"
" — compiling Rust/C extensions can take several minutes",
flush=True,
)
t = threading.Thread(target=_heartbeat, daemon=True)
t.start()
try:
subprocess.run(
cmd,
cwd=PROJECT_ROOT,
check=True,
env=env,
)
finally:
done.set()
t.join(timeout=0.2)
def _is_windows() -> bool:
return sys.platform == "win32"
def _venv_scripts_dir() -> Path | None:
"""Return the venv Scripts directory if we're running inside the project venv."""
venv_dir = PROJECT_ROOT / "venv"
if not venv_dir.is_dir():
return None
scripts = venv_dir / ("Scripts" if _is_windows() else "bin")
return scripts if scripts.is_dir() else None
def _hermes_exe_shims(scripts_dir: Path) -> list[Path]:
"""Entry-point shims that uv may try to rewrite during ``pip install -e .``.
On Windows these are .exe launchers generated by setuptools/uv. On POSIX
they're regular Python scripts which can be replaced atomically — no
self-replacement hazard exists outside Windows.
"""
if not _is_windows():
return []
names = set(_load_console_script_names()) or {"hermes", "hermes-agent", "hermes-acp"}
# The gateway shim is not a [project.scripts] entry point, but older
# update/install paths still rewrite and quarantine it.
names.add("hermes-gateway")
return [scripts_dir / f"{name}.exe" for name in sorted(names)]
def _quarantine_running_hermes_exe(
scripts_dir: Path, *, max_attempts: int = 4
) -> list[tuple[Path, Path]]:
"""Pre-empt Windows file lock on the running ``hermes.exe``.
Windows allows RENAMING a mapped/running executable (the kernel tracks the
file by handle, not path), but blocks DELETE/REPLACE while it's loaded. uv
needs to overwrite the entry-point shims during ``pip install -e .``;
when ``hermes update`` runs, ``hermes.exe`` IS the live process, and uv
fails with ``Access is denied. (os error 5)``.
We rename live shims to ``hermes.exe.old.<unix-ms>`` first. uv then writes
fresh shims at the original paths. The ``.old`` files are cleaned up on
the next hermes invocation by ``_cleanup_quarantined_exes``.
Rename can still fail when *another* process has opened the .exe without
``FILE_SHARE_DELETE`` — typically AV real-time scanners with transient
handles (recovers in <1s), or the Hermes Desktop backend child process
(won't recover until the user closes it). We mitigate:
1. Retry up to ``max_attempts`` times with exponential backoff
(100/250/500/1000 ms). Handles the AV-scanner case.
2. If all retries fail, schedule the .exe for replacement on next
reboot via ``MoveFileExW(MOVEFILE_DELAY_UNTIL_REBOOT)``. This still
lets uv create a fresh shim at the original path (Windows will keep
the old file's content under a new name until the reboot), so the
update can complete; the user just needs to reboot to fully unload
the stale image.
3. Print a clear warning naming the most likely culprit (running
Hermes Desktop / gateway / REPL) and pointing to ``--force``.
Returns the list of (original, quarantined) pairs so the caller can roll
back if the install itself fails before uv writes a replacement. Pairs
where we used ``MOVEFILE_DELAY_UNTIL_REBOOT`` are NOT returned — they
are already deferred and roll-back is meaningless.
"""
moved: list[tuple[Path, Path]] = []
if not _is_windows():
return moved
import time
stamp = int(time.time() * 1000)
# Backoff schedule: first attempt is immediate, subsequent ones sleep.
# 100ms / 250ms / 500ms covers the typical AV scanner re-scan window.
backoff_ms = [0, 100, 250, 500, 1000]
attempts = max(1, min(max_attempts, len(backoff_ms)))
for shim in _hermes_exe_shims(scripts_dir):
if not shim.exists():
continue
target = shim.with_suffix(shim.suffix + f".old.{stamp}")
last_exc: OSError | None = None
for attempt in range(attempts):
delay = backoff_ms[attempt] / 1000.0
if delay:
time.sleep(delay)
try:
shim.rename(target)
moved.append((shim, target))
last_exc = None
break
except OSError as e:
last_exc = e
continue
if last_exc is None:
continue
# All in-process renames failed. Try MoveFileEx with
# MOVEFILE_DELAY_UNTIL_REBOOT as a last resort. This succeeds in the
# exact case where the inline rename failed (another process holds
# the handle without share-delete), at the cost of requiring a
# reboot to fully reclaim the old .exe.
scheduled = _schedule_replace_on_reboot(shim, target)
if scheduled:
print(
f"{shim.name} is locked by another process; scheduled "
f"replacement on next reboot."
)
print(
" The new shim was written at the same path, but a "
"reboot is needed to fully unload the old one."
)
# Do NOT append to ``moved``: we don't want roll-back to undo a
# reboot-deferred operation.
continue
# Truly couldn't budge the .exe. Print an actionable warning and let
# uv try its luck — sometimes uv's own retry handling pulls through.
print(
f" ⚠ Could not quarantine {shim.name} ({last_exc.__class__.__name__}: "
f"another process is holding it open)."
)
print(
" Close Hermes Desktop, exit other `hermes` REPLs, stop the "
"gateway, or pause AV scanning, then re-run `hermes update`."
)
return moved
def _schedule_replace_on_reboot(shim: Path, quarantine_target: Path) -> bool:
"""Schedule ``shim`` -> ``quarantine_target`` via PendingFileRenameOperations.
Uses Win32 ``MoveFileExW`` with ``MOVEFILE_REPLACE_EXISTING |
MOVEFILE_DELAY_UNTIL_REBOOT``. The OS persists the rename in
``HKLM\\System\\CurrentControlSet\\Control\\Session Manager\\
PendingFileRenameOperations`` and applies it before any user-mode code
runs on next boot — at which point no process can hold the .exe.
Returns ``True`` if the schedule call succeeded, ``False`` otherwise
(non-Windows, ctypes failure, lack of privilege, etc.). Never raises.
"""
if not _is_windows():
return False
try:
import ctypes
from ctypes import wintypes
MOVEFILE_REPLACE_EXISTING = 0x1
MOVEFILE_DELAY_UNTIL_REBOOT = 0x4
MoveFileExW = ctypes.windll.kernel32.MoveFileExW
MoveFileExW.argtypes = [wintypes.LPCWSTR, wintypes.LPCWSTR, wintypes.DWORD]
MoveFileExW.restype = wintypes.BOOL
ok = MoveFileExW(
str(shim),
str(quarantine_target),
MOVEFILE_REPLACE_EXISTING | MOVEFILE_DELAY_UNTIL_REBOOT,
)
return bool(ok)
except Exception:
return False
def _restore_quarantined_exes(moved: list[tuple[Path, Path]]) -> None:
"""Roll back ``_quarantine_running_hermes_exe`` if uv didn't write replacements."""
for original, quarantined in moved:
try:
if not original.exists() and quarantined.exists():
quarantined.rename(original)
except OSError:
pass
def _run_quarantined_install(
cmd: list[str],
*,
env: dict[str, str] | None = None,
scripts_dir: Path | None = None,
) -> None:
"""Run an editable install, quarantining the running ``hermes.exe`` first.
Any ``pip install -e .`` (or ``--reinstall``) rewrites the entry-point
shims, and on Windows the live ``hermes.exe`` is the running process —
pip can neither delete nor overwrite it, so without quarantine the shim
is left missing and ``hermes`` drops off PATH. This wraps
:func:`_run_install_with_heartbeat` with the same rename-out-of-the-way /
restore-on-failure dance that the primary install path uses, so EVERY
install that touches the shims is protected — including the
verification-repair reinstalls in
:func:`_verify_core_dependencies_installed`, which previously called
``_run_install_with_heartbeat`` directly and bypassed quarantine.
Off-Windows (``scripts_dir is None``) this is a thin pass-through.
"""
moved: list[tuple[Path, Path]] = []
if scripts_dir is not None:
moved = _quarantine_running_hermes_exe(scripts_dir)
try:
_run_install_with_heartbeat(cmd, env=env)
except BaseException:
# Restore shims if pip/uv didn't write replacements (e.g. install
# failed before the entry-points step). Don't swallow the error.
if scripts_dir is not None:
_restore_quarantined_exes(moved)
raise
def _cleanup_quarantined_exes(scripts_dir: Path | None = None) -> None:
"""Sweep ``hermes.exe.old.*`` left by prior updates.
Called early on every hermes invocation. The .old files are unlocked once
their owning process exited, so deletion succeeds the next run. Silent
no-op when nothing's there or on file-locked / permission errors.
"""
if not _is_windows():
return
if scripts_dir is None:
scripts_dir = _venv_scripts_dir()
if scripts_dir is None:
return
try:
for stale in scripts_dir.glob("*.exe.old.*"):
try:
stale.unlink()
except OSError:
pass # still locked or in use — try again next run
except OSError:
pass
# Import probes for venv corruption after a failed lazy ``uv pip install``.
# Metadata can look fine while ``.py`` files were removed mid-install (#57828).
# Canonical tables live in the stdlib-only ``_early_recovery`` module (which
# also probes/repairs BEFORE this module's third-party imports can run) so the
# early and full recovery layers can never drift apart.
_LAZY_REFRESH_IMPORT_PROBES: tuple[tuple[str, str], ...] = (
_early_recovery_mod.LAZY_REFRESH_IMPORT_PROBES
)
_LAZY_REFRESH_REPAIR_PACKAGES: dict[str, str] = (
_early_recovery_mod.LAZY_REFRESH_REPAIR_PACKAGES
)
def _run_package_only_install(
cmd: list[str],
*,
env: dict[str, str] | None = None,
) -> None:
"""Run a package-only pip/uv install without quarantining entry-point shims.
``pip install --upgrade pip`` and ``--force-reinstall <pkg>`` do not
rewrite ``hermes.exe``. The editable-install quarantine path would rename
shims without uv recreating them on Windows (#57828).
"""
_run_install_with_heartbeat(cmd, env=env)
def _lazy_refresh_repair_specs(packages: list[str]) -> list[str]:
"""Map repair package names to their declared pin specs in pyproject.toml."""
try:
import tomllib # Python 3.11+
except ImportError: # pragma: no cover
return packages
pyproject = PROJECT_ROOT / "pyproject.toml"
if not pyproject.is_file():
return packages
try:
with open(pyproject, "rb") as f:
raw_deps = tomllib.load(f).get("project", {}).get("dependencies", []) or []
except Exception as exc:
logger.debug("lazy refresh repair spec lookup failed: %s", exc)
return packages
name_to_spec: dict[str, str] = {}
try:
from packaging.requirements import Requirement # type: ignore
for spec in raw_deps:
try:
req = Requirement(spec)
name_to_spec[req.name.lower()] = spec.split(";", 1)[0].strip()
except Exception:
continue
except Exception:
for spec in raw_deps:
head = spec.split(";", 1)[0].strip()
bare = head
for op in ("==", ">=", "<=", "~=", ">", "<", "!="):
if op in bare:
bare = bare.split(op, 1)[0]
break
key = bare.strip().split("[", 1)[0].strip().lower()
if key:
name_to_spec[key] = head
return [name_to_spec.get(pkg.lower(), pkg) for pkg in packages]
def _detect_broken_lazy_refresh_imports(
install_cmd_prefix: list[str],
*,
env: dict[str, str] | None = None,
) -> list[str] | None:
"""Probe lazy-refresh packages via real imports.
Returns:
- ``[]`` when probes ran and every package imported cleanly
- ``[dist, ...]`` when probes ran and some packages failed
- ``None`` when the probe could not run (missing venv Python, subprocess
failure, non-zero probe exit) — this is *indeterminate*, not healthy
"""
venv_python = _resolve_install_target_python(install_cmd_prefix, env)
if venv_python is None:
return None
probe_lines = "\n".join(
f" ({mod!r}, {attr!r})," for mod, attr in _LAZY_REFRESH_IMPORT_PROBES
)
check_script = (
"import os\n"
"import sys\n"
"probes = [\n"
f"{probe_lines}\n"
"]\n"
"broken = []\n"
"for mod, attr in probes:\n"
" try:\n"
" imported = __import__(mod)\n"
" if not hasattr(imported, attr):\n"
" broken.append(mod)\n"
" elif mod == 'certifi':\n"
" # The module can import cleanly while cacert.pem is\n"
" # missing/corrupt (brew Python upgrade, interrupted venv\n"
" # rebuild) - every TLS call then fails (#29866).\n"
" bundle = imported.where()\n"
" if not os.path.isfile(bundle) or os.path.getsize(bundle) < 1024:\n"
" broken.append(mod)\n"
" except Exception:\n"
" broken.append(mod)\n"
"print('\\n'.join(broken))\n"
)
try:
result = subprocess.run(
[str(venv_python), "-c", check_script],
capture_output=True,
text=True, encoding="utf-8", errors="replace",
check=False,
env=env,
)
except Exception as exc:
logger.debug("lazy refresh import probe failed: %s", exc)
return None
if result.returncode != 0:
logger.debug(
"lazy refresh import probe exited %s: %s",
result.returncode,
(result.stderr or "")[:200],
)
return None
broken_modules = [
line.strip() for line in result.stdout.splitlines() if line.strip()
]
packages: list[str] = []
seen: set[str] = set()
for mod in broken_modules:
pkg = _LAZY_REFRESH_REPAIR_PACKAGES.get(mod)
if pkg and pkg not in seen:
seen.add(pkg)
packages.append(pkg)
return packages
def _repair_broken_lazy_refresh_imports(
install_cmd_prefix: list[str],
packages: list[str],
*,
env: dict[str, str] | None = None,
) -> bool:
"""Force-reinstall ``packages`` and re-probe imports. Never raises."""
if not packages:
return True
specs = _lazy_refresh_repair_specs(packages)
try:
_run_package_only_install(
install_cmd_prefix + ["install", "--force-reinstall", *specs],
env=env,
)
except subprocess.CalledProcessError as exc:
logger.warning("lazy refresh venv repair failed: %s", exc)
return False
after = _detect_broken_lazy_refresh_imports(install_cmd_prefix, env=env)
# Indeterminate re-probe is not confirmed success.
return after == []
def _repair_venv_via_import_probes(
install_cmd_prefix: list[str],
*,
env: dict[str, str] | None = None,
) -> str:
"""Probe imports and force-reinstall any broken lazy-refresh packages.
Uses real ``import`` checks (not distribution metadata) so a venv where
METADATA remains but ``.py`` files were wiped mid-install is still
detected (#57828). Package-only reinstall — never rewrites ``hermes.exe``.
Never raises. Returns one of:
- ``"healthy"`` — probes ran and found nothing broken
- ``"repaired"`` — probes found breakage and force-reinstall confirmed clean
- ``"failed"`` — probes found breakage and repair did not confirm clean
- ``"indeterminate"`` — probes could not run; do NOT treat as healthy
"""
broken = _detect_broken_lazy_refresh_imports(install_cmd_prefix, env=env)
if broken is None:
print(
" ⚠ Import probes unavailable — cannot confirm venv package health."
)
return "indeterminate"
if not broken:
return "healthy"
print(
" → Detected corrupted venv packages via import probes: "
f"{', '.join(broken)}; repairing..."
)
if _repair_broken_lazy_refresh_imports(
install_cmd_prefix, broken, env=env
):
print(" ✓ Venv repair succeeded")
return "repaired"
manual = " ".join(
shlex.quote(s) for s in _lazy_refresh_repair_specs(broken)
)
print(" ⚠ Venv repair incomplete. Run manually, then `hermes update`:")
print(
f" {' '.join(install_cmd_prefix)} install --force-reinstall {manual}"
)
return "failed"
def _install_python_dependencies_with_optional_fallback(
install_cmd_prefix: list[str],
*,
env: dict[str, str] | None = None,
group: str = "all",
) -> None:
"""Install base deps plus as many optional extras as the environment supports.
By default this targets ``.[all]``; Termux callers can pass
``group='termux-all'`` to use the curated Android-compatible profile.
On Windows, pre-renames live ``hermes.exe`` / ``hermes-gateway.exe`` shims
in the venv Scripts dir before each install attempt so uv can write fresh
copies (Windows blocks REPLACE on a running .exe but allows RENAME). See
``_quarantine_running_hermes_exe`` for the rationale.
"""
scripts_dir = _venv_scripts_dir() if _is_windows() else None
def _install(args: list[str]) -> None:
_run_quarantined_install(
install_cmd_prefix + args, env=env, scripts_dir=scripts_dir
)
try:
_install(["install", "-e", f".[{group}]"])
_verify_console_scripts_installed(install_cmd_prefix, env=env)
return
except subprocess.CalledProcessError:
print(
" ⚠ Optional extras failed, reinstalling base dependencies and retrying extras individually..."
)
_install(["install", "-e", "."])
failed_extras: list[str] = []
installed_extras: list[str] = []
for extra in _load_installable_optional_extras(group=group):
try:
_install(["install", "-e", f".[{extra}]"])
installed_extras.append(extra)
except subprocess.CalledProcessError:
failed_extras.append(extra)
if installed_extras:
print(
f" ✓ Reinstalled optional extras individually: {', '.join(installed_extras)}"
)
if failed_extras:
print(
f" ⚠ Skipped optional extras that still failed: {', '.join(failed_extras)}"
)
# Belt-and-suspenders: verify every declared core dependency from
# pyproject.toml's [project.dependencies] is actually importable in the
# target venv. uv's incremental resolver has — in the wild — produced
# partial installs where a newly added base dep (e.g. ``pathspec``)
# silently fails to land on top of a half-stale venv, and the only
# symptom is a downstream subprocess crashing with ModuleNotFoundError
# hours later inside ``hermes update``'s desktop-rebuild or skill-sync
# stage. Reinstall with --reinstall to force resolution if anything is
# missing, then re-verify so the failure surfaces here instead of
# downstream.
_verify_core_dependencies_installed(install_cmd_prefix, env=env, group=group)
_verify_console_scripts_installed(install_cmd_prefix, env=env)
def _load_console_script_names() -> list[str]:
"""Return ``[project.scripts]`` entry-point names from pyproject.toml."""
try:
import tomllib # Python 3.11+
except ImportError: # pragma: no cover
return []
pyproject = PROJECT_ROOT / "pyproject.toml"
if not pyproject.is_file():
return []
try:
with open(pyproject, "rb") as f:
data = tomllib.load(f)
scripts = data.get("project", {}).get("scripts", {}) or {}
return [str(name) for name in scripts if name]
except Exception as e:
logger.debug("console script verification: failed to read pyproject.toml: %s", e)
return []
def _verify_console_scripts_installed(
install_cmd_prefix: list[str],
*,
env: dict[str, str] | None = None,
) -> None:
"""Ensure every declared console_script shim exists on disk after install.
On Windows, ``uv pip install -e .`` can register ``hermes.exe`` in the
wheel RECORD while the file never lands on disk — typically when the live
``hermes.exe`` shim is locked during ``hermes update``, or when uv/distlib
skips a launcher write. The symptom is ``hermes-agent.exe`` and
``hermes-acp.exe`` present but ``hermes.exe`` missing, so ``hermes`` drops
off PATH even though the install reported success (issue #52931).
If any shim is missing we reinstall with ``--reinstall -e .`` under the
same quarantine dance as the primary install path, then re-check.
"""
if not _is_windows():
return
scripts_dir = _venv_scripts_dir()
if scripts_dir is None:
return
names = _load_console_script_names()
if not names:
return
def _missing() -> list[str]:
return [
name
for name in names
if not (scripts_dir / f"{name}.exe").is_file()
]
missing = _missing()
if not missing:
return
print(
f" ⚠ Verification: {len(missing)} console script(s) missing on disk: "
f"{', '.join(missing)}"
)
print(" → Reinstalling entry points with --reinstall...")
try:
_run_quarantined_install(
install_cmd_prefix + ["install", "--reinstall", "-e", "."],
env=env,
scripts_dir=scripts_dir,
)
except subprocess.CalledProcessError as e:
logger.warning("console script verification: repair install failed: %s", e)
print(
" ⚠ Entry point repair failed; try `hermes update --force` after "
"closing other hermes processes."
)
return
still_missing = _missing()
if still_missing:
print(
f" ⚠ Still missing after repair: {', '.join(still_missing)}. "
"Workaround: python -m hermes_cli.main <command>"
)
else:
print(" ✓ All console entry points restored")
def _verify_core_dependencies_installed(
install_cmd_prefix: list[str],
*,
env: dict[str, str] | None = None,
group: str = "all",
) -> None:
"""Check that every base dep from pyproject.toml is importable; if not, retry.
Reads ``pyproject.toml`` directly (so we don't trust the venv's stale
metadata), filters out deps gated by ``;`` environment markers that don't
apply to this platform, and runs ``importlib.metadata.version()`` in the
venv interpreter for each one. If anything is missing we reinstall the
base group with ``--reinstall`` to force uv to re-resolve, then check
again. We treat the final state as a warning rather than a hard failure
so a single broken-on-PyPI dep can't block an otherwise-successful
update — but the warning makes the partial install visible at the spot
that caused it, instead of hours later in a downstream subprocess.
"""
try:
import tomllib # Python 3.11+
except ImportError: # pragma: no cover — Python < 3.11 unsupported but be safe
return
pyproject = PROJECT_ROOT / "pyproject.toml"
if not pyproject.is_file():
return
try:
with open(pyproject, "rb") as f:
data = tomllib.load(f)
raw_deps = data.get("project", {}).get("dependencies", []) or []
except Exception as e:
logger.debug("dep verification: failed to read pyproject.toml: %s", e)
return
# Parse each "name OP version ; marker" string into (dist_name, marker_obj).
# We use packaging.requirements when available (it ships with pip/uv envs),
# falling back to a naive split that's good enough for the canonical
# ``name==version[; marker]`` style this repo uses.
deps: list[tuple[str, "object | None"]] = []
try:
from packaging.requirements import Requirement # type: ignore
for spec in raw_deps:
try:
req = Requirement(spec)
deps.append((req.name, req.marker))
except Exception:
continue
except Exception:
for spec in raw_deps:
head = spec.split(";", 1)[0]
for op in ("==", ">=", "<=", "~=", ">", "<", "!="):
if op in head:
head = head.split(op, 1)[0]
break
name = head.strip().split("[", 1)[0].strip()
if name:
deps.append((name, None))
# Apply environment markers to drop deps that don't apply on this platform
# (e.g. ``ptyprocess ; sys_platform != 'win32'`` is correctly skipped on
# Windows). Without markers we'd false-positive every cross-platform exclusion.
applicable: list[str] = []
for name, marker in deps:
if marker is None:
applicable.append(name)
continue
try:
if marker.evaluate(): # type: ignore[union-attr]
applicable.append(name)
except Exception:
applicable.append(name)
if not applicable:
return
# Run the check inside the venv Python — sys.executable here may be the
# outer Python that drove ``hermes update``, not the venv we just wrote
# to. The uv install_cmd_prefix encodes which environment we targeted
# (either ``[uv, pip]`` with VIRTUAL_ENV in env, or
# ``[sys.executable, -m, pip]`` for the in-process Python); resolve the
# right interpreter for the verification.
venv_python = _resolve_install_target_python(install_cmd_prefix, env)
if venv_python is None:
return
def _missing_deps() -> list[str]:
check_script = (
"import importlib.metadata as md, sys\n"
"missing=[]\n"
"for name in sys.argv[1:]:\n"
" try: md.version(name)\n"
" except md.PackageNotFoundError: missing.append(name)\n"
"print('\\n'.join(missing))\n"
)
try:
result = subprocess.run(
[str(venv_python), "-c", check_script, *applicable],
capture_output=True,
text=True, encoding="utf-8", errors="replace",
check=False,
env=env,
)
except Exception as e:
logger.debug("dep verification: subprocess failed: %s", e)
return []
return [line.strip() for line in result.stdout.splitlines() if line.strip()]
missing = _missing_deps()
if not missing:
return
print(
f" ⚠ Verification: {len(missing)} declared dep(s) missing after install: "
f"{', '.join(missing[:8])}{'...' if len(missing) > 8 else ''}"
)
print(" → Reinstalling base group with --reinstall to repair...")
# Reinstall base group with --reinstall so uv re-resolves from scratch
# against the current pyproject. We don't pass ``[{group}]`` here on
# purpose — the missing dep is in *base* deps; rerunning the full all-
# extras install can cost minutes and trips on whatever optional extra
# was already broken upstream. Base is fast and is what's actually wrong.
#
# Quarantine the running ``hermes.exe`` first: ``--reinstall -e .``
# rewrites the entry-point shims, and on Windows pip can't overwrite the
# live launcher, which would leave ``hermes`` off PATH.
scripts_dir = _venv_scripts_dir() if _is_windows() else None
repair_args = ["install", "--reinstall", "-e", "."]
try:
_run_quarantined_install(
install_cmd_prefix + repair_args, env=env, scripts_dir=scripts_dir
)
except subprocess.CalledProcessError as e:
logger.warning("dep verification: repair install failed: %s", e)
print(" ⚠ Repair install failed; check `hermes update` output above.")
return
still_missing = _missing_deps()
if not still_missing:
print(" ✓ All declared core dependencies now installed")
return
# Last-ditch: install each remaining missing dep with its pin directly.
# Useful when uv's resolver thinks the env is satisfied but the on-disk
# package metadata says otherwise (rare but observed).
name_to_spec = {}
for spec in raw_deps:
head = spec.split(";", 1)[0].strip()
bare = head
for op in ("==", ">=", "<=", "~=", ">", "<", "!="):
if op in bare:
bare = bare.split(op, 1)[0]
break
name_to_spec[bare.strip().split("[", 1)[0].strip()] = head
specs = [name_to_spec.get(n, n) for n in still_missing]
print(
f" → Force-installing remaining missing dep(s): {', '.join(specs)}"
)
try:
_run_install_with_heartbeat(
install_cmd_prefix + ["install", "--reinstall", *specs], env=env
)
except subprocess.CalledProcessError as e:
logger.warning("dep verification: per-package repair failed: %s", e)
print(
f" ⚠ Could not install: {', '.join(still_missing)}. "
"Run `hermes update --force` after closing other hermes processes."
)
return
final_missing = _missing_deps()
if final_missing:
print(
f" ⚠ Still missing after repair: {', '.join(final_missing)}. "
"Run `hermes update --force` after closing other hermes processes."
)
else:
print(" ✓ All declared core dependencies now installed")
def _resolve_install_target_python(
install_cmd_prefix: list[str], env: dict[str, str] | None
) -> Path | None:
"""Figure out which Python interpreter the install just targeted.
``_install_python_dependencies_with_optional_fallback`` is called with
either ``[uv, pip]`` (and a ``VIRTUAL_ENV`` env var pointing at the
target venv) or ``[sys.executable, -m, pip]`` (the in-process Python).
The verification step needs the *resulting* environment's Python so
``importlib.metadata`` queries the right site-packages.
"""
if env and "VIRTUAL_ENV" in env:
venv_root = Path(env["VIRTUAL_ENV"])
scripts = venv_root / ("Scripts" if _is_windows() else "bin")
candidate = scripts / ("python.exe" if _is_windows() else "python")
if candidate.exists():
return candidate
# Fallback: assume install_cmd_prefix[0] is the python interpreter (the
# ``[sys.executable, -m, pip]`` shape). Skip if it looks like ``uv``.
if install_cmd_prefix:
first = Path(install_cmd_prefix[0])
if first.exists() and "uv" not in first.name.lower():
return first
return None
def _is_termux_env(env: dict[str, str] | None = None) -> bool:
return _is_termux_startup_environment(env)
def _is_windows_npm_path(npm_path: str) -> bool:
"""Return True if ``npm_path`` points at a Windows npm shim.
On WSL the Windows install dir is exposed through the ``/mnt/c`` drive
mount and PATH interop, so ``shutil.which("npm")`` can hand back
``/mnt/c/Program Files/nodejs/npm`` (or the ``npm.cmd`` / ``npm.exe``
shim). Those are detected here by their ``.exe``/``.cmd``/``.bat``
suffix, a ``/mnt/`` drive-mount prefix, or an embedded backslash (a UNC
path). Callers use this only on a POSIX host — on native Windows an
``npm.cmd`` shim is the correct executable.
"""
low = npm_path.lower()
return (
low.endswith((".exe", ".cmd", ".bat"))
or low.startswith("/mnt/")
or "\\" in npm_path
)
def _resolve_node_runtime_npm() -> str | None:
"""Resolve an npm executable that belongs to the host's Node runtime.
On WSL/Linux ``shutil.which("npm")`` may resolve a Windows npm exposed
through PATH interop. Running that Windows npm against the Linux checkout
operates over ``\\wsl.localhost\\...`` UNC paths and fails with EISDIR /
symlink errors in symlink-heavy trees like ``ui-tui`` (#30271). Refuse a
Windows npm on a POSIX host and re-scan PATH (skipping ``/mnt/*`` interop
entries) for a Linux-native npm. Returns the npm path, or ``None`` when
no suitable npm is reachable.
"""
from hermes_constants import find_node_executable
npm = find_node_executable("npm")
# On native Windows the platform npm (``npm.cmd``) is exactly what we
# want — only reject Windows shims when we're a POSIX/WSL process.
if _is_windows():
return npm
if not npm:
return None
if not _is_windows_npm_path(npm):
return npm
# The first resolution was a Windows npm. Re-scan PATH skipping the
# ``/mnt/*`` Windows drive mounts WSL injects, so a Linux-native npm that
# came later on PATH is still found.
for directory in os.environ.get("PATH", "").split(os.pathsep):
if not directory or directory.lower().startswith("/mnt/"):
continue
candidate = shutil.which("npm", path=directory)
if candidate and not _is_windows_npm_path(candidate):
return candidate
return None
class _UpdateOutputStream:
"""Stream wrapper used during ``hermes update`` to survive terminal loss.
Wraps the process's original stdout/stderr so that:
* Every write is also mirrored to an append-only log file
(``~/.hermes/logs/update.log``) that users can inspect after the
terminal disconnects.
* Writes to the original stream that fail with ``BrokenPipeError`` /
``OSError`` / ``ValueError`` (closed file) no longer cascade into
process exit — the update keeps going, only the on-screen output
stops.
Combined with ``SIGHUP -> SIG_IGN`` installed by
``_install_hangup_protection``, this makes ``hermes update`` safe to
run in a plain SSH session that might disconnect mid-install.
"""
def __init__(self, original, log_file):
self._original = original
self._log = log_file
self._original_broken = False
def write(self, data):
# Mirror to the log file first — it's the most reliable destination.
if self._log is not None:
try:
self._log.write(data)
except Exception:
# Log errors should never abort the update.
pass
if self._original_broken:
return len(data) if isinstance(data, (str, bytes)) else 0
try:
return self._original.write(data)
except (BrokenPipeError, OSError, ValueError):
# Terminal vanished (SSH disconnect, shell close). Stop trying
# to write to it, but keep the update running.
self._original_broken = True
return len(data) if isinstance(data, (str, bytes)) else 0
def flush(self):
if self._log is not None:
try:
self._log.flush()
except Exception:
pass
if self._original_broken:
return
try:
self._original.flush()
except (BrokenPipeError, OSError, ValueError):
self._original_broken = True
def isatty(self):
if self._original_broken:
return False
try:
return self._original.isatty()
except Exception:
return False
def fileno(self):
# Some tools probe fileno(); defer to the underlying stream and let
# callers handle failures (same behaviour as the unwrapped stream).
return self._original.fileno()
def __getattr__(self, name):
return getattr(self._original, name)
def _install_hangup_protection(gateway_mode: bool = False):
"""Protect ``cmd_update`` from SIGHUP and broken terminal pipes.
Users commonly run ``hermes update`` in an SSH session or a terminal
that may close mid-install. Without protection, ``SIGHUP`` from the
terminal kills the Python process during ``pip install`` and leaves
the venv half-installed; the documented workaround ("use screen /
tmux") shouldn't be required for something as routine as an update.
Protections installed:
1. ``SIGHUP`` is set to ``SIG_IGN``. POSIX preserves ``SIG_IGN``
across ``exec()``, so pip and git subprocesses also stop dying on
hangup.
2. ``sys.stdout`` / ``sys.stderr`` are wrapped to mirror output to
``~/.hermes/logs/update.log`` and to silently absorb
``BrokenPipeError`` when the terminal vanishes.
``SIGINT`` (Ctrl-C) and ``SIGTERM`` (systemd shutdown) are
**intentionally left alone** — those are legitimate cancellation
signals the user or OS sent on purpose.
In gateway mode (``hermes update --gateway``) the update is already
spawned detached from a terminal, so this function is a no-op.
Returns a dict that ``cmd_update`` can pass to
``_finalize_update_output`` on exit. Returning a dict rather than a
tuple keeps the call site forward-compatible with future additions.
"""
state = {
"prev_stdout": sys.stdout,
"prev_stderr": sys.stderr,
"log_file": None,
"installed": False,
}
if gateway_mode:
return state
import signal as _signal
# (1) Ignore SIGHUP for the remainder of this process.
if hasattr(_signal, "SIGHUP"):
try:
_signal.signal(_signal.SIGHUP, _signal.SIG_IGN)
except (ValueError, OSError):
# Called from a non-main thread — not fatal. The update still
# runs, just without hangup protection.
pass
# (2) Mirror output to update.log and wrap stdio for broken-pipe
# tolerance. Any failure here is non-fatal; we just skip the wrap.
try:
# Late-bound import so tests can monkeypatch
# hermes_cli.config.get_hermes_home to simulate setup failure.
from hermes_cli.config import get_hermes_home as _get_hermes_home
logs_dir = _get_hermes_home() / "logs"
logs_dir.mkdir(parents=True, exist_ok=True)
log_path = logs_dir / "update.log"
log_file = open(log_path, "a", buffering=1, encoding="utf-8")
import datetime as _dt
log_file.write(
f"\n=== hermes update started "
f"{_dt.datetime.now().isoformat(timespec='seconds')} ===\n"
)
state["log_file"] = log_file
sys.stdout = _UpdateOutputStream(state["prev_stdout"], log_file)
sys.stderr = _UpdateOutputStream(state["prev_stderr"], log_file)
state["installed"] = True
except Exception:
# Leave stdio untouched on any setup failure. Update continues
# without mirroring.
state["log_file"] = None
return state
def _finalize_update_output(state):
"""Restore stdio and close the update.log handle opened by ``_install_hangup_protection``."""
if not state:
return
if state.get("installed"):
try:
sys.stdout = state.get("prev_stdout", sys.stdout)
except Exception:
pass
try:
sys.stderr = state.get("prev_stderr", sys.stderr)
except Exception:
pass
log_file = state.get("log_file")
if log_file is not None:
try:
log_file.flush()
log_file.close()
except Exception:
pass
def _resolve_update_branch(args) -> str:
"""Normalize ``args.branch`` into a non-empty branch name.
Centralizes the "default to main, accept --branch override, treat empty
or whitespace-only values as the default" parsing so every consumer of
``--branch`` (check path, git-update path, ZIP-fallback path) agrees on
the same answer.
"""
return (getattr(args, "branch", None) or "main").strip() or "main"
def _size_delta_label(saved_mb: float) -> str:
"""Human label for a before/after database size delta, in MB.
A negative delta means the file GREW — concurrent session writes during a
long optimize can outweigh what the rebuild freed. Printing
"reclaimed -163.0 MB" for that reads as data loss, so say "grew by"
instead.
"""
if saved_mb >= 0:
return f"reclaimed {saved_mb:.1f} MB"
return f"grew by {-saved_mb:.1f} MB"
def cmd_update(args):
"""Update Hermes Agent to the latest version.
Thin wrapper around ``_cmd_update_impl``: installs hangup protection,
runs the update, then restores stdio on the way out (even on
``sys.exit`` or unhandled exceptions).
"""
from hermes_cli.config import (
detect_install_method,
format_docker_update_message,
is_managed,
managed_error,
recommended_update_command_for_method,
)
if is_managed():
managed_error("update Hermes Agent")
return
# Docker users can't ``git pull`` — the image excludes ``.git`` from
# the build context. Bail with a friendly explanation pointing at
# ``docker pull`` BEFORE any of the apply-path / check-path branches
# below get a chance to error out with misleading "Not a git
# repository" text. See format_docker_update_message() for the full
# rationale and tag-pinning / config-persistence notes.
install_method = detect_install_method(PROJECT_ROOT)
if install_method == "docker":
print(format_docker_update_message())
sys.exit(1)
if install_method in {"nix", "nixos"}:
print(recommended_update_command_for_method(install_method))
sys.exit(1)
if getattr(args, "check", False):
# --check honors --branch so the "any new commits?" answer matches
# what a subsequent `hermes update --branch=<x>` would actually pull.
branch = _resolve_update_branch(args)
_cmd_update_check(
branch=branch,
branch_explicit=bool(getattr(args, "branch", None)),
)
return
gateway_mode = getattr(args, "gateway", False)
# Protect against mid-update terminal disconnects (SIGHUP) and tolerate
# writes to a closed stdout. No-op in gateway mode. See
# _install_hangup_protection for rationale.
_update_io_state = _install_hangup_protection(gateway_mode=gateway_mode)
try:
_cmd_update_impl(args, gateway_mode=gateway_mode)
finally:
_finalize_update_output(_update_io_state)
def _coalesce_session_name_args(argv: list) -> list:
"""Join unquoted multi-word session names after -c/--continue and -r/--resume.
When a user types ``hermes -c Pokemon Agent Dev`` without quoting the
session name, argparse sees three separate tokens. This function merges
them into a single argument so argparse receives
``['-c', 'Pokemon Agent Dev']`` instead.
Tokens are collected after the flag until we hit another flag (``-*``)
or a known top-level subcommand.
"""
_SUBCOMMANDS = {
"chat",
"model",
"gateway",
"setup",
"whatsapp",
"whatsapp-cloud",
"login",
"logout",
"auth",
"status",
"cron",
"doctor",
"config",
"pairing",
"skills",
"tools",
"mcp",
"sessions",
"insights",
"version",
"update",
"uninstall",
"profile",
"dashboard",
"serve",
"desktop",
"gui",
"honcho",
"claw",
"plugins",
"security",
"acp",
"webhook",
"memory",
"dump",
"debug",
"backup",
"import",
"completion",
"logs",
}
_SESSION_FLAGS = {"-c", "--continue", "-r", "--resume"}
result = []
i = 0
while i < len(argv):
token = argv[i]
if token in _SESSION_FLAGS:
result.append(token)
i += 1
# Collect subsequent non-flag, non-subcommand tokens as one name
parts: list = []
while (
i < len(argv)
and not argv[i].startswith("-")
and argv[i] not in _SUBCOMMANDS
):
parts.append(argv[i])
i += 1
if parts:
result.append(" ".join(parts))
else:
result.append(token)
i += 1
return result
def cmd_profile(args):
"""Profile management — create, delete, list, switch, alias."""
from hermes_cli.profiles import (
list_profiles,
create_profile,
delete_profile,
seed_profile_skills,
set_active_profile,
get_active_profile_name,
check_alias_collision,
create_wrapper_script,
remove_wrapper_script,
_is_wrapper_dir_in_path,
_get_wrapper_dir,
)
from hermes_constants import display_hermes_home
action = getattr(args, "profile_action", None)
if action is None:
# Bare `hermes profile` — show current profile status
profile_name = get_active_profile_name()
dhh = display_hermes_home()
print(f"\nActive profile: {profile_name}")
print(f"Path: {dhh}")
profiles = list_profiles()
for p in profiles:
if p.name == profile_name or (profile_name == "default" and p.is_default):
if p.model:
print(
f"Model: {p.model}"
+ (f" ({p.provider})" if p.provider else "")
)
print(
f"Gateway: {'running' if p.gateway_running else 'stopped'}"
)
print(f"Skills: {p.skill_count} installed")
if p.alias_path:
alias_display = p.alias_name or p.name
print(f"Alias: {alias_display} → hermes -p {p.name}")
break
print()
return
if action == "list":
profiles = list_profiles()
active = get_active_profile_name()
if not profiles:
print("No profiles found.")
return
# Header
print(
f"\n {'Profile':<16} {'Model':<28} {'Gateway':<12} "
f"{'Alias':<12} {'Distribution'}"
)
print(
f" {'' * 15} {'' * 27} {'' * 11} "
f"{'' * 11} {'' * 20}"
)
for p in profiles:
marker = (
""
if (p.name == active or (active == "default" and p.is_default))
else " "
)
name = p.name
model = (p.model or "")[:26]
gw = "running" if p.gateway_running else "stopped"
alias = (p.alias_name or p.name) if p.alias_path else ""
if p.is_default:
alias = ""
if p.distribution_name:
dist = f"{p.distribution_name}@{p.distribution_version or '?'}"
dist = dist[:30]
else:
dist = ""
print(f"{marker}{name:<15} {model:<28} {gw:<12} {alias:<12} {dist}")
print()
elif action == "use":
name = args.profile_name
try:
set_active_profile(name)
if name == "default":
print("Switched to: default (~/.hermes)")
else:
print(f"Switched to: {name}")
except (ValueError, FileNotFoundError) as e:
print(f"Error: {e}")
sys.exit(1)
elif action == "create":
name = args.profile_name
clone = getattr(args, "clone", False)
clone_all = getattr(args, "clone_all", False)
no_alias = getattr(args, "no_alias", False)
no_skills = getattr(args, "no_skills", False)
try:
clone_from = getattr(args, "clone_from", None)
clone_config = clone or clone_from is not None
profile_dir = create_profile(
name=name,
clone_from=clone_from,
clone_all=clone_all,
clone_config=clone_config,
no_alias=no_alias,
no_skills=no_skills,
description=getattr(args, "description", None),
)
print(f"\nProfile '{name}' created at {profile_dir}")
if clone_config or clone_all:
source_label = (
getattr(args, "clone_from", None) or get_active_profile_name()
)
if clone_all:
print(
f"Full copy from {source_label} "
"(excluding session history, backups, and snapshots)."
)
else:
print(
f"Cloned config, .env, SOUL.md, and skills from {source_label}."
)
# Auto-clone Honcho config for the new profile (only with clone operations)
if clone_config or clone_all:
try:
from plugins.memory.honcho.cli import clone_honcho_for_profile
if clone_honcho_for_profile(name):
print(f"Honcho config cloned (peer: {name})")
except Exception:
pass # Honcho plugin not installed or not configured
# Seed bundled skills for fresh profiles only. Clone operations
# already copied the source profile's skills, including any
# user-installed or intentionally removed skills.
if not (clone_config or clone_all):
result = seed_profile_skills(profile_dir)
if result and result.get("skipped_opt_out"):
print(
"No bundled skills seeded (--no-skills). "
"Delete .no-bundled-skills in the profile to opt back in."
)
elif result:
copied = len(result.get("copied", []))
print(f"{copied} bundled skills synced.")
else:
print(
"⚠ Skills could not be seeded. Run `{} update` to retry.".format(
name
)
)
# Create wrapper alias
if not no_alias:
collision = check_alias_collision(name)
if collision:
print(f"\n⚠ Cannot create alias '{name}'{collision}")
print(
f" Choose a custom alias: hermes profile alias {name} --name <custom>"
)
print(f" Or access via flag: hermes -p {name} chat")
else:
wrapper_path = create_wrapper_script(name)
if wrapper_path:
print(f"Wrapper created: {wrapper_path}")
if not _is_wrapper_dir_in_path():
print(f"\n{_get_wrapper_dir()} is not in your PATH.")
print(
" Add to your shell config (~/.bashrc or ~/.zshrc):"
)
print(' export PATH="$HOME/.local/bin:$PATH"')
# Profile dir for display
try:
profile_dir_display = "~/" + str(profile_dir.relative_to(Path.home()))
except ValueError:
profile_dir_display = str(profile_dir)
# Next steps
print("\nNext steps:")
print(f" {name} setup Configure API keys and model")
print(f" {name} chat Start chatting")
print(f" {name} gateway start Start the messaging gateway")
if clone or clone_all:
print(f"\n Edit {profile_dir_display}/.env for different API keys")
print(f" Edit {profile_dir_display}/SOUL.md for different personality")
else:
print(
f"\n ⚠ This profile has no API keys yet. Run '{name} setup' first,"
)
print(" or it will inherit keys from your shell environment.")
print(f" Edit {profile_dir_display}/SOUL.md to customize personality")
print()
except (ValueError, FileExistsError, FileNotFoundError) as e:
print(f"Error: {e}")
sys.exit(1)
elif action == "delete":
name = args.profile_name
yes = getattr(args, "yes", False)
try:
delete_profile(name, yes=yes)
except (ValueError, FileNotFoundError) as e:
print(f"Error: {e}")
sys.exit(1)
elif action == "describe":
# Read or write a profile's description. The description is
# consumed by the kanban decomposer to route tasks based on
# role instead of name alone.
from hermes_cli import profiles as _profiles_mod
all_flag = bool(getattr(args, "all_missing", False))
auto_flag = bool(getattr(args, "auto", False))
overwrite_flag = bool(getattr(args, "overwrite", False))
text_value = getattr(args, "text", None)
name = getattr(args, "profile_name", None)
if all_flag and not auto_flag:
print("profile describe: --all requires --auto", file=sys.stderr)
sys.exit(2)
if all_flag and (text_value or name):
print(
"profile describe: --all is mutually exclusive with a profile name / --text",
file=sys.stderr,
)
sys.exit(2)
if not all_flag and not name:
print("profile describe: profile name is required (or --all --auto)", file=sys.stderr)
sys.exit(2)
if text_value and auto_flag:
print(
"profile describe: --text is mutually exclusive with --auto",
file=sys.stderr,
)
sys.exit(2)
# Show current description if no operation requested.
if name and not text_value and not auto_flag:
try:
if _profiles_mod.normalize_profile_name(name) == "default":
from hermes_constants import get_hermes_home as _hh
profile_dir = Path(_hh())
else:
profile_dir = _profiles_mod.get_profile_dir(name)
except Exception as exc:
print(f"Error: {exc}", file=sys.stderr)
sys.exit(1)
if not profile_dir.is_dir():
print(f"Error: profile '{name}' not found", file=sys.stderr)
sys.exit(1)
meta = _profiles_mod.read_profile_meta(profile_dir)
desc = meta.get("description") or ""
if not desc:
print(f"(no description set for '{name}')")
else:
tag = "[auto] " if meta.get("description_auto") else ""
print(f"{tag}{desc}")
sys.exit(0)
# --text path: just write the user-authored description.
if text_value:
try:
if _profiles_mod.normalize_profile_name(name) == "default":
from hermes_constants import get_hermes_home as _hh
profile_dir = Path(_hh())
else:
profile_dir = _profiles_mod.get_profile_dir(name)
_profiles_mod.write_profile_meta(
profile_dir,
description=text_value,
description_auto=False,
)
print(f"Description updated for '{name}'.")
except Exception as exc:
print(f"Error: {exc}", file=sys.stderr)
sys.exit(1)
sys.exit(0)
# --auto path: invoke the LLM describer.
from hermes_cli import profile_describer as _pd
if all_flag:
targets = _pd.list_describable_profiles(missing_only=True)
if not targets:
print("All profiles already have descriptions.")
sys.exit(0)
else:
targets = [name]
ok_count = 0
fail_count = 0
for tgt in targets:
outcome = _pd.describe_profile(tgt, overwrite=overwrite_flag)
if outcome.ok:
ok_count += 1
print(f"Described '{outcome.profile_name}': {outcome.description}")
else:
fail_count += 1
print(
f"profile describe {outcome.profile_name}: {outcome.reason}",
file=sys.stderr,
)
if not all_flag:
sys.exit(0 if ok_count == 1 else 1)
sys.exit(0 if ok_count > 0 else 1)
elif action == "show":
name = args.profile_name
from hermes_cli.profiles import (
get_profile_dir,
profile_exists,
_read_config_model,
_check_gateway_running,
_count_skills,
_read_distribution_meta,
_get_wrapper_dir,
find_alias_for_profile,
)
if not profile_exists(name):
print(f"Error: Profile '{name}' does not exist.")
sys.exit(1)
profile_dir = get_profile_dir(name)
model, provider = _read_config_model(profile_dir)
gw = _check_gateway_running(profile_dir)
skills = _count_skills(profile_dir)
dist_name, dist_version, dist_source = _read_distribution_meta(profile_dir)
alias_name = find_alias_for_profile(name)
print(f"\nProfile: {name}")
print(f"Path: {profile_dir}")
if model:
print(f"Model: {model}" + (f" ({provider})" if provider else ""))
print(f"Gateway: {'running' if gw else 'stopped'}")
print(f"Skills: {skills}")
print(
f".env: {'exists' if (profile_dir / '.env').exists() else 'not configured'}"
)
print(
f"SOUL.md: {'exists' if (profile_dir / 'SOUL.md').exists() else 'not configured'}"
)
if dist_name:
print(f"Distribution: {dist_name}@{dist_version or '?'}")
if dist_source:
print(f"Installed from: {dist_source}")
print(f" (run `hermes profile info {name}` for full manifest)")
if alias_name:
is_windows = sys.platform == "win32"
wrapper = _get_wrapper_dir() / (f"{alias_name}.bat" if is_windows else alias_name)
print(f"Alias: {alias_name} → hermes -p {name} ({wrapper})")
print()
elif action == "alias":
name = args.profile_name
remove = getattr(args, "remove", False)
custom_name = getattr(args, "alias_name", None)
from hermes_cli.profiles import profile_exists, validate_alias_name
if not profile_exists(name):
print(f"Error: Profile '{name}' does not exist.")
sys.exit(1)
alias_name = custom_name or name
try:
validate_alias_name(alias_name)
except ValueError as exc:
print(f"Error: {exc}")
sys.exit(1)
if remove:
if remove_wrapper_script(alias_name):
print(f"✓ Removed alias '{alias_name}'")
else:
print(f"No alias '{alias_name}' found to remove.")
else:
collision = check_alias_collision(alias_name)
if collision:
print(f"Error: {collision}")
sys.exit(1)
wrapper_path = create_wrapper_script(
alias_name, target=name if custom_name else None
)
if wrapper_path:
print(f"✓ Alias created: {wrapper_path}")
if not _is_wrapper_dir_in_path():
print(f"{_get_wrapper_dir()} is not in your PATH.")
elif action == "rename":
from hermes_cli.profiles import rename_profile
try:
new_dir = rename_profile(args.old_name, args.new_name)
print(f"\nProfile renamed: {args.old_name}{args.new_name}")
print(f"Path: {new_dir}\n")
except (ValueError, FileExistsError, FileNotFoundError) as e:
print(f"Error: {e}")
sys.exit(1)
elif action == "export":
from hermes_cli.profiles import export_profile
name = args.profile_name
output = args.output or f"{name}.tar.gz"
try:
result_path = export_profile(name, output)
print(f"✓ Exported '{name}' to {result_path}")
except (ValueError, FileNotFoundError) as e:
print(f"Error: {e}")
sys.exit(1)
elif action == "import":
from hermes_cli.profiles import import_profile
try:
profile_dir = import_profile(
args.archive, name=getattr(args, "import_name", None)
)
name = profile_dir.name
print(f"✓ Imported profile '{name}' at {profile_dir}")
# Offer to create alias
collision = check_alias_collision(name)
if not collision:
wrapper_path = create_wrapper_script(name)
if wrapper_path:
print(f" Wrapper created: {wrapper_path}")
print()
except (ValueError, FileExistsError, FileNotFoundError) as e:
print(f"Error: {e}")
sys.exit(1)
elif action == "install":
import tempfile
from hermes_cli.profile_distribution import (
plan_install,
install_distribution,
DistributionError,
)
try:
# Preview: stage the distribution into a scratch dir, show the
# manifest, then do the real install. The double-stage avoids
# any side-effects if the user declines.
with tempfile.TemporaryDirectory(prefix="hermes_dist_preview_") as tmp:
plan = plan_install(
args.source,
Path(tmp),
override_name=getattr(args, "install_name", None),
)
_render_distribution_plan(plan)
if not getattr(args, "yes", False):
try:
answer = input("\nProceed with install? [y/N] ").strip().lower()
except (EOFError, KeyboardInterrupt):
answer = ""
if answer not in {"y", "yes"}:
print("Install cancelled.")
return
plan = install_distribution(
args.source,
name=getattr(args, "install_name", None),
force=getattr(args, "force", False),
create_alias=getattr(args, "alias", False),
)
print(f"\n✓ Installed '{plan.manifest.name}' v{plan.manifest.version}")
print(f" Profile path: {plan.target_dir}")
if plan.manifest.env_requires:
print(
f" Next: copy .env.EXAMPLE to .env and fill in required keys:\n"
f" {plan.target_dir}/.env.EXAMPLE"
)
if plan.has_cron:
print(
" Cron jobs were included but are NOT scheduled automatically.\n"
f" Review them with: hermes -p {plan.manifest.name} cron list"
)
print(f"\n Use with: hermes -p {plan.manifest.name} chat")
except (DistributionError, ValueError) as e:
print(f"Error: {e}")
sys.exit(1)
elif action == "update":
from hermes_cli.profile_distribution import (
update_distribution,
read_manifest,
DistributionError,
)
from hermes_cli.profiles import get_profile_dir, normalize_profile_name
name = args.profile_name
try:
canon = normalize_profile_name(name)
current = read_manifest(get_profile_dir(canon))
if current is None:
print(
f"Error: Profile '{canon}' is not a distribution (no distribution.yaml). "
"Only profiles installed via `hermes profile install` can be updated."
)
sys.exit(1)
force_config = getattr(args, "force_config", False)
if not getattr(args, "yes", False):
print(f"\nUpdate '{canon}' from: {current.source or '(no source)'}")
print(f" Currently at version {current.version}")
if force_config:
print(" --force-config set: config.yaml WILL be overwritten.")
else:
print(" config.yaml will be preserved (pass --force-config to overwrite).")
print(" User data (memories, sessions, auth, .env) will NOT be touched.")
try:
answer = input("\nProceed? [y/N] ").strip().lower()
except (EOFError, KeyboardInterrupt):
answer = ""
if answer not in {"y", "yes"}:
print("Update cancelled.")
return
plan = update_distribution(canon, force_config=force_config)
print(f"\n✓ Updated '{plan.manifest.name}' → v{plan.manifest.version}")
if plan.has_cron:
print(
" Cron files were refreshed. Review with: "
f"hermes -p {plan.manifest.name} cron list"
)
except (DistributionError, ValueError) as e:
print(f"Error: {e}")
sys.exit(1)
elif action == "info":
from hermes_cli.profile_distribution import describe_distribution, DistributionError
try:
data = describe_distribution(args.profile_name)
except (DistributionError, ValueError) as e:
print(f"Error: {e}")
sys.exit(1)
if not data:
print(
f"Profile '{args.profile_name}' is not a distribution "
"(no distribution.yaml)."
)
return
print(f"\nDistribution: {data.get('name')}")
print(f"Version: {data.get('version', '?')}")
if data.get("description"):
print(f"Description: {data['description']}")
if data.get("author"):
print(f"Author: {data['author']}")
if data.get("license"):
print(f"License: {data['license']}")
if data.get("hermes_requires"):
print(f"Requires: Hermes {data['hermes_requires']}")
if data.get("source"):
print(f"Source: {data['source']}")
if data.get("installed_at"):
print(f"Installed: {data['installed_at']}")
env_reqs = data.get("env_requires") or []
if env_reqs:
print("\nEnvironment variables:")
for er in env_reqs:
tag = "required" if er.get("required", True) else "optional"
line = f" {er['name']} ({tag})"
if er.get("description"):
line += f"{er['description']}"
print(line)
if er.get("default") is not None:
print(f" default: {er['default']}")
print()
def _render_distribution_plan(plan) -> None:
"""Print a human-readable summary of a pending distribution install."""
from hermes_cli.profile_distribution import MANIFEST_FILENAME
mf = plan.manifest
print(f"\nDistribution: {mf.name} v{mf.version}")
if mf.description:
print(f" {mf.description}")
if mf.author:
print(f" Author: {mf.author}")
if mf.hermes_requires:
print(f" Requires: Hermes {mf.hermes_requires}")
print(f" Source: {plan.provenance}")
print(f" Target: {plan.target_dir}")
if plan.existing:
# Distinguish "updating an existing distribution" (well-understood
# semantics — dist-owned overwritten, config preserved, user data
# untouched) from "overwriting a hand-built plain profile" (same
# mechanics but the user didn't sign up for this when they created
# the profile manually).
existing_is_distribution = (plan.target_dir / MANIFEST_FILENAME).is_file()
if existing_is_distribution:
print(" (profile exists — will overwrite distribution-owned files only)")
else:
print(
" ⚠ Profile exists but is NOT a distribution. Installing here will\n"
" overwrite its SOUL.md, skills/, cron/, and mcp.json.\n"
" Your memories, sessions, auth.json, and .env will be preserved,\n"
" but any hand-edits to distribution-owned files will be lost."
)
if mf.env_requires:
print("\n Env vars:")
for er in mf.env_requires:
tag = "required" if er.required else "optional"
# Check both the current shell environment and the target profile's
# .env file so we don't nag about keys the user already has set up.
already = os.environ.get(er.name) is not None
if not already and plan.target_dir.is_dir():
env_path = plan.target_dir / ".env"
if env_path.is_file():
try:
# .env is written as UTF-8 everywhere in the codebase,
# but a Notepad-edited file can carry a BOM — read as
# utf-8-sig so the first key isn't hidden behind
# U+FEFF (#62617).
for raw in env_path.read_text(encoding="utf-8-sig").splitlines():
line = raw.strip()
if not line or line.startswith("#"):
continue
key = line.split("=", 1)[0].strip()
if key == er.name:
already = True
break
except (OSError, UnicodeDecodeError):
# UnicodeDecodeError is a ValueError, not an OSError, so
# the old guard let a mis-encoded .env abort the whole
# install preview. Skip the pre-check instead.
pass
status = "✓ set" if already else ("needs setting" if er.required else "")
line = f"{er.name} ({tag}, {status})"
if er.description:
line += f"{er.description}"
print(line)
if plan.has_cron:
print(
"\n ⚠ This distribution ships cron jobs. They will NOT run "
"automatically — review and enable manually."
)
def _report_dashboard_status() -> int:
"""Print live listening dashboard processes and return the count."""
from gateway.status import _pid_exists
live: list[tuple[int, str]] = []
for pid, command in _scan_dashboard_processes():
runtime = _parse_dashboard_runtime(command)
if runtime is None:
continue
mode, host, port = runtime
if mode != "dashboard":
continue
if port <= 0 or not _pid_exists(pid):
continue
if not _dashboard_listening(host, port):
continue
live.append((pid, command))
if not live:
print("No hermes dashboard processes running.")
return 0
print(f"{len(live)} hermes dashboard process(es) running:")
for pid, command in live:
print(f" PID {pid}: {command}")
return len(live)
def _dashboard_listening(host: str, port: int) -> bool:
"""True when something is accepting TCP connections at host:port.
Any listener counts — even a 401 response proves a dashboard is up.
Used by the unified profile-launch routing to decide attach-vs-start.
"""
import socket
try:
with socket.create_connection((_dashboard_probe_host(host), port), timeout=1.5):
return True
except OSError:
return False
def _maybe_setup_dashboard_auth_interactively(args) -> None:
"""Offer to configure dashboard auth when a non-loopback bind has none.
Called from ``cmd_dashboard`` just before ``start_server``. The auth
gate engages on every non-loopback bind (``--insecure`` is a no-op since
the June 2026 hardening), and ``start_server`` fails closed when no
``DashboardAuthProvider`` is registered. Rather than greet an interactive
operator with that hard error, prompt them to set up the bundled
username/password provider on the spot — or point them at
``hermes dashboard register`` for OAuth.
No-ops (so the existing fail-closed ``SystemExit`` remains the backstop)
when:
* the bind is loopback (gate never engages), or
* a provider is already registered, or
* stdin/stdout isn't a TTY (Docker/s6, CI, piped ``--no-open`` runs).
"""
host = getattr(args, "host", "127.0.0.1") or "127.0.0.1"
try:
from hermes_cli.web_server import should_require_auth
if not should_require_auth(host):
return # loopback bind — gate never engages
except Exception:
return # if we can't tell, defer to start_server's own gate
try:
from hermes_cli.dashboard_auth import list_providers
if list_providers():
return # a provider is already configured/registered
except Exception:
return
# Only prompt an interactive operator. Non-TTY callers fall through to
# start_server's fail-closed SystemExit (with the corrected fix hint).
if not (sys.stdin.isatty() and sys.stdout.isatty()):
return
print()
print(
f"⚠ The dashboard is binding to a non-loopback address ({host}) and "
f"needs an auth provider."
)
print(
" Non-loopback binds always require authentication "
"(--insecure no longer bypasses this)."
)
print()
print(" How do you want to authenticate the dashboard?")
print(" [1] Username & password (quickest; for a trusted LAN / VPN)")
print(" [2] OAuth via Nous Portal (run `hermes dashboard register`)")
print(" [3] Cancel")
print()
try:
choice = input(" Choice [1]: ").strip() or "1"
except (EOFError, KeyboardInterrupt):
print("\n Cancelled.")
sys.exit(1)
if choice == "2":
print()
print(
" Run this on the host where the dashboard lives, then start "
"the dashboard again:\n"
" hermes dashboard register\n"
" It provisions a Nous Portal OAuth client and writes "
"HERMES_DASHBOARD_OAUTH_CLIENT_ID into ~/.hermes/.env for you.\n"
" Docs: https://hermes-agent.nousresearch.com/docs/"
"user-guide/features/web-dashboard#authentication-gated-mode"
)
sys.exit(0)
if choice not in ("1",):
print(" Cancelled.")
sys.exit(1)
# ── Username/password setup ──────────────────────────────────────────
import getpass
import secrets
print()
try:
username = input(" Username [admin]: ").strip() or "admin"
password = getpass.getpass(" Password: ")
confirm = getpass.getpass(" Confirm password: ")
except (EOFError, KeyboardInterrupt):
print("\n Cancelled.")
sys.exit(1)
if not password:
print(" ✗ Empty password — aborting.")
sys.exit(1)
if password != confirm:
print(" ✗ Passwords don't match — aborting.")
sys.exit(1)
try:
from plugins.dashboard_auth.basic import hash_password
except Exception as exc:
print(f" ✗ Could not load the password provider: {exc}")
sys.exit(1)
password_hash = hash_password(password)
# A stable token-signing secret so sessions survive a dashboard restart.
secret = secrets.token_urlsafe(32)
try:
from hermes_cli.config import load_config, save_config
from hermes_cli.plugins_cmd import ensure_basic_auth_plugin_enabled_in_config
cfg = load_config()
dash = cfg.setdefault("dashboard", {})
basic = dash.setdefault("basic_auth", {})
basic["username"] = username
basic["password_hash"] = password_hash
# Never persist plaintext: clear any stale plaintext password key.
basic["password"] = ""
if not str(basic.get("secret", "") or "").strip():
basic["secret"] = secret
# The bundled basic provider is a backend plugin that still honours
# plugins.disabled. Unblock it when we just wrote basic_auth so the
# discover_plugins(force=True) call below can register the provider
# (#54489). Surface the mutation so an operator who deliberately
# disabled it isn't surprised.
if ensure_basic_auth_plugin_enabled_in_config(cfg):
print(
" ✓ Re-enabled the bundled 'basic' auth plugin "
"(was in plugins.disabled)"
)
save_config(cfg)
except Exception as exc:
print(f" ✗ Failed to write config.yaml: {exc}")
sys.exit(1)
# Re-run plugin discovery so the basic provider registers from the
# just-written config before start_server's gate check runs.
try:
from hermes_cli.plugins import discover_plugins
discover_plugins(force=True)
except Exception as exc:
print(f" ⚠ Plugin re-discovery failed ({exc}); the gate may still "
"fail closed. Set the password again or restart the dashboard.")
print()
print(f" ✓ Username/password auth configured (user: {username}).")
print(" Saved to config.yaml under dashboard.basic_auth.")
print(" Sign in at the dashboard with these credentials.")
print()
def _read_ssh_session_token_file(path: str) -> str:
"""Read and unlink a Desktop SSH token from its private runtime directory."""
if sys.platform == "win32":
from hermes_cli.windows_ssh_runtime import read_token
return read_token(path)
import stat as _stat
from pathlib import Path as _Path
from hermes_constants import get_hermes_home as _get_hermes_home
if not os.path.isabs(path):
raise SystemExit("--ssh-session-token-file must be absolute")
token_path = _Path(path)
token_root = _get_hermes_home() / "desktop-ssh"
try:
relative = token_path.relative_to(token_root)
except ValueError as exc:
raise SystemExit("--ssh-session-token-file must be under the desktop-ssh directory") from exc
if len(relative.parts) != 2 or not re.fullmatch(r"[0-9a-f]{32}", relative.parts[0]):
raise SystemExit("--ssh-session-token-file has an invalid runtime path")
if not re.fullmatch(r"[0-9a-f]{16}\.token", relative.parts[1]):
raise SystemExit("--ssh-session-token-file has an invalid filename")
directory_flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0)
file_flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
root_fd = -1
directory_fd = -1
file_fd = -1
try:
try:
root_fd = os.open(token_root, directory_flags)
root_stat = os.fstat(root_fd)
if not _stat.S_ISDIR(root_stat.st_mode):
raise SystemExit("--ssh-session-token-file has an unsafe runtime root")
if hasattr(os, "getuid") and root_stat.st_uid != os.getuid():
raise SystemExit("--ssh-session-token-file runtime root has the wrong owner")
directory_fd = os.open(relative.parts[0], directory_flags, dir_fd=root_fd)
directory_stat = os.fstat(directory_fd)
if not _stat.S_ISDIR(directory_stat.st_mode):
raise SystemExit("--ssh-session-token-file has an unsafe parent directory")
if hasattr(os, "getuid") and directory_stat.st_uid != os.getuid():
raise SystemExit("--ssh-session-token-file parent has the wrong owner")
if (directory_stat.st_mode & 0o777) != 0o700:
raise SystemExit("--ssh-session-token-file parent has unsafe permissions")
file_fd = os.open(relative.parts[1], file_flags, dir_fd=directory_fd)
except SystemExit:
raise
except OSError as exc:
if exc.errno == getattr(__import__("errno"), "ELOOP", -1):
raise SystemExit("--ssh-session-token-file is a symlink") from exc
raise SystemExit("--ssh-session-token-file is not accessible") from exc
file_stat = os.fstat(file_fd)
if not _stat.S_ISREG(file_stat.st_mode):
raise SystemExit("--ssh-session-token-file is not a regular file")
if file_stat.st_size != 64:
raise SystemExit("--ssh-session-token-file contains an invalid token")
if hasattr(os, "getuid") and file_stat.st_uid != os.getuid():
raise SystemExit("--ssh-session-token-file has the wrong owner")
if hasattr(os, "getuid") and (file_stat.st_mode & 0o777) & ~0o600:
raise SystemExit("--ssh-session-token-file has unsafe permissions")
with os.fdopen(file_fd, "r") as token_stream:
file_fd = -1
token = token_stream.read(65)
if not re.fullmatch(r"[0-9a-f]{64}", token):
raise SystemExit("--ssh-session-token-file contains an invalid token")
return token
finally:
if file_fd >= 0:
os.close(file_fd)
if directory_fd >= 0:
try:
os.unlink(relative.parts[1], dir_fd=directory_fd)
except OSError:
pass
os.close(directory_fd)
if root_fd >= 0:
os.close(root_fd)
def _is_electron_packaged_web_dist(path: str) -> bool:
"""True when *path* looks like an Electron-packaged renderer dist.
Packaged Desktop sets ``HERMES_WEB_DIST`` to ``.../app.asar/dist`` or
``.../app.asar.unpacked/dist``. A standalone ``hermes dashboard`` that
inherits that value serves the desktop frontend in the browser
(issue #52945 — "Desktop IPC bridge is unavailable").
"""
if not path:
return False
# Both app.asar and app.asar.unpacked contain this marker; normalize
# separators so Windows paths match too.
return "app.asar" in path.replace("\\", "/")
def cmd_dashboard(args):
"""Start the web UI server, or (with --stop/--status) manage running ones."""
_token_file = getattr(args, "ssh_session_token_file", None)
if _token_file and (
getattr(args, "status", False) or getattr(args, "stop", False)
):
raise SystemExit("--ssh-session-token-file cannot be used with --status or --stop")
# --status: report running dashboards and exit, no deps needed.
if getattr(args, "status", False):
count = _report_dashboard_status()
sys.exit(0 if count == 0 else 0) # status is informational, always 0
# --stop: kill any running dashboards and exit, no deps needed.
if getattr(args, "stop", False):
pids = _find_stale_dashboard_pids()
if not pids:
print("No hermes dashboard processes running.")
sys.exit(0)
# Reuse the same SIGTERM-grace-SIGKILL path used after `hermes update`.
_kill_stale_dashboard_processes(reason="requested via --stop")
# _kill_stale_dashboard_processes prints outcomes itself. Exit 0 if
# we killed at least one, 1 if they were all unkillable.
remaining = _find_stale_dashboard_pids()
sys.exit(1 if remaining else 0)
# `serve` is the headless backend: no UI build, no SPA mount, neutral
# ready sentinel. Resolved once and threaded through the re-exec, the
# build gate, and start_server.
_headless_backend = getattr(args, "headless_backend", False)
_ssh_owner_nonce = getattr(args, "ssh_owner_nonce", None)
if _ssh_owner_nonce and not re.fullmatch(r"[0-9a-f]{16}", _ssh_owner_nonce):
raise SystemExit("--ssh-owner-nonce must be 16 lowercase hex characters")
_ssh_session_token = None
if _token_file and not _headless_backend:
raise SystemExit("--ssh-session-token-file is only valid with hermes serve")
# ── Sanitize Desktop-inherited env that hijacks a standalone launch ─
# Desktop Electron spawns its backend with HERMES_DESKTOP=1 plus
# HERMES_WEB_DIST=<packaged app.asar[/unpacked]/dist> (and often
# HERMES_SERVE_HEADLESS=1 on the serve path). A shell that inherits
# those vars then runs `hermes dashboard` would otherwise:
# - serve the desktop renderer → "Desktop IPC bridge is unavailable"
# (issue #52945), or
# - disable the SPA via inherited HERMES_SERVE_HEADLESS.
# Only strip Electron-packaged WEB_DIST contamination — caller-managed
# HERMES_WEB_DIST overrides (dev / custom builds) must still work.
# The desktop-spawned backend itself (HERMES_DESKTOP=1) keeps its dist.
# Intentionally headless `serve` re-sets HERMES_SERVE_HEADLESS below.
if os.environ.get("HERMES_DESKTOP") != "1":
_inherited_web_dist = os.environ.get("HERMES_WEB_DIST", "")
if _is_electron_packaged_web_dist(_inherited_web_dist):
os.environ.pop("HERMES_WEB_DIST", None)
if not _headless_backend:
os.environ.pop("HERMES_SERVE_HEADLESS", None)
# ── Unified profile launch routing ────────────────────────────────
# The dashboard is a MACHINE management surface: it can read/write any
# profile via the per-request ?profile= scoping. Running one dashboard
# per profile just fragments that (port collisions, N processes, and a
# "which dashboard am I on?" guessing game). So when a NAMED profile
# launches the dashboard (`worker dashboard` → HERMES_HOME points into
# profiles/), default to the machine dashboard:
# - already running → open the browser at ?profile=<name> and exit
# - not running → re-exec as the machine dashboard (pinned to the
# default profile so _apply_profile_override can't re-route through
# the sticky active_profile file) with the launching profile
# preselected in the UI's switcher.
# `--isolated` opts out and preserves the old per-profile behavior.
try:
from hermes_cli.profiles import get_active_profile_name
_launch_profile = get_active_profile_name()
except Exception:
_launch_profile = "default"
if (
_launch_profile not in ("default", "custom")
and not getattr(args, "isolated", False)
and not getattr(args, "open_profile", "")
# Desktop pool backends are intentionally per-profile.
and os.environ.get("HERMES_DESKTOP") != "1"
):
url = f"http://{args.host or '127.0.0.1'}:{args.port}/?profile={_launch_profile}"
if _dashboard_listening(args.host, args.port):
print(f"Machine dashboard already running on port {args.port}.")
print(f" Managing profile '{_launch_profile}': {url}")
if not args.no_open:
try:
import webbrowser
webbrowser.open(url)
except Exception:
pass
sys.exit(0)
print(
f"Routing to the machine dashboard (profile '{_launch_profile}' "
f"preselected). Use --isolated for a dedicated per-profile server."
)
reexec_argv = [
sys.executable, "-m", "hermes_cli.main",
"-p", "default",
# Preserve the lean serve path across the re-exec so a named-profile
# `serve` doesn't silently rebuild the UI as `dashboard`.
"serve" if _headless_backend else "dashboard",
"--port", str(args.port),
"--host", args.host,
"--open-profile", _launch_profile,
]
if _ssh_owner_nonce:
reexec_argv.extend(["--ssh-owner-nonce", _ssh_owner_nonce])
if _token_file:
reexec_argv.extend(["--ssh-session-token-file", _token_file])
if args.no_open:
reexec_argv.append("--no-open")
if getattr(args, "insecure", False):
reexec_argv.append("--insecure")
if getattr(args, "skip_build", False):
reexec_argv.append("--skip-build")
from tools.environments.local import build_subprocess_env
# Exact env preservation: HERMES_HOME is explicitly pinned to the
# machine root below — the factory must not re-inject a profile home.
env = build_subprocess_env(scrub_secrets=False, inherit_profile_home=False)
# Pin the child to the machine ROOT, not the launching profile's
# HERMES_HOME. We must resolve the root explicitly instead of just
# dropping HERMES_HOME: in the Docker layout the machine root is
# /opt/data (set via `ENV HERMES_HOME=/opt/data`), so an unset
# HERMES_HOME falls back to $HOME/.hermes = /opt/data/.hermes — an
# empty, auto-seeded home where the dashboard sees only the default
# profile and the install-method stamp is missing (so the Docker
# update-button guard also misfires). get_default_hermes_root()
# returns the root for both layouts: ~/.hermes for a standard install
# and /opt/data for Docker (it strips a trailing profiles/<name>).
# See the support report for the double-mount workaround this avoids.
try:
from hermes_constants import get_default_hermes_root
env["HERMES_HOME"] = str(get_default_hermes_root())
except Exception:
# Best-effort: if root resolution fails, fall back to the prior
# behaviour (drop HERMES_HOME) rather than block the reroute.
env.pop("HERMES_HOME", None)
# On Windows, os.execvpe() does not truly replace the process — it
# spawns via CreateProcess then the parent exits. Under Python 3.14+
# this can crash with STATUS_ACCESS_VIOLATION (0xC0000005) when
# re-executing the dashboard for a non-default profile. Use
# subprocess.Popen + sys.exit() on Windows to avoid the crash.
if sys.platform == "win32":
proc = subprocess.Popen(reexec_argv, env=env)
sys.exit(proc.wait())
else:
os.execvpe(sys.executable, reexec_argv, env)
if _token_file:
_ssh_session_token = _read_ssh_session_token_file(_token_file)
# Attach gui.log early so dashboard startup/build failures are captured in
# the same logs directory as every other Hermes surface.
try:
from hermes_logging import setup_logging as _setup_logging_gui
_setup_logging_gui(mode="gui")
except Exception:
pass
try:
import fastapi # noqa: F401
import uvicorn # noqa: F401
except ImportError as e:
print("Web UI dependencies not installed (need fastapi + uvicorn).")
print(
f"Re-install the package into this interpreter so metadata updates apply:\n"
f" cd {PROJECT_ROOT}\n"
f" {sys.executable} -m pip install -e .\n"
"If `pip` is missing in this venv, use: uv pip install -e ."
)
print(f"Import error: {e}")
sys.exit(1)
# Seed bundled skills on first dashboard launch so the desktop GUI's
# skills picker / agent skill discovery sees the bundled library.
# cmd_chat does this in its own pre-dispatch block; the dashboard
# backend is the desktop's primary entrypoint and needs the same.
_sync_bundled_skills_quietly()
# Bridge terminal.* config into the TERMINAL_* env vars for THIS process,
# mirroring the CLI (cli.py env_mappings) and gateway (gateway/run.py
# _terminal_env_map) startup bridges. The dashboard/serve backend runs
# agents in-process (tui_gateway.ws → server._make_agent) and ticks cron
# jobs itself when desktop-spawned — without this bridge those consumers
# saw an unset TERMINAL_ENV and silently ran every command on the host
# even when config.yaml selects `terminal.backend: docker`
# (#63141, #54449, #61115, #65696). PTY chat spawns already bridge their
# child env copy; this covers the in-process consumers.
try:
from hermes_cli.config import apply_terminal_config_to_env
apply_terminal_config_to_env()
except Exception:
logger.debug("terminal config → env bridge failed for dashboard/serve",
exc_info=True)
if _headless_backend:
# Don't build the SPA, and tell mount_spa() (read at web_server import
# below) to disable it even if a stray dist exists. Set it first.
os.environ["HERMES_SERVE_HEADLESS"] = "1"
elif "HERMES_WEB_DIST" not in os.environ and not getattr(args, "skip_build", False):
if not _build_web_ui(PROJECT_ROOT / "web", fatal=True):
sys.exit(1)
elif getattr(args, "skip_build", False):
# --build-mode skip trusts the caller to have pre-built the web UI.
# Verify the dist actually exists; otherwise the server will start
# and serve 404s with no obvious cause (issue #23817).
_dist_root = (
Path(os.environ["HERMES_WEB_DIST"])
if "HERMES_WEB_DIST" in os.environ
else PROJECT_ROOT / "hermes_cli" / "web_dist"
)
if not (_dist_root / "index.html").exists():
# The caller promised a pre-built dist but there isn't one.
# Instead of hard-failing (issue #59288 — desktop launches with
# --build-mode skip after a wipe of web_dist), warn and attempt
# ONE recovery build through the normal build path. Only the
# default dist location is recoverable: a custom HERMES_WEB_DIST
# points at a caller-managed directory the build cannot populate.
_recoverable = "HERMES_WEB_DIST" not in os.environ
if _recoverable:
print(f"⚠ --skip-build was passed but no web dist found at: {_dist_root}")
print(" Attempting one recovery build of the web UI...")
_build_web_ui(PROJECT_ROOT / "web", fatal=True)
if not (_dist_root / "index.html").exists():
print(f"✗ --skip-build was passed but no web dist found at: {_dist_root}")
if _recoverable:
print(" The recovery build did not produce a usable dist.")
print(" Pre-build first: npm install --workspace web && npm run build -w web")
print(" Or drop --skip-build to build automatically.")
sys.exit(1)
print(" ✓ Recovery build produced a web dist")
print(f"→ Skipping web UI build (--skip-build); using dist at {_dist_root}")
else:
# HERMES_WEB_DIST is set without --skip-build: the build is skipped
# (the env var points at a caller-managed dist), so validate it the
# same way the --skip-build branch does — otherwise the server starts
# and serves 404s with no obvious cause (same failure mode as #23817,
# via the env-var path).
_dist_root = Path(os.environ["HERMES_WEB_DIST"]).expanduser()
if not (_dist_root / "index.html").exists():
print(f"✗ HERMES_WEB_DIST is set but no web dist found at: {_dist_root}")
print(" Pre-build first: npm install --workspace web && npm run build -w web")
print(" Or unset HERMES_WEB_DIST to build and use the default web UI dist.")
sys.exit(1)
# Write the expanded path back: web_server reads HERMES_WEB_DIST raw
# at import (no expanduser), so a validated "~/dist" would otherwise
# pass here and still 404 there.
os.environ["HERMES_WEB_DIST"] = str(_dist_root)
print(f"→ Using web dist from HERMES_WEB_DIST: {_dist_root}")
# Discover and load plugins so any DashboardAuthProvider plugin
# (e.g. plugins/dashboard_auth/nous) registers BEFORE start_server's
# fail-closed gate check runs. The top-level argparse setup skips
# plugin discovery for built-in subcommands like ``dashboard`` to
# save ~500ms startup; we have to trigger it explicitly here because
# the dashboard's server-side runtime depends on plugin-registered
# providers (image_gen, web, dashboard_auth, …).
try:
from hermes_cli.plugins import discover_plugins
discover_plugins()
except Exception as exc:
# Discovery failures must not block dashboard startup outright —
# log and proceed; the gate's fail-closed branch will surface
# the missing-provider state if it matters.
print(f"⚠ Plugin discovery failed: {exc}", file=sys.stderr)
# Desktop chat uses the dashboard's in-process /api/ws gateway, which builds
# agents via tui_gateway.server._make_agent. That path only snapshots the
# tool registry — it never starts MCP discovery (the stdio TUI does that in
# tui_gateway/entry.py, which the dashboard process doesn't run). Without
# this, a profile's configured MCP servers never connect, so desktop
# sessions show no MCP tools. Spawn discovery in the background here so a
# slow/dead server can't block dashboard startup.
try:
from hermes_cli.mcp_startup import start_background_mcp_discovery
start_background_mcp_discovery(
logger=logger,
thread_name="dashboard-mcp-discovery",
)
except Exception:
logger.debug(
"Background MCP tool discovery failed at dashboard startup",
exc_info=True,
)
from hermes_cli.web_server import start_server
# Interactive auth setup: if this bind will engage the auth gate but no
# provider is registered yet, offer to configure one here (TTY only)
# instead of hard-failing inside start_server. Non-interactive callers
# (Docker/s6, CI, --no-open pipelines) fall through to start_server's
# fail-closed SystemExit unchanged.
_maybe_setup_dashboard_auth_interactively(args)
# The in-browser Chat tab (the embedded TUI over PTY/WebSocket) is always
# available — the desktop app and the dashboard's own Chat tab both rely on
# the `/api/ws` + `/api/pty` sockets, so there is no reason to gate them.
start_server(
host=args.host,
port=args.port,
open_browser=not args.no_open,
allow_public=getattr(args, "insecure", False),
initial_profile=getattr(args, "open_profile", "") or "",
headless=_headless_backend,
ssh_session_token=_ssh_session_token,
ssh_owner_nonce=_ssh_owner_nonce,
)
def cmd_dashboard_register(args):
"""Register a self-hosted dashboard OAuth client with Nous Portal."""
from hermes_cli.dashboard_register import cmd_dashboard_register as _impl
_impl(args)
def cmd_gateway_enroll(args):
"""Enroll a self-hosted gateway with a relay connector."""
from hermes_cli.gateway_enroll import cmd_gateway_enroll as _impl
_impl(args)
def cmd_completion(args, parser=None):
"""Print shell completion script."""
from hermes_cli.completion import generate_bash, generate_zsh, generate_fish
shell = getattr(args, "shell", "bash")
if shell == "zsh":
print(generate_zsh(parser))
elif shell == "fish":
print(generate_fish(parser))
else:
print(generate_bash(parser))
def cmd_prompt_size(args):
"""Show a byte/char breakdown of the system prompt + tool schemas."""
from hermes_cli.prompt_size import cmd_prompt_size as _impl
_impl(args)
def cmd_logs(args):
"""View and filter Hermes log files."""
from hermes_cli.logs import tail_log, list_logs
log_name = getattr(args, "log_name", "agent") or "agent"
if log_name == "list":
list_logs()
return
tail_log(
log_name,
num_lines=getattr(args, "lines", 50),
follow=getattr(args, "follow", False),
level=getattr(args, "level", None),
session=getattr(args, "session", None),
since=getattr(args, "since", None),
component=getattr(args, "component", None),
)
def cmd_console(args):
"""Open the safe Hermes command console."""
from hermes_cli.console_engine import run_console_repl
return run_console_repl()
def _build_provider_choices() -> list[str]:
"""Build the --provider choices list from CANONICAL_PROVIDERS + 'auto'."""
try:
from hermes_cli.models import CANONICAL_PROVIDERS as _cp
return ["auto"] + [p.slug for p in _cp]
except Exception:
# Fallback: static list guarantees the CLI always works
return [
"auto", "openrouter", "nous", "openai-codex", "xai-oauth", "copilot-acp", "copilot",
"anthropic", "gemini", "vertex", "xai", "bedrock", "azure-foundry",
"ollama-cloud", "huggingface", "zai", "kimi-coding", "kimi-coding-cn",
"stepfun", "minimax", "minimax-cn", "kilocode", "novita", "xiaomi", "arcee",
"nvidia", "deepseek", "alibaba", "qwen-oauth", "opencode-zen", "opencode-go",
]
# Top-level subcommands that argparse knows about WITHOUT running plugin
# discovery. Used to short-circuit eager plugin imports (which can take
# 500ms+ pulling in google.cloud.pubsub_v1, aiohttp, grpc, etc.) when the
# user's invocation clearly doesn't need any plugin-registered subcommand.
#
# Keep this in sync with the ``subparsers.add_parser("NAME", ...)`` calls
# below in ``main()``. Missing an entry here only costs a one-time
# discovery; extra entries here would let a plugin command silently fail
# to parse.
_BUILTIN_SUBCOMMANDS = frozenset(
{
"acp", "approvals", "auth", "backup", "bundles", "checkpoints", "claw", "completion",
"computer-use",
"config", "console", "cron", "curator", "dashboard", "serve", "debug", "doctor",
"dump", "egress", "fallback", "gateway", "hooks", "import", "import-agent", "insights",
"gui", "desktop", "kanban", "login", "logout", "logs", "lsp", "mcp", "memory", "migrate", "moa",
"journey", "memory-graph", "learning",
"model", "monitoring", "pairing", "pets", "plugins", "portal", "profile",
"project", "proxy",
"prompt-size",
"send", "sessions", "setup",
"skin", "skills", "slack", "status", "tools", "uninstall", "update",
"version", "webhook", "whatsapp", "whatsapp-cloud", "chat", "secrets", "security",
# Help-ish invocations — plugin commands not being listed in
# top-level --help is an acceptable trade-off for skipping an
# expensive eager import of every bundled plugin module.
"help",
}
)
# Top-level flags that take a value. Needed by ``_first_positional_argv``
# so that in ``hermes -m gpt5 chat``, ``gpt5`` is correctly skipped as a
# flag value rather than misclassified as a subcommand. Kept in sync with
# the top-level flags declared in ``hermes_cli/_parser.py``.
#
# Correctness-safe either way: missing an entry here only makes the
# fast-path bail out too eagerly (we run plugin discovery when we didn't
# need to); extra entries would make us skip a real positional.
_TOP_LEVEL_VALUE_FLAGS = frozenset(
{
"-z", "--oneshot",
"-m", "--model",
"--provider",
"-t", "--toolsets",
"-r", "--resume",
"-s", "--skills",
"--usage-file",
# ``-c / --continue`` is nargs='?' (optional value). Treat it as
# value-taking: if the next token is a subcommand-looking word
# the user almost certainly meant it as the session name, and
# either interpretation keeps us on the safe side.
"-c", "--continue",
}
)
def _first_positional_argv() -> str | None:
"""Return the first non-flag, non-flag-value token in ``sys.argv[1:]``.
Used by ``main()`` to decide whether plugin discovery has to run at
argparse-setup time. Handles common invocations like
``hermes -m gpt5 --provider openai chat "msg"`` by skipping the
values attached to known top-level flags.
Does NOT fully simulate argparse — unknown ``--foo=bar`` / ``--foo
bar`` flags degrade gracefully (``bar`` may be wrongly classified as
a positional, which at worst forces a one-time plugin discovery).
"""
argv = sys.argv[1:]
i = 0
while i < len(argv):
tok = argv[i]
if tok == "--":
# Everything after ``--`` is positional.
if i + 1 < len(argv):
return argv[i + 1]
return None
if tok.startswith("-"):
# ``--flag=value`` carries its value inline — single token.
if "=" in tok:
i += 1
continue
if tok in _TOP_LEVEL_VALUE_FLAGS and i + 1 < len(argv):
i += 2
continue
i += 1
continue
return tok
return None
def _plugin_cli_discovery_needed() -> bool:
"""True when the CLI might be invoking a plugin-registered subcommand.
Returning False lets ``main()`` skip plugin discovery entirely during
argparse setup, saving ~500-650ms per invocation for users whose
enabled plugins don't contribute any CLI command.
"""
first = _first_positional_argv()
if first is None:
# Bare ``hermes`` or only flags → defaults to ``chat``.
return False
if first in _BUILTIN_SUBCOMMANDS:
return False
# Unknown token — could be a plugin subcommand, OR a chat prompt
# starting with a non-flag word. Either way we need discovery: if it
# IS a plugin command, argparse needs the subparser; if it's a chat
# prompt, argparse will route it via positional handling and the
# extra discovery cost is amortized over a full agent run anyway.
return True
def _resolve_deferred_platform_cli_command(command_name: str | None) -> None:
"""Materialize the deferred platform whose top-level CLI command matches.
Bundled platform plugins are cheap-registered as *deferred* entries to
avoid importing every gateway SDK during normal startup. A platform that
registers a top-level ``hermes <name>`` command (e.g. Photon ->
``ctx.register_cli_command(name="photon", ...)``) only runs that side
effect when its module is imported. On the unknown-top-level-command slow
path, ``discover_plugins()`` records the deferred loader but does not
import it, so the CLI registration never happens and ``hermes photon``
fails with argparse ``invalid choice`` (issue #54678).
Resolving only the platform whose name matches the first positional token
keeps normal startup cheap while making the targeted command available.
"""
if not command_name:
return
try:
from gateway.platform_registry import platform_registry
platform_registry.get(command_name)
except Exception as exc:
logging.getLogger(__name__).debug(
"Deferred platform CLI resolution failed for %s: %s",
command_name,
exc,
)
_AGENT_COMMANDS = {None, "chat", "acp", "rl"}
_AGENT_SUBCOMMANDS = {
"cron": ("cron_command", {"run", "tick"}),
"gateway": ("gateway_command", {"run"}),
"mcp": ("mcp_action", {"serve"}),
}
def _is_tui_chat_launch(args) -> bool:
return bool(getattr(args, "tui", False) or os.environ.get("HERMES_TUI") == "1")
def _command_has_dedicated_mcp_startup(args) -> bool:
if args.command == "acp":
return True
if args.command == "gateway" and getattr(args, "gateway_command", None) == "run":
return True
if args.command == "cron" and getattr(args, "cron_command", None) in {"run", "tick"}:
return True
return False
def _should_background_mcp_startup(args) -> bool:
if _is_tui_chat_launch(args):
return False
return args.command in {None, "chat", "rl"}
def _prepare_agent_startup(args) -> None:
"""Discover plugins/MCP/hooks for commands that can run an agent turn."""
# --yolo: chokepoint guarantee that HERMES_YOLO_MODE is set before ANY
# plugin/tool discovery below imports tools.approval, which freezes
# _YOLO_MODE_FROZEN at import time (PR #7994 security design). main()'s
# dispatch path also sets this earlier, but _prepare_agent_startup() is
# reachable from other launchers too (e.g. the Termux fast-CLI path),
# so the guarantee lives here where the import is actually triggered
# (#60328).
if getattr(args, "yolo", False):
os.environ["HERMES_YOLO_MODE"] = "1"
_apply_safe_mode(args)
_sub_attr, _sub_set = _AGENT_SUBCOMMANDS.get(args.command, (None, None))
if not (
args.command in _AGENT_COMMANDS
or (_sub_attr and getattr(args, _sub_attr, None) in _sub_set)
):
return
_accept_hooks = bool(getattr(args, "accept_hooks", False))
try:
from hermes_cli.plugins import discover_plugins
discover_plugins()
except Exception:
logger.warning(
"plugin discovery failed at CLI startup",
exc_info=True,
)
_run_inline_mcp_discovery = True
if _is_tui_chat_launch(args):
# The TUI launcher hands off to a dedicated startup path that already
# backgrounds MCP discovery with a bounded join before the first tool
# snapshot.
_run_inline_mcp_discovery = False
elif _command_has_dedicated_mcp_startup(args):
# These entrypoints already do their own MCP startup later on the real
# runtime path (gateway executor, ACP launcher, cron job runner).
_run_inline_mcp_discovery = False
elif _should_background_mcp_startup(args):
try:
from hermes_cli.mcp_startup import start_background_mcp_discovery
start_background_mcp_discovery(
logger=logger,
thread_name="cli-mcp-discovery",
)
except Exception:
logger.debug(
"Background MCP tool discovery failed at CLI startup",
exc_info=True,
)
_run_inline_mcp_discovery = False
if _run_inline_mcp_discovery:
try:
# MCP tool discovery remains synchronous for entrypoints that do
# not own a later bounded/executor startup path.
from tools.mcp_tool import discover_mcp_tools
discover_mcp_tools()
except Exception:
logger.debug(
"MCP tool discovery failed at CLI startup",
exc_info=True,
)
try:
from hermes_cli.config import load_config
from agent.shell_hooks import register_from_config
register_from_config(load_config(), accept_hooks=_accept_hooks)
except Exception:
logger.debug(
"shell-hook registration failed at CLI startup",
exc_info=True,
)
def _apply_safe_mode(args) -> None:
if not getattr(args, "safe_mode", False):
return
os.environ["HERMES_SAFE_MODE"] = "1"
os.environ["HERMES_IGNORE_USER_CONFIG"] = "1"
os.environ["HERMES_IGNORE_RULES"] = "1"
def _set_chat_arg_defaults(args) -> None:
for attr, default in [
("query", None),
("model", None),
("provider", None),
("toolsets", None),
("verbose", False),
("resume", None),
("continue_last", None),
("worktree", False),
]:
if not hasattr(args, attr):
setattr(args, attr, default)
def _try_termux_fast_cli_launch() -> bool:
"""Run obvious Termux non-TUI chat/oneshot/version paths on a light parser."""
if not _is_termux_startup_environment():
return False
if os.environ.get("HERMES_TERMUX_DISABLE_FAST_CLI") == "1":
return False
argv = sys.argv[1:]
if "-h" in argv or "--help" in argv:
return False
# Let the TUI fast path (or full dispatch) handle anything that resolves to
# the TUI — explicit --tui/env or display.interface=tui. `--cli` forces this
# to stay False so the classic fast path still runs.
if _wants_tui_early(argv):
return False
if _is_termux_fast_version_argv(argv):
_print_version_info(check_updates=False)
return True
first = _first_positional_argv()
has_oneshot = any(
arg == "-z" or arg == "--oneshot" or arg.startswith("--oneshot=")
for arg in argv
)
if not has_oneshot and first not in {None, "chat"}:
return False
from hermes_cli._parser import build_top_level_parser
parser, _subparsers, chat_parser = build_top_level_parser()
chat_parser.set_defaults(func=cmd_chat)
args = parser.parse_args(_coalesce_session_name_args(argv))
if getattr(args, "version", False):
_print_version_info(check_updates=False)
return True
if getattr(args, "oneshot", None):
_prepare_agent_startup(args)
_run_and_exit_oneshot(
args.oneshot,
model=getattr(args, "model", None),
provider=getattr(args, "provider", None),
toolsets=getattr(args, "toolsets", None),
usage_file=getattr(args, "usage_file", None),
)
if (args.resume or args.continue_last) and args.command is None:
args.command = "chat"
if args.command in {None, "chat"}:
_set_chat_arg_defaults(args)
interactive_prompt = not getattr(args, "query", None) and not getattr(args, "image", None)
if interactive_prompt:
# Bare Termux CLI should reach the prompt first and do agent-only
# discovery on the first submitted turn instead of before input.
setattr(args, "compact", True)
os.environ["HERMES_DEFER_AGENT_STARTUP"] = "1"
os.environ["HERMES_FAST_STARTUP_BANNER"] = "1"
if getattr(args, "accept_hooks", False):
os.environ["HERMES_ACCEPT_HOOKS"] = "1"
else:
_prepare_agent_startup(args)
cmd_chat(args)
return True
return False
def _try_termux_fast_tui_launch() -> bool:
"""Launch obvious Termux TUI invocations before building every subparser.
`hermes --tui` is the hot path on phones. The full parser setup imports
command modules for model, fallback, migrate, kanban, bundles, plugins,
etc. even though the TUI immediately execs Node. On Termux only, parse the
lightweight top-level/chat parser and hand off to ``cmd_chat`` when the
invocation is unambiguously the built-in TUI/chat path.
"""
if not _is_termux_startup_environment():
return False
if "-h" in sys.argv[1:] or "--help" in sys.argv[1:]:
return False
wants_tui = _wants_tui_early(sys.argv[1:])
if not wants_tui:
return False
first = _first_positional_argv()
if first not in {None, "chat"}:
return False
from hermes_cli._parser import build_top_level_parser
parser, _subparsers, chat_parser = build_top_level_parser()
chat_parser.set_defaults(func=cmd_chat)
args = parser.parse_args(_coalesce_session_name_args(sys.argv[1:]))
# Preserve top-level behaviours whose semantics are not "launch chat/TUI".
if getattr(args, "version", False) or getattr(args, "oneshot", None):
return False
if getattr(args, "command", None) not in {None, "chat"}:
return False
if not _resolve_use_tui(args):
return False
cmd_chat(args)
return True
def cmd_memory(args):
sub = getattr(args, "memory_command", None)
if sub == "off":
from hermes_cli.config import load_config, save_config
config = load_config()
if not isinstance(config.get("memory"), dict):
config["memory"] = {}
config["memory"]["provider"] = ""
save_config(config)
print("\n ✓ Memory provider: built-in only")
print(" Saved to config.yaml\n")
elif sub == "reset":
from hermes_constants import get_hermes_home, display_hermes_home
mem_dir = get_hermes_home() / "memories"
target = getattr(args, "target", "all")
files_to_reset = []
if target in {"all", "memory"}:
files_to_reset.append(("MEMORY.md", "agent notes"))
if target in {"all", "user"}:
files_to_reset.append(("USER.md", "user profile"))
# Check what exists
existing = [
(f, desc) for f, desc in files_to_reset if (mem_dir / f).exists()
]
if not existing:
print(
f"\n Nothing to reset — no memory files found in {display_hermes_home()}/memories/\n"
)
return
print("\n This will permanently erase the following memory files:")
for f, desc in existing:
path = mem_dir / f
size = path.stat().st_size
print(f"{f} ({desc}) — {size:,} bytes")
if not getattr(args, "yes", False):
try:
answer = input("\n Type 'yes' to confirm: ").strip().lower()
except (EOFError, KeyboardInterrupt):
print("\n Cancelled.\n")
return
if answer != "yes":
print(" Cancelled.\n")
return
for f, desc in existing:
(mem_dir / f).unlink()
print(f" ✓ Deleted {f} ({desc})")
print(
"\n Memory reset complete. New sessions will start with a blank slate."
)
print(f" Files were in: {display_hermes_home()}/memories/\n")
else:
from hermes_cli.memory_setup import memory_command
memory_command(args)
def cmd_acp(args):
"""Launch Hermes Agent as an ACP server."""
try:
from acp_adapter.entry import main as acp_main
acp_argv = []
if getattr(args, "acp_version", False):
acp_argv.append("--version")
if getattr(args, "check", False):
acp_argv.append("--check")
if getattr(args, "setup", False):
acp_argv.append("--setup")
if getattr(args, "setup_browser", False):
acp_argv.append("--setup-browser")
if getattr(args, "assume_yes", False):
acp_argv.append("--yes")
acp_main(acp_argv)
except ImportError:
print("ACP dependencies not installed.", file=sys.stderr)
print("Install them with: pip install -e '.[acp]'", file=sys.stderr)
sys.exit(1)
def cmd_tools(args):
action = getattr(args, "tools_action", None)
if action in {"list", "disable", "enable"}:
from hermes_cli.tools_config import tools_disable_enable_command
tools_disable_enable_command(args)
elif action == "post-setup":
from hermes_cli.tools_config import run_post_setup_command
sys.exit(run_post_setup_command(args))
else:
_require_tty("tools")
from hermes_cli.tools_config import tools_command
tools_command(args)
def cmd_insights(args):
try:
from hermes_state import SessionDB
from agent.insights import InsightsEngine
db = SessionDB()
engine = InsightsEngine(db)
report = engine.generate(days=args.days, source=args.source)
print(engine.format_terminal(report))
db.close()
except Exception as e:
print(f"Error generating insights: {e}")
def cmd_monitoring(args):
"""Gateway monitoring status: health & diagnostics export posture."""
from hermes_cli.config import load_config
action = getattr(args, "monitoring_action", None) or "status"
config = load_config()
mon_raw = config.get("monitoring")
mon: dict = mon_raw if isinstance(mon_raw, dict) else {}
if action == "status":
from agent.monitoring import otlp_exporter
gh_raw = mon.get("gateway_health_export")
gh: dict = gh_raw if isinstance(gh_raw, dict) else {}
export_raw = mon.get("export")
export_cfg: dict = export_raw if isinstance(export_raw, dict) else {}
otlp_raw = export_cfg.get("otlp")
otlp: dict = otlp_raw if isinstance(otlp_raw, dict) else {}
print("Gateway monitoring")
print(f" Health export: {'enabled' if gh.get('enabled') else 'disabled'} "
f"(monitoring.gateway_health_export.enabled)")
if gh.get("enabled"):
print(f" Metrics: {'on' if gh.get('metrics_enabled', True) else 'off'} "
f"(interval {gh.get('export_interval_seconds', 60)}s)")
print(f" Diagnostic events: {'on' if gh.get('diagnostic_events_enabled', True) else 'off'}")
print(f" Warning/error logs: {'on' if gh.get('warning_error_events_enabled', True) else 'off'} "
f"(interval {gh.get('logs_export_interval_seconds', 5)}s)")
print(" Content safety: always on "
"(rendered messages are never exported; not configurable)")
endpoint = otlp.get("endpoint") or ""
if otlp.get("enabled") and endpoint:
print(f" OTLP endpoint: {endpoint}")
else:
print(" OTLP endpoint: not configured (monitoring.export.otlp)")
print(f" OTel SDK: {'installed' if otlp_exporter.is_available() else 'not installed'} "
f"(optional extra: hermes-agent[otlp])")
print("\n Scope: gateway service health + redacted diagnostics only.")
print(" No prompts, messages, tool args/results, usage analytics, or traces.")
return
print(f"Unknown monitoring action: {action}", file=sys.stderr)
sys.exit(2)
def cmd_skills(args):
# Route 'config' action to skills_config module
if getattr(args, "skills_action", None) == "config":
_require_tty("skills config")
from hermes_cli.skills_config import skills_command as skills_config_command
skills_config_command(args)
else:
from hermes_cli.skills_hub import skills_command
skills_command(args)
def cmd_pairing(args):
from hermes_cli.pairing import pairing_command
pairing_command(args)
def cmd_plugins(args):
from hermes_cli.plugins_cmd import plugins_command
plugins_command(args)
def cmd_mcp(args):
from hermes_cli.mcp_config import mcp_command
mcp_command(args)
def cmd_claw(args):
from hermes_cli.claw import claw_command
claw_command(args)
def main():
"""Main entry point for hermes CLI."""
# Cosmetic: make the process show up as 'hermes' instead of 'python3.11'
# in ps/top/htop. Non-fatal — just a nicer UX.
_set_process_title()
# Force UTF-8 stdio on Windows before anything prints. No-op elsewhere.
try:
from hermes_cli.stdio import configure_windows_stdio
configure_windows_stdio()
except Exception:
pass
# Sweep stale ``hermes.exe.old.*`` quarantine files left by previous
# ``hermes update`` runs on Windows. Silent no-op on non-Windows or when
# there's nothing to clean. See ``_quarantine_running_hermes_exe``.
try:
_cleanup_quarantined_exes()
except Exception:
pass
# If the checkout changed since the last launch (hermes update, manual
# git pull, old-updater update that predates newer clears), sweep stale
# __pycache__ once so no process — this one's lazy imports included —
# resolves fresh source against old bytecode. Never raises.
_sweep_stale_bytecode_if_checkout_changed()
# Self-heal a venv left half-built by an interrupted ``hermes update``
# (Ctrl-C, terminal close, WSL OOM mid-install). Skip when the user is
# *running* update — that flow writes and clears its own marker, and we
# don't want a recovery install racing the real one. Never raises.
#
# The substring match is deliberately loose: argv isn't parsed yet at this
# point, and the failure modes are asymmetric. Over-matching (e.g.
# ``hermes skills install update``) merely defers recovery one launch;
# under-matching (missing ``hermes -p work update``) would race a recovery
# install against the real one. Loose wins.
try:
if "update" not in sys.argv[1:]:
_recover_from_interrupted_install()
except Exception:
pass
if _try_termux_fast_tui_launch():
return
if _try_termux_fast_cli_launch():
return
from hermes_cli._parser import build_top_level_parser
parser, subparsers, chat_parser = build_top_level_parser()
chat_parser.set_defaults(func=cmd_chat)
# =========================================================================
# model command (parser built in hermes_cli/subcommands/model.py)
# =========================================================================
build_model_parser(subparsers, cmd_model=cmd_model)
from hermes_cli.moa_cmd import cmd_moa
moa_parser = subparsers.add_parser(
"moa",
help="Configure Mixture of Agents provider/model slots",
description="Configure the provider/model set used by /moa <prompt>.",
)
moa_subparsers = moa_parser.add_subparsers(dest="moa_command")
moa_subparsers.add_parser("list", aliases=["ls"], help="Show current MoA model slots")
moa_configure = moa_subparsers.add_parser("configure", aliases=["config"], help="Interactively pick MoA models")
moa_configure.add_argument("name", nargs="?", help="Preset name to create or update")
moa_delete = moa_subparsers.add_parser("delete", aliases=["rm"], help="Delete a MoA preset")
moa_delete.add_argument("name", help="Preset name to delete")
moa_parser.set_defaults(func=cmd_moa)
# =========================================================================
# fallback command — manage the fallback provider chain
# =========================================================================
from hermes_cli.fallback_cmd import cmd_fallback
fallback_parser = subparsers.add_parser(
"fallback",
help="Manage fallback providers (tried when the primary model fails)",
description=(
"Manage the fallback provider chain. Fallback providers are tried "
"in order when the primary model fails with rate-limit, overload, or "
"connection errors. See: "
"https://hermes-agent.nousresearch.com/docs/user-guide/features/fallback-providers"
),
)
fallback_subparsers = fallback_parser.add_subparsers(dest="fallback_command")
fallback_subparsers.add_parser(
"list",
aliases=["ls"],
help="Show the current fallback chain (default when no subcommand)",
)
fallback_subparsers.add_parser(
"add",
help="Pick a provider + model (same picker as `hermes model`) and append to the chain",
)
fallback_subparsers.add_parser(
"remove",
aliases=["rm"],
help="Pick an entry to delete from the chain",
)
fallback_subparsers.add_parser(
"clear",
help="Remove all fallback entries",
)
fallback_parser.set_defaults(func=cmd_fallback)
# =========================================================================
# secrets command — external secret managers (Bitwarden, 1Password)
# =========================================================================
secrets_parser = subparsers.add_parser(
"secrets",
help="Manage external secret sources (Bitwarden, 1Password)",
description=(
"Pull API keys from an external secret manager at process startup "
"instead of storing them in ~/.hermes/.env. Supports Bitwarden "
"Secrets Manager and 1Password. See: "
"https://hermes-agent.nousresearch.com/docs/user-guide/secrets/"
),
)
secrets_subparsers = secrets_parser.add_subparsers(dest="secrets_command")
secrets_bw = secrets_subparsers.add_parser(
"bitwarden",
aliases=["bw"],
help="Bitwarden Secrets Manager integration",
)
secrets_op = secrets_subparsers.add_parser(
"onepassword",
aliases=["op", "1password"],
help="1Password (op:// references) integration",
)
# Lazy import — only pays for itself when this subcommand is actually used.
from hermes_cli import secrets_cli as _secrets_cli
from hermes_cli import onepassword_secrets_cli as _op_secrets_cli
_secrets_cli.register_cli(secrets_bw)
_op_secrets_cli.register_cli(secrets_op)
def _dispatch_secrets(args): # noqa: ANN001
sub = getattr(args, "secrets_command", None)
bw_sub = getattr(args, "secrets_bw_command", None)
op_sub = getattr(args, "secrets_op_command", None)
if sub in ("bitwarden", "bw") and bw_sub is not None:
return args.func(args)
if sub in ("onepassword", "op", "1password") and op_sub is not None:
return args.func(args)
secrets_parser.print_help()
return 0
secrets_parser.set_defaults(func=_dispatch_secrets)
# =========================================================================
# egress command — iron-proxy outbound credential-injection firewall
# =========================================================================
# NOTE: this is the OUTBOUND egress firewall (ironsh/iron-proxy).
# `hermes proxy` (defined elsewhere in this file) is a separate INBOUND
# OAuth-aggregator reverse proxy. Different direction, different purpose.
egress_parser = subparsers.add_parser(
"egress",
help="Manage the iron-proxy egress credential-injection firewall",
description=(
"Manage iron-proxy, the optional TLS-intercepting egress firewall "
"that swaps proxy tokens for real API credentials before outbound "
"requests leave a sandbox. Disabled by default. See: "
"https://hermes-agent.nousresearch.com/docs/user-guide/egress/iron-proxy"
),
)
from hermes_cli import proxy_cli as _proxy_cli
_proxy_cli.register_cli(egress_parser)
def _dispatch_egress(args): # noqa: ANN001
# The egress subparser uses dest='egress_command' to stay disjoint
# from the inbound OAuth ``hermes proxy`` subparser (dest='proxy_command').
sub = getattr(args, "egress_command", None)
if sub is not None and hasattr(args, "func") and args.func is not _dispatch_egress:
return args.func(args)
egress_parser.print_help()
return 0
egress_parser.set_defaults(func=_dispatch_egress)
# =========================================================================
# migrate command
# =========================================================================
from hermes_cli.migrate import cmd_migrate, cmd_migrate_xai
migrate_parser = subparsers.add_parser(
"migrate",
help="Migrate configuration for retired models or deprecated settings",
description=(
"Diagnose and (optionally) rewrite the active config.yaml to "
"replace references to retired models or deprecated settings."
),
)
migrate_subparsers = migrate_parser.add_subparsers(dest="migrate_type")
migrate_xai = migrate_subparsers.add_parser(
"xai",
help="Migrate xAI models scheduled for retirement on May 15, 2026",
description=(
"Scan config.yaml for references to xAI models retiring on "
"May 15, 2026 and, with --apply, rewrite them in-place to the "
"official replacements per the xAI migration guide. The original "
"config.yaml is backed up before any rewrite."
),
)
migrate_xai.add_argument(
"--apply",
action="store_true",
help="Rewrite config.yaml in-place (default: dry-run, no writes)",
)
migrate_xai.add_argument(
"--no-backup",
action="store_true",
help="Skip the timestamped backup of config.yaml when applying",
)
migrate_xai.set_defaults(func=cmd_migrate_xai)
migrate_parser.set_defaults(func=cmd_migrate)
# =========================================================================
# gateway + proxy commands (parsers built in hermes_cli/subcommands/gateway.py)
# =========================================================================
build_gateway_parser(
subparsers, cmd_gateway=cmd_gateway, cmd_proxy=cmd_proxy, cmd_gateway_enroll=cmd_gateway_enroll
)
# =========================================================================
# lsp command
# =========================================================================
try:
from agent.lsp.cli import register_subparser as _lsp_register
_lsp_register(subparsers)
except Exception as _lsp_err: # noqa: BLE001
# LSP is optional infrastructure — never let a registration
# failure break the CLI overall.
logger.debug("LSP CLI registration failed: %s", _lsp_err)
# =========================================================================
# setup command (parser built in hermes_cli/subcommands/setup.py)
# =========================================================================
build_setup_parser(subparsers, cmd_setup=cmd_setup)
# =========================================================================
# whatsapp command (parser built in hermes_cli/subcommands/whatsapp.py)
# =========================================================================
build_whatsapp_parser(subparsers, cmd_whatsapp=cmd_whatsapp)
# =========================================================================
# whatsapp-cloud command (official Meta Cloud API; complement to Baileys)
# =========================================================================
whatsapp_cloud_parser = subparsers.add_parser(
"whatsapp-cloud",
help="Set up WhatsApp Business Cloud API integration",
description=(
"Configure the official Meta WhatsApp Business Cloud API "
"adapter (Business account required, public webhook URL "
"required). Distinct from `hermes whatsapp` which sets up "
"the Baileys bridge for personal accounts."
),
)
whatsapp_cloud_parser.set_defaults(func=cmd_whatsapp_cloud)
# =========================================================================
# slack command (parser built in hermes_cli/subcommands/slack.py)
# =========================================================================
build_slack_parser(subparsers, cmd_slack=cmd_slack)
# =========================================================================
# send command — pipe shell-script output to any configured platform
# =========================================================================
from hermes_cli.send_cmd import register_send_subparser
register_send_subparser(subparsers)
# =========================================================================
# login command (parser built in hermes_cli/subcommands/login.py)
# =========================================================================
build_login_parser(subparsers, cmd_login=cmd_login)
# =========================================================================
# logout command (parser built in hermes_cli/subcommands/logout.py)
# =========================================================================
build_logout_parser(subparsers, cmd_logout=cmd_logout)
# =========================================================================
# auth command (parser built in hermes_cli/subcommands/auth.py)
# =========================================================================
build_auth_parser(subparsers, cmd_auth=cmd_auth)
# =========================================================================
# status command (parser built in hermes_cli/subcommands/status.py)
# =========================================================================
build_status_parser(subparsers, cmd_status=cmd_status)
# =========================================================================
# cron command (parser built in hermes_cli/subcommands/cron.py)
# =========================================================================
build_cron_parser(subparsers, cmd_cron=cmd_cron)
# =========================================================================
# webhook command (parser built in hermes_cli/subcommands/webhook.py)
# =========================================================================
build_webhook_parser(subparsers, cmd_webhook=cmd_webhook)
# =========================================================================
# portal command — Nous Portal status + Tool Gateway routing
# =========================================================================
from hermes_cli.portal_cli import add_parser as _add_portal_parser
_add_portal_parser(subparsers)
# =========================================================================
# kanban command — multi-profile collaboration board
# =========================================================================
from hermes_cli.kanban import build_parser as _build_kanban_parser
kanban_parser = _build_kanban_parser(subparsers)
kanban_parser.set_defaults(func=cmd_kanban)
# =========================================================================
# project command — named, multi-folder workspaces
# =========================================================================
from hermes_cli.projects_cmd import build_parser as _build_project_parser
project_parser = _build_project_parser(subparsers)
project_parser.set_defaults(func=cmd_project)
# =========================================================================
# hooks command — shell-hook inspection and management
# =========================================================================
# hooks command (parser built in hermes_cli/subcommands/hooks.py)
# =========================================================================
build_hooks_parser(subparsers, cmd_hooks=cmd_hooks)
# =========================================================================
# doctor command (parser built in hermes_cli/subcommands/doctor.py)
# =========================================================================
build_doctor_parser(subparsers, cmd_doctor=cmd_doctor)
# =========================================================================
# security command — on-demand supply-chain audit
# =========================================================================
# security command (parser built in hermes_cli/subcommands/security.py)
# =========================================================================
build_security_parser(subparsers, cmd_security=cmd_security)
# =========================================================================
# approvals command (parser built in hermes_cli/subcommands/approvals.py)
# =========================================================================
build_approvals_parser(subparsers, cmd_approvals=cmd_approvals)
# =========================================================================
# dump command (parser built in hermes_cli/subcommands/dump.py)
# =========================================================================
build_dump_parser(subparsers, cmd_dump=cmd_dump)
# =========================================================================
# debug command (parser built in hermes_cli/subcommands/debug.py)
# =========================================================================
build_debug_parser(subparsers, cmd_debug=cmd_debug)
# =========================================================================
# backup command (parser built in hermes_cli/subcommands/backup.py)
# =========================================================================
build_backup_parser(subparsers, cmd_backup=cmd_backup)
# =========================================================================
# checkpoints command
# =========================================================================
checkpoints_parser = subparsers.add_parser(
"checkpoints",
help="Inspect / prune / clear ~/.hermes/checkpoints/",
description="Manage the filesystem checkpoint store — the shadow git "
"repo hermes uses to snapshot working directories before "
"write_file/patch/terminal calls. Lets you see how much "
"space checkpoints occupy, force a prune, or wipe the base.",
)
from hermes_cli.checkpoints import register_cli as _register_checkpoints_cli
_register_checkpoints_cli(checkpoints_parser)
# =========================================================================
# import command (parser built in hermes_cli/subcommands/import_cmd.py)
# =========================================================================
build_import_cmd_parser(subparsers, cmd_import=cmd_import)
# =========================================================================
# import-agent command (parser: hermes_cli/subcommands/import_agent.py)
# =========================================================================
def cmd_import_agent(args):
from hermes_cli.agent_import import import_agent_command
import_agent_command(args)
build_import_agent_parser(subparsers, cmd_import_agent=cmd_import_agent)
# =========================================================================
# config command (parser built in hermes_cli/subcommands/config.py)
# =========================================================================
build_config_parser(subparsers, cmd_config=cmd_config)
# =========================================================================
# skin command (parser built in hermes_cli/subcommands/skin.py)
# =========================================================================
build_skin_parser(subparsers, cmd_skin=cmd_skin)
# =========================================================================
# console command (parser built in hermes_cli/subcommands/console.py)
# =========================================================================
build_console_parser(subparsers, cmd_console=cmd_console)
# =========================================================================
# pairing command (parser built in hermes_cli/subcommands/pairing.py)
# =========================================================================
build_pairing_parser(subparsers, cmd_pairing=cmd_pairing)
# =========================================================================
# skills command (parser built in hermes_cli/subcommands/skills.py)
# =========================================================================
build_skills_parser(subparsers, cmd_skills=cmd_skills)
# =========================================================================
# bundles command — skill bundles (alias /<name> for multiple skills)
# =========================================================================
bundles_parser = subparsers.add_parser(
"bundles",
help="Create, list, and manage skill bundles (aliases for multiple skills)",
description=(
"Skill bundles let you load several skills under one slash "
"command. `/<bundle>` from the CLI or gateway loads every "
"referenced skill at once."
),
)
from hermes_cli.bundles import register_cli as _bundles_register, bundles_command
_bundles_register(bundles_parser)
bundles_parser.set_defaults(func=bundles_command)
# =========================================================================
# plugins command (parser built in hermes_cli/subcommands/plugins.py)
# =========================================================================
build_plugins_parser(subparsers, cmd_plugins=cmd_plugins)
# =========================================================================
# Plugin CLI commands — dynamically registered by memory/general plugins.
# Plugins provide a register_cli(subparser) function that builds their
# own argparse tree. No hardcoded plugin commands in main.py.
#
# Skipped when the invocation is already targeting a known built-in
# subcommand — ``hermes --help``, ``hermes version``, ``hermes logs``,
# etc. This avoids eagerly importing every bundled plugin module
# (google.cloud.pubsub_v1, aiohttp, grpc, PIL …) which costs
# 500-650ms on typical installs.
# =========================================================================
if _plugin_cli_discovery_needed():
try:
from plugins.memory import discover_plugin_cli_commands
from hermes_cli.plugins import discover_plugins, get_plugin_manager
seen_plugin_commands = set()
for cmd_info in discover_plugin_cli_commands():
plugin_parser = subparsers.add_parser(
cmd_info["name"],
help=cmd_info["help"],
description=cmd_info.get("description", ""),
formatter_class=__import__("argparse").RawDescriptionHelpFormatter,
)
cmd_info["setup_fn"](plugin_parser)
if cmd_info.get("handler_fn") is not None:
plugin_parser.set_defaults(func=cmd_info["handler_fn"])
seen_plugin_commands.add(cmd_info["name"])
discover_plugins()
# A bundled platform whose top-level CLI command is the one being
# invoked is still only a deferred entry at this point; import it
# so its register_cli_command side effect runs before we read
# _cli_commands (issue #54678).
_resolve_deferred_platform_cli_command(_first_positional_argv())
for cmd_info in get_plugin_manager()._cli_commands.values():
if cmd_info["name"] in seen_plugin_commands:
continue
plugin_parser = subparsers.add_parser(
cmd_info["name"],
help=cmd_info["help"],
description=cmd_info.get("description", ""),
formatter_class=__import__("argparse").RawDescriptionHelpFormatter,
)
cmd_info["setup_fn"](plugin_parser)
if cmd_info.get("handler_fn") is not None:
plugin_parser.set_defaults(func=cmd_info["handler_fn"])
except Exception as _exc:
logging.getLogger(__name__).debug("Plugin CLI discovery failed: %s", _exc)
# =========================================================================
# curator command — background skill maintenance
# =========================================================================
curator_parser = subparsers.add_parser(
"curator",
help="Background skill maintenance (curator) — status, run, pause, pin",
description=(
"The curator is an auxiliary-model background task that "
"periodically reviews agent-created skills, prunes stale ones, "
"consolidates overlaps, and archives obsolete skills. "
"Bundled and hub-installed skills are never touched. "
"Archives are recoverable; auto-deletion never happens."
),
)
try:
from hermes_cli.curator import register_cli as _register_curator_cli
_register_curator_cli(curator_parser)
except Exception as _exc:
logging.getLogger(__name__).debug("curator CLI wiring failed: %s", _exc)
# =========================================================================
# pets command — petdex animated mascots (CLI / TUI / desktop display)
# =========================================================================
pets_parser = subparsers.add_parser(
"pets",
help="Browse, install, and select petdex animated pets",
description=(
"Petdex (https://github.com/crafter-station/petdex) is a public "
"gallery of animated sprite pets for coding agents. Install one "
"and Hermes shows it reacting to agent activity across the CLI, "
"TUI, and desktop app."
),
)
try:
from hermes_cli.pets import register_cli as _register_pets_cli
_register_pets_cli(pets_parser)
except Exception as _exc:
logging.getLogger(__name__).debug("pets CLI wiring failed: %s", _exc)
# =========================================================================
# journey command — learned skills + memories over time, in the terminal
# =========================================================================
journey_parser = subparsers.add_parser(
"journey",
aliases=["learning", "memory-graph"],
help="Timeline of learned skills + memories over time",
description=(
"A terminal rendition of the desktop Star Map / Memory Graph: a "
"timeline bar chart of learned skills and memories over time "
"(oldest at top, newest at bottom) plus a playable constellation "
"scrubber. Mirrors the TUI `/journey` overlay and the desktop panel."
),
)
try:
from hermes_cli.journey import register_cli as _register_journey_cli
_register_journey_cli(journey_parser)
except Exception as _exc:
logging.getLogger(__name__).debug("journey CLI wiring failed: %s", _exc)
# =========================================================================
# memory command (parser built in hermes_cli/subcommands/memory.py)
# =========================================================================
build_memory_parser(subparsers, cmd_memory=cmd_memory)
# =========================================================================
# tools command (parser built in hermes_cli/subcommands/tools.py)
# =========================================================================
build_tools_parser(subparsers, cmd_tools=cmd_tools)
# =========================================================================
# computer-use command — manage Computer Use (cua-driver) on macOS
# =========================================================================
computer_use_parser = subparsers.add_parser(
"computer-use",
help="Manage the Computer Use (cua-driver) backend (macOS/Windows/Linux)",
description=(
"Install or check the cua-driver binary used by the\n"
"`computer_use` toolset. Supported on macOS, Windows, and\n"
"Linux.\n\n"
"Use `hermes computer-use install` to fetch and run the\n"
"upstream cua-driver installer. This is equivalent to the\n"
"post-setup hook that `hermes tools` runs when you first\n"
"enable the Computer Use toolset, and is a stable target\n"
"for re-running the install if it didn't fire (e.g. when\n"
"toggling the toolset on a returning-user setup).\n\n"
"Use `hermes computer-use doctor` to run cua-driver's\n"
"`health_report` MCP tool and surface its check matrix\n"
"(TCC, bundle identity, version, platform support, ...)\n"
"in human-readable form."
),
)
computer_use_sub = computer_use_parser.add_subparsers(dest="computer_use_action")
computer_use_install = computer_use_sub.add_parser(
"install",
help="Install or repair the cua-driver binary (macOS/Windows/Linux)",
)
computer_use_install.add_argument(
"--upgrade",
action="store_true",
help=(
"Re-run the upstream installer even if cua-driver is already on "
"PATH. The upstream install.sh always pulls the latest release, "
"so this performs an in-place upgrade."
),
)
computer_use_sub.add_parser(
"status",
help="Print whether cua-driver is installed and on PATH",
)
computer_use_doctor = computer_use_sub.add_parser(
"doctor",
help="Run cua-driver `health_report` and surface the check matrix",
description=(
"Drive cua-driver's stable `health_report` MCP tool and render\n"
"its check matrix (TCC permissions, bundle identity, version,\n"
"platform support, screenshot probe, …) as human-readable\n"
"output. cua-driver owns the health model; this command stays\n"
"thin so new checks added upstream surface here without code\n"
"changes. Exits 0 when overall=ok, 1 when degraded/failed, 2\n"
"when the binary is missing or unreachable."
),
)
computer_use_doctor.add_argument(
"--include",
action="append",
default=[],
metavar="CHECK",
help=(
"Run only the listed checks. Repeat for multiple "
"(e.g. --include tcc_accessibility --include bundle_identity). "
"Unknown names are reported by cua-driver."
),
)
computer_use_doctor.add_argument(
"--skip",
action="append",
default=[],
metavar="CHECK",
help="Skip the listed checks. Repeat for multiple. Wins over --include.",
)
computer_use_doctor.add_argument(
"--json",
action="store_true",
help="Emit the raw structured payload as JSON (same shape as `tools/call`).",
)
computer_use_perms = computer_use_sub.add_parser(
"permissions",
help="Check or grant macOS Accessibility + Screen Recording (macOS)",
description=(
"Computer Use drives the Mac through cua-driver, whose TCC grants\n"
"attach to cua-driver's own identity (com.trycua.driver) — not the\n"
"terminal or the Hermes app. `status` reports the driver's grant\n"
"state; `grant` launches CuaDriver via LaunchServices so the macOS\n"
"permission dialog is attributed to the process that does the work."
),
)
computer_use_perms_sub = computer_use_perms.add_subparsers(
dest="computer_use_perms_action"
)
computer_use_perms_status = computer_use_perms_sub.add_parser(
"status",
help="Report Accessibility + Screen Recording grant state (read-only)",
)
computer_use_perms_status.add_argument(
"--json",
action="store_true",
help="Emit the normalized permission payload as JSON.",
)
computer_use_perms_sub.add_parser(
"grant",
help="Request the grants (opens the dialog attributed to CuaDriver)",
)
def cmd_computer_use(args):
action = getattr(args, "computer_use_action", None)
if action == "install":
from hermes_cli.tools_config import install_cua_driver
install_cua_driver(upgrade=bool(getattr(args, "upgrade", False)))
return
if action == "status":
import subprocess
from tools.computer_use.cua_backend import (
cua_driver_update_check,
resolve_cua_driver_cmd,
)
# Must match the runtime resolver: Desktop/TUI processes can omit
# ~/.local/bin even though the official installer put the driver there.
path = resolve_cua_driver_cmd()
if path:
version = ""
try:
from hermes_cli.tools_config import _cua_driver_env
version = subprocess.run(
[path, "--version"],
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=5,
env=_cua_driver_env(),
).stdout.strip()
except Exception:
pass
if version:
print(f"cua-driver: installed at {path} ({version})")
else:
print(f"cua-driver: installed at {path}")
try:
st = cua_driver_update_check()
if st and st.get("update_available"):
latest = st.get("latest_version") or "?"
print(f" ⬆ Update available: cua-driver {latest}.")
print(" Run: hermes computer-use install --upgrade")
elif st:
print(" ✓ Up to date.")
else:
# Older driver (no check-update verb) or offline.
print(" Refresh to latest: hermes computer-use install --upgrade")
except Exception:
print(" Refresh to latest: hermes computer-use install --upgrade")
return
print("cua-driver: not installed")
print(" Run: hermes computer-use install")
return
if action == "doctor":
from tools.computer_use.doctor import run_doctor
code = run_doctor(
include=list(getattr(args, "include", []) or []),
skip=list(getattr(args, "skip", []) or []),
json_output=bool(getattr(args, "json", False)),
)
sys.exit(code)
if action == "permissions":
perms_action = getattr(args, "computer_use_perms_action", None)
if perms_action == "grant":
from tools.computer_use.permissions import request_permissions_grant
sys.exit(request_permissions_grant())
if perms_action == "status":
import json as _json
from tools.computer_use.permissions import computer_use_status
st = computer_use_status()
if bool(getattr(args, "json", False)):
print(_json.dumps(st, indent=2, sort_keys=True))
sys.exit(0 if st["ready"] else 1)
if not st["platform_supported"]:
print(f"Computer Use is not supported on {st['platform']}.")
sys.exit(1)
if not st["installed"]:
print("cua-driver: not installed. Run: hermes computer-use install")
sys.exit(1)
glyph = lambda v: "" if v is True else ("" if v is False else "") # noqa: E731
print(f"cua-driver: {st['version'] or 'installed'} ({st['platform']})")
if st["can_grant"]: # macOS TCC permissions
print(f" {glyph(st['accessibility'])} Accessibility")
print(f" {glyph(st['screen_recording'])} Screen Recording")
if not st["ready"]:
print(" Grant: hermes computer-use permissions grant")
else: # no TCC model — readiness is driver health
print(f" {glyph(st['ready'])} driver health (no permission toggles on {st['platform']})")
for c in st["checks"]:
if c["status"] != "ok":
print(f"{c['label']}: {c['message']}")
if st["error"]:
print(f"{st['error']}")
sys.exit(0 if st["ready"] else 1)
computer_use_perms.print_help()
return
# No subcommand → show help
computer_use_parser.print_help()
computer_use_parser.set_defaults(func=cmd_computer_use)
# =========================================================================
# mcp command (parser built in hermes_cli/subcommands/mcp.py)
# =========================================================================
build_mcp_parser(subparsers, cmd_mcp=cmd_mcp)
# =========================================================================
# sessions command
# =========================================================================
sessions_parser = subparsers.add_parser(
"sessions",
help="Manage session history (list, rename, export, prune, delete)",
description="View and manage the SQLite session store",
)
sessions_subparsers = sessions_parser.add_subparsers(dest="sessions_action")
sessions_list = sessions_subparsers.add_parser("list", help="List recent sessions")
sessions_list.add_argument(
"--source", help="Filter by source (cli, telegram, discord, etc.)"
)
sessions_list.add_argument(
"--limit", type=int, default=20, help="Max sessions to show"
)
sessions_list.add_argument(
"--workspace",
metavar="NEEDLE",
help="Only sessions in one workspace: a git repo root or project dir "
"(matched by path substring or basename).",
)
def _add_session_filter_args(p, default_older_help):
p.add_argument(
"--older-than",
metavar="AGE",
help=default_older_help,
)
p.add_argument(
"--newer-than",
metavar="AGE",
help="Only match sessions active within the last AGE "
"(e.g. '5h', '2d') or after an ISO timestamp",
)
p.add_argument(
"--before",
metavar="TIME",
help="Only match sessions started before TIME "
"(duration ago like '5h', or ISO timestamp like '2026-07-05 14:30')",
)
p.add_argument(
"--after",
metavar="TIME",
help="Only match sessions started at/after TIME "
"(duration ago like '5h', or ISO timestamp)",
)
p.add_argument("--source", help="Only match sessions from this source")
p.add_argument(
"--title", help="Only match sessions whose title contains this substring"
)
p.add_argument(
"--end-reason", help="Only match sessions with this end reason"
)
p.add_argument(
"--cwd", help="Only match sessions whose working directory is under this path"
)
p.add_argument(
"--min-messages", type=int, help="Only match sessions with >= N messages"
)
p.add_argument(
"--max-messages", type=int, help="Only match sessions with <= N messages"
)
p.add_argument(
"--model",
help="Only match sessions whose model name contains this substring "
"(e.g. 'sonnet', 'gpt-5', 'hermes')",
)
p.add_argument(
"--provider",
help="Only match sessions billed through this provider "
"(e.g. openrouter, anthropic, nous)",
)
p.add_argument(
"--user", help="Only match sessions from this user ID"
)
p.add_argument(
"--chat-id", help="Only match sessions from this chat/channel ID"
)
p.add_argument(
"--chat-type",
help="Only match sessions with this chat type (e.g. dm, group)",
)
p.add_argument(
"--branch",
help="Only match sessions whose git branch contains this substring",
)
p.add_argument(
"--min-tokens", type=int,
help="Only match sessions with >= N total tokens (input+output)",
)
p.add_argument(
"--max-tokens", type=int,
help="Only match sessions with <= N total tokens (input+output)",
)
p.add_argument(
"--min-cost", type=float,
help="Only match sessions costing >= N USD (actual or estimated)",
)
p.add_argument(
"--max-cost", type=float,
help="Only match sessions costing <= N USD (actual or estimated)",
)
p.add_argument(
"--min-tool-calls", type=int,
help="Only match sessions with >= N tool calls",
)
p.add_argument(
"--max-tool-calls", type=int,
help="Only match sessions with <= N tool calls",
)
p.add_argument(
"--dry-run",
action="store_true",
help="List matching sessions without changing anything",
)
p.add_argument(
"--yes", "-y", action="store_true", help="Skip confirmation"
)
sessions_export = sessions_subparsers.add_parser(
"export", help="Export sessions to JSONL, Markdown, or QMD"
)
sessions_export.add_argument(
"output",
nargs="?",
help=(
"Output path. JSONL: file path (use - for stdout, required). "
"md/qmd: output directory (default: <hermes home>/session-exports)"
),
)
sessions_export.add_argument(
"--format",
choices=["jsonl", "md", "qmd", "html", "trace"],
default="jsonl",
help=(
"Export format (default: jsonl). 'trace' emits Claude Code JSONL "
"for the Hugging Face Agent Trace Viewer"
),
)
sessions_export.add_argument(
"--upload",
action="store_true",
help=(
"trace only: upload to your Hugging Face traces dataset instead "
"of writing a local file (needs HF_TOKEN)"
),
)
sessions_export.add_argument(
"--public",
action="store_true",
help="trace --upload only: create/update a public dataset instead of private",
)
sessions_export.add_argument(
"--no-redact",
action="store_true",
help=(
"trace only: skip the forced secret redaction; "
"only use after manual review"
),
)
sessions_export.add_argument(
"--only",
choices=["user-prompts"],
help=(
"Export only a filtered view (user-prompts: one prompt record "
"per line for jsonl, headed sections for md)"
),
)
sessions_export.add_argument(
"--session-id", help="Session ID or unique prefix to export"
)
_add_session_filter_args(
sessions_export,
"Only export sessions older than AGE (duration like '5h'/'2d', "
"bare number of days, or an ISO timestamp)",
)
sessions_export.add_argument(
"--redact",
action="store_true",
help="Redact secrets (API keys, tokens, credentials) from exported content",
)
sessions_export.add_argument(
"--lineage",
choices=["single", "logical"],
default="single",
help="md/qmd only: export one row or its compression lineage",
)
sessions_export.add_argument(
"--delete-after-verified",
action="store_true",
help="md/qmd only: after verified single-session export, delete that session (needs --yes)",
)
sessions_export.add_argument(
"--force",
action="store_true",
help="md/qmd only: overwrite an existing export file",
)
sessions_delete = sessions_subparsers.add_parser(
"delete", help="Delete a specific session"
)
sessions_delete.add_argument("session_id", help="Session ID to delete")
sessions_delete.add_argument(
"--yes", "-y", action="store_true", help="Skip confirmation"
)
sessions_prune = sessions_subparsers.add_parser(
"prune",
help="Delete old sessions (filterable by time window, source, title, ...)",
)
_add_session_filter_args(
sessions_prune,
"Delete sessions older than AGE — days if bare number, or a duration "
"like '5h'/'2d'/'1w', or an ISO timestamp (bare prune with no filters "
"defaults to 90 days; any filter matches all ages)",
)
sessions_prune.add_argument(
"--include-archived",
action="store_true",
help="Also delete archived sessions (excluded by default)",
)
sessions_archive = sessions_subparsers.add_parser(
"archive",
help="Bulk-archive (soft-hide) sessions matching filters — no deletion",
)
_add_session_filter_args(
sessions_archive,
"Only archive sessions older than AGE (duration like '5h'/'2d', "
"bare number of days, or ISO timestamp)",
)
sessions_subparsers.add_parser(
"optimize",
help="Reclaim disk space: merge FTS5 segments + VACUUM (no data change)",
)
sessions_optimize_storage = sessions_subparsers.add_parser(
"optimize-storage",
help="Migrate the search index to the compact v23 layout (reclaims disk on large DBs)",
description=(
"Rebuild the full-text search index in the compact v23 "
"external-content layout. On large databases this reclaims a "
"large fraction of state.db (the old layout stored duplicate "
"copies of every message and indexed tool output). Runs "
"foreground with a progress bar, throttles so a running gateway "
"stays responsive, and VACUUMs at the end. Safe to interrupt and "
"re-run — it resumes where it left off. No conversation data is "
"changed; only the search index is rebuilt."
),
)
sessions_optimize_storage.add_argument(
"--no-vacuum",
action="store_true",
default=False,
help="Skip the final VACUUM (index is rebuilt but freed pages aren't returned to the OS until a later VACUUM)",
)
sessions_optimize_storage.add_argument(
"--yes", "-y",
action="store_true",
default=False,
help="Skip the disk-space confirmation prompt",
)
sessions_repair = sessions_subparsers.add_parser(
"repair",
help="Repair a malformed state.db schema so hidden sessions reappear",
description=(
"Recover a state.db whose schema is malformed (e.g. 'table "
"messages_fts already exists'), which makes Desktop/Dashboard show "
"no sessions. A backup is made first; sessions and messages are "
"preserved and the FTS search index is rebuilt if needed."
),
)
sessions_repair.add_argument(
"--check-only",
action="store_true",
help="Only report whether the database opens cleanly; do not modify it",
)
sessions_repair.add_argument(
"--no-backup",
action="store_true",
help="Skip the timestamped backup copy (not recommended)",
)
sessions_recover = sessions_subparsers.add_parser(
"recover",
help="Rebuild canonical session data into a separate clean database",
description=(
"Offline, non-destructive recovery for a damaged state.db. The "
"source database and its WAL/SHM/rollback-journal sidecars are "
"copied before SQLite opens anything. Canonical rows are rebuilt "
"into a new output database; derived search indexes are recreated "
"and the active database is never replaced automatically."
),
)
sessions_recover.add_argument(
"--source",
type=Path,
required=True,
help="Source state.db or preserved backup to inspect/recover",
)
sessions_recover.add_argument(
"--output",
type=Path,
help="New recovery database path (required unless --inspect-only)",
)
sessions_recover.add_argument(
"--inspect-only",
action="store_true",
help="Only report canonical table readability; do not create an output database",
)
sessions_recover.add_argument(
"--work-dir",
type=Path,
help="Existing directory for the disposable source copy (defaults beside the output)",
)
sessions_recover.add_argument(
"--chunk-size",
type=int,
default=1000,
help="Rows committed per recovery batch (default: 1000)",
)
sessions_recover.add_argument(
"--allow-partial",
action="store_true",
help=(
"Best-effort salvage across damaged row ranges; the output remains "
"separate and every skipped range is recorded"
),
)
sessions_recover.add_argument(
"--report",
type=Path,
help="JSON report path (defaults to <output>.recovery.json)",
)
sessions_subparsers.add_parser("stats", help="Show session store statistics")
sessions_rename = sessions_subparsers.add_parser(
"rename", help="Set or change a session's title"
)
sessions_rename.add_argument("session_id", help="Session ID to rename")
sessions_rename.add_argument("title", nargs="+", help="New title for the session")
sessions_retitle = sessions_subparsers.add_parser(
"retitle-skills",
help="Re-title sessions whose auto-title came from a /skill's own text",
description=(
"Sessions opened with a /skill were auto-titled from the expanded "
"message, which embeds the whole skill body — so the title "
"describes the SKILL, not the request. This regenerates those "
"titles from what the user actually typed. Lists what it would "
"change unless --apply is passed."
),
)
sessions_retitle.add_argument(
"--apply",
action="store_true",
help="Write the new titles (default: dry run)",
)
sessions_retitle.add_argument(
"--limit",
type=int,
default=200,
help="Maximum sessions to examine (default: 200)",
)
sessions_browse = sessions_subparsers.add_parser(
"browse",
help="Interactive session picker — browse, search, and resume sessions",
)
sessions_browse.add_argument(
"--source", help="Filter by source (cli, telegram, discord, etc.)"
)
sessions_browse.add_argument(
"--limit", type=int, default=500, help="Max sessions to load (default: 500)"
)
# cmd_sessions lives in hermes_cli/sessions_cmd.py (main.py decomposition).
# sessions_parser is threaded in via functools.partial because the
# fallthrough branch calls sessions_parser.print_help() (formerly a
# closure capture of this main()-local).
sessions_parser.set_defaults(
func=_functools.partial(cmd_sessions, sessions_parser=sessions_parser)
)
# =========================================================================
# insights command (parser built in hermes_cli/subcommands/insights.py)
# =========================================================================
build_insights_parser(subparsers, cmd_insights=cmd_insights)
build_monitoring_parser(subparsers, cmd_monitoring=cmd_monitoring)
# =========================================================================
# claw command (parser built in hermes_cli/subcommands/claw.py)
# =========================================================================
build_claw_parser(subparsers, cmd_claw=cmd_claw)
# =========================================================================
# version command (parser built in hermes_cli/subcommands/version.py)
# =========================================================================
build_version_parser(subparsers, cmd_version=cmd_version)
# =========================================================================
# update command (parser built in hermes_cli/subcommands/update.py)
# =========================================================================
build_update_parser(subparsers, cmd_update=cmd_update)
# =========================================================================
# uninstall command (parser built in hermes_cli/subcommands/uninstall.py)
# =========================================================================
build_uninstall_parser(subparsers, cmd_uninstall=cmd_uninstall)
# =========================================================================
# acp command (parser built in hermes_cli/subcommands/acp.py)
# =========================================================================
build_acp_parser(subparsers, cmd_acp=cmd_acp)
# =========================================================================
# profile command (parser built in hermes_cli/subcommands/profile.py)
# =========================================================================
build_profile_parser(subparsers, cmd_profile=cmd_profile)
# =========================================================================
# completion command
# =========================================================================
completion_parser = subparsers.add_parser(
"completion",
help="Print shell completion script (bash, zsh, or fish)",
)
completion_parser.add_argument(
"shell",
nargs="?",
default="bash",
choices=["bash", "zsh", "fish"],
help="Shell type (default: bash)",
)
completion_parser.set_defaults(func=lambda args: cmd_completion(args, parser))
# =========================================================================
# dashboard command (parser built in hermes_cli/subcommands/dashboard.py)
# =========================================================================
build_dashboard_parser(
subparsers,
cmd_dashboard=cmd_dashboard,
cmd_dashboard_register=cmd_dashboard_register,
)
# =========================================================================
# desktop (a.k.a. gui) command
#
# The canonical name is "desktop"; "gui" is kept as a deprecated alias
# for one release. The Hermes-Setup.exe success screen tells users to
# run `hermes desktop` from a terminal, so the canonical name needs
# to be the one that appears in --help (argparse promotes the primary
# name; aliases stay hidden).
# =========================================================================
# gui command (parser built in hermes_cli/subcommands/gui.py)
# =========================================================================
build_gui_parser(subparsers, cmd_gui=cmd_gui)
# =========================================================================
# logs command (parser built in hermes_cli/subcommands/logs.py)
# =========================================================================
build_logs_parser(subparsers, cmd_logs=cmd_logs)
# =========================================================================
# prompt-size command (parser built in hermes_cli/subcommands/prompt_size.py)
# =========================================================================
build_prompt_size_parser(subparsers, cmd_prompt_size=cmd_prompt_size)
# =========================================================================
# Parse and execute
# =========================================================================
# Pre-process argv so unquoted multi-word session names after -c / -r
# are merged into a single token before argparse sees them.
# e.g. ``hermes -c Pokemon Agent Dev`` → ``hermes -c 'Pokemon Agent Dev'``
# ── Container-aware routing ────────────────────────────────────────
# When NixOS container mode is active, route ALL subcommands into
# the managed container. This MUST run before parse_args() so that
# --help, unrecognised flags, and every subcommand are forwarded
# transparently instead of being intercepted by argparse on the host.
from hermes_cli.config import get_container_exec_info
container_info = get_container_exec_info()
if container_info:
_exec_in_container(container_info, sys.argv[1:])
# Unreachable: os.execvp never returns on success (process is replaced)
# and raises OSError on failure (which propagates as a traceback).
sys.exit(1)
_processed_argv = _coalesce_session_name_args(sys.argv[1:])
# ── Defensive subparser routing (bpo-9338 workaround) ───────────
# On some Python versions (notably <3.11), argparse fails to route
# subcommand tokens when the parent parser has nargs='?' optional
# arguments (--continue). The symptom: "unrecognized arguments: model"
# even though 'model' is a registered subcommand.
#
# Fix: when argv contains a token matching a known subcommand, set
# subparsers.required=True to force deterministic routing. If that
# fails (e.g. 'hermes -c model' where 'model' is consumed as the
# session name for --continue), fall back to the default behaviour.
import io as _io
_known_cmds = (
set(subparsers.choices.keys()) if hasattr(subparsers, "choices") else set()
)
_has_cmd_token = any(
t in _known_cmds for t in _processed_argv if not t.startswith("-")
)
if _has_cmd_token:
subparsers.required = True
_saved_stderr = sys.stderr
try:
sys.stderr = _io.StringIO()
args = parser.parse_args(_processed_argv)
sys.stderr = _saved_stderr
except SystemExit as exc:
sys.stderr = _saved_stderr
# Help/version flags (exit code 0) already printed output —
# re-raise immediately to avoid a second parse_args printing
# the same help text again (#10230).
if exc.code == 0:
raise
# Subcommand name was consumed as a flag value (e.g. -c model).
# Fall back to optional subparsers so argparse handles it normally.
subparsers.required = False
args = parser.parse_args(_processed_argv)
else:
subparsers.required = False
args = parser.parse_args(_processed_argv)
# Handle --version flag
if args.version:
cmd_version(args)
return
# --yolo: set HERMES_YOLO_MODE *before* plugin discovery. The call to
# _prepare_agent_startup() below triggers discover_plugins() → tool
# imports, and tools.approval freezes _YOLO_MODE_FROZEN at module
# import time (PR #7994, security hardening against prompt-injection).
# If the env var is set only later (e.g. inside cmd_chat), the frozen
# value is already False and --yolo silently does nothing.
if getattr(args, "yolo", False):
os.environ["HERMES_YOLO_MODE"] = "1"
# Discover Python plugins and register shell hooks once, before any
# command that can fire lifecycle hooks. Both are idempotent; gated
# so introspection/management commands (hermes hooks list, cron
# list, gateway status, mcp add, ...) don't pay discovery cost or
# trigger consent prompts for hooks the user is still inspecting.
_prepare_agent_startup(args)
# Handle top-level --oneshot / -z: single-shot mode, stdout = final
# response only, nothing else. Bypasses cli.py entirely.
if getattr(args, "oneshot", None):
_run_and_exit_oneshot(
args.oneshot,
model=getattr(args, "model", None),
provider=getattr(args, "provider", None),
toolsets=getattr(args, "toolsets", None),
usage_file=getattr(args, "usage_file", None),
)
# Handle top-level --resume / --continue as shortcut to chat
if (args.resume or args.continue_last) and args.command is None:
args.command = "chat"
for attr, default in [
("query", None),
("model", None),
("provider", None),
("toolsets", None),
("verbose", None),
("worktree", False),
]:
if not hasattr(args, attr):
setattr(args, attr, default)
cmd_chat(args)
return
# Default to chat if no command specified
if args.command is None:
for attr, default in [
("query", None),
("model", None),
("provider", None),
("toolsets", None),
("verbose", None),
("resume", None),
("continue_last", None),
("worktree", False),
]:
if not hasattr(args, attr):
setattr(args, attr, default)
cmd_chat(args)
return
# Execute the command. Propagate the handler's return code as the
# process exit code so subcommands that signal failure (e.g.
# ``hermes egress start`` refusing when credential_source=bitwarden
# is misconfigured) actually exit non-zero. Handlers that return
# None are treated as success (exit 0).
if hasattr(args, "func"):
rc = args.func(args)
if isinstance(rc, int) and rc != 0:
sys.exit(rc)
else:
parser.print_help()
if __name__ == "__main__":
main()