Files
hermes-agent/hermes_cli/main.py

4246 lines
171 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 and update status
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
from hermes_cli.cli_output import line_input
suppress_platform_ver_console()
import os
import sys
# ── Startup fast-path bootstrap ─────────────────────────────────────────
# Two lines of inline path math so ``python hermes_cli/main.py`` (script
# mode — sys.path[0] is hermes_cli/, not the repo root) can import the
# canonical helpers; everything else lives in hermes_cli._startup_fast.
_bootstrap_root = os.path.realpath(os.path.join(os.path.dirname(__file__), os.pardir))
if _bootstrap_root not in sys.path:
sys.path.insert(0, _bootstrap_root)
from hermes_cli import _startup_fast # noqa: E402
# Early venv self-heal — MUST run before any third-party import below. When
# a prior ``hermes update`` left a recovery marker and a core package's import
# files were wiped (#57828 — failed lazy backend refresh), the module-level
# ``from hermes_cli.env_loader import ...`` / ``from hermes_cli.config import
# ...`` imports further down would crash before ``main()`` ever reaches
# ``_recover_from_interrupted_install()``. ``_early_recovery`` is stdlib-only
# (safe to import on a corrupted venv), repairs just enough for this module to
# finish importing, and leaves the marker lifecycle to the full recovery path.
# The module import itself is unguarded on purpose: it lives in this same
# package directory, so if IT can't import, nothing else in hermes_cli can
# either. It is also the canonical home of the probe/repair tables reused by
# the full recovery path below.
from hermes_cli import _early_recovery as _early_recovery_mod
try:
_early_recovery_mod.recover_if_needed()
except Exception:
pass
# Startup-liveness watchdog (OOF-298): for gateway runs, arm BEFORE the heavy
# module-level import graph below — an import-time deadlock (native-extension
# init, contended import lock) is exactly the "wedged before the event loop,
# no logs, live PID" class this watchdog exists for. ``hermes_startup_watchdog``
# is stdlib-only, so importing it here cannot itself wedge on application
# code. The match requires the ADJACENT token pair ``gateway run`` (the
# subcommand shape, wherever global flags like ``-p <profile>`` put it) so
# unrelated commands that merely mention both words in different arguments
# never arm a 300s hard-exit timer, while flag-carrying invocations still
# do — under-arming recreates OOF-298. Foreground `hermes gateway run`
# still arms — a pre-loop wedge is just as dead without a supervisor, and
# the stack dump plus exit beats a silent hang; GatewayRunner disarms once
# the event loop is confirmed live.
def _argv_is_gateway_run(argv: list) -> bool:
return any(
a == "gateway" and b == "run" for a, b in zip(argv, argv[1:])
)
if _argv_is_gateway_run(sys.argv[1:]):
try:
from hermes_startup_watchdog import arm_startup_watchdog as _arm_sw
_arm_sw()
del _arm_sw
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,
skills: 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,
skills=skills,
usage_file=usage_file,
)
except KeyboardInterrupt:
rc = 130
except SystemExit as exc:
if exc.code is not None and not isinstance(exc.code, int):
print(exc.code, file=sys.stderr)
rc = 1
else:
rc = exc.code
except BaseException:
# Defense-in-depth. ``run_oneshot`` already converts agent failures
# into an int return code and only re-raises KeyboardInterrupt /
# SystemExit (handled above). Anything still escaping here means
# ``run_oneshot`` itself malfunctioned — surface it on stderr but never
# fall through to normal interpreter teardown, which is the exact path
# that aborts with SIGABRT on AL2023 (the bug this routine fixes).
import traceback
try:
traceback.print_exc()
except Exception:
pass
rc = 1
try:
_cleanup_oneshot_runtime()
finally:
# The hard exit is the safety boundary for #43055. Even an interrupt
# during best-effort cleanup must not fall back into interpreter
# finalization, where the reported native SIGABRT occurs.
_exit_after_oneshot(rc)
def _project_root_str_fast() -> str:
return _startup_fast.project_root_str()
def _ensure_project_root_on_path_fast() -> None:
_startup_fast.ensure_project_root_on_path()
def _set_process_title() -> None:
"""Set the process title to 'hermes' so tools like 'ps', 'top', and
'htop' show the app name instead of 'python3.xx'.
Purely cosmetic — non-fatal on any platform.
Strategy (try in order):
1. ``setproctitle`` (opt-in dep — installed via ``hermes tools`` or
``pip install setproctitle``, or bundled in a future release).
2. ctypes ``prctl(PR_SET_NAME)`` (Linux only, 15-char limit).
3. ctypes ``pthread_setname_np`` (macOS only, kernel thread name —
changes lldb/top but not ``ps aux``).
4. No-op on Windows (the .exe name is already ``hermes.exe``).
"""
# Strategy 1: setproctitle (best — works on macOS, Linux, BSD)
try:
import setproctitle # type: ignore[import-untyped]
setproctitle.setproctitle("hermes")
return
except ImportError:
pass
# Strategy 2/3: platform-specific ctypes fallback
import ctypes
import platform
try:
system = platform.system()
if system == "Linux":
libc = ctypes.CDLL("libc.so.6", use_errno=True)
libc.prctl(15, b"hermes", 0, 0, 0) # PR_SET_NAME = 15
elif system == "Darwin":
libc = ctypes.CDLL("libc.dylib", use_errno=True)
libc.pthread_setname_np(b"hermes")
# Windows: the .exe name is already ``hermes.exe`` — nothing to do.
except Exception:
pass
# Cheap, dependency-free read of `display.interface` from config.yaml for the
# earliest hot-path decisions (mouse-residue suppression, Termux fast launch)
# that run *before* hermes_cli.config is importable. Mirrors the explicit
# precedence used everywhere else: `--cli` always wins, then `--tui`/env, then
# this config value. Cached so the multiple early callers don't re-parse YAML.
_EARLY_INTERFACE_CACHE: "list | None" = None
def _config_default_interface_early() -> str:
"""Return the configured default interface ("cli"/"tui") via a minimal
YAML read. Best-effort: any error falls back to "cli" (legacy behavior)."""
global _EARLY_INTERFACE_CACHE
if _EARLY_INTERFACE_CACHE is not None:
return _EARLY_INTERFACE_CACHE[0]
value = "cli"
try:
home = os.environ.get("HERMES_HOME")
if home:
cfg_path = os.path.join(home, "config.yaml")
else:
cfg_path = os.path.join(os.path.expanduser("~"), ".hermes", "config.yaml")
if os.path.exists(cfg_path):
import yaml as _yaml_iface
with open(cfg_path, encoding="utf-8") as _f:
raw = _yaml_iface.load(
_f, Loader=getattr(_yaml_iface, "CSafeLoader", None) or _yaml_iface.SafeLoader
) or {}
disp = raw.get("display", {})
if isinstance(disp, dict):
iface = disp.get("interface")
if isinstance(iface, str) and iface.strip().lower() == "tui":
value = "tui"
except Exception:
value = "cli" # best-effort — default to classic REPL on any error
_EARLY_INTERFACE_CACHE = [value]
return value
def _wants_tui_early(argv: "list[str] | None" = None) -> bool:
"""Earliest TUI decision, usable before argparse/config imports.
Precedence: explicit ``--cli`` wins (forces classic REPL), then
explicit ``--tui``/``HERMES_TUI=1``, then a real-TTY gate (a
non-interactive stdio can't host the Ink UI, so ambient config never
boots it there), then ``display.interface`` in config.
The TTY gate is load-bearing for headless spawners — kanban workers,
cron jobs, pipes run ``hermes … chat -q`` with stdio on a pipe. This
is the earliest launch decision (it runs before ``cmd_chat`` /
``_resolve_use_tui``), so a ``display.interface: tui`` default used to
boot the TUI here — whose no-TTY bail-out exits 0 without doing the
task → "protocol violation" on every attempt. An explicit ``--tui``
still reaches the informative bail-out.
"""
if argv is None:
argv = sys.argv[1:]
if "--cli" in argv:
return False
if os.environ.get("HERMES_TUI") == "1" or "--tui" in argv:
return True
try:
if not (sys.stdin.isatty() and sys.stdout.isatty()):
return False
except Exception:
return False
return _config_default_interface_early() == "tui"
# Mouse-tracking residue suppression — runs BEFORE every other import on the
# TUI hot path so the terminal stops emitting SGR/X10 mouse reports while the
# Python launcher is still doing imports (≈100–300ms in cooked + echo mode,
# before the Node TUI takes stdin into raw mode). During that window any
# incoming bytes are echoed straight back to the user's shell scrollback as
# ``^[[<…M`` text. The TUI itself runs `resetTerminalModes()` again in
# `entry.tsx`; this is just the earlier cousin. ``HERMES_TUI_NO_EARLY_DISABLE``
# escapes the behaviour for diagnostics.
def _suppress_mouse_residue_early() -> None:
if os.environ.get("HERMES_TUI_NO_EARLY_DISABLE") == "1":
return
if not _wants_tui_early():
return
try:
# Skip when stdout is redirected (`hermes --tui … >log`, CI capture):
# the bytes can't reach the terminal anyway and would just pollute
# the log with raw CSI.
if not os.isatty(1):
return
# Disable every mouse-tracking variant we know about. Idempotent and
# safe to send even when no tracking is currently asserted.
os.write(
1,
b"\x1b[?1003l\x1b[?1002l\x1b[?1001l\x1b[?1000l\x1b[?9l"
b"\x1b[?1006l\x1b[?1005l\x1b[?1015l\x1b[?1016l\x1b[?2029l",
)
except OSError:
pass
_suppress_mouse_residue_early()
def _try_ultrafast_version() -> bool:
"""Handle ``hermes --version`` before config/logging imports."""
return _startup_fast.try_fast_version()
_ensure_project_root_on_path_fast()
if _try_ultrafast_version():
raise SystemExit(0)
import argparse
import json
import re
import shutil
import subprocess
from pathlib import Path
from typing import Optional
from hermes_cli.subcommands.cron import build_cron_parser
from hermes_cli.subcommands.sync import build_sync_parser
from hermes_cli.subcommands.gateway import build_gateway_parser
from hermes_cli.subcommands.profile import build_profile_parser
from hermes_cli.subcommands.model import build_model_parser
from hermes_cli.subcommands.setup import build_setup_parser
from hermes_cli.subcommands.whatsapp import build_whatsapp_parser, build_whatsapp_cloud_parser
from hermes_cli.subcommands.slack import build_slack_parser
from hermes_cli.subcommands.login import build_login_parser
from hermes_cli.subcommands.logout import build_logout_parser
from hermes_cli.subcommands.auth import build_auth_parser
from hermes_cli.subcommands.status import build_status_parser
from hermes_cli.subcommands.pause import build_pause_parser
from hermes_cli.subcommands.webhook import build_webhook_parser
from hermes_cli.subcommands.hooks import build_hooks_parser
from hermes_cli.subcommands.doctor import build_doctor_parser
from hermes_cli.subcommands.verify import build_verify_parser
from hermes_cli.subcommands.security import build_security_parser
from hermes_cli.subcommands.approvals import build_approvals_parser
from hermes_cli.subcommands.dump import build_dump_parser
from hermes_cli.subcommands.debug import build_debug_parser
from hermes_cli.subcommands.backup import build_backup_parser
from hermes_cli.subcommands.import_cmd import build_import_cmd_parser
from hermes_cli.subcommands.import_agent import build_import_agent_parser
from hermes_cli.subcommands.config import build_config_parser
from hermes_cli.subcommands.skin import build_skin_parser
from hermes_cli.subcommands.console import build_console_parser
from hermes_cli.subcommands.update import build_update_parser
from hermes_cli.subcommands.uninstall import build_uninstall_parser
from hermes_cli.subcommands.dashboard import build_dashboard_parser, build_serve_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
from hermes_cli.subcommands.moa import build_moa_parser
from hermes_cli.subcommands.fallback import build_fallback_parser
from hermes_cli.subcommands.worktree import build_worktree_parser
from hermes_cli.subcommands.browser import build_browser_parser
from hermes_cli.subcommands.secrets import build_secrets_parser
from hermes_cli.subcommands.egress import build_egress_parser
from hermes_cli.subcommands.migrate import build_migrate_parser
from hermes_cli.subcommands.checkpoints import build_checkpoints_parser
from hermes_cli.subcommands.bundles import build_bundles_parser
from hermes_cli.subcommands.curator import build_curator_parser
from hermes_cli.subcommands.pets import build_pets_parser
from hermes_cli.subcommands.journey import build_journey_parser
from hermes_cli.subcommands.computer_use import build_computer_use_parser
from hermes_cli.subcommands.sessions import build_sessions_parser
from hermes_cli.subcommands.completion import build_completion_parser
def _require_tty(command_name: str) -> None:
"""Exit with a clear error if stdin is not a terminal.
Interactive TUI commands (hermes tools, hermes setup, hermes model) use
curses or input() prompts that spin at 100% CPU when stdin is a pipe.
This guard prevents accidental non-interactive invocation.
"""
if not sys.stdin.isatty():
print(
f"Error: 'hermes {command_name}' requires an interactive terminal.\n"
f"It cannot be run through a pipe or non-interactive subprocess.\n"
f"Run it directly in your terminal instead.",
file=sys.stderr,
)
sys.exit(1)
# Add project root to path
PROJECT_ROOT = Path(_project_root_str_fast())
_ensure_project_root_on_path_fast()
# ---------------------------------------------------------------------------
# Profile override — MUST happen before any hermes module import.
#
# Many modules cache HERMES_HOME at import time (module-level constants).
# We intercept --profile/-p from sys.argv here and set the env var so that
# every subsequent ``os.getenv("HERMES_HOME", ...)`` resolves correctly.
# The flag is stripped from sys.argv so argparse never sees it.
# Falls back to ~/.hermes/active_profile for sticky default.
# ---------------------------------------------------------------------------
def _apply_profile_override() -> None:
"""Pre-parse --profile/-p and set HERMES_HOME before imports."""
argv = sys.argv[1:]
profile_name = None
consume = 0
profile_index = None
def _inside_mcp_add_args(index: int) -> bool:
"""True once argv reaches `hermes mcp add ... --args <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`.
from hermes_cli._parser import top_level_value_flag_sets
value_flags, optional_value_flags = top_level_value_flag_sets()
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 supervisor-launched gateway child 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) or pin ``HERMES_HOME`` to the profile directory (step 1.5), and
# a bare invocation means "the root HERMES_HOME profile". If a supervised
# default-profile child read active_profile here, switching the active
# profile (e.g. via the dashboard or ``hermes profile use``) would
# silently redirect the default gateway into that profile — the default
# gateway then assumes the other profile's identity/credentials (logs
# under the other profile's tree, connects with its Telegram bot token)
# and double-polls a token already owned by that profile's own gateway.
# See issue #74872 and the "Docker & Profiles & Dashboard" report.
#
# Supervisor markers honored (see gateway/restart.py
# ``is_gateway_supervisor_process`` for the sibling detection used by
# restart routing):
# - HERMES_SUPERVISED_CHILD: generalized marker exported by the
# generated systemd unit, launchd plist, and Windows Scheduled-Task
# launchers (#74872).
# - HERMES_S6_SUPERVISED_CHILD: legacy s6 container marker (back-compat;
# exported by S6ServiceManager's run-script).
# - INVOCATION_ID: set by systemd for service children only (never in
# interactive shells) — covers already-installed gateway units that
# predate the HERMES_SUPERVISED_CHILD marker. Consulted ONLY for
# gateway commands: INVOCATION_ID is inherited by every descendant of
# a systemd-launched process (self-hosted CI runners, user services
# running unrelated hermes commands), so honoring it globally would
# silently disable the sticky active_profile for those.
# - HERMES_GATEWAY_EXTERNAL_SUPERVISOR: explicit external-supervisor
# opt-in (``hermes gateway run --external-supervisor``).
#
# XPC_SERVICE_NAME is deliberately NOT consulted here: interactive macOS
# terminals set it too, and a false positive would silently break the
# sticky active_profile for every interactive command.
def _under_gateway_supervisor() -> bool:
if os.environ.get("HERMES_SUPERVISED_CHILD"):
return True
if os.environ.get("HERMES_S6_SUPERVISED_CHILD"):
return True
is_gateway_cmd = next(
(a for a in argv if not a.startswith("-")), None
) == "gateway"
if is_gateway_cmd and os.environ.get("INVOCATION_ID"):
return True
return os.environ.get(
"HERMES_GATEWAY_EXTERNAL_SUPERVISOR", ""
).strip().lower() in {"1", "true", "yes", "on"}
if profile_name is None and not _under_gateway_supervisor():
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()
# Windows launcher self-heal — the ``hermes`` command users run is a COPY of
# the venv console script, staged into the managed binary dir (the default
# Hermes root's ``bin``, next to the managed uv) by install.ps1. That dir
# lives OUTSIDE the git checkout precisely because an earlier layout staged
# the copies at ``<checkout>\bin``, where ``hermes update``'s autostash
# (``git stash push --include-untracked``) swept them off disk; with the
# desktop updater's ``--keep-stash`` nothing restored them and ``hermes``
# stopped resolving in every new terminal (venv\Scripts itself must stay off
# PATH — it shadows the user's ``python``, #83797). Re-staging at process
# start reaches already-broken installs through the one channel that still
# works there: the desktop app spawning its backend via
# ``python -m hermes_cli.main``. Costs a few stat calls when healthy; gates
# fail toward inaction so source checkouts are untouched. Sits AFTER the
# profile override on purpose — no hermes module may be imported before
# profiles resolve. The launcher dir itself is per-machine (the helper
# anchors on the DEFAULT root, not HERMES_HOME), so profile sessions heal
# the same shared dir.
if sys.platform == "win32":
try:
from hermes_cli import _install_repair as _install_repair_mod
_install_repair_mod.ensure_windows_bin_launchers(_bootstrap_root)
except Exception:
pass
# 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
# Updating dependencies must not import optional secret-manager libraries into
# the updater process before ``uv`` replaces the environment. On Windows,
# Bitwarden's cryptography import maps ``_rust.pyd`` and the parent updater then
# prevents its own child installer from replacing that file (#73381). Profile
# flags have already been stripped above, so the first remaining argument is
# the authoritative argparse subcommand. Dotenv/managed config still loads;
# only external secret fetches are unnecessary for installation maintenance.
load_hermes_dotenv(
project_env=PROJECT_ROOT / ".env",
load_external_secrets=sys.argv[1:2] != ["update"],
)
# 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 # noqa: F401 (tests patch hermes_cli.main._time.sleep)
from datetime import datetime
from hermes_cli import __version__, __release_date__
# Provider model-selection wizard flows extracted to hermes_cli/model_setup_flows.py
# (god-file decomposition Phase 2). Re-imported here so select_provider_and_model and
# existing test monkeypatches (hermes_cli.main._model_flow_*) keep resolving unchanged.
from hermes_cli.model_setup_flows import (
_prompt_auth_credentials_choice,
_model_flow_openrouter,
_model_flow_nous,
_model_flow_openai_codex,
_model_flow_xai_oauth,
_model_flow_qwen_oauth,
_model_flow_minimax_oauth,
_model_flow_custom,
_model_flow_azure_foundry,
_model_flow_named_custom,
_model_flow_copilot,
_model_flow_copilot_acp,
_model_flow_kimi,
_model_flow_stepfun,
_model_flow_bedrock_api_key,
_model_flow_bedrock,
_model_flow_vertex,
_model_flow_api_key_provider,
_model_flow_anthropic,
_model_flow_moa,
_model_flow_ai_gateway,
)
logger = logging.getLogger(__name__)
from hermes_cli.main_agent_cmds import ( # noqa: E402,F401 (re-exported; tests patch hermes_cli.main.<name>)
_cmd_skills_trust,
cmd_acp,
cmd_insights,
cmd_memory,
cmd_monitoring,
cmd_skills,
cmd_tools,
)
from hermes_cli.main_platform_setup import ( # noqa: E402,F401 (re-exported; tests patch hermes_cli.main.<name>)
cmd_slack,
cmd_sync,
cmd_whatsapp,
cmd_whatsapp_cloud,
)
from hermes_cli.main_dashboard import ( # noqa: E402,F401 (re-exported; tests patch hermes_cli.main.<name>)
_DASHBOARD_SYSTEMD_UNIT,
_UpdateOutputStream,
_dashboard_cmdline_for_pid,
_dashboard_listening,
_dashboard_probe_host,
_extract_scope_from_cgroup,
_finalize_update_output,
_find_stale_dashboard_pids,
_get_pid_cgroup_path,
_get_systemd_service_for_pid,
_install_hangup_protection,
_is_electron_packaged_web_dist,
_maybe_setup_dashboard_auth_interactively,
_parse_dashboard_runtime,
_read_ssh_session_token_file,
_report_dashboard_status,
_resolve_dashboard_web_dist,
_respawn_dashboard_processes,
_restart_managed_dashboard_service,
_route_named_profile_dashboard,
_try_restart_systemd_service,
)
from hermes_cli.main_provider_setup import ( # noqa: E402,F401 (re-exported; tests patch hermes_cli.main.<name>)
_AUX_TASKS,
_build_provider_picker_rows,
_named_custom_provider_map,
_DEFAULT_QWEN_PORTAL_MODELS,
_DELEGATION_TASK_DESC,
_DELEGATION_TASK_KEY,
_DELEGATION_TASK_NAME,
_GENERIC_API_KEY_PROVIDERS,
_all_aux_tasks,
_auto_provider_name,
_aux_config_menu,
_aux_flow_custom_endpoint,
_aux_flow_provider_model,
_aux_select_for_task,
_aux_task_display_name,
_clear_stale_openai_base_url,
_custom_provider_api_key_config_value,
_custom_provider_base_url_config_value,
_delegation_cfg_as_task,
_format_aux_current,
_infer_stepfun_region,
_is_profile_api_key_provider,
_prompt_api_key,
_prompt_custom_api_mode_selection,
_prompt_provider_choice,
_prompt_reasoning_effort_selection,
_remove_custom_provider,
_reset_aux_to_auto,
_run_anthropic_oauth_flow,
_save_aux_choice,
_save_custom_provider,
_stepfun_base_url_for_region,
)
from hermes_cli.main_install_repair import ( # noqa: E402,F401 (re-exported; tests patch hermes_cli.main.<name>)
ShimQuarantineError,
_LAZY_REFRESH_IMPORT_PROBES,
_LAZY_REFRESH_REPAIR_PACKAGES,
_PENDING_RENAME_KEY,
_PENDING_RENAME_VALUE,
_QUARANTINE_GRACE_SECONDS,
_UPDATE_REEXEC_ENV,
_cleanup_pending_shim_renames,
_cleanup_quarantined_exes,
_clear_lazy_refresh_incomplete_marker,
_clear_marker_file,
_clear_update_incomplete_marker,
_default_venv_install_target,
_detect_broken_lazy_refresh_imports,
_filter_pending_shim_renames,
_hermes_exe_shims,
_insert_python_pin,
_install_python_dependencies_with_optional_fallback,
_interpreter_scripts_dir,
_is_termux_env,
_is_uv_command,
_is_windows,
_is_windows_npm_path,
_lazy_refresh_marker_path,
_lazy_refresh_repair_specs,
_load_console_script_names,
_load_installable_optional_extras,
_norm_exe_path,
_pytest_owns_live_checkout,
_quarantine_running_hermes_exe,
_quarantine_stamp_ms,
_recover_core_update_marker_locked,
_recover_from_interrupted_install,
_recover_lazy_refresh_marker_locked,
_reexec_dependency_sync_off_windows_shim,
_repair_broken_lazy_refresh_imports,
_repair_venv_via_import_probes,
_resolve_install_target_python,
_resolve_node_runtime_npm,
_resolve_update_branch,
_restore_quarantined_exes,
_run_install_with_heartbeat,
_run_package_only_install,
_run_quarantined_install,
_update_marker_path,
_venv_scripts_dir,
_verify_console_scripts_installed,
_verify_core_dependencies_installed,
_windows_running_hermes_launcher_locked,
_windows_shim_in_process_chain,
)
from hermes_cli.main_desktop import ( # noqa: E402,F401 (re-exported; tests patch hermes_cli.main.<name>)
_DESKTOP_PREVIOUS_SUFFIX,
_DESKTOP_STAGING_PREFIX,
_ELECTRON_FALLBACK_MIRROR,
_HTML_TAG_WITH_URL,
_LINUX_PASSWORD_STORES,
_MACHINE_ATTRIBUTE_USER_ENABLED,
_MODULE_TAG,
_PE_MACHINE_AMD64,
_PE_MACHINE_ARM64,
_PE_MACHINE_I386,
_PE_MACHINE_NAMES,
_PE_MACHINE_TO_NAME,
_build_desktop_app,
_compute_desktop_content_hash,
_desktop_backup_unpacked_dir,
_desktop_build_needed,
_desktop_dist_exists,
_desktop_exe_integrity_error,
_desktop_launch_options,
_desktop_linux_needs_disable_setuid_sandbox,
_desktop_linux_needs_no_sandbox,
_desktop_linux_sandbox_fixup,
_desktop_linux_sandbox_helper_is_regular_file,
_desktop_linux_userns_sandbox_available,
_desktop_macos_bundle_id,
_desktop_macos_has_valid_real_signature,
_desktop_macos_local_codesign,
_desktop_macos_local_signing_identity,
_desktop_macos_relaunchable_fixup,
_desktop_macos_setup_tcc_identity,
_desktop_packaged_executable,
_desktop_packaged_executable_in,
_desktop_staging_dir,
_desktop_stamp_path,
_desktop_unpacked_root,
_detect_linux_password_store,
_discard_desktop_staging,
_electron_dir,
_electron_dist_binary,
_electron_dist_ok,
_electron_download_cache_dirs,
_electron_pkg_staged_missing_dist,
_ensure_desktop_exe_launchable,
_expected_windows_pe_machines,
_force_adhoc_macos_signing,
_macos_codesigning_identity_valid,
_parse_pe_machine,
_pe_machine_or_none,
_purge_electron_build_cache,
_redownload_electron_dist,
_register_linux_desktop_entry,
_renderer_bundle_dir,
_renderer_bundle_torn,
_rollback_desktop_from_backup,
_stop_desktop_processes_locking_build,
_swap_staged_desktop_app,
_try_redownload_electron_dist,
_windows_native_machine,
_windows_native_machine_from_iswow64,
_windows_user_runnable_pe_machines,
_write_desktop_build_stamp,
cmd_gui,
)
from hermes_cli.main_web_build import ( # noqa: E402,F401 (re-exported; tests patch hermes_cli.main.<name>)
_BYTECODE_FINGERPRINT_FILE,
_build_web_ui,
_compute_web_ui_content_hash,
_do_build_web_ui,
_missing_web_build_tool,
_nixos_build_env,
_record_bytecode_fingerprint,
_run_npm_install_deterministic,
_run_npm_watching_for_engine_failure,
_run_with_idle_timeout,
_sweep_stale_bytecode_if_checkout_changed,
_web_ui_build_needed,
_web_ui_stamp_path,
_write_web_ui_build_stamp,
)
from hermes_cli.main_tui_launch import ( # noqa: E402,F401 (re-exported; tests patch hermes_cli.main.<name>)
_NPM_LOCK_RUNTIME_KEYS,
_TUI_BUILD_INPUT_DIRS,
_TUI_BUILD_INPUT_FILES,
_TUI_BUILD_INPUT_SUFFIXES,
_apply_tui_python_env,
_ensure_tui_node,
_ensure_tui_workspace,
_find_bundled_tui,
_iter_tui_build_inputs,
_launch_tui,
_make_tui_argv,
_normalize_tui_toolsets,
_npm_lifecycle_env,
_npm_lock_workspace_closure,
_pin_kanban_board_env,
_print_tui_exit_summary,
_read_cgroup_memory_limit,
_read_tui_active_session_file,
_resolve_tui_heap_mb,
_resolve_use_tui,
_restore_tui_workspace,
_safe_tui_cwd,
_sync_bundled_skills_quietly,
_termux_workspace_install_context,
_tui_need_npm_install,
_tui_need_rebuild,
_tui_selected_workspace_keys,
_workspace_root,
)
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 _has_any_provider_configured(*, strict_profile_scope: bool = False) -> bool:
"""Check if at least one inference provider is usable.
``strict_profile_scope``: the caller has bound a NAMED profile's home and
secret scope and wants an answer for that profile only — launch-process
env and host-wide fallbacks (gh auth, Claude Code credentials) must not
make it appear ready. Unscoped callers keep the legacy behavior.
"""
from hermes_cli.config import get_env_path, get_hermes_home, load_config
from hermes_cli.auth import get_auth_status
# Determine whether Hermes itself has been explicitly configured (model
# in config that isn't the hardcoded default). Used below to gate external
# tool credentials (Claude Code, Codex CLI) that shouldn't silently skip
# the setup wizard on a fresh install.
from hermes_cli.config import DEFAULT_CONFIG
_DEFAULT_MODEL = DEFAULT_CONFIG.get("model", "")
cfg = load_config()
model_cfg = cfg.get("model")
if isinstance(model_cfg, dict):
_default = model_cfg.get("default")
if isinstance(_default, dict):
from hermes_cli.config import split_model_config_default
_model_name, _ = split_model_config_default(_default)
else:
_model_name = (_default or "")
_model_name = (str(_model_name) if not isinstance(_model_name, str) else _model_name).strip()
elif isinstance(model_cfg, str):
_model_name = model_cfg.strip()
else:
_model_name = ""
_has_hermes_config = _model_name and _model_name != _DEFAULT_MODEL
# Check env vars (may be set by .env or shell).
# OPENAI_BASE_URL alone counts — local models (vLLM, llama.cpp, etc.)
# often don't require an API key.
from hermes_cli.auth import PROVIDER_REGISTRY
# Collect all provider env vars
provider_env_vars = {
"OPENROUTER_API_KEY",
"OPENAI_API_KEY",
"ANTHROPIC_API_KEY",
"ANTHROPIC_TOKEN",
"OPENAI_BASE_URL",
}
for pconfig in PROVIDER_REGISTRY.values():
if pconfig.auth_type == "api_key":
provider_env_vars.update(pconfig.api_key_env_vars)
if strict_profile_scope:
from agent.secret_scope import current_secret_scope
read_provider_env = (current_secret_scope() or {}).get
else:
read_provider_env = os.getenv
if any(read_provider_env(v) for v in provider_env_vars):
return True
# Check .env file for keys
env_file = get_env_path()
if env_file.exists():
try:
for line in env_file.read_text(encoding="utf-8").splitlines():
line = line.strip()
if line.startswith("#") or "=" not in line:
continue
if line.startswith("export "):
line = line[7:]
key, _, val = line.partition("=")
val = val.strip().strip("'\"")
if key.strip() in provider_env_vars and val:
return True
except Exception:
pass
# Cheap local checks first: auth.json and config.yaml are on-disk lookups,
# while the PROVIDER_REGISTRY sweep below spawns subprocesses (gh) and can
# take 15-20s — long enough that desktop setup.status calls time out.
# Check for Nous Portal OAuth credentials
auth_file = get_hermes_home() / "auth.json"
if auth_file.exists():
try:
import json
auth = json.loads(auth_file.read_text(encoding="utf-8-sig"))
active = auth.get("active_provider")
active_config = PROVIDER_REGISTRY.get(str(active or "").strip().lower())
if active and not (
strict_profile_scope and active_config and active_config.auth_type == "api_key"
):
status = get_auth_status(active)
if status.get("logged_in"):
return True
except Exception:
pass
# Check config.yaml — if model is a dict with an explicit provider set,
# the user has gone through setup (fresh installs have model as a plain
# string). Also covers custom endpoints that store api_key/base_url in
# config rather than .env.
if isinstance(model_cfg, dict):
cfg_provider = (model_cfg.get("provider") or "").strip()
cfg_base_url = (model_cfg.get("base_url") or "").strip()
cfg_api_key = (model_cfg.get("api_key") or "").strip()
if cfg_provider or cfg_base_url or cfg_api_key:
return True
# Check provider-specific auth fallbacks (for example, Copilot via gh auth).
if not strict_profile_scope:
try:
for provider_id, pconfig in PROVIDER_REGISTRY.items():
if pconfig.auth_type != "api_key":
continue
status = get_auth_status(provider_id)
if status.get("logged_in"):
return True
except Exception:
pass
# Check for Claude Code OAuth credentials (~/.claude/.credentials.json)
# Only count these if Hermes has been explicitly configured — Claude Code
# being installed doesn't mean the user wants Hermes to use their tokens.
if _has_hermes_config and not strict_profile_scope:
try:
from agent.anthropic_adapter import (
read_claude_code_credentials,
is_claude_code_token_valid,
)
creds = read_claude_code_credentials()
if creds and (
is_claude_code_token_valid(creds) or creds.get("refreshToken")
):
return True
except Exception:
pass
return False
def _confirm_startup_expensive_model_override(args) -> None:
"""Guard startup -m/--provider overrides before the first API call."""
explicit_model = (getattr(args, "model", None) or "").strip()
explicit_provider = (getattr(args, "provider", None) or "").strip()
if not explicit_model and not explicit_provider:
return
try:
from hermes_cli.config import load_config
from hermes_cli.model_selection_guards import (
combined_message,
selection_warnings,
)
except Exception as exc:
logger.warning("startup model cost guard unavailable: %s", exc)
return
try:
config = load_config()
except Exception as exc:
logger.warning("startup model cost guard could not load config: %s", exc)
config = {}
if not isinstance(config, dict):
config = {}
model_cfg = config.get("model") or {}
if not isinstance(model_cfg, dict):
model_cfg = {}
security_cfg = config.get("security") or {}
if not isinstance(security_cfg, dict):
security_cfg = {}
model = explicit_model or (model_cfg.get("default") or "").strip()
if not model:
return
provider = (explicit_provider or model_cfg.get("provider") or "").strip()
try:
# Unified registry: cost guard + id-keyed guards (e.g. the
# data-training-tier warning) all fire at startup too.
warnings = selection_warnings(
model,
provider=provider,
base_url=(model_cfg.get("base_url") or ""),
api_key=(model_cfg.get("api_key") or ""),
)
except Exception as exc:
logger.warning("startup model cost guard failed for %s/%s: %s", provider, model, exc)
return
if not warnings:
return
# Cost and provider-routing confirmation is intentionally independent of
# --yolo / --accept-hooks: those flags approve local command/tool risk, not
# paid aggregator spend or a surprising provider route.
is_interactive = sys.stdin.isatty()
allow_unattended_data_training = (
security_cfg.get("allow_data_training_tiers_noninteractive") is True
)
if not is_interactive and allow_unattended_data_training:
acknowledged = [
warning for warning in warnings if warning.kind == "data_policy"
]
if acknowledged:
sys.stderr.write(combined_message(acknowledged) + "\n")
sys.stderr.write(
"Proceeding in non-interactive mode because "
"security.allow_data_training_tiers_noninteractive is true.\n"
)
warnings = [
warning for warning in warnings if warning.kind != "data_policy"
]
if not warnings:
return
message = combined_message(warnings)
if not is_interactive:
sys.stderr.write(message + "\n")
if any(warning.kind == "data_policy" for warning in warnings):
sys.stderr.write(
"To acknowledge data-training tiers for unattended runs, set "
"security.allow_data_training_tiers_noninteractive to true "
"in config.yaml.\n"
)
sys.stderr.write(
"Refusing this startup model override in non-interactive mode. "
"Run interactively and confirm if you intend to use it.\n"
)
raise SystemExit(1)
sys.stderr.write(message + "\n")
try:
reply = input("Use this model for this invocation? [y/N] ").strip().lower()
except (EOFError, KeyboardInterrupt):
reply = ""
if reply not in {"y", "yes"}:
sys.stderr.write("Model override cancelled.\n")
raise SystemExit(1)
def _resolve_workspace_key() -> Optional[str]:
"""The current workspace identity for cwd-scoped resume.
Git repo root when CWD is inside a repo (so all sessions across its
subdirs/worktrees group together), else the CWD itself. Returns None when
neither can be determined — callers fall back to the global MRU then.
"""
try:
import subprocess
result = subprocess.run(
["git", "rev-parse", "--show-toplevel"],
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=5,
)
if result.returncode == 0 and result.stdout.strip():
return os.path.abspath(result.stdout.strip())
except Exception:
pass
try:
return os.getcwd()
except Exception:
return None
def _resolve_last_session(source: str = "cli") -> Optional[str]:
"""Look up the most recently-used session ID for a source.
Scoped to the current workspace first (git repo root, else cwd) so
``hermes -c`` from repo A continues repo A's last session rather than the
global MRU. Falls back to the unscoped MRU when no session matches the
current workspace, preserving the old behaviour for fresh directories.
"""
db = None
try:
from hermes_state import SessionDB
db = SessionDB()
ws_key = _resolve_workspace_key()
if ws_key:
sessions = db.search_sessions(source=source, limit=1, workspace_key=ws_key)
if sessions:
return sessions[0]["id"]
# Fallback: global MRU for this source.
sessions = db.search_sessions(source=source, limit=1)
return sessions[0]["id"] if sessions else None
except Exception:
pass
finally:
if db is not None:
try:
db.close()
except Exception:
pass
return None
def _probe_container(cmd: list, backend: str, via_sudo: bool = False):
"""Run a container inspect probe, returning the CompletedProcess.
Catches TimeoutExpired specifically for a human-readable message;
all other exceptions propagate naturally.
"""
try:
return subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=15)
except subprocess.TimeoutExpired:
label = f"sudo {backend}" if via_sudo else backend
print(
f"Error: timed out waiting for {label} to respond.\n"
f"The {backend} daemon may be unresponsive or starting up.",
file=sys.stderr,
)
sys.exit(1)
def _exec_in_container(container_info: dict, cli_args: list):
"""Replace the current process with a command inside the managed container.
Probes whether sudo is needed (rootful containers), then os.execvp
into the container. On success the Python process is replaced entirely
and the container's exit code becomes the process exit code (OS semantics).
On failure, OSError propagates naturally.
Args:
container_info: dict with backend, container_name, exec_user, hermes_bin
cli_args: the original CLI arguments (everything after 'hermes')
"""
backend = container_info["backend"]
container_name = container_info["container_name"]
exec_user = container_info["exec_user"]
hermes_bin = container_info["hermes_bin"]
runtime = shutil.which(backend)
if not runtime:
print(
f"Error: {backend} not found on PATH. Cannot route to container.",
file=sys.stderr,
)
sys.exit(1)
# Rootful containers (NixOS systemd service) are invisible to unprivileged
# users — Podman uses per-user namespaces, Docker needs group access.
# Probe whether the runtime can see the container; if not, try via sudo.
sudo_path = None
probe = _probe_container(
[runtime, "inspect", "--format", "ok", container_name],
backend,
)
if probe.returncode != 0:
sudo_path = shutil.which("sudo")
if sudo_path:
probe2 = _probe_container(
[sudo_path, "-n", runtime, "inspect", "--format", "ok", container_name],
backend,
via_sudo=True,
)
if probe2.returncode != 0:
print(
f"Error: container '{container_name}' not found via {backend}.\n"
f"\n"
f"The container is likely running as root. Your user cannot see it\n"
f"because {backend} uses per-user namespaces. Grant passwordless\n"
f"sudo for {backend} — the -n (non-interactive) flag is required\n"
f"because a password prompt would hang or break piped commands.\n"
f"\n"
f"On NixOS:\n"
f"\n"
f" security.sudo.extraRules = [{{\n"
f' users = [ "{os.getenv("USER", "your-user")}" ];\n'
f' commands = [{{ command = "{runtime}"; options = [ "NOPASSWD" ]; }}];\n'
f" }}];\n"
f"\n"
f"Or run: sudo hermes {' '.join(cli_args)}",
file=sys.stderr,
)
sys.exit(1)
else:
print(
f"Error: container '{container_name}' not found via {backend}.\n"
f"The container may be running under root. Try: sudo hermes {' '.join(cli_args)}",
file=sys.stderr,
)
sys.exit(1)
is_tty = sys.stdin.isatty()
tty_flags = ["-it"] if is_tty else ["-i"]
env_flags = []
for var in ("TERM", "COLORTERM", "LANG", "LC_ALL"):
val = os.environ.get(var)
if val:
env_flags.extend(["-e", f"{var}={val}"])
cmd_prefix = [sudo_path, "-n", runtime] if sudo_path else [runtime]
exec_cmd = (
cmd_prefix
+ ["exec"]
+ tty_flags
+ ["-u", exec_user]
+ env_flags
+ [container_name, hermes_bin]
+ cli_args
)
os.execvp(exec_cmd[0], exec_cmd)
def _resolve_session_by_name_or_id(name_or_id: str) -> Optional[str]:
"""Resolve a session name (title) or ID to a session ID.
- If it looks like a session ID (contains underscore + hex), try direct lookup first.
- Otherwise, treat it as a title and use resolve_session_by_title (auto-latest).
- Falls back to the other method if the first doesn't match.
- If the resolved session is a compression root, follow the chain forward
to the latest continuation. Users who remember the old root ID (e.g.
from an exit summary printed before the bug fix, or from notes) get
resumed at the live tip instead of a stale parent with no messages.
"""
db = None
try:
from hermes_state import SessionDB
db = SessionDB()
# Try as exact session ID first
session = db.get_session(name_or_id)
resolved_id: Optional[str] = None
if session:
resolved_id = session["id"]
else:
# Try as title (with auto-latest for lineage)
resolved_id = db.resolve_session_by_title(name_or_id)
if resolved_id:
# Project forward through compression chain so resumes land on
# the live tip instead of a dead compressed parent.
try:
resolved_id = db.get_compression_tip(resolved_id) or resolved_id
except Exception:
pass
return resolved_id
except Exception:
pass
finally:
if db is not None:
try:
db.close()
except Exception:
pass
return None
def _create_titled_session(title: str) -> Optional[str]:
"""Create a fresh session with the given title; return its session id.
Used by ``chat -c <title> --create-if-missing`` (#86794): programmatic
callers (plugins, scripts) that want "send to this named thread, making
it if needed" get a deterministic outcome instead of a silent no-op.
The session id follows the same timestamp+uuid shape the CLI uses for a
brand-new session; the title is recorded with user provenance so
auto-titling never overwrites it.
"""
db = None
try:
import uuid as _uuid
from hermes_state import SessionDB
now = datetime.now()
timestamp_str = now.strftime("%Y%m%d_%H%M%S")
short_uuid = _uuid.uuid4().hex[:6]
new_session_id = f"{timestamp_str}_{short_uuid}"
db = SessionDB()
db.create_session(new_session_id, source="cli")
db.set_session_title(new_session_id, title)
return new_session_id
except Exception:
# Programmatic callers (the #86794 use case) rely on --create-if-missing
# being deterministic; swallow the failure to keep the error path simple,
# but log the underlying cause so it lands in errors.log and stays
# debuggable (DB lock, I/O error, import error — all otherwise invisible).
logger.exception("Failed to create titled session %r", title)
return None
finally:
if db is not None:
try:
db.close()
except Exception:
pass
def _resolve_continue_arg(args, *, use_tui: bool) -> None:
"""Resolve ``-c/--continue`` into ``args.resume``.
Handles both forms:
- ``-c <name>``: resolve by title/ID. On miss, fail loudly on **stderr**
(exit 1) so programmatic callers see the error even under quiet mode
(#86794); with ``--create-if-missing``, create a fresh titled session
and resume into it instead.
- bare ``-c``: continue this terminal's breadcrumb session if valid,
else the most recent session (workspace-scoped MRU, then global
fallback).
"""
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
elif getattr(args, "create_if_missing", False):
# --create-if-missing: no session matches the title — create a
# new session with that title and proceed. This is the
# programmatic-caller primitive ("send to this named thread,
# making it if needed"); without it a background/quiet send to
# a not-yet-existing named session silently no-ops (#86794).
new_sid = _create_titled_session(continue_val)
if new_sid:
args.resume = new_sid
else:
print(
f"No session found matching '{continue_val}' and "
"a new titled session could not be created.",
file=sys.stderr,
)
sys.exit(1)
else:
print(f"No session found matching '{continue_val}'.", file=sys.stderr)
print(
"Use 'hermes sessions list' to see available sessions, or "
"pass --create-if-missing to start a new session with that title.",
file=sys.stderr,
)
sys.exit(1)
else:
# -c with no argument — prefer this terminal's own breadcrumb
# (written at session start / rotation) so side-by-side terminals
# each continue their own conversation. Falls back to the
# most-recent session when there is no valid breadcrumb, or when
# session.terminal_continue is false in config.yaml.
if getattr(args, "create_if_missing", False):
# --create-if-missing only makes sense with a named session;
# with a bare -c there is nothing to create, so surface the
# no-op to programmatic callers instead of silently ignoring it.
print(
"--create-if-missing requires a session name: "
"`-c <name> --create-if-missing`",
file=sys.stderr,
)
try:
from hermes_cli.terminal_breadcrumbs import resolve_breadcrumb_session
_crumb_id = resolve_breadcrumb_session()
except Exception:
_crumb_id = None
if _crumb_id:
args.resume = _crumb_id
else:
# No valid breadcrumb — 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)
def _resolve_chat_session_args(args, use_tui: bool) -> None:
"""Normalize --in / --resume / --continue on ``args`` before agent init.
Order matters: ``--in DIR`` chdirs first so workspace-scoped "latest"/-c
lookups key off DIR (and pins the session there, skipping cwd restore);
then ``--resume latest`` → MRU id, ``--continue`` → ``--resume``,
``--resume @claude/@codex`` → imported session id, title → id; finally
cd back into a resumed session's recorded cwd (best-effort, opt-out via
--no-restore-cwd, skipped under --worktree).
"""
# --in DIR: run in DIR. Must happen before any session resolution so the
# workspace-scoped "latest"/-c lookups key off DIR, and it pins the
# session there — an explicit --in wins over a resumed session's
# recorded cwd (so the restore step below is skipped).
in_dir = getattr(args, "in_dir", None)
if in_dir:
# Git Bash / MSYS hands the CLI POSIX-style paths (`--in ~` expands to
# `/c/Users/x` before Python ever sees it; MSYS2's path conversion is
# disabled for native executables). Translate the MSYS/Cygwin/WSL
# drive-root spellings to native Windows form first — no-op elsewhere.
from tools.environments.local import _msys_to_windows_path
_target_dir = os.path.abspath(
os.path.expanduser(_msys_to_windows_path(in_dir))
)
if not os.path.isdir(_target_dir):
print(f"Error: --in directory not found: {in_dir}")
sys.exit(1)
try:
os.chdir(_target_dir)
except OSError as e:
print(f"Error: cannot enter --in directory {in_dir}: {e}")
sys.exit(1)
args.no_restore_cwd = True
# --resume latest: keyword for "most recent session" — same resolution
# as `-c` with no name (workspace-scoped MRU, then global fallback).
# The keyword wins over a session literally titled "latest"; that
# session stays reachable via its ID or `-c latest` (title match).
_resume_raw = getattr(args, "resume", None)
if isinstance(_resume_raw, str) and _resume_raw.strip().lower() == "latest":
_source = "tui" if use_tui else "cli"
_last_id = _resolve_last_session(source=_source)
if not _last_id and _source == "tui":
_last_id = _resolve_last_session(source="cli")
if _last_id:
args.resume = _last_id
else:
kind = "TUI" if use_tui else "CLI"
print(f"No previous {kind} session found to resume.")
print("Use 'hermes sessions list' to see available sessions.")
sys.exit(1)
# Resolve --continue into --resume with the latest session or by name
_resolve_continue_arg(args, use_tui=use_tui)
# --resume @claude / --resume @codex: import a foreign session (Claude
# Code / Codex CLI) and resume the newly created Hermes session.
_resume_foreign = getattr(args, "resume", None)
if isinstance(_resume_foreign, str) and _resume_foreign.strip().lower() in (
"@claude",
"@codex",
):
from hermes_cli.foreign_sessions import (
import_foreign_session,
pick_foreign_session,
)
_foreign_source = _resume_foreign.strip().lower().lstrip("@")
_picked = pick_foreign_session(_foreign_source)
if _picked is None:
sys.exit(1)
try:
_imported_id = import_foreign_session(_picked.source, _picked.path)
except ValueError as e:
print(f"Error: {e}")
sys.exit(1)
print(f"✓ Imported as {_imported_id} — resuming it now.")
print(f" (later: hermes --resume {_imported_id})")
args.resume = _imported_id
# Resolve --resume by title if it's not a direct session ID
resume_val = getattr(args, "resume", None)
if resume_val:
resolved = _resolve_session_by_name_or_id(resume_val)
if resolved:
args.resume = resolved
# If resolution fails, keep the original value — _init_agent will
# report "Session not found" with the original input
# Session<->workspace binding: cd back into a resumed session's recorded cwd
# so it resumes in the repo it belonged to. Opt out with --no-restore-cwd;
# skipped under --worktree (that path owns its own dir). Best-effort — a
# missing dir warns and stays put rather than failing the resume.
if (
getattr(args, "resume", None)
and not getattr(args, "no_restore_cwd", False)
and not getattr(args, "worktree", False)
):
_resume_db = None
try:
from hermes_state import SessionDB
_resume_db = SessionDB()
_saved_cwd = ((_resume_db.get_session(args.resume) or {}).get("cwd") or "").strip()
if _saved_cwd and not os.path.isdir(_saved_cwd):
print(f"⚠ session's recorded dir is gone ({_saved_cwd}); staying in {os.getcwd()}")
elif _saved_cwd and os.path.realpath(_saved_cwd) != os.path.realpath(os.getcwd()):
os.chdir(_saved_cwd)
print(f"↪ restored workspace dir: {_saved_cwd}")
except Exception:
pass # never let cwd-restore break a resume
finally:
if _resume_db is not None:
try:
_resume_db.close()
except Exception:
pass
def _warn_retired_xai_models() -> None:
"""One-shot xAI retirement warning on stderr; 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
def _start_chat_background_prefetch() -> None:
"""Kick off the update-check/banner prefetch and the bundled-skills sync.
Update check is opt-in on Termux (it imports rich/prompt_toolkit in the
foreground and competes for CPU on single-core devices). The skills sync
is idempotent and hash-gated, so it normally runs in a daemon thread;
the ONE exception is an unseeded ~/.hermes/skills — there the banner
prefetch would race the sync and cache an empty skills index, so the
first run syncs in the foreground and drops the banner's skills cache.
"""
# Start update check in background (runs while other init happens).
# On Termux this imports rich/prompt_toolkit in the foreground and then
# competes for CPU on single-core devices, so keep it opt-in there.
if _termux_should_prefetch_update_check():
try:
from hermes_cli.banner import prefetch_banner_data, prefetch_update_check
prefetch_update_check()
# Warm git banner state + skills index off-thread too — their
# subprocess/file-I/O waits overlap the CPU-bound cli import.
prefetch_banner_data()
except Exception:
pass
# Sync bundled skills on every CLI launch. Normally runs in a background
# daemon thread: the sync is idempotent, hash-gated (unchanged skills are
# skipped), and nothing on the banner path depends on it, yet the scan
# alone costs ~120-170ms of rglob/hashing on the startup path. Skill
# loading happens at agent init (first message), by which point the
# sync has long finished.
#
# FIRST RUN is the exception: with an empty ~/.hermes/skills the banner
# prefetch races the background sync, caches an empty skills index, and
# the very first launch greets the user with "No skills installed ·
# 0 skills" while 69 bundled skills land milliseconds later (full-surface
# CLI QA sweep, Aug 2026). Run the sync in the foreground exactly once —
# only when the skills dir has no SKILL.md yet — so the first impression
# matches reality; every later launch keeps the background path.
def _skills_dir_is_unseeded() -> bool:
try:
from hermes_cli.config import get_hermes_home
skills_dir = Path(get_hermes_home()) / "skills"
if not skills_dir.is_dir():
return True
return next(skills_dir.rglob("SKILL.md"), None) is None
except Exception:
return False
def _skills_sync_bg() -> None:
try:
_sync_bundled_skills_for_startup()
except Exception:
pass
if _skills_dir_is_unseeded():
_skills_sync_bg()
# The banner prefetch thread (started above) may have scanned the
# still-empty dir and cached an empty skills index — drop it so the
# banner recomputes against the freshly seeded tree.
try:
import hermes_cli.banner as _banner_mod
_banner_mod._available_skills_cache = None
except Exception:
pass
else:
threading.Thread(
target=_skills_sync_bg, name="bundled-skills-sync", daemon=True
).start()
def cmd_chat(args):
"""Run interactive chat CLI."""
_apply_safe_mode(args)
_apply_user_config_bypass(args)
_guard_noninteractive_user_config(args)
use_tui = _resolve_use_tui(args)
_resolve_chat_session_args(args, use_tui)
_warn_retired_xai_models()
# 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_chat_background_prefetch()
# --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-rules: skip auto-injection of AGENTS.md/SOUL.md/.cursorrules
# (rules), memory entries, and any preloaded skills coming from user config.
# Maps to AIAgent(skip_context_files=True, skip_memory=True).
if getattr(args, "ignore_rules", False):
os.environ["HERMES_IGNORE_RULES"] = "1"
# --source: tag session source for filtering (e.g. 'tool' for third-party integrations)
if getattr(args, "source", None):
os.environ["HERMES_SESSION_SOURCE"] = args.source
_pin_kanban_board_env()
_confirm_startup_expensive_model_override(args)
if use_tui:
_launch_tui(
getattr(args, "resume", None),
tui_dev=getattr(args, "tui_dev", False),
model=getattr(args, "model", None),
provider=getattr(args, "provider", None),
toolsets=getattr(args, "toolsets", None),
skills=getattr(args, "skills", None),
verbose=getattr(args, "verbose", None),
quiet=getattr(args, "quiet", False),
query=getattr(args, "query", None),
image=getattr(args, "image", None),
worktree=getattr(args, "worktree", False),
checkpoints=getattr(args, "checkpoints", False),
pass_session_id=getattr(args, "pass_session_id", False),
max_turns=getattr(args, "max_turns", None),
accept_hooks=getattr(args, "accept_hooks", False),
)
# --query-file: read the single query from a file (or stdin via '-') so
# callers never have to shell-quote message bodies. This is the transport
# the Bot Mode DM protocol uses — interpolating arbitrary text into a
# double-quoted shell argument truncates on quotes and executes $(...)
# (see tools/bot_mode_probe.py).
_qfile = getattr(args, "query_file", None)
if _qfile:
if args.query:
# argparse's mutually-exclusive group catches the normal CLI path;
# this guards programmatic callers that fill the namespace directly.
print("Error: -q/--query and --query-file are mutually exclusive", file=sys.stderr)
sys.exit(2)
try:
if _qfile == "-":
args.query = sys.stdin.read()
else:
with open(_qfile, "r", encoding="utf-8", errors="replace") as _fh:
args.query = _fh.read()
except OSError as _e:
print(f"Error: cannot read --query-file {_qfile}: {_e}", file=sys.stderr)
sys.exit(2)
if not (args.query or "").strip():
print(f"Error: --query-file {_qfile} is empty", file=sys.stderr)
sys.exit(2)
# Build kwargs from args
kwargs = {
"model": args.model,
"provider": getattr(args, "provider", None),
"reasoning": getattr(args, "reasoning", None),
"toolsets": args.toolsets,
"skills": getattr(args, "skills", None),
"verbose": getattr(args, "verbose", None),
"quiet": getattr(args, "quiet", False),
"query": args.query,
"oneshot": bool(getattr(args, "oneshot_exit", False)),
"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),
"run_budget": getattr(args, "run_budget", 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:
from cli import main as cli_main
cli_main(**kwargs)
except ValueError as e:
print(f"Error: {e}")
sys.exit(1)
except ImportError as e:
# Mixed-version installs (new cli.py, older hermes_cli.config) crash
# here — e.g. missing resolve_turn_limit / split_model_config_default
# (#96900). The agent-setup mixin prints this hint too late: HermesCLI
# construction already failed. Fast-chat launch also goes through
# cmd_chat, so this one catch covers `hermes` / `hermes chat`.
from hermes_constants import emit_partial_update_hint
if emit_partial_update_hint(e):
sys.exit(1)
raise
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 _forward_command(name: str, module: str, attr: str, *, forward_return: bool = False, doc: str = ""):
"""A ``hermes <cmd>`` handler that hands ``args`` to ``<module>.<attr>``.
The import happens at CALL time so ``hermes --version`` and the other fast
paths never pay for it, and ``patch("<module>.<attr>")`` keeps intercepting.
``forward_return`` surfaces the handler's return code to ``main()``; the
default swallows it (only kanban/project ever propagated theirs).
"""
def _cmd(args):
import importlib
result = getattr(importlib.import_module(module), attr)(args)
return result if forward_return else None
_cmd.__name__ = _cmd.__qualname__ = name
_cmd.__doc__ = doc or None
return _cmd
cmd_setup = _forward_command("cmd_setup", "hermes_cli.setup", "run_setup_wizard", doc='Interactive setup wizard.')
cmd_login = _forward_command("cmd_login", "hermes_cli.auth", "login_command", doc='Authenticate Hermes CLI with a provider.')
cmd_logout = _forward_command("cmd_logout", "hermes_cli.auth", "logout_command", doc='Clear provider authentication.')
cmd_auth = _forward_command("cmd_auth", "hermes_cli.auth_commands", "auth_command", doc='Manage pooled credentials.')
cmd_status = _forward_command("cmd_status", "hermes_cli.status", "show_status", doc='Show status of all components.')
cmd_cron = _forward_command("cmd_cron", "hermes_cli.cron", "cron_command", doc='Cron job management.')
cmd_webhook = _forward_command("cmd_webhook", "hermes_cli.webhook", "webhook_command", doc='Webhook subscription management.')
cmd_kanban = _forward_command("cmd_kanban", "hermes_cli.kanban", "kanban_command", forward_return=True, doc='Multi-profile collaboration board.')
cmd_project = _forward_command("cmd_project", "hermes_cli.projects_cmd", "projects_command", forward_return=True, doc='Manage projects (named, multi-folder workspaces).')
cmd_hooks = _forward_command("cmd_hooks", "hermes_cli.hooks", "hooks_command", doc='Shell-hook inspection and management.')
cmd_doctor = _forward_command("cmd_doctor", "hermes_cli.doctor", "run_doctor", doc='Check configuration and dependencies.')
cmd_dump = _forward_command("cmd_dump", "hermes_cli.dump", "run_dump", doc='Dump setup summary for support/debugging.')
cmd_debug = _forward_command("cmd_debug", "hermes_cli.debug", "run_debug", doc='Debug tools (share report, etc.).')
cmd_skin = _forward_command("cmd_skin", "hermes_cli.skin_cmd", "skin_command", doc='Skin management (list / use / set).')
cmd_import = _forward_command("cmd_import", "hermes_cli.backup", "run_import", doc='Restore a Hermes backup from a zip file.')
cmd_dashboard_register = _forward_command("cmd_dashboard_register", "hermes_cli.dashboard_register", "cmd_dashboard_register", doc='Register a self-hosted dashboard OAuth client with Nous Portal.')
cmd_gateway_enroll = _forward_command("cmd_gateway_enroll", "hermes_cli.gateway_enroll", "cmd_gateway_enroll", doc='Enroll a self-hosted gateway with a relay connector.')
cmd_prompt_size = _forward_command("cmd_prompt_size", "hermes_cli.prompt_size", "cmd_prompt_size", doc='Show a byte/char breakdown of the system prompt + tool schemas.')
cmd_pairing = _forward_command("cmd_pairing", "hermes_cli.pairing", "pairing_command")
cmd_plugins = _forward_command("cmd_plugins", "hermes_cli.plugins_cmd", "plugins_command")
cmd_mcp = _forward_command("cmd_mcp", "hermes_cli.mcp_config", "mcp_command")
cmd_claw = _forward_command("cmd_claw", "hermes_cli.claw", "claw_command")
cmd_import_agent = _forward_command("cmd_import_agent", "hermes_cli.agent_import", "import_agent_command")
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
from hermes_cli.setup import run_setup_action_with_navigation
run_setup_action_with_navigation(
"Model & Provider",
lambda: select_provider_and_model(args=args),
cancelled_message="No change.",
)
# Provider id -> flow(config, current_model, args). Lambdas resolve the
# _model_flow_* names at call time so test monkeypatches on hermes_cli.main
# keep intercepting. Custom-slug providers (always ``custom:*``),
# remove-custom and the generic API-key set are the fallthrough branches in
# select_provider_and_model.
_PROVIDER_MODEL_FLOWS = {
"openrouter": lambda c, m, a: _model_flow_openrouter(c, m),
"moa": lambda c, m, a: _model_flow_moa(c, m),
"ai-gateway": lambda c, m, a: _model_flow_ai_gateway(c, m),
"nous": lambda c, m, a: _model_flow_nous(c, m, args=a),
"openai-codex": lambda c, m, a: _model_flow_openai_codex(c, m),
"xai-oauth": lambda c, m, a: _model_flow_xai_oauth(c, m, args=a),
"qwen-oauth": lambda c, m, a: _model_flow_qwen_oauth(c, m),
"minimax-oauth": lambda c, m, a: _model_flow_minimax_oauth(c, m, args=a),
"copilot-acp": lambda c, m, a: _model_flow_copilot_acp(c, m),
"copilot": lambda c, m, a: _model_flow_copilot(c, m),
"custom": lambda c, m, a: _model_flow_custom(c),
"anthropic": lambda c, m, a: _model_flow_anthropic(c, m),
"kimi-coding": lambda c, m, a: _model_flow_kimi(c, m),
"stepfun": lambda c, m, a: _model_flow_stepfun(c, m),
"bedrock": lambda c, m, a: _model_flow_bedrock(c, m),
"vertex": lambda c, m, a: _model_flow_vertex(c, m),
"azure-foundry": lambda c, m, a: _model_flow_azure_foundry(c, m),
}
def select_provider_and_model(args=None):
"""Core provider selection + model picking logic.
Shared by ``cmd_model`` (``hermes model``) and the setup wizard
(``setup_model_provider`` in setup.py). Handles the full flow:
provider picker, credential prompting, model selection, and config
persistence.
"""
from hermes_cli.auth import (
resolve_provider,
AuthError,
format_auth_error,
)
from hermes_cli.config import (
get_compatible_custom_providers,
load_config,
get_env_value,
)
from hermes_cli.providers import (
custom_provider_aliases,
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 _norm_base_url(url: str) -> str:
return str(url or "").strip().rstrip("/").lower()
# Add user-defined custom providers from config.yaml
_custom_provider_map = _named_custom_provider_map(
config
) # key → {name, base_url, api_key}
def _canonical_named_custom_key(provider_id: str) -> str:
requested = str(provider_id or "").strip().lower()
for key, provider_info in _custom_provider_map.items():
if requested in custom_provider_aliases(
provider_info.get("name", ""),
provider_info.get("provider_key", ""),
):
return key
return provider_id
def _active_custom_key_from_base_url() -> str:
if effective_provider != "custom" or not isinstance(model_cfg, dict):
return ""
current_base = _norm_base_url(model_cfg.get("base_url", ""))
if not current_base:
return ""
for key, provider_info in _custom_provider_map.items():
if _norm_base_url(provider_info.get("base_url", "")) == current_base:
return key
return ""
active = _active_custom_key_from_base_url()
if active is None:
active = ""
if not active and effective_provider != "auto":
active_def = resolve_provider_full(
effective_provider,
config.get("providers"),
compatible_custom_providers,
)
if active_def is not None:
active = active_def.id
if active_def.source == "user-config":
active = _canonical_named_custom_key(active)
else:
warning = (
f"Unknown provider '{effective_provider}'. Check 'hermes model' for "
"available providers, or run 'hermes doctor' to diagnose config "
"issues."
)
print(f"Warning: {warning} Falling back to auto provider detection.")
if not active:
try:
active = resolve_provider("auto")
except AuthError as exc:
if effective_provider == "auto":
warning = format_auth_error(exc)
print(f"Warning: {warning} Falling back to auto provider detection.")
active = None # no provider yet; default to first in list
# Detect custom endpoint
if active == "openrouter" and get_env_value("OPENAI_BASE_URL"):
active = "custom"
from hermes_cli.models import _PROVIDER_LABELS
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 (group rows drill into a member sub-picker
# that resolves back to a concrete slug, so the dispatch below is unchanged).
ordered, default_idx = _build_provider_picker_rows(
config, active, provider_labels, _custom_provider_map
)
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.
# Flows are looked up by name at call time (globals()) so test
# monkeypatches on hermes_cli.main._model_flow_* keep intercepting.
flow = _PROVIDER_MODEL_FLOWS.get(selected_provider)
if flow is not None:
flow(config, current_model, args)
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 in _GENERIC_API_KEY_PROVIDERS
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()
# ─────────────────────────────────────────────────────────────────────────────
# 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.
# ─────────────────────────────────────────────────────────────────────────────
# Lazy re-exports (PEP 562 ``__getattr__`` below). Tests and downstream call
# sites read ``hermes_cli.main.<name>`` for the model catalog and for the
# sessions/update/dashboard command surface that was split out of this file.
# Importing those modules eagerly costs ~50-100ms on every ``hermes``
# invocation, including ``hermes --version``; resolving on first attribute
# access confines the cost to callers that touch them. Monkeypatching keeps
# working: patch.object sets a real module attribute, which shadows __getattr__.
_LAZY_COMMAND_EXPORTS = {
"hermes_cli.sessions_cmd": (
"_annotate_session_statuses",
"_relative_time",
"_session_browse_picker",
"_session_status_tag",
"_size_delta_label",
"cmd_sessions",
),
"hermes_cli.dashboard_procs": (
"_detect_concurrent_hermes_instances",
"_kill_stale_dashboard_processes",
"_scan_dashboard_processes",
),
"hermes_cli.update_cmd": (
"_web_toolchain_roots",
"_web_build_toolchain_ready",
"_warn_incomplete_gateway_fleet_restart",
"_warn_gateway_restart_phase_aborted",
"_surviving_gateway_pids_after_failed_restart",
"_service_unit_supports_graceful_sigusr1_restart",
"_restart_phase_failure_is_incomplete",
"_print_update_completion",
"_log_only_write",
"_for_each_systemd_gateway_unit",
"_print_curator_recent_run_notice",
"_ORPHAN_RESCUE_REFS_TO_KEEP",
"_ORPHAN_RESCUE_REF_MAX_AGE_DAYS",
"_UPDATE_CRITICAL_FILES",
"_abort_dependency_sync_if_self_locked",
"_assess_parked_branch_switch",
"_atomic_replace_dir",
"_capture_active_lazy_features",
"_capture_active_tool_dependencies",
"_capture_head_sha",
"_classify_concurrent_instance",
"_cmd_update_check",
"_cmd_update_impl",
"_cold_start_windows_gateway_after_update",
"_defer_update_for_self_lock",
"_dependency_sync_would_rewrite",
"_detect_self_loaded_native_modules",
"_detect_venv_python_processes",
"_discard_lockfile_churn",
"_discard_stashed_changes",
"_ensure_acp_launcher",
"_ensure_uv_for_termux",
"_filter_non_gateway_concurrent_instances",
"_finish_dashboard_update_cleanup",
"_fleet_probe_expected_runtimes",
"_format_time_ago",
"_gateway_prompt",
"_get_origin_url",
"_handoff_reapable_backend_pids",
"_install_psutil_android_compat",
"_is_fork",
"_ledger_manual_serve_holders",
"_ledger_reapable_backend_pids",
"_leftover_pausable_gateway_pids",
"_npm_lockfile_changed",
"_npm_manifests_digest",
"_orphaned_desktop_backend_pids",
"_park_stashed_changes",
"_pause_windows_gateways_for_update",
"_print_parked_branch_kept_notice",
"_print_parked_branch_skip_warning",
"_prune_orphan_rescue_refs",
"_purge_stale_hermes_modules",
"_record_npm_lockfile_hash",
"_refresh_active_lazy_features",
"_refresh_active_memory_provider_dependencies",
"_refresh_bootstrap_cache_scripts",
"_refresh_windows_gateway_launchers",
"_relaunch_stopped_serves",
"_reload_updated_runtime_modules",
"_restore_active_tool_dependencies",
"_restore_stashed_changes",
"_resume_windows_gateways_after_update",
"_run_logged_subprocess",
"_run_pre_update_backup",
"_stash_local_changes_if_needed",
"_stop_process_trees",
"_sync_with_upstream_if_needed",
"_update_node_dependencies",
"_update_via_zip",
"_upgrade_pip_before_lazy_refresh",
"_validate_critical_files_syntax",
"_validate_critical_modules_import",
"_venv_launcher_ancestors",
"_wait_for_windows_update_gateway_exit",
"_warn_orphaned_update_autostashes",
"_warn_pending_fleet_restart_on_startup",
"_write_marker_file",
"_write_update_incomplete_marker",
),
}
# name -> (module, attr). Includes the model-catalog export and one back-compat
# alias: the warn-only ``_warn_stale_dashboard_processes`` was replaced by the
# kill helper; external callers importing the old name get the new one.
_LAZY_ATTR_SOURCES: dict[str, tuple[str, str]] = {
attr: (module, attr) for module, attrs in _LAZY_COMMAND_EXPORTS.items() for attr in attrs
}
_LAZY_ATTR_SOURCES.update({
"_PROVIDER_MODELS": ("hermes_cli.models", "_PROVIDER_MODELS"),
"_warn_stale_dashboard_processes": (
"hermes_cli.dashboard_procs",
"_kill_stale_dashboard_processes",
),
})
def _self():
"""This module, for attribute access at call time.
Bare-name global lookups inside this module do not go through the PEP 562
__getattr__ below, so internal callers of the lazily re-exported names use
_self().<name> instead. That resolves the lazy re-export on first use and
keeps monkeypatches on hermes_cli.main.<name> working, exactly like a
globals lookup did. ``sys`` is imported locally because some tests patch
this module's ``sys`` attribute.
"""
import sys as _sys
return _sys.modules[__name__]
def __getattr__(name):
"""Defer the model-catalog and command-module imports until first read."""
source = _LAZY_ATTR_SOURCES.get(name)
if source is None:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
import importlib
value = getattr(importlib.import_module(source[0]), source[1])
# Cache on the module so subsequent accesses skip the import machinery.
globals()[name] = value
return value
def cmd_verify(args):
"""Detect a project's run recipe and smoke-test it."""
from hermes_cli.verify_cmd import run_verify_command
sys.exit(run_verify_command(args))
def cmd_security(args):
"""Dispatch `hermes security <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_config(args):
"""Configuration management."""
from hermes_cli.config import config_command
try:
config_command(args)
except RuntimeError as exc:
# Safety net for the fail-closed config write guard (unparseable /
# non-mapping / unreadable config.yaml raises RuntimeError from
# require_readable_config_before_write). set/unset already surface
# this per-branch; this covers migrate and future write subcommands
# so no path ends in a raw traceback.
print(f"✗ {exc}", file=sys.stderr)
sys.exit(1)
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 _print_version_info(*, check_updates: bool = True) -> None:
# Single source of truth for version output — shared with the
# `hermes --version` pre-import fast path (the `version` subcommand
# was consolidated into `--version`).
_startup_fast.print_fast_version_info(check_updates=check_updates)
def cmd_version(args):
"""Show version (--version/-V flag)."""
_print_version_info(check_updates=True)
def cmd_uninstall(args):
"""Uninstall Hermes Agent (or just the Chat GUI with --gui)."""
# Machine-readable install snapshot for the desktop app's uninstall UI.
# Must run before any TTY gate — it's called from a non-interactive child.
if getattr(args, "gui_summary", False):
from hermes_cli.gui_uninstall import gui_install_summary
print(json.dumps(gui_install_summary()))
return
# GUI-only uninstall. The desktop app shells out to this non-interactively
# with --yes, so only gate on a TTY when we actually need to prompt.
if getattr(args, "gui", False):
if not getattr(args, "yes", False):
_require_tty("uninstall --gui")
from hermes_cli.uninstall import run_gui_uninstall
run_gui_uninstall(args)
return
# Full/keep-data uninstall. ``--yes`` runs non-interactively (the desktop
# app's lite/full modes drive this from a detached cleanup script), so only
# gate on a TTY when we actually need to prompt for the option + confirm.
if not getattr(args, "yes", False):
_require_tty("uninstall")
from hermes_cli.uninstall import run_uninstall
run_uninstall(args)
def _clear_bytecode_cache(root: Path) -> int:
"""Remove all __pycache__ directories under *root*.
Stale .pyc files can cause ImportError after code updates when Python
loads a cached bytecode file that references names that no longer exist
(or don't yet exist) in the updated source. Clearing them forces Python
to recompile from the .py source on next import.
Returns the number of directories removed.
"""
removed = 0
for dirpath, dirnames, _ in os.walk(root):
# Skip venv / node_modules / .git entirely
dirnames[:] = [
d
for d in dirnames
if d not in {"venv", ".venv", "node_modules", ".git", ".worktrees"}
]
if os.path.basename(dirpath) == "__pycache__":
try:
shutil.rmtree(dirpath)
removed += 1
except OSError:
pass
dirnames.clear() # nothing left to recurse into
return removed
# Update pipeline lives in hermes_cli/update_cmd.py (main.py decomposition,
# mechanical move). Its names are re-exported lazily through the module-level
# __getattr__ above (see _LAZY_COMMAND_EXPORTS) so argparse wiring and test
# monkeypatches on hermes_cli.main.<name> keep resolving unchanged without
# paying the update_cmd import cost on every CLI invocation.
# ---------------------------------------------------------------------------
# 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>"
# }
# ─── Desktop stage-and-swap pack (#86443) ───────────────────────────────────
#
# electron-builder packs IN PLACE: before-pack.mjs wipes ``release/<platform>-
# unpacked`` (or the mac ``Hermes.app``) and the Electron unpack + asar + rename
# then rebuild it. Any failure after that wipe — corrupt cached zip, blocked
# download, missing dep, disk full — leaves the user with NO app, and
# ``hermes update`` used to report "partially complete" over an empty
# release/. Fix the class, not the predicate: build into a STAGING output
# dir next to release/, verify the staged result, and only then swap it over
# the live tree with renames. On any failure the live app is untouched.
# ─── 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.
# Dashboard process-hygiene helpers live in hermes_cli/dashboard_procs.py
# (main.py decomposition, mechanical move). Re-exported lazily through the
# module-level __getattr__ above so callers and test monkeypatches on
# hermes_cli.main.<name> keep resolving unchanged.
# Back-compat alias: some tests and any external callers may import the old
# warn-only name. The new behaviour (kill stale processes) replaces it.
# Resolved lazily via _LAZY_ATTR_SOURCES near the module __getattr__.
# =========================================================================
# Fork detection and upstream management for `hermes update`
# =========================================================================
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 (
is_managed,
managed_error,
)
if is_managed():
managed_error("update Hermes Agent")
return
# --plan is read-only and deployment-kind aware, so it runs BEFORE the
# docker/nix/apt refusal gates: on an image-managed or package-managed
# install the plan itself reports "not updatable in place" plus the
# right mechanism — strictly more useful than the bare refusal text.
if getattr(args, "plan", False):
# Read-only plan phase (#91277 Phase 2): inventory every running
# Hermes runtime across profiles, its supervisor, and its running
# code version — without mutating anything. Safe on a live fleet.
from hermes_cli.update_inventory import (
collect_runtime_inventory,
print_update_plan,
)
print_update_plan(collect_runtime_inventory())
return
# Image-managed / package-managed admission gate (#91277 Phase 3): one
# shared decision for every mutation surface. Consults the baked image
# provenance marker first (authoritative, fail-closed on malformed),
# then the pre-existing docker/nix/apt heuristics. Prints the real
# update command, records a `refused` receipt so fleet tooling sees the
# blocked attempt, and exits 2 (refused-by-contract, distinct from
# exit 1 errors).
from hermes_cli.update_contract import (
evaluate_update_admission,
record_refusal_receipt,
)
refusal = evaluate_update_admission(PROJECT_ROOT)
if refusal is not None:
print(refusal.message)
record_refusal_receipt(refusal)
sys.exit(2)
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)
_self()._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)
# Cross-process mutual exclusion. The dashboard's Update button spawns
# this same command detached, and the desktop hands off to the Tauri
# updater / install-mode bootstrap — all three mutate one checkout. Two of
# them running together rewrite source under a live interpreter and strand
# the tree half-updated. Share the marker the Tauri updater and Electron
# already use rather than inventing a second lock.
from hermes_cli.update_lock import (
UPDATE_EXIT_CONCURRENT,
UpdateLock,
describe_holder,
)
_update_lock = UpdateLock()
if not _update_lock.acquire():
print(describe_holder(_update_lock.holder))
_finalize_update_output(_update_io_state)
sys.exit(UPDATE_EXIT_CONCURRENT)
# Exit code for the Windows hand-off child's hard exit (see finally).
# None = not a SystemExit-shaped outcome; real exceptions keep the
# normal raise path so their traceback still prints.
_update_handoff_exit_code: int | None = None
try:
_self()._cmd_update_impl(args, gateway_mode=gateway_mode)
except SystemExit as _update_exit:
# Receipt boundary (#91283 review): the impl has many early
# sys.exit paths (concurrent-instance preflight, venv-holder
# refusal, head-pinned no-op, fetch failure) that never reach an
# inner finalize. Persist any still-open receipt with the real
# exit code, then let the exit proceed unchanged. No-op when an
# inner path already finalized (exactly-once by construction).
try:
from hermes_cli.update_receipt import finalize_pending_update_receipt
_code = _update_exit.code if isinstance(_update_exit.code, int) else 1
finalize_pending_update_receipt(_code, f"sys.exit({_code})")
except Exception:
pass
_update_handoff_exit_code = (
_update_exit.code if isinstance(_update_exit.code, int) else 0
)
raise
except BaseException as _update_exc:
try:
from hermes_cli.update_receipt import finalize_pending_update_receipt
finalize_pending_update_receipt(
1, f"{type(_update_exc).__name__}: {_update_exc}"
)
except Exception:
pass
raise
else:
try:
from hermes_cli.update_receipt import finalize_pending_update_receipt
finalize_pending_update_receipt(0, "completed at command boundary")
except Exception:
pass
_update_handoff_exit_code = 0
finally:
_update_lock.release()
_finalize_update_output(_update_io_state)
# Windows hand-off child (#93581): the re-exec'd venv child cannot
# rely on graceful interpreter shutdown — a leftover non-daemon
# thread from the update tail keeps the console busy long after
# the receipt is durable (success, exit 0, "completed at command
# boundary"), freezing the PowerShell window for minutes. By this
# point every durable step is done (receipt finalized above, lock
# released, stdio restored), so on the hand-off path only, flush
# and exit hard instead of waiting for the interpreter to unwind
# — the same treatment #79040's cron workaround applies. No-op on
# every non-hand-off invocation: the marker env is set solely by
# _reexec_dependency_sync_off_windows_shim when it spawns the child.
if _update_handoff_exit_code is not None and os.environ.get(_UPDATE_REEXEC_ENV) == "1":
logger.debug(
"Update hand-off child %s exiting via os._exit(%s)",
os.getpid(), _update_handoff_exit_code,
)
sys.stdout.flush()
sys.stderr.flush()
os._exit(_update_handoff_exit_code)
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",
"update",
"uninstall",
"profile",
"dashboard",
"serve",
"desktop",
"gui",
"honcho",
"claw",
"plugins",
"security",
"acp",
"webhook",
"peer",
"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
from hermes_cli.profile_cmd import cmd_profile, _render_distribution_plan # noqa: E402,F401 (re-export)
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`.
_self()._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)
# `hermes serve` is headless/non-interactive: fail closed on a corrupt
# config.yaml instead of silently starting on defaults where provider
# auto-detection can adopt unnamed .env credentials (issue #81952).
# Same policy + escape hatch as _guard_noninteractive_user_config.
if _headless_backend:
from hermes_cli.config import (
InvalidUserConfigError,
require_parseable_user_config,
)
try:
require_parseable_user_config(
ignore_user_config=bool(getattr(args, "ignore_user_config", False))
)
except InvalidUserConfigError as exc:
print(f"Error: {exc}", file=sys.stderr)
raise SystemExit(2) from exc
_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)
_route_named_profile_dashboard(args, _headless_backend, _ssh_owner_nonce, _token_file)
# Apply the final process/profile policy after dashboard routing, but before
# importing the web server or opening dashboard state. Applying it before a
# named-profile re-exec could leak that profile's higher limit into the
# machine/default dashboard, whose lower policy intentionally cannot undo it.
# This also covers Desktop SSH's isolated `serve` child, which does not route.
from hermes_cli.resource_limits import apply_nofile_soft_limit
apply_nofile_soft_limit()
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)
_resolve_dashboard_web_dist(args, _headless_backend)
# 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.
#
# Desktop-spawned headless backends start it AFTER the socket binds
# instead (start_server's ready path): the thread's first act is the
# ~350ms `mcp` SDK import, which holds the GIL against the main thread's
# own web_server import and pushes the READY sentinel — and every
# renderer paint behind it — back by that much. The Desktop can't issue
# an agent turn until its WebSocket is up anyway, and _make_agent's
# bounded wait_for_mcp_discovery + the late-binding refresh cover a
# server that is still connecting when the first turn lands.
_mcp_discovery_after_bind = _headless_backend and os.environ.get("HERMES_DESKTOP") == "1"
if not _mcp_discovery_after_bind:
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,
start_mcp_discovery_after_bind=_mcp_discovery_after_bind,
)
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_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()
# 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", "pause", "peer", "pets", "plugins", "portal", "profile",
"project", "proxy",
"prompt-size",
"resume",
"send", "sessions", "setup",
"skin", "skills", "slack", "status", "sync", "tools", "uninstall", "update",
"webhook", "whatsapp", "whatsapp-cloud", "worktree", "chat", "secrets", "security",
"browser",
"verify",
# 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",
}
)
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).
"""
from hermes_cli._parser import top_level_value_flag_sets
required_value_flags, optional_value_flags = top_level_value_flag_sets()
value_flags = required_value_flags | optional_value_flags
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 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)
_apply_user_config_bypass(args)
_guard_noninteractive_user_config(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))
if not _is_tui_chat_launch(args):
# The TUI backend process does its own plugin discovery; the launcher
# only spawns Node, so discovery here would be thrown-away work.
try:
from hermes_cli.plugins import start_background_plugin_discovery
# Discovery runs in a daemon thread so its ~150ms of manifest
# scanning + plugin imports overlaps the rest of startup (cli /
# prompt_toolkit imports, worktree git calls). Correctness is
# unchanged: every synchronous reader goes through
# discover_plugins(), which joins this thread first — including
# the discover_plugins() call model_tools makes at import time,
# which happens before any tool list is built.
start_background_plugin_discovery()
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
_hooks_cfg = load_config()
register_from_config(_hooks_cfg, accept_hooks=_accept_hooks)
from agent.outbound_webhooks import (
register_from_config as register_outbound_webhooks,
)
register_outbound_webhooks(_hooks_cfg)
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 _apply_user_config_bypass(args) -> None:
"""Apply the explicit config bypass before any startup config reads."""
if getattr(args, "ignore_user_config", False):
os.environ["HERMES_IGNORE_USER_CONFIG"] = "1"
def _guard_noninteractive_user_config(args) -> None:
"""Fail closed before a non-interactive invocation initializes providers."""
if getattr(args, "_noninteractive_config_validated", False):
return
is_noninteractive = (
bool(getattr(args, "oneshot", None))
or bool(getattr(args, "query", None))
)
if not is_noninteractive:
return
from hermes_cli.config import (
InvalidUserConfigError,
require_parseable_user_config,
)
try:
require_parseable_user_config(
ignore_user_config=bool(
getattr(args, "ignore_user_config", False)
or getattr(args, "safe_mode", False)
)
)
except InvalidUserConfigError as exc:
print(f"Error: {exc}", file=sys.stderr)
raise SystemExit(2) from exc
setattr(args, "_noninteractive_config_validated", True)
def _set_chat_arg_defaults(args) -> None:
"""Fill the chat-parser attrs cmd_chat reads when chat was not parsed."""
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 _run_oneshot_from_args(args) -> None:
"""Top-level --oneshot / -z: single-shot mode, stdout = final response only.
Bypasses cli.py entirely; _run_and_exit_oneshot never returns.
"""
_confirm_startup_expensive_model_override(args)
_run_and_exit_oneshot(
args.oneshot,
model=getattr(args, "model", None),
provider=getattr(args, "provider", None),
toolsets=getattr(args, "toolsets", None),
skills=getattr(args, "skills", None),
usage_file=getattr(args, "usage_file", None),
)
def _try_fast_serve_launch() -> bool:
"""Dispatch an unambiguous built-in ``serve`` without the full CLI tree.
Desktop launches this exact command on every cold start. Building parsers
for unrelated Hermes commands performs thousands of filesystem-backed
translation lookups on Windows even though none of those commands are
usable in this process. Unknown or globally-scoped arguments fall back to
normal parsing so compatibility and error reporting remain unchanged.
"""
if os.environ.get("HERMES_DISABLE_FAST_SERVE_LAUNCH") == "1":
return False
argv = sys.argv[1:]
if not argv or argv[0] != "serve" or "-h" in argv or "--help" in argv:
return False
# Container routing is top-level policy and must run before host dispatch.
try:
from hermes_cli.config import get_container_exec_info
if get_container_exec_info():
return False
except Exception:
return False
parser = build_serve_parser(
cmd_dashboard=cmd_dashboard,
add_help=False,
exit_on_error=False,
)
try:
args, unknown = parser.parse_known_args(argv[1:])
except (argparse.ArgumentError, ValueError):
return False
if unknown:
return False
cmd_dashboard(args)
return True
def _try_fast_chat_launch() -> bool:
"""Fast path for unambiguous interactive chat launches (all hosts).
``hermes`` / ``hermes -w -s foo --yolo`` / ``hermes chat`` don't need the
full argparse tree: building all ~40 subcommand parsers costs ~140ms of
pure-Python argparse setup plus their module imports, none of which the
chat path uses. Parse the lightweight top-level/chat parser instead and
dispatch straight to ``cmd_chat``.
Bails out (returns False) whenever the invocation is not certainly a
chat launch — a subcommand positional, ``--help``, unknown flags — so
every other path still goes through the full parser unchanged. Mirrors
``_try_termux_fast_cli_launch`` minus the Termux-specific deferred
startup; kept separate so phone-tuned behavior doesn't leak to desktops.
"""
if os.environ.get("HERMES_DISABLE_FAST_CHAT_LAUNCH") == "1":
return False
argv = sys.argv[1:]
if "-h" in argv or "--help" in argv:
return False
# Container-aware routing must win: when NixOS container mode is
# active, EVERY invocation is forwarded into the managed container.
try:
from hermes_cli.config import get_container_exec_info
if get_container_exec_info():
return False
except Exception:
return False
# TUI launches have their own startup path (bounded MCP joins etc.) —
# keep them on full dispatch outside Termux.
if _wants_tui_early(argv):
return False
if _first_positional_argv() 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)
try:
args, unknown = parser.parse_known_args(_coalesce_session_name_args(argv))
except SystemExit:
return False
if unknown:
# Flags the light parser doesn't know — could belong to a plugin
# subcommand or a newer full-parser flag. Fall back to full dispatch.
return False
if getattr(args, "version", False):
return False
if getattr(args, "command", None) not in {None, "chat"}:
return False
if getattr(args, "yolo", False):
os.environ["HERMES_YOLO_MODE"] = "1"
_prepare_agent_startup(args)
if getattr(args, "oneshot", None):
_run_oneshot_from_args(args)
if (args.resume or args.continue_last) and args.command is None:
args.command = "chat"
_set_chat_arg_defaults(args)
cmd_chat(args)
return True
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 _startup_fast.is_termux_fast_version_argv(argv):
_print_version_info(check_updates=True)
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=True)
return True
if getattr(args, "oneshot", None):
_prepare_agent_startup(args)
_run_oneshot_from_args(args)
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 _advertise_agent_env() -> None:
"""Advertise the agent harness to child processes.
``AI_AGENT`` is the emerging cross-agent standard (huggingface_hub's agent
detection reads it; pi and other agents set it — earendil-works/pi#7493)
so generic tooling can attribute subprocesses to the harness that spawned
them. The value must be our id in the public agent-harness registry
(``hermes-agent`` in huggingface.js ``agent-harnesses.ts``): standard-var
matching is exact, so any other value is counted as "unknown".
``HERMES_AGENT`` is the Hermes-specific marker. setdefault: never
clobber an outer harness (e.g. Hermes running inside another agent's
terminal).
"""
os.environ.setdefault("AI_AGENT", "hermes-agent")
os.environ.setdefault("HERMES_AGENT", "true")
def _attach_plugin_cli_command(subparsers, cmd_info) -> None:
"""Register one plugin-provided top-level command from its descriptor."""
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"])
def _register_plugin_cli_commands(subparsers) -> None:
"""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 already targets a known built-in subcommand (``hermes --help``,
``hermes logs``, ...) — eagerly importing every bundled plugin module
(google.cloud.pubsub_v1, aiohttp, grpc, PIL …) costs 500-650ms.
"""
if not _plugin_cli_discovery_needed():
return
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():
_attach_plugin_cli_command(subparsers, cmd_info)
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"] not in seen_plugin_commands:
_attach_plugin_cli_command(subparsers, cmd_info)
except Exception as _exc:
logging.getLogger(__name__).debug("Plugin CLI discovery failed: %s", _exc)
def _build_cli_parser():
"""Build the full ``hermes`` argparse tree. Returns ``(parser, subparsers)``.
Registration ORDER is the order subcommands appear in ``hermes --help``;
keep it stable. Each group lives in ``hermes_cli/subcommands/<group>.py``
(handlers injected so those modules never import main) or exposes its own
``register_cli``/``build_parser`` from a sibling module.
"""
from hermes_cli._parser import build_top_level_parser
parser, subparsers, chat_parser = build_top_level_parser()
chat_parser.set_defaults(func=cmd_chat)
build_model_parser(subparsers, cmd_model=cmd_model)
build_moa_parser(subparsers)
build_fallback_parser(subparsers)
build_worktree_parser(subparsers)
build_browser_parser(subparsers)
build_secrets_parser(subparsers)
# OUTBOUND iron-proxy egress firewall; ``hermes proxy`` (gateway group) is
# the separate INBOUND OAuth-aggregator reverse proxy.
build_egress_parser(subparsers)
build_migrate_parser(subparsers)
build_gateway_parser(
subparsers, cmd_gateway=cmd_gateway, cmd_proxy=cmd_proxy, cmd_gateway_enroll=cmd_gateway_enroll
)
# LSP is optional infrastructure — never let a registration failure
# break the CLI overall.
try:
from agent.lsp.cli import register_subparser as _lsp_register
_lsp_register(subparsers)
except Exception as _lsp_err: # noqa: BLE001
logger.debug("LSP CLI registration failed: %s", _lsp_err)
build_setup_parser(subparsers, cmd_setup=cmd_setup)
build_whatsapp_parser(subparsers, cmd_whatsapp=cmd_whatsapp)
build_whatsapp_cloud_parser(subparsers, cmd_whatsapp_cloud=cmd_whatsapp_cloud)
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)
build_login_parser(subparsers, cmd_login=cmd_login)
build_logout_parser(subparsers, cmd_logout=cmd_logout)
build_auth_parser(subparsers, cmd_auth=cmd_auth)
build_status_parser(subparsers, cmd_status=cmd_status)
build_pause_parser(subparsers)
build_cron_parser(subparsers, cmd_cron=cmd_cron)
build_sync_parser(subparsers, cmd_sync=cmd_sync)
build_webhook_parser(subparsers, cmd_webhook=cmd_webhook)
# peer command — bot-to-bot DMs across machines (peer Hermes gateways)
from hermes_cli.subcommands.peer import build_peer_parser
build_peer_parser(subparsers)
# 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
_build_kanban_parser(subparsers).set_defaults(func=cmd_kanban)
# project command — named, multi-folder workspaces
from hermes_cli.projects_cmd import build_parser as _build_project_parser
_build_project_parser(subparsers).set_defaults(func=cmd_project)
build_hooks_parser(subparsers, cmd_hooks=cmd_hooks)
build_doctor_parser(subparsers, cmd_doctor=cmd_doctor)
build_verify_parser(subparsers, cmd_verify=cmd_verify)
build_security_parser(subparsers, cmd_security=cmd_security)
build_approvals_parser(subparsers, cmd_approvals=cmd_approvals)
build_dump_parser(subparsers, cmd_dump=cmd_dump)
build_debug_parser(subparsers, cmd_debug=cmd_debug)
build_backup_parser(subparsers, cmd_backup=cmd_backup)
build_checkpoints_parser(subparsers)
build_import_cmd_parser(subparsers, cmd_import=cmd_import)
build_import_agent_parser(subparsers, cmd_import_agent=cmd_import_agent)
build_config_parser(subparsers, cmd_config=cmd_config)
build_skin_parser(subparsers, cmd_skin=cmd_skin)
build_console_parser(subparsers, cmd_console=cmd_console)
build_pairing_parser(subparsers, cmd_pairing=cmd_pairing)
build_skills_parser(subparsers, cmd_skills=cmd_skills)
build_bundles_parser(subparsers)
build_plugins_parser(subparsers, cmd_plugins=cmd_plugins)
_register_plugin_cli_commands(subparsers)
build_curator_parser(subparsers)
build_pets_parser(subparsers)
build_journey_parser(subparsers)
build_memory_parser(subparsers, cmd_memory=cmd_memory)
build_tools_parser(subparsers, cmd_tools=cmd_tools)
build_computer_use_parser(subparsers)
build_mcp_parser(subparsers, cmd_mcp=cmd_mcp)
# Lazy indirection: sessions_cmd is only imported when the subcommand
# runs, and monkeypatches on hermes_cli.main.cmd_sessions keep working.
build_sessions_parser(subparsers, cmd_sessions=lambda a, **kw: _self().cmd_sessions(a, **kw))
build_insights_parser(subparsers, cmd_insights=cmd_insights)
build_monitoring_parser(subparsers, cmd_monitoring=cmd_monitoring)
build_claw_parser(subparsers, cmd_claw=cmd_claw)
# NOTE: the `hermes version` subcommand was removed — `hermes --version`
# / `-V` now carries the full output including update status.
build_update_parser(subparsers, cmd_update=cmd_update)
build_uninstall_parser(subparsers, cmd_uninstall=cmd_uninstall)
build_acp_parser(subparsers, cmd_acp=cmd_acp)
build_profile_parser(subparsers, cmd_profile=cmd_profile)
build_completion_parser(subparsers, cmd_completion=cmd_completion, parser=parser)
build_dashboard_parser(
subparsers,
cmd_dashboard=cmd_dashboard,
cmd_dashboard_register=cmd_dashboard_register,
)
# desktop (a.k.a. gui): 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`, so that name must be the one shown
# in --help (argparse promotes the primary name; aliases stay hidden).
build_gui_parser(subparsers, cmd_gui=cmd_gui)
build_logs_parser(subparsers, cmd_logs=cmd_logs)
build_prompt_size_parser(subparsers, cmd_prompt_size=cmd_prompt_size)
return parser, subparsers
def _parse_cli_args(parser, subparsers, argv):
"""Parse ``argv`` with the bpo-9338 subparser-routing workaround.
argv is first pre-processed so unquoted multi-word session names after
-c / -r merge into one token (``hermes -c Pokemon Agent Dev`` →
``hermes -c 'Pokemon Agent Dev'``).
On some Python versions (notably <3.11), argparse fails to route
subcommand tokens when the parent parser has nargs='?' optional arguments
(--continue): "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
_processed_argv = _coalesce_session_name_args(argv)
_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 not _has_cmd_token:
subparsers.required = False
return parser.parse_args(_processed_argv)
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)
return args
def _default_to_chat(args) -> None:
"""No subcommand given: run chat (top-level --resume/--continue is a chat shortcut)."""
if args.resume or args.continue_last:
args.command = "chat"
_set_chat_arg_defaults(args)
cmd_chat(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()
# Let child processes (and tools like huggingface_hub) detect they run
# under an AI agent harness.
_advertise_agent_env()
# 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
# Cheap hint only (#95294): an interrupted update that pulled code but
# never restarted the fleet. Do NOT restart here — that is ``hermes
# update`` catch-up work. Skip when the user is already running update.
try:
if "update" not in sys.argv[1:]:
_warn_pending_fleet_restart_on_startup()
except Exception:
pass
if _try_termux_fast_tui_launch():
return
if _try_termux_fast_cli_launch():
return
if _try_fast_serve_launch():
return
if _try_fast_chat_launch():
return
parser, subparsers = _build_cli_parser()
# ── 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)
args = _parse_cli_args(parser, subparsers, sys.argv[1:])
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)
if getattr(args, "oneshot", None):
_run_oneshot_from_args(args)
# No subcommand (optionally with top-level --resume / --continue) → chat.
if args.command is None:
_default_to_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()