3703 lines
150 KiB
Python
3703 lines
150 KiB
Python
#!/usr/bin/env python3
|
|
"""Hermes CLI - Main entry point.
|
|
|
|
Usage:
|
|
hermes # Interactive chat (default)
|
|
hermes chat / gateway / setup / status / cron / doctor / update / ...
|
|
hermes --version # Show version and update status
|
|
hermes <cmd> --help # Per-command help
|
|
"""
|
|
|
|
# hermes_bootstrap must be the very first import — it sets up UTF-8 stdio on
|
|
# Windows (no-op on POSIX). Guarded: after a ``git pull`` / interrupted
|
|
# ``hermes update`` the editable install's ``.pth`` may not list it yet; crashing
|
|
# here would block ``hermes update``.
|
|
try:
|
|
import hermes_bootstrap # noqa: F401
|
|
except ModuleNotFoundError as exc:
|
|
if exc.name != "hermes_bootstrap":
|
|
raise # the bootstrap exists but cannot load: skipping it would skip PM activation
|
|
|
|
# A `hermes update` killed while git was writing the new tree leaves a mix of old and new files that
|
|
# fails at the next import, whichever it is — put the old tree back before importing anything else
|
|
# from the checkout, then rerun the command (this module may itself be one of the new files).
|
|
# ``_early_recovery`` is stdlib-only and imported unguarded on purpose: same package
|
|
# dir, so if IT can't import nothing in hermes_cli can.
|
|
from hermes_cli import _early_recovery as _early_recovery_mod
|
|
|
|
if _early_recovery_mod.restore_interrupted_pull():
|
|
_early_recovery_mod.relaunch_after_restore()
|
|
|
|
# Windows: neutralize CPython's ``platform._syscmd_ver`` before anything else
|
|
# imports — it shells out ``cmd /c ver`` and flashes a console when this
|
|
# process is windowless (pythonw gateway, kanban workers). No-op on POSIX.
|
|
from hermes_cli._subprocess_compat import suppress_platform_ver_console
|
|
|
|
suppress_platform_ver_console()
|
|
|
|
import os
|
|
import re
|
|
import sys
|
|
|
|
# Inline path math so ``python hermes_cli/main.py`` (script mode: sys.path[0]
|
|
# is hermes_cli/, not the repo root) can import 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
|
|
|
|
# A literal ``~``/``$VAR`` in HERMES_HOME (fish, or any quoted value) must become absolute
|
|
# before the first reader — otherwise it resolves against cwd and scaffolds <cwd>/~/.hermes.
|
|
_startup_fast.normalize_hermes_home_env()
|
|
|
|
# Startup-liveness watchdog: for gateway runs, arm BEFORE the heavy 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 it exists for. ``hermes_startup_watchdog`` is stdlib-only so it
|
|
# cannot itself wedge. The match requires the ADJACENT pair ``gateway run``
|
|
# (wherever global flags like ``-p <profile>`` put it) so unrelated commands
|
|
# mentioning both words never arm a 300s hard-exit timer. Foreground runs arm
|
|
# too — a pre-loop wedge is just as dead without a supervisor; GatewayRunner
|
|
# disarms once the event loop is 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 fires in a native-extension finalizer
|
|
during ``Py_FinalizeEx``, *after* the response printed. Flush, shut down
|
|
file logging, then ``os._exit`` past finalization. The ``atexit`` chain is
|
|
deliberately skipped — several handlers re-enter native code that may be
|
|
the abort source; stateful cleanup lives in ``_cleanup_oneshot_runtime``.
|
|
|
|
See #30387, #43055.
|
|
"""
|
|
for stream in (sys.stdout, sys.stderr):
|
|
try:
|
|
stream.flush()
|
|
except Exception:
|
|
pass
|
|
try:
|
|
logging.shutdown()
|
|
except Exception:
|
|
pass
|
|
os._exit(rc if isinstance(rc, int) else (0 if rc is None else 1))
|
|
|
|
|
|
_oneshot_cleanup_done = False
|
|
# (module, attr, kwargs, exceptions swallowed). MCP shutdown may raise
|
|
# BaseException-derived errors from executor teardown; the rest are Exception.
|
|
_ONESHOT_CLEANUPS = (
|
|
("tools.terminal_tool", "cleanup_all_environments", {}, Exception),
|
|
("tools.async_delegation", "interrupt_all", {"reason": "oneshot shutdown"}, Exception),
|
|
("tools.browser_tool_lifecycle", "_emergency_cleanup_all_sessions", {}, Exception),
|
|
("tools.mcp_tool_lifecycle", "shutdown_mcp_servers", {}, BaseException),
|
|
("agent.auxiliary_client", "shutdown_cached_clients", {}, Exception),
|
|
)
|
|
|
|
|
|
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
|
|
import importlib
|
|
|
|
for module, attr, kwargs, swallow in _ONESHOT_CLEANUPS:
|
|
try:
|
|
getattr(importlib.import_module(module), attr)(**kwargs)
|
|
except swallow:
|
|
pass
|
|
|
|
|
|
def _run_and_exit_oneshot(
|
|
prompt: str,
|
|
*,
|
|
model: object = None,
|
|
provider: object = None,
|
|
toolsets: object = None,
|
|
skills: object = None,
|
|
usage_file: object = None,
|
|
resume: object = None,
|
|
reasoning: 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,
|
|
resume=resume,
|
|
reasoning=reasoning,
|
|
)
|
|
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:
|
|
# ``run_oneshot`` already maps agent failures to an int rc; anything
|
|
# still escaping means it malfunctioned. Print it but never fall
|
|
# through to interpreter teardown (the SIGABRT path this routine fixes).
|
|
import traceback
|
|
try:
|
|
traceback.print_exc()
|
|
except Exception:
|
|
pass
|
|
rc = 1
|
|
try:
|
|
_cleanup_oneshot_runtime()
|
|
finally:
|
|
# Even an interrupt during cleanup must not fall back into interpreter
|
|
# finalization, where the native SIGABRT occurs.
|
|
# The hard exit is the safety boundary for #43055.
|
|
_exit_after_oneshot(rc)
|
|
|
|
|
|
def _warn_if_unsupervised_pid1(pid: "int | None" = None) -> None:
|
|
"""Warn when this process is PID 1 with nothing above it to reap orphans.
|
|
|
|
The official image's ENTRYPOINT (``docker/entrypoint-dispatch.sh`` -> s6-overlay's
|
|
``/init``) is the reaper for orphaned grandchildren (browser tooling, MCP servers, shell
|
|
children). A Compose service that overrides ``entrypoint:`` to invoke hermes directly makes
|
|
hermes itself PID 1 — nothing then ``wait()``s on those orphans and they accumulate as
|
|
zombies without bound (#111577). Outside a container a user process is never PID 1, so
|
|
this is quiet everywhere else; it mirrors the dispatcher's own non-PID-1 warning.
|
|
"""
|
|
if (pid if pid is not None else os.getpid()) != 1:
|
|
return
|
|
print(
|
|
"[hermes] WARNING: this process is PID 1 with no init above it "
|
|
"(entrypoint override?). Orphaned child processes will not be "
|
|
"reaped and will accumulate as zombies. Use the image's default "
|
|
"ENTRYPOINT (docker/entrypoint-dispatch.sh) instead of overriding "
|
|
"it, or run with `docker run --init` / `init: true` in Compose.",
|
|
file=sys.stderr,
|
|
)
|
|
|
|
|
|
def _set_process_title() -> None:
|
|
"""Cosmetic: show 'hermes' instead of 'python3.xx' in ps/top/htop.
|
|
|
|
Order: opt-in ``setproctitle`` dep; ctypes ``prctl(PR_SET_NAME)`` (Linux,
|
|
15-char limit); ``pthread_setname_np`` (macOS — lldb/top only, not ``ps
|
|
aux``); no-op on Windows (the .exe is already ``hermes.exe``). Never fatal.
|
|
"""
|
|
try:
|
|
import setproctitle # type: ignore[import-untyped]
|
|
|
|
setproctitle.setproctitle("hermes")
|
|
return
|
|
except ImportError:
|
|
pass
|
|
|
|
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")
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
# Cheap read of `display.interface` for the earliest hot-path decisions
|
|
# (mouse-residue suppression, Termux fast launch) that run before
|
|
# hermes_cli.config is importable. Cached per config path so early callers
|
|
# don't re-parse YAML, and so the answer follows the home the process ends up
|
|
# in: mouse-residue suppression reads this BEFORE `_apply_profile_override()`
|
|
# sets HERMES_HOME, and a cache keyed on nothing pinned every later caller to
|
|
# the default home's interface for the whole run (#116902).
|
|
_EARLY_INTERFACE_CACHE: "tuple[str, str] | None" = None
|
|
|
|
|
|
def _early_interface_config_path() -> str:
|
|
"""config.yaml of the home this process is currently pointed at."""
|
|
home = os.environ.get("HERMES_HOME")
|
|
if home:
|
|
return os.path.join(home, "config.yaml")
|
|
return os.path.join(os.path.expanduser("~"), ".hermes", "config.yaml")
|
|
|
|
|
|
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
|
|
cfg_path = _early_interface_config_path()
|
|
if _EARLY_INTERFACE_CACHE is not None and _EARLY_INTERFACE_CACHE[0] == cfg_path:
|
|
return _EARLY_INTERFACE_CACHE[1]
|
|
value = "cli"
|
|
try:
|
|
if os.path.exists(cfg_path):
|
|
import hermes_yaml as _yaml_iface
|
|
|
|
with open(cfg_path, encoding="utf-8-sig") as _f:
|
|
raw = _yaml_iface.safe_load(_f) 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 = (cfg_path, value)
|
|
return value
|
|
|
|
|
|
def _wants_tui_early(argv: "list[str] | None" = None) -> bool:
|
|
"""Earliest TUI decision, usable before argparse/config imports.
|
|
|
|
Precedence: ``--cli`` wins, then ``--tui``/``HERMES_TUI=1``, then a
|
|
real-TTY gate, then ``display.interface``. The TTY gate is load-bearing
|
|
for headless spawners (kanban workers, cron, pipes running ``chat -q``):
|
|
a ``display.interface: tui`` default used to boot the TUI here, whose
|
|
no-TTY bail-out exits 0 without doing the task. An explicit ``--tui``
|
|
still reaches that 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 any(flag in argv for flag in ("--tui", "--native", "--tui-native")):
|
|
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: while the launcher is still importing (~100-300ms, cooked+echo
|
|
# mode, before the Node TUI takes stdin raw) incoming SGR/X10 mouse reports
|
|
# echo into the shell scrollback as ``^[[<…M``. entry.tsx's
|
|
# `resetTerminalModes()` is the later 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:
|
|
if not os.isatty(1): # redirected stdout: raw CSI would pollute the log
|
|
return
|
|
# Every mouse-tracking variant we know about; idempotent.
|
|
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()
|
|
|
|
|
|
_startup_fast.ensure_project_root_on_path()
|
|
|
|
# ``hermes --version`` is answered before config/logging imports.
|
|
if _startup_fast.try_fast_version():
|
|
raise SystemExit(0)
|
|
|
|
import argparse
|
|
import contextlib
|
|
import json
|
|
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.usage import build_usage_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.vault import build_vault_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.codex_runtime import build_codex_runtime_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 1 if stdin is not a terminal: curses/input() prompts spin at 100% CPU on a pipe."""
|
|
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)
|
|
|
|
|
|
PROJECT_ROOT = Path(_startup_fast.project_root_str())
|
|
_startup_fast.ensure_project_root_on_path()
|
|
|
|
|
|
# Profile override — MUST happen before any hermes module import: many modules
|
|
# cache HERMES_HOME at import time. --profile/-p is pre-parsed from sys.argv,
|
|
# HERMES_HOME set, and the flag stripped so argparse never sees it. Falls back
|
|
# to ~/.hermes/active_profile for the sticky default.
|
|
_PROFILE_NAME_RE = r"^[a-z0-9][a-z0-9_-]{0,63}$" # mirrors hermes_cli.profiles._PROFILE_ID_RE
|
|
|
|
|
|
def _inside_mcp_add_args(argv: list, 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 _looks_like_hermes_invocation() -> bool:
|
|
"""False when ``sys.argv`` belongs to a test runner rather than a ``hermes`` run.
|
|
|
|
pytest's own ``-p no:xdist`` reaches ``_scan_profile_flag`` through ``sys.argv`` at import
|
|
time; it must stay a silent skip, while a real ``hermes -p 'Work Bot'`` must fail loudly.
|
|
"""
|
|
return "pytest" not in (sys.argv[0] or "")
|
|
|
|
|
|
def _exit_invalid_profile_name(value: str) -> None:
|
|
from hermes_cli.profiles import _invalid_profile_name_error
|
|
|
|
print(f"Error: {_invalid_profile_name_error(value)}", file=sys.stderr)
|
|
print("Run `hermes profile list` to see your profiles.", file=sys.stderr)
|
|
sys.exit(2)
|
|
|
|
|
|
def _looks_like_option_value(value: str) -> bool:
|
|
"""A ``-p`` value that clearly belongs to some other tool (pytest's ``-p no:xdist``, a
|
|
third-party ``-p --flag``), never a mistyped profile name."""
|
|
return value.startswith("-") or ":" in value
|
|
|
|
|
|
def _scan_profile_flag(argv: list) -> tuple:
|
|
"""Find -p/--profile/--profile= in argv -> (name, tokens_consumed, index).
|
|
|
|
Historically the flag worked even after the subcommand (`hermes chat -p
|
|
coder`), so scan broadly; stop at ``--`` and at the `mcp add --args`
|
|
passthrough region. The value is normalised (strip + casefold, matching
|
|
``profiles.normalize_profile_name``) before validation so ``-p Work`` selects
|
|
``work``. A value that cannot be a profile name is rejected so
|
|
resolve_profile_env never sys.exits on it; the rejection is explained (exit 2)
|
|
only when the flag comes BEFORE the first subcommand token under a real
|
|
``hermes`` run — after a subcommand, ``-p`` may belong to that subcommand or a
|
|
plugin (`hermes kanban ... -p 8080`), and option-looking values (``no:xdist``,
|
|
``--flag``) are always a silent skip.
|
|
"""
|
|
from hermes_cli._parser import top_level_value_flag_sets
|
|
|
|
value_flags, optional_value_flags = top_level_value_flag_sets()
|
|
i = 0
|
|
saw_subcommand = False
|
|
while i < len(argv):
|
|
arg = argv[i]
|
|
if arg == "--" or (arg == "--args" and _inside_mcp_add_args(argv, i)):
|
|
break
|
|
if arg in {"--profile", "-p"} and i + 1 < len(argv):
|
|
raw = argv[i + 1]
|
|
value = raw.strip().casefold()
|
|
if re.match(_PROFILE_NAME_RE, value):
|
|
return value, 2, i
|
|
if not saw_subcommand and not _looks_like_option_value(raw) and _looks_like_hermes_invocation():
|
|
_exit_invalid_profile_name(raw)
|
|
break
|
|
if arg.startswith("--profile="):
|
|
return arg.split("=", 1)[1].strip().casefold(), 1, i
|
|
takes_value = "=" not in arg and i + 1 < len(argv) and (
|
|
arg in value_flags
|
|
or (arg in optional_value_flags and not argv[i + 1].startswith("-"))
|
|
)
|
|
if not takes_value and not arg.startswith("-"):
|
|
saw_subcommand = True
|
|
i += 2 if takes_value else 1
|
|
return None, 0, None
|
|
|
|
|
|
def _resolve_sudo_user_profile_env(name: str) -> str | None:
|
|
"""Resolve `sudo hermes -p <name>` against the invoking user's home.
|
|
|
|
This runs before argparse, so `--run-as-user` is not available yet. For
|
|
sudo invocations the best signal is SUDO_USER: root is only doing the
|
|
privileged install/start action; the profile store belongs to the user.
|
|
"""
|
|
if name == "default":
|
|
return None
|
|
from hermes_constants import named_profile_is_live, sudo_invoker_default_home
|
|
|
|
sudo_home = sudo_invoker_default_home()
|
|
if sudo_home is None:
|
|
return None
|
|
candidate = sudo_home / "profiles" / name
|
|
return str(candidate) if named_profile_is_live(candidate) else None
|
|
|
|
|
|
def _under_gateway_supervisor(argv: list) -> bool:
|
|
"""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>`` or pin HERMES_HOME to the profile dir; a bare invocation
|
|
means "the root HERMES_HOME profile". If a supervised default-profile
|
|
child read active_profile, switching the active profile (dashboard,
|
|
``hermes profile use``) would silently redirect the default gateway into
|
|
that profile — adopting its credentials and double-polling a Telegram
|
|
token already owned by that profile's own gateway (#74872).
|
|
|
|
Markers (see gateway/restart.py ``is_gateway_supervisor_process``):
|
|
HERMES_SUPERVISED_CHILD (systemd unit / launchd plist / Windows task),
|
|
HERMES_S6_SUPERVISED_CHILD (legacy s6 container), INVOCATION_ID (systemd
|
|
service children only — consulted ONLY for gateway commands because it is
|
|
inherited by every descendant of a systemd-launched process, e.g.
|
|
self-hosted CI runners), HERMES_GATEWAY_EXTERNAL_SUPERVISOR (explicit
|
|
opt-in). XPC_SERVICE_NAME is deliberately NOT consulted: interactive macOS
|
|
terminals set it too.
|
|
"""
|
|
if os.environ.get("HERMES_SUPERVISED_CHILD") or 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"}
|
|
|
|
|
|
def _s6_supervised_gateway_run(argv: list) -> bool:
|
|
"""A bare ``gateway run`` inside the s6 image names the ``gateway-default`` slot too.
|
|
|
|
``_maybe_redirect_run_to_s6_supervision`` turns it into a start of the supervised slot for the
|
|
current profile, and it is the image's own CMD. Following the sticky ``active_profile`` there
|
|
started that profile's named slot on every container boot: the one the boot reconciler just
|
|
registered down, because a started named slot is a second gateway beside the multiplexer.
|
|
``--no-supervise`` keeps the foreground run, which follows ``active_profile`` as before (#22502).
|
|
"""
|
|
words = [a for a in argv if not a.startswith("-")]
|
|
if words[:2] != ["gateway", "run"] or "--no-supervise" in argv:
|
|
return False
|
|
if os.environ.get("HERMES_GATEWAY_NO_SUPERVISE", "").lower() in ("1", "true", "yes"):
|
|
return False
|
|
from hermes_cli.service_manager import _s6_running
|
|
return _s6_running()
|
|
|
|
|
|
def _apply_profile_override() -> None:
|
|
"""Pre-parse --profile/-p and set HERMES_HOME before imports."""
|
|
argv = sys.argv[1:]
|
|
profile_name, consume, profile_index = _scan_profile_flag(argv)
|
|
|
|
# HERMES_HOME already set with no explicit flag: trust it only when it
|
|
# points at a specific profile dir ("profiles" as immediate parent). If it
|
|
# points at the hermes root (systemd hardcodes HERMES_HOME=/root/.hermes)
|
|
# we must still read active_profile — the user may have run
|
|
# `hermes profile use` and the gateway should honour it (#22502).
|
|
hermes_home_env = os.environ.get("HERMES_HOME", "")
|
|
if profile_name is None and hermes_home_env and Path(hermes_home_env).parent.name == "profiles":
|
|
return
|
|
|
|
if (profile_name is None and not _under_gateway_supervisor(argv)
|
|
and not _startup_fast.is_desktop_ssh_backend_argv(argv)
|
|
and not _s6_supervised_gateway_run(argv)):
|
|
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-sig").strip()
|
|
if name and name != "default":
|
|
profile_name = name # consume stays 0: nothing to strip
|
|
except (UnicodeDecodeError, OSError):
|
|
pass # corrupted file, skip
|
|
|
|
if profile_name is None:
|
|
return
|
|
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()
|
|
# ``-p``/active_profile re-homed the process after hermes_bootstrap ran: re-point the temp vars
|
|
# at THIS home's scratch dir (a user-set TMPDIR is still left alone).
|
|
try:
|
|
from hermes_constants import export_scratch_tmp_env as _export_scratch_tmp_env
|
|
|
|
_export_scratch_tmp_env()
|
|
except Exception:
|
|
pass # an unwritable home leaves the system temp dir in place; never block startup
|
|
|
|
# PM runs after profile resolution but before application dependency imports.
|
|
if sys.argv[1:2] == ["pm"]:
|
|
from pm.cli import main as _pm_main
|
|
|
|
raise SystemExit(_pm_main(sys.argv[2:]))
|
|
|
|
# Windows launcher self-heal — the ``hermes`` command is a COPY of the venv
|
|
# console script staged into the managed bin dir (outside the checkout, since
|
|
# ``hermes update``'s autostash once swept ``<checkout>\bin`` copies off disk;
|
|
# venv\Scripts must stay off PATH as it shadows the user's ``python``).
|
|
# Re-staging at process start reaches already-broken installs via the desktop
|
|
# app's ``python -m hermes_cli.main`` spawn. Gates fail toward inaction. Sits
|
|
# AFTER the profile override on purpose — no hermes module may import before
|
|
# profiles resolve; the helper anchors on the DEFAULT root, so profile
|
|
# sessions heal the same shared dir.
|
|
# 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). Costs a few stat calls when healthy; gates fail toward inaction so source checkouts
|
|
# are untouched.
|
|
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
|
|
|
|
# ``update`` must not resolve external secret sources (Windows self-lock via cryptography, slow
|
|
# helpers inside the import probe) — ``_early_recovery._should_skip_external_secret_sources``
|
|
# owns that argv check for every dotenv load in the process. See #73381.
|
|
load_hermes_dotenv(project_env=PROJECT_ROOT / ".env")
|
|
|
|
# Bridge security.redact_secrets → HERMES_REDACT_SECRETS BEFORE hermes_logging
|
|
# imports agent.redact, which snapshots the flag exactly once at import. A
|
|
# .env value still wins — this is config.yaml fallback only. network.force_ipv4
|
|
# is read from the same parse to avoid a second full load_config() (~17ms).
|
|
_FORCE_IPV4_EARLY = False
|
|
try:
|
|
# The effective-config cache (shared raw parse with read_raw_config()) means this SAME parse
|
|
# serves hermes_logging, hermes_time and later raw reads: 3-4 config.yaml parses become one.
|
|
# Managed overlay included: administrator-pinned redact_secrets / force_ipv4 win here too.
|
|
from hermes_cli.config_effective import load_user_config_effective as _load_effective_early
|
|
|
|
_cfg_path = get_hermes_home() / "config.yaml"
|
|
if _cfg_path.exists():
|
|
_early_cfg_raw = _load_effective_early(_cfg_path)
|
|
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
|
|
|
|
# Centralized file logging for every subcommand (agent.log + errors.log).
|
|
# Dashboard entrypoints use GUI mode so gui.log captures pre-dispatch 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 before any HTTP client is created.
|
|
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
|
|
from datetime import datetime
|
|
|
|
from hermes_cli.model_setup_flows import (
|
|
_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,
|
|
_model_flow_vertex,
|
|
_model_flow_api_key_provider,
|
|
_model_flow_anthropic,
|
|
_model_flow_moa,
|
|
_model_flow_ai_gateway,
|
|
_model_flow_plugin_provider,
|
|
_is_profile_plugin_flow_provider,
|
|
)
|
|
logger = logging.getLogger(__name__)
|
|
from hermes_cli.main_agent_cmds import (
|
|
cmd_acp,
|
|
cmd_insights,
|
|
cmd_memory,
|
|
cmd_monitoring,
|
|
cmd_skills,
|
|
cmd_tools,
|
|
)
|
|
from hermes_cli.main_platform_setup import (
|
|
cmd_slack,
|
|
cmd_sync,
|
|
cmd_whatsapp,
|
|
cmd_whatsapp_cloud,
|
|
)
|
|
from hermes_cli.process_identity import is_desktop_owned_backend as _is_desktop_owned_backend
|
|
from hermes_cli.main_dashboard import (
|
|
_attach_to_host_backend,
|
|
_finalize_update_output,
|
|
_find_stale_dashboard_pids,
|
|
_install_hangup_protection,
|
|
_is_electron_packaged_web_dist,
|
|
_maybe_setup_dashboard_auth_interactively,
|
|
_read_ssh_session_token_file,
|
|
_report_dashboard_status,
|
|
_resolve_dashboard_web_dist,
|
|
_route_named_profile_dashboard,
|
|
)
|
|
from hermes_cli.main_dashboard import ( # frozen updater surface: update_cmd*.py resolve these via _m()
|
|
_respawn_dashboard_processes,
|
|
)
|
|
from hermes_cli.main_provider_setup import (
|
|
_GENERIC_API_KEY_PROVIDERS,
|
|
_aux_config_menu,
|
|
_build_provider_picker_rows,
|
|
_clear_stale_openai_base_url,
|
|
_is_profile_api_key_provider,
|
|
_named_custom_provider_map,
|
|
_offer_reasoning_after_pick,
|
|
_prompt_main_reasoning_effort,
|
|
_prompt_provider_choice,
|
|
_remove_custom_provider,
|
|
)
|
|
# Frozen external updater API: old in-memory siblings still import these names
|
|
# after a checkout swap. Keep their inert shims separate from live launch helpers.
|
|
from hermes_cli.old_updater_main import (
|
|
ShimQuarantineError,
|
|
_BYTECODE_FINGERPRINT_FILE,
|
|
_desktop_stamp_path,
|
|
_detect_broken_lazy_refresh_imports,
|
|
_expected_windows_pe_machines,
|
|
_hermes_exe_shims,
|
|
_insert_python_pin,
|
|
_interpreter_scripts_dir,
|
|
_load_installable_optional_extras,
|
|
_parse_pe_machine,
|
|
_quarantine_running_hermes_exe,
|
|
_repair_broken_lazy_refresh_imports,
|
|
_resolve_install_target_python,
|
|
_restore_quarantined_exes,
|
|
_run_install_with_heartbeat,
|
|
_run_package_only_install,
|
|
_run_quarantined_install,
|
|
_run_with_idle_timeout,
|
|
_self,
|
|
_verify_console_scripts_installed,
|
|
_verify_core_dependencies_installed,
|
|
_web_ui_build_needed,
|
|
_windows_native_machine,
|
|
_windows_shim_in_process_chain,
|
|
_write_web_ui_build_stamp,
|
|
)
|
|
from hermes_cli.main_install_repair import _cleanup_quarantined_exes
|
|
from hermes_cli.main_install_repair import ( # frozen updater surface: update_cmd*.py resolve these via _m()
|
|
_UPDATE_REEXEC_ENV,
|
|
_clear_lazy_refresh_incomplete_marker,
|
|
_clear_marker_file,
|
|
_clear_update_incomplete_marker,
|
|
_is_termux_env,
|
|
_is_windows,
|
|
_is_windows_npm_path,
|
|
_lazy_refresh_marker_path,
|
|
_pytest_owns_live_checkout,
|
|
_reexec_dependency_sync_off_windows_shim,
|
|
_resolve_node_runtime_npm,
|
|
_resolve_update_branch,
|
|
_update_marker_path,
|
|
_venv_scripts_dir,
|
|
)
|
|
from hermes_cli.main_desktop import (
|
|
cmd_gui,
|
|
)
|
|
from hermes_cli.main_desktop import ( # frozen updater surface: update_cmd*.py resolve these via _m()
|
|
_desktop_build_needed,
|
|
_desktop_dist_exists,
|
|
_desktop_macos_relaunchable_fixup,
|
|
_desktop_packaged_executable,
|
|
_install_rebuilt_desktop_app,
|
|
)
|
|
from hermes_cli.main_web_build import (
|
|
_sweep_stale_bytecode_if_checkout_changed,
|
|
)
|
|
from hermes_cli.main_web_build import ( # frozen updater surface: update_cmd*.py resolve these via _m()
|
|
_build_web_ui,
|
|
_nixos_build_env,
|
|
_record_bytecode_fingerprint,
|
|
_run_npm_install_deterministic,
|
|
)
|
|
from hermes_cli.main_tui_launch import (
|
|
_launch_tui,
|
|
_pin_kanban_board_env,
|
|
_resolve_use_tui,
|
|
_sync_bundled_skills_quietly,
|
|
)
|
|
|
|
|
|
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-sig", 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-sig", 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-sig", errors="replace").strip()
|
|
if rel:
|
|
common_dir = (git_dir / rel).resolve()
|
|
except OSError:
|
|
pass
|
|
head = (git_dir / "HEAD").read_text(encoding="utf-8-sig", 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-sig', 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
|
|
from hermes_cli.version_info import get_version_info
|
|
|
|
version_info = get_version_info()
|
|
code_identity = version_info.commit or version_info.derived_version
|
|
skills_dir = PROJECT_ROOT / "skills"
|
|
try:
|
|
stat = skills_dir.stat()
|
|
return f"skills:{code_identity}:{stat.st_mtime_ns}:{stat.st_size}"
|
|
except OSError:
|
|
return f"skills:{code_identity}: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-sig").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 _dotenv_has_provider_key(env_file: Path, provider_env_vars: set) -> bool:
|
|
"""True if ~/.hermes/.env assigns a non-empty value to any provider key."""
|
|
if not env_file.exists():
|
|
return False
|
|
try:
|
|
for line in env_file.read_text(encoding="utf-8-sig").splitlines():
|
|
line = line.strip()
|
|
if line.startswith("#") or "=" not in line:
|
|
continue
|
|
if line.startswith("export "):
|
|
# Strip the bash-compatible ``export `` prefix so lines like ``export API_KEY=...`` parse as
|
|
# ``API_KEY`` rather than being stored under the wrong key ``"export API_KEY"`` (#6659).
|
|
line = line[7:]
|
|
key, _, val = line.partition("=")
|
|
if key.strip() in provider_env_vars and val.strip().strip("'\""):
|
|
return True
|
|
except Exception:
|
|
pass
|
|
return False
|
|
|
|
|
|
def _auth_store_logged_in(auth_file: Path, registry, strict_profile_scope: bool) -> bool:
|
|
"""True if auth.json's active provider is logged in (api_key providers ignored under strict scope)."""
|
|
from hermes_cli.auth import get_auth_status
|
|
|
|
if not auth_file.exists():
|
|
return False
|
|
try:
|
|
auth = json.loads(auth_file.read_text(encoding="utf-8-sig"))
|
|
active = auth.get("active_provider")
|
|
active_config = registry.get(str(active or "").strip().lower())
|
|
if active and not (
|
|
strict_profile_scope and active_config and active_config.auth_type == "api_key"
|
|
):
|
|
return bool(get_auth_status(active).get("logged_in"))
|
|
except Exception:
|
|
pass
|
|
return False
|
|
|
|
|
|
def _has_any_provider_configured(*, strict_profile_scope: bool = False) -> bool:
|
|
"""Check if at least one inference provider is usable. Never creates one: the Nous free tier
|
|
counts only once its identity exists, and the boot bootstrap (``hermes_cli.free_tier_bootstrap``)
|
|
is the only thing that creates it; ``cmd_chat`` runs the bootstrap before asking.
|
|
|
|
``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 DEFAULT_CONFIG, get_env_path, get_hermes_home, load_config
|
|
from hermes_cli.auth import PROVIDER_REGISTRY, get_auth_status
|
|
|
|
cfg = load_config()
|
|
model_cfg = cfg.get("model")
|
|
_model_name = model_cfg if isinstance(model_cfg, str) else ""
|
|
if isinstance(model_cfg, dict):
|
|
_model_name = model_cfg.get("default") or ""
|
|
if isinstance(_model_name, dict):
|
|
from hermes_cli.config import split_model_config_default
|
|
_model_name, _ = split_model_config_default(_model_name)
|
|
_model_name = str(_model_name).strip()
|
|
# "Explicitly configured" = model differs from the hardcoded default; gates
|
|
# Claude Code credentials so they don't skip setup on a fresh install.
|
|
_has_hermes_config = _model_name and _model_name != DEFAULT_CONFIG.get("model", "")
|
|
|
|
# Env vars (.env or shell). OPENAI_BASE_URL alone counts — local models
|
|
# (vLLM, llama.cpp) often need no API key.
|
|
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
|
|
if _dotenv_has_provider_key(get_env_path(), provider_env_vars):
|
|
return True
|
|
|
|
# Cheap on-disk checks (auth.json, config.yaml) first: the PROVIDER_REGISTRY
|
|
# sweep below spawns subprocesses (gh) and can take 15-20s — long enough
|
|
# that desktop setup.status calls time out.
|
|
if _auth_store_logged_in(get_hermes_home() / "auth.json", PROVIDER_REGISTRY, strict_profile_scope):
|
|
return True
|
|
|
|
# model as a dict with provider/base_url/api_key means setup ran (fresh
|
|
# installs have a plain string); also covers custom endpoints kept in config.
|
|
if isinstance(model_cfg, dict) and any(
|
|
(model_cfg.get(k) or "").strip() for k in ("provider", "base_url", "api_key")
|
|
):
|
|
return True
|
|
|
|
# Provider-specific auth fallbacks (e.g. Copilot via gh auth).
|
|
if not strict_profile_scope:
|
|
try:
|
|
if any(
|
|
get_auth_status(pid).get("logged_in")
|
|
for pid, pconfig in PROVIDER_REGISTRY.items()
|
|
if pconfig.auth_type == "api_key"
|
|
):
|
|
return True
|
|
except Exception:
|
|
pass
|
|
|
|
# Claude Code OAuth credentials count only once Hermes is explicitly
|
|
# configured — having Claude Code installed isn't consent to use its tokens.
|
|
if _has_hermes_config and not strict_profile_scope:
|
|
try:
|
|
from agent.anthropic_credentials 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
|
|
|
|
# Nothing explicit anywhere: an existing Nous free-tier identity counts while the tier is on.
|
|
try:
|
|
from hermes_cli.anon_auth import guest_enabled, has_guest
|
|
return guest_enabled() and has_guest()
|
|
except Exception as exc:
|
|
logger.debug("free tier check on first run skipped: %s", exc)
|
|
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 = {}
|
|
_dict = lambda v: v if isinstance(v, dict) else {} # noqa: E731
|
|
config = _dict(config)
|
|
model_cfg = _dict(config.get("model"))
|
|
security_cfg = _dict(config.get("security"))
|
|
|
|
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
|
|
|
|
# Intentionally independent of --yolo / --accept-hooks: those approve local
|
|
# command risk, not paid aggregator spend or a surprising provider route.
|
|
is_interactive = sys.stdin.isatty()
|
|
if not is_interactive and security_cfg.get("allow_data_training_tiers_noninteractive") is True:
|
|
acknowledged = [w for w in warnings if w.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 = [w for w in warnings if w.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:
|
|
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
|
|
|
|
|
|
@contextlib.contextmanager
|
|
def _session_db():
|
|
"""Yield a read-only ``SessionDB`` (lazy import, so test patches on ``hermes_state``
|
|
intercept). Every caller is a lookup (last session, title → id, recorded cwd), so it
|
|
never opens a writer beside the one the CLI acquires from the registry a moment later.
|
|
Open failures yield None and any error raised by the ``with`` body is swallowed —
|
|
callers fall through to their ``return None``."""
|
|
db = None
|
|
try:
|
|
from hermes_state import SessionDB
|
|
|
|
db = SessionDB(read_only=True)
|
|
except Exception:
|
|
pass
|
|
try:
|
|
yield db # body errors (incl. AttributeError on a None db) are swallowed
|
|
except Exception:
|
|
pass
|
|
finally:
|
|
if db is not None:
|
|
try:
|
|
db.close()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def _latest_session_id(use_tui: bool) -> Optional[str]:
|
|
"""MRU session for the active interface; a TUI launch falls back to the CLI MRU."""
|
|
last_id = _resolve_last_session(source="tui" if use_tui else "cli")
|
|
if not last_id and use_tui:
|
|
last_id = _resolve_last_session(source="cli")
|
|
return last_id
|
|
|
|
|
|
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.
|
|
"""
|
|
# A finite `hermes -z`/`chat -q` run is CLI history too: `hermes -z … --resume latest` chains on it.
|
|
if source == "cli":
|
|
from run_agent import CLI_FAMILY_SOURCES
|
|
source = sorted(CLI_FAMILY_SOURCES)
|
|
with _session_db() as db:
|
|
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
|
|
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.
|
|
inspect_cmd = [runtime, "inspect", "--format", "ok", container_name]
|
|
cmd_prefix = [runtime]
|
|
if _probe_container(inspect_cmd, backend).returncode != 0:
|
|
sudo_path = shutil.which("sudo")
|
|
if not sudo_path:
|
|
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)
|
|
cmd_prefix = [sudo_path, "-n", runtime]
|
|
if _probe_container(cmd_prefix[:2] + inspect_cmd, backend, via_sudo=True).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)
|
|
|
|
env_flags = []
|
|
for var in ("TERM", "COLORTERM", "LANG", "LC_ALL"):
|
|
val = os.environ.get(var)
|
|
if val:
|
|
env_flags.extend(["-e", f"{var}={val}"])
|
|
|
|
exec_cmd = (
|
|
cmd_prefix
|
|
+ ["exec", "-it" if sys.stdin.isatty() else "-i", "-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 title or ID to a session ID (None if neither matches).
|
|
|
|
A compression root is followed forward to its latest continuation so an
|
|
old root ID (exit summary, notes) resumes at the live tip.
|
|
"""
|
|
with _session_db() as db:
|
|
# Exact session ID first, then title (with auto-latest for lineage).
|
|
session = db.get_session(name_or_id)
|
|
resolved_id = session["id"] if session else 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
|
|
return None
|
|
|
|
|
|
def _create_titled_session(title: str) -> Optional[str]:
|
|
"""Create a fresh titled session (``chat -c <title> --create-if-missing``).
|
|
|
|
Same timestamp+uuid id shape the CLI uses; the title is recorded with
|
|
user provenance so auto-titling never overwrites it.
|
|
|
|
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.
|
|
"""
|
|
db = None
|
|
try:
|
|
from hermes_state_ids import new_session_id as mint_session_id
|
|
from hermes_state_registry import acquire
|
|
|
|
new_session_id = mint_session_id()
|
|
# The CLI acquires the registry handle for this same path moments later; share it
|
|
# instead of minting a second writer for one INSERT (close() releases the refcount).
|
|
db = acquire()
|
|
db.create_session(new_session_id, source="cli")
|
|
db.set_session_title(new_session_id, title)
|
|
return new_session_id
|
|
except Exception:
|
|
# Programmatic callers rely on --create-if-missing being deterministic;
|
|
# swallow the failure but log the cause so it lands in errors.log
|
|
# (DB lock, I/O error, import error — all otherwise invisible).
|
|
# See #86794.
|
|
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``.
|
|
|
|
``-c <name>``: resolve by title/ID; on miss fail loudly on **stderr** (exit
|
|
1) so programmatic callers see it even under quiet mode, or with
|
|
``--create-if-missing`` create a fresh titled session. Bare ``-c``: this
|
|
terminal's breadcrumb session if valid, else the MRU session.
|
|
|
|
Handles both forms: See #86794.
|
|
"""
|
|
continue_val = getattr(args, "continue_last", None)
|
|
if continue_val and not getattr(args, "resume", None):
|
|
if isinstance(continue_val, str):
|
|
resolved = _resolve_session_by_name_or_id(continue_val)
|
|
if resolved:
|
|
args.resume = resolved
|
|
elif getattr(args, "create_if_missing", False):
|
|
# "send to this named thread, making it if needed" — without it
|
|
# a quiet send to a not-yet-existing session silently no-ops.
|
|
# --create-if-missing: no session matches the title — create a new session with that title
|
|
# and proceed. See #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:
|
|
# Bare -c: this terminal's breadcrumb (so side-by-side terminals
|
|
# each continue their own conversation), else the MRU session
|
|
# (also when session.terminal_continue is false).
|
|
if getattr(args, "create_if_missing", False):
|
|
# Nothing to create without a name — surface the no-op.
|
|
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
|
|
last_id = _latest_session_id(use_tui)
|
|
if last_id:
|
|
args.resume = last_id
|
|
else:
|
|
kind = "TUI" if use_tui else "CLI"
|
|
print(
|
|
f"No previous {kind} session to continue. Start a new one with "
|
|
"`hermes`, or list sessions with `hermes sessions list`.",
|
|
file=sys.stderr,
|
|
)
|
|
sys.exit(1)
|
|
|
|
|
|
def _apply_in_dir(args) -> None:
|
|
"""--in DIR: chdir first so workspace-scoped lookups key off DIR; pins the session there."""
|
|
in_dir = getattr(args, "in_dir", None)
|
|
if not in_dir:
|
|
return
|
|
# Git Bash / MSYS hands us POSIX-style paths (`--in ~` → `/c/Users/x`);
|
|
# translate drive-root spellings to native Windows form. 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)
|
|
# Every cwd consumer (resolve_agent_cwd -> Codex app-server thread cwd, the
|
|
# terminal tool, context-file discovery) prefers TERMINAL_CWD over the process
|
|
# cwd, so a value inherited from a parent surface, the shell or .env outlives
|
|
# this chdir and re-homes the session in the old directory (#106220). Refresh
|
|
# it. An unset variable stays unset: the backends then derive from the new
|
|
# process cwd (local exports it at cli import, docker mounts it, ssh and
|
|
# container backends keep their own remote/sandbox default).
|
|
if os.environ.get("TERMINAL_CWD", "").strip():
|
|
os.environ["TERMINAL_CWD"] = _target_dir
|
|
args.no_restore_cwd = True
|
|
|
|
|
|
def _import_foreign_resume(args) -> None:
|
|
"""--resume @claude / @codex: import a foreign session and resume it."""
|
|
_resume_foreign = getattr(args, "resume", None)
|
|
if not (isinstance(_resume_foreign, str) and _resume_foreign.strip().lower() in ("@claude", "@codex")):
|
|
return
|
|
from hermes_cli.foreign_sessions import import_foreign_session, pick_foreign_session
|
|
|
|
_picked = pick_foreign_session(_resume_foreign.strip().lower().lstrip("@"))
|
|
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
|
|
|
|
|
|
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).
|
|
"""
|
|
_apply_in_dir(args)
|
|
|
|
# --resume latest: same resolution as bare `-c`. The keyword wins over a
|
|
# session literally titled "latest" (still reachable by ID or `-c latest`).
|
|
_resume_raw = getattr(args, "resume", None)
|
|
if isinstance(_resume_raw, str) and _resume_raw.strip().lower() == "latest":
|
|
_last_id = _latest_session_id(use_tui)
|
|
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_arg(args, use_tui=use_tui)
|
|
|
|
_import_foreign_resume(args)
|
|
|
|
resume_val = getattr(args, "resume", None)
|
|
if resume_val:
|
|
# On miss keep the original so _init_agent reports "Session not found" with it.
|
|
args.resume = _resolve_session_by_name_or_id(resume_val) or resume_val
|
|
|
|
# cd back into a resumed session's recorded cwd (opt out: --no-restore-cwd;
|
|
# --worktree owns its own dir). A missing dir warns and stays put.
|
|
if (
|
|
getattr(args, "resume", None)
|
|
and not getattr(args, "no_restore_cwd", False)
|
|
and not getattr(args, "worktree", False)
|
|
):
|
|
with _session_db() as db: # never let cwd-restore break a resume
|
|
_saved_cwd = ((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}")
|
|
|
|
|
|
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 (~120-170ms of rglob/hashing) so it normally
|
|
runs in a daemon thread — skill loading happens at agent init, long after.
|
|
The ONE exception is an unseeded ~/.hermes/skills: there the banner
|
|
prefetch races the sync and caches an empty index ("No skills installed"
|
|
on the very first launch), so the first run syncs in the foreground and
|
|
drops the banner's skills cache.
|
|
"""
|
|
if _termux_should_prefetch_update_check():
|
|
try:
|
|
from hermes_cli.banner import prefetch_banner_data, prefetch_update_check
|
|
|
|
prefetch_update_check()
|
|
prefetch_banner_data() # git banner state + skills index off-thread
|
|
except Exception:
|
|
pass
|
|
|
|
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()
|
|
# Drop the banner's possibly-empty skills cache so it recomputes.
|
|
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 _first_run_setup_guard(args) -> None:
|
|
"""No provider configured: offer `hermes setup` (TTY) or exit 1 with guidance."""
|
|
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)
|
|
|
|
|
|
def _read_query_file(args) -> None:
|
|
"""--query-file: read the single query from a file (or stdin via '-').
|
|
|
|
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 not _qfile:
|
|
return
|
|
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-sig", 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)
|
|
|
|
|
|
# args attr -> (kwarg, default) passed through to _launch_tui / cli.main.
|
|
_CHAT_PASSTHROUGH = (
|
|
("provider", None), ("toolsets", None), ("skills", None), ("verbose", None),
|
|
("quiet", False), ("query", None), ("image", None), ("resume", None),
|
|
("worktree", False), ("checkpoints", False), ("pass_session_id", False),
|
|
("max_turns", None),
|
|
)
|
|
|
|
|
|
def cmd_chat(args):
|
|
"""Run interactive chat CLI."""
|
|
_apply_safe_mode(args)
|
|
_apply_user_config_bypass(args)
|
|
_guard_noninteractive_user_config(args)
|
|
from hermes_cli.stream_json import stream_json_requested
|
|
# Structured stdout is a non-interactive protocol: it overrides HERMES_TUI/display.interface too.
|
|
use_tui = False if stream_json_requested(args) else _resolve_use_tui(args)
|
|
|
|
_resolve_chat_session_args(args, use_tui)
|
|
|
|
_warn_retired_xai_models()
|
|
|
|
# First-run guard: the free-tier bootstrap runs first (synchronously here; it is the only thing
|
|
# that may create the identity), then the inventory decides whether setup is needed.
|
|
from hermes_cli.free_tier_bootstrap import run_bootstrap
|
|
|
|
run_bootstrap(announce=False)
|
|
if not _has_any_provider_configured():
|
|
_first_run_setup_guard(args)
|
|
return
|
|
|
|
_start_chat_background_prefetch()
|
|
|
|
# --yolo: bypass all dangerous command approvals. main() also sets this
|
|
# before _prepare_agent_startup() — the authoritative site, since it runs
|
|
# before tool imports freeze _YOLO_MODE_FROZEN. This 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 AGENTS.md/SOUL.md/.cursorrules injection, memory
|
|
# entries and preloaded skills (AIAgent(skip_context_files, skip_memory)).
|
|
if getattr(args, "ignore_rules", False):
|
|
os.environ["HERMES_IGNORE_RULES"] = "1"
|
|
# --source: tag session source for filtering (e.g. 'tool' for integrations)
|
|
if getattr(args, "source", None):
|
|
os.environ["HERMES_SESSION_SOURCE"] = args.source
|
|
# Explicit flag, not a label inherited from a parent TUI/Desktop session — one-shot
|
|
# runs must keep it (see run_agent._session_source_for_agent).
|
|
os.environ["HERMES_SESSION_SOURCE_EXPLICIT"] = "1"
|
|
|
|
_pin_kanban_board_env()
|
|
_confirm_startup_expensive_model_override(args)
|
|
|
|
passthrough = {k: getattr(args, k, d) for k, d in _CHAT_PASSTHROUGH}
|
|
if use_tui:
|
|
_launch_tui(
|
|
passthrough.pop("resume"),
|
|
tui_dev=getattr(args, "tui_dev", False),
|
|
native_mode=getattr(args, "tui_native", False) or None,
|
|
model=getattr(args, "model", None),
|
|
accept_hooks=getattr(args, "accept_hooks", False),
|
|
**passthrough,
|
|
)
|
|
|
|
_read_query_file(args)
|
|
|
|
safe_mode = getattr(args, "safe_mode", False)
|
|
kwargs = {
|
|
"model": args.model,
|
|
"reasoning": getattr(args, "reasoning", None),
|
|
"toolsets": args.toolsets,
|
|
"query": args.query,
|
|
"oneshot": bool(getattr(args, "oneshot_exit", False)),
|
|
"run_budget": getattr(args, "run_budget", None),
|
|
"output_format": getattr(args, "output_format", "text"),
|
|
"ignore_rules": getattr(args, "ignore_rules", False) or safe_mode,
|
|
"ignore_user_config": getattr(args, "ignore_user_config", False) or safe_mode,
|
|
"compact": getattr(args, "compact", False),
|
|
**{k: getattr(args, k, d) for k, d in _CHAT_PASSTHROUGH},
|
|
}
|
|
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."""
|
|
# aiohttp is an extras install; keep it off the common path.
|
|
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>``.
|
|
|
|
Imports at CALL time so fast paths never pay for it and
|
|
``patch("<module>.<attr>")`` keeps intercepting. ``forward_return``
|
|
surfaces the return code to ``main()`` (kanban/project/mcp propagate).
|
|
"""
|
|
|
|
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", forward_return=True, 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", forward_return=True, 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", forward_return=True, 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", forward_return=True)
|
|
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 keep intercepting.
|
|
# ``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 _norm_base_url(url: str) -> str:
|
|
return str(url or "").strip().rstrip("/").lower()
|
|
|
|
|
|
def _resolve_active_provider(config, model_cfg, effective_provider, custom_provider_map):
|
|
"""Provider slug currently in effect (the picker's default row), or None.
|
|
|
|
Order: a saved custom provider whose base_url matches model.base_url →
|
|
the configured/env provider (named custom → canonical map key) → auto
|
|
detection. Unknown/unauthenticated providers warn and fall back to auto.
|
|
"""
|
|
from hermes_cli.auth import AuthError, format_auth_error, resolve_provider
|
|
from hermes_cli.config import get_compatible_custom_providers, get_env_value
|
|
from hermes_cli.providers import custom_provider_aliases, resolve_provider_full
|
|
|
|
active = ""
|
|
if effective_provider == "custom" and isinstance(model_cfg, dict):
|
|
current_base = _norm_base_url(model_cfg.get("base_url", ""))
|
|
if current_base:
|
|
active = next(
|
|
(k for k, info in custom_provider_map.items()
|
|
if _norm_base_url(info.get("base_url", "")) == current_base),
|
|
"",
|
|
)
|
|
if not active and effective_provider != "auto":
|
|
active_def = resolve_provider_full(
|
|
effective_provider,
|
|
config.get("providers"),
|
|
get_compatible_custom_providers(config),
|
|
)
|
|
if active_def is not None:
|
|
active = active_def.id
|
|
if active_def.source == "user-config":
|
|
requested = str(active or "").strip().lower()
|
|
active = next(
|
|
(k for k, info in custom_provider_map.items()
|
|
if requested in custom_provider_aliases(
|
|
info.get("name", ""), info.get("provider_key", ""))),
|
|
active,
|
|
)
|
|
else:
|
|
print(
|
|
f"Warning: Unknown provider '{effective_provider}'. Check 'hermes model' for "
|
|
"available providers, or run 'hermes doctor' to diagnose config "
|
|
"issues. Falling back to auto provider detection."
|
|
)
|
|
if not active:
|
|
try:
|
|
active = resolve_provider("auto")
|
|
except AuthError as exc:
|
|
if exc.code == "no_provider_configured":
|
|
# The picker that is about to open IS the fix; a warning that says
|
|
# "run `hermes model`" from inside `hermes model` is circular.
|
|
print("No provider is set up yet — pick one below. (Nous Portal works without an API key.)")
|
|
elif effective_provider == "auto":
|
|
print(f"Warning: {format_auth_error(exc)} 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"
|
|
return active
|
|
|
|
|
|
def _pick_provider(config, active, provider_labels, custom_provider_map):
|
|
"""Provider picker (+ group member sub-picker) -> concrete slug, or None on cancel."""
|
|
# Group rows drill into a member sub-picker that resolves back to a
|
|
# concrete slug, so the flow dispatch 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":
|
|
return None
|
|
selected_key, group_label, selected_members = ordered[provider_idx]
|
|
if not selected_members:
|
|
return selected_key
|
|
# Default to the active member when it lives in this group. The group row
|
|
# carries the descriptive text, so member rows show only their short label.
|
|
member_idx = _prompt_provider_choice(
|
|
[provider_labels.get(m, m) for m in selected_members],
|
|
default=selected_members.index(active) if active in selected_members else 0,
|
|
title=f"Select {group_label.split(' ▸', 1)[0]} provider:",
|
|
)
|
|
return None if member_idx is None else selected_members[member_idx]
|
|
|
|
|
|
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.config import load_config
|
|
|
|
config = load_config()
|
|
model_cfg = config.get("model")
|
|
current_model = model_cfg.get("default", "") if isinstance(model_cfg, dict) else model_cfg
|
|
current_model = current_model or "(not set)"
|
|
|
|
# Effective provider the same way the CLI resolves it at startup:
|
|
# config.yaml model.provider > env var > auto-detect
|
|
config_provider = model_cfg.get("provider") if isinstance(model_cfg, dict) else None
|
|
effective_provider = config_provider or os.getenv("HERMES_INFERENCE_PROVIDER") or "auto"
|
|
|
|
# User-defined custom providers from config.yaml: key → {name, base_url, api_key}
|
|
_custom_provider_map = _named_custom_provider_map(config)
|
|
active = _resolve_active_provider(config, model_cfg, effective_provider, _custom_provider_map)
|
|
|
|
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()
|
|
|
|
selected_provider = _pick_provider(config, active, provider_labels, _custom_provider_map)
|
|
if selected_provider is None:
|
|
print("No change.")
|
|
return
|
|
if selected_provider == "aux-config":
|
|
_aux_config_menu()
|
|
return
|
|
if selected_provider == "reasoning":
|
|
# Effort for the CURRENT default model, no model change.
|
|
_prompt_main_reasoning_effort(current_model, active or "")
|
|
return
|
|
|
|
# Provider-specific setup + model selection. Flows resolve the
|
|
# _model_flow_* names at call time so test monkeypatches on
|
|
# hermes_cli.main keep intercepting.
|
|
flow = _PROVIDER_MODEL_FLOWS.get(selected_provider)
|
|
if flow is None and _is_profile_plugin_flow_provider(selected_provider):
|
|
# Registered plugin profile with no bespoke flow: the generic one, keyed by its auth_type.
|
|
flow = lambda c, m, a: _model_flow_plugin_provider(c, selected_provider, m) # noqa: E731
|
|
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)
|
|
|
|
# Every flow persists through _save_model_choice; a changed model.default means a pick
|
|
# landed, so offer its reasoning effort here once instead of inside each flow.
|
|
_offer_reasoning_after_pick(current_model)
|
|
|
|
# Post-switch cleanup: switching to a named provider (anything except
|
|
# "custom") leaves a stale OPENAI_BASE_URL in ~/.hermes/.env that poisons
|
|
# auxiliary clients using provider:auto — clear it proactively. (#5161)
|
|
if selected_provider not in {
|
|
"custom",
|
|
"cancel",
|
|
"remove-custom",
|
|
} and not selected_provider.startswith("custom:"):
|
|
_clear_stale_openai_base_url()
|
|
|
|
|
|
def _detect_venv_python_processes(*, exclude_pids: set[int] | None = None) -> list[tuple[int, str, str]]:
|
|
# Shim to stop the old updater doing work until relaunch. Do not scan or kill.
|
|
# Current Windows checks use the real detector in update_cmd_windows instead.
|
|
return []
|
|
|
|
|
|
# Retired hooks must bypass update_cmd: its current dispatcher captures PM helpers
|
|
# before swapping code, but a historical caller may first resolve these afterward.
|
|
# Frozen updater surface (PEP 562 ``__getattr__`` below): the frozen
|
|
# ``hermes_cli/update_cmd*.py`` files resolve these names via ``_m().<name>``
|
|
# on hermes_cli.main; importing update_cmd eagerly would cost every ``hermes``
|
|
# invocation ~50-100ms, so they resolve on first read. Nothing else may be
|
|
# added here — internal import paths are not a stable API.
|
|
_FROZEN_UPDATER_SURFACE: dict[str, tuple[str, ...]] = {
|
|
"hermes_cli.update_cmd": (
|
|
"_assess_parked_branch_switch",
|
|
"_cold_start_windows_gateway_after_update", "_discard_stashed_changes",
|
|
"_filter_non_gateway_concurrent_instances", "_fleet_probe_expected_runtimes",
|
|
"_get_origin_url", "_park_stashed_changes",
|
|
"_pause_windows_gateways_for_update", "_print_parked_branch_kept_notice",
|
|
"_print_parked_branch_skip_warning",
|
|
"_refresh_bootstrap_cache_scripts", "_refresh_windows_gateway_launchers",
|
|
"_restore_stashed_changes",
|
|
"_resume_windows_gateways_after_update", "_run_logged_subprocess", "_run_pre_update_backup",
|
|
"_stash_local_changes_if_needed", "_sync_with_upstream_if_needed",
|
|
"_venv_launcher_ancestors",
|
|
"_wait_for_windows_update_gateway_exit", "_warn_orphaned_update_autostashes",
|
|
),
|
|
"hermes_cli.old_updater_deps": (
|
|
"_capture_active_lazy_features", "_handoff_reapable_backend_pids",
|
|
"_ledger_manual_serve_holders", "_ledger_reapable_backend_pids",
|
|
"_leftover_pausable_gateway_pids", "_npm_lockfile_changed",
|
|
"_orphaned_desktop_backend_pids", "_refresh_active_lazy_features",
|
|
"_refresh_active_memory_provider_dependencies", "_relaunch_stopped_serves",
|
|
"_stop_process_trees",
|
|
),
|
|
"hermes_cli.update_cmd_maint": (
|
|
"_purge_stale_hermes_modules", "_reload_updated_runtime_modules",
|
|
),
|
|
"hermes_cli.dashboard_procs": (
|
|
"_detect_concurrent_hermes_instances", "_kill_stale_dashboard_processes",
|
|
),
|
|
}
|
|
_FROZEN_ATTR_SOURCES: dict[str, str] = {
|
|
attr: module for module, attrs in _FROZEN_UPDATER_SURFACE.items() for attr in attrs
|
|
}
|
|
|
|
|
|
def __getattr__(name):
|
|
"""Resolve the frozen updater surface on first read (see _FROZEN_UPDATER_SURFACE)."""
|
|
if name == "_write_update_incomplete_marker":
|
|
from hermes_cli._old_updater import stop_for_relaunch as value
|
|
else:
|
|
module = _FROZEN_ATTR_SOURCES.get(name)
|
|
if module is None:
|
|
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
import importlib
|
|
|
|
value = getattr(importlib.import_module(module), name)
|
|
globals()[name] = value # cache: later accesses skip __getattr__
|
|
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:
|
|
# Fail-closed config write guard (require_readable_config_before_write);
|
|
# covers migrate and future write subcommands so none end in a traceback.
|
|
print(f"✗ {exc}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
|
|
def cmd_backup(args):
|
|
"""Back up Hermes home directory to a zip file."""
|
|
from hermes_cli import backup
|
|
|
|
if getattr(args, "quick", False):
|
|
backup.run_quick_backup(args)
|
|
elif not backup.run_backup(args):
|
|
raise SystemExit(1) # archive written but incomplete: never shell-success for a timer
|
|
|
|
|
|
def _print_version_info(*, check_updates: bool = True) -> None:
|
|
# Shared with the `hermes --version` pre-import fast path.
|
|
_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).
|
|
|
|
``--yes`` paths run from the desktop app's non-interactive cleanup scripts,
|
|
so the TTY gate applies only when we actually need to prompt.
|
|
"""
|
|
# Machine-readable snapshot for the desktop uninstall UI; before any TTY gate.
|
|
if getattr(args, "gui_summary", False):
|
|
from hermes_cli.gui_uninstall import gui_install_summary
|
|
|
|
print(json.dumps(gui_install_summary()))
|
|
return
|
|
|
|
if getattr(args, "data", False):
|
|
if not getattr(args, "yes", False) and not getattr(args, "dry_run", False):
|
|
_require_tty("uninstall --data")
|
|
from hermes_cli.uninstall import run_data_uninstall
|
|
|
|
run_data_uninstall(args)
|
|
return
|
|
|
|
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
|
|
|
|
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__ dirs under *root* (stale .pyc → ImportError after updates).
|
|
|
|
Returns the number of directories removed.
|
|
"""
|
|
removed = 0
|
|
for dirpath, dirnames, _ in os.walk(root):
|
|
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
|
|
|
|
|
|
def _finalize_update_receipt(code: int, reason: str) -> None:
|
|
"""Best-effort receipt close at the command boundary; no-op if already finalized."""
|
|
try:
|
|
# 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).
|
|
from hermes_cli.update_receipt import finalize_pending_update_receipt
|
|
|
|
finalize_pending_update_receipt(code, reason)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def _update_preflight_handled(args) -> bool:
|
|
"""Managed-install refusal, --plan, admission gate, --check. True = nothing more to do."""
|
|
from hermes_cli.config import is_managed, managed_error
|
|
from hermes_cli.update_channel import handle_metadata_args
|
|
|
|
if handle_metadata_args(args, PROJECT_ROOT):
|
|
sys.exit(0)
|
|
if is_managed():
|
|
managed_error("update Hermes Agent")
|
|
return True
|
|
|
|
# --plan is read-only and deployment-kind aware, so it runs BEFORE the
|
|
# docker/nix/apt refusal gates: on an image/package-managed install the
|
|
# plan itself reports "not updatable in place" plus the right mechanism.
|
|
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 True
|
|
|
|
if getattr(args, "list_venv_holders", False):
|
|
# Read-only twin of the Windows venv-holder refusal (#117246): same scan and classifiers,
|
|
# machine-readable, exit 3 when holders remain so automation can stop those PIDs and retry.
|
|
import json
|
|
|
|
from hermes_cli.update_cmd_windows import VENV_HOLDERS_EXIT, list_venv_holders
|
|
|
|
holders = list_venv_holders()
|
|
print(json.dumps(holders, indent=2))
|
|
if holders:
|
|
sys.exit(VENV_HOLDERS_EXIT)
|
|
return True
|
|
|
|
# Image/package-managed admission gate: baked provenance marker first
|
|
# (fail-closed on malformed), then docker/nix/apt heuristics. Records a
|
|
# `refused` receipt and exits 2 (refused-by-contract, distinct from errors).
|
|
# Image-managed / package-managed admission gate (#91277 Phase 3): one shared decision for every
|
|
# mutation surface. 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).
|
|
# Shared admission gate (#91277 Phase 3): same marker-first decision as the apply path, so --check can
|
|
# never report git state for an install whose real update mechanism is an image pull.
|
|
# The response keeps the pre-existing per-kind error codes the dashboard UI already keys on. See #91277.
|
|
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 its answer matches what update would pull.
|
|
branch = _resolve_update_branch(args)
|
|
from hermes_cli.update_cmd import _cmd_update_check
|
|
|
|
_cmd_update_check(
|
|
branch=branch,
|
|
branch_explicit=bool(getattr(args, "branch", None)),
|
|
**({"channel": args.channel} if getattr(args, "channel", None) else {}),
|
|
)
|
|
return True
|
|
return False
|
|
|
|
|
|
from hermes_cli.update_receipt import update_receipt_scope
|
|
|
|
|
|
@update_receipt_scope()
|
|
def cmd_update(args):
|
|
"""Update Hermes Agent: hangup protection + update lock around ``_cmd_update_impl``."""
|
|
from hermes_cli.update_owning_install import retarget_to_owning_install
|
|
|
|
retarget_to_owning_install(PROJECT_ROOT)
|
|
if _update_preflight_handled(args):
|
|
return
|
|
gateway_mode = getattr(args, "gateway", False)
|
|
|
|
_update_io_state = _install_hangup_protection(gateway_mode=gateway_mode)
|
|
# Cross-process mutual exclusion: dashboard Update button, Tauri updater
|
|
# and this command all mutate one checkout; two at once strand it
|
|
# half-updated. Shares the marker the Tauri/Electron updaters already use.
|
|
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)
|
|
|
|
|
|
from hermes_cli.update_cmd import _cmd_update_impl
|
|
from pm import InstallError
|
|
|
|
try:
|
|
_cmd_update_impl(args, gateway_mode=gateway_mode)
|
|
except (InstallError, OSError, subprocess.SubprocessError) as exc:
|
|
print(f"✗ Update failed: {exc}")
|
|
_finalize_update_receipt(1, f"{type(exc).__name__}: {exc}")
|
|
if gateway_mode:
|
|
from hermes_cli.update_cmd_fleet import _write_gateway_update_exit_code
|
|
_write_gateway_update_exit_code(False)
|
|
|
|
raise SystemExit(1) from exc
|
|
except SystemExit as _update_exit:
|
|
# Receipt boundary: the impl has many early sys.exit paths that never
|
|
# reach an inner finalize. Persist any still-open receipt with the real
|
|
# exit code (no-op if already finalized), then let the exit proceed.
|
|
_code = _update_exit.code if isinstance(_update_exit.code, int) else 1
|
|
_finalize_update_receipt(_code, f"sys.exit({_code})")
|
|
if gateway_mode and _code:
|
|
from hermes_cli.update_cmd_fleet import _write_gateway_update_exit_code
|
|
_write_gateway_update_exit_code(False)
|
|
|
|
raise
|
|
except BaseException as _update_exc:
|
|
if gateway_mode:
|
|
from hermes_cli.update_cmd_fleet import _write_gateway_update_exit_code
|
|
_write_gateway_update_exit_code(False)
|
|
_finalize_update_receipt(1, f"{type(_update_exc).__name__}: {_update_exc}")
|
|
raise
|
|
else:
|
|
from hermes_cli.update_receipt import COMMAND_BOUNDARY_STOP_REASON
|
|
|
|
_finalize_update_receipt(0, COMMAND_BOUNDARY_STOP_REASON)
|
|
|
|
finally:
|
|
_update_lock.release()
|
|
_finalize_update_output(_update_io_state)
|
|
|
|
|
|
|
|
def _coalesce_session_name_args(argv: list) -> list:
|
|
"""Join unquoted multi-word session names after -c/--continue and -r/--resume.
|
|
|
|
``hermes -c Pokemon Agent Dev`` → ``['-c', 'Pokemon Agent Dev']``; tokens
|
|
are collected until the next flag (``-*``) or 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", "usage",
|
|
}
|
|
_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
|
|
|
|
|
|
def _dashboard_lifecycle_flags(args, token_file) -> None:
|
|
"""--status / --stop: report or kill running dashboards and exit (no deps needed)."""
|
|
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")
|
|
if getattr(args, "status", False):
|
|
_report_dashboard_status()
|
|
sys.exit(0) # status is informational, always 0
|
|
if getattr(args, "stop", False):
|
|
# Scoped to the invoking home (`-p` applied by _apply_profile_override): another
|
|
# install's or profile's backend on this machine is never a target (#113978).
|
|
from hermes_constants import get_hermes_home
|
|
|
|
own_home = str(get_hermes_home())
|
|
if not _find_stale_dashboard_pids(scope_home=own_home):
|
|
print("No hermes dashboard processes running for this profile.")
|
|
sys.exit(0)
|
|
# Reuse the same SIGTERM-grace-SIGKILL path used after `hermes update`;
|
|
# it prints outcomes itself. Exit 1 only if a pid was unkillable — judged
|
|
# from the kill result, not a re-scan: a launchd KeepAlive job respawns
|
|
# its backend on a fresh PID, which is not a failed stop.
|
|
from hermes_cli.dashboard_procs import _kill_stale_dashboard_processes
|
|
|
|
result = _kill_stale_dashboard_processes(reason="requested via --stop", scope_home=own_home)
|
|
sys.exit(1 if result["failed"] else 0)
|
|
|
|
|
|
def _dashboard_validate_serve_args(args, headless_backend, token_file):
|
|
"""Headless-serve argument checks -> ssh_owner_nonce (or None)."""
|
|
# `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")
|
|
if token_file and not headless_backend:
|
|
raise SystemExit("--ssh-session-token-file is only valid with hermes serve")
|
|
return ssh_owner_nonce
|
|
|
|
|
|
def _dashboard_sanitize_desktop_env(headless_backend) -> None:
|
|
"""Strip 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). A shell inheriting those then running
|
|
`hermes dashboard` would serve the desktop renderer ("Desktop IPC bridge
|
|
is unavailable", #52945) or disable the SPA. Only Electron-packaged
|
|
WEB_DIST contamination is stripped from browser dashboards — caller-managed
|
|
overrides (dev / custom builds) must still work, while headless `serve`
|
|
keeps the packaged path used by the Desktop backend. Headless `serve`
|
|
re-sets HERMES_SERVE_HEADLESS itself.
|
|
|
|
The Desktop's legacy fallback spawn (`dashboard --no-open`, taken when the
|
|
`serve --help` probe times out on a cold host) is not headless yet must keep
|
|
its packaged dist: it is told apart by the per-spawn
|
|
HERMES_DASHBOARD_SESSION_TOKEN, which the terminal pane never receives and
|
|
the terminal tool's env policy strips from agent children.
|
|
"""
|
|
desktop_owned_child = _is_desktop_owned_backend()
|
|
if (
|
|
not headless_backend
|
|
and not desktop_owned_child
|
|
and _is_electron_packaged_web_dist(os.environ.get("HERMES_WEB_DIST", ""))
|
|
):
|
|
os.environ.pop("HERMES_WEB_DIST", None)
|
|
if not headless_backend:
|
|
os.environ.pop("HERMES_SERVE_HEADLESS", None)
|
|
|
|
|
|
def _require_dashboard_web_deps() -> None:
|
|
"""Exit with the right message when the dashboard's web-server packages can't import.
|
|
|
|
A plain missing-package ImportError gets the standard repair guidance; the
|
|
``DLL load failed ... _ssl`` signature of Windows Smart App Control blocking the
|
|
embedded runtime gets the policy guidance instead, so users stop looping on
|
|
repair for a block repair can never lift (#63796)."""
|
|
try:
|
|
import fastapi # noqa: F401
|
|
import uvicorn # noqa: F401
|
|
except ImportError as e:
|
|
from hermes_cli.main_dep_hints import (
|
|
missing_optional_deps_message,
|
|
smart_app_control_block_message,
|
|
)
|
|
|
|
print(smart_app_control_block_message(e) or missing_optional_deps_message(
|
|
"dashboard", "its web-server packages (fastapi, uvicorn)", "all"))
|
|
print(f"Details: {e}")
|
|
sys.exit(1)
|
|
|
|
|
|
def _dashboard_prepare_runtime(args, headless_backend) -> bool:
|
|
"""Deps check, skills seed, terminal env bridge, plugins, MCP discovery.
|
|
|
|
Returns ``start_mcp_discovery_after_bind`` for start_server.
|
|
"""
|
|
# 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
|
|
|
|
_require_dashboard_web_deps()
|
|
|
|
# Seed bundled skills on first dashboard launch so the desktop GUI's
|
|
# skills picker / agent skill discovery sees the bundled library.
|
|
_sync_bundled_skills_quietly()
|
|
|
|
# Bridge terminal.* config into TERMINAL_* env for THIS process, like the
|
|
# CLI (cli.py env_mappings) and gateway (_terminal_env_map) do. The
|
|
# dashboard/serve backend runs agents in-process (tui_gateway.ws →
|
|
# server._make_agent) and ticks cron itself when desktop-spawned; without
|
|
# this those consumers saw an unset TERMINAL_ENV and ran every command on
|
|
# the host even under `terminal.backend: docker` (#63141, #54449).
|
|
try:
|
|
# PTY chat spawns already bridge their child env copy; this covers the in-process consumers. See
|
|
# #61115, #65696.
|
|
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)
|
|
# Load plugins so any DashboardAuthProvider plugin registers BEFORE
|
|
# start_server's fail-closed gate check. Argparse setup skips discovery
|
|
# for built-in subcommands (~500ms), but 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:
|
|
# Must not block startup; the gate's fail-closed branch surfaces a
|
|
# missing provider if it matters.
|
|
print(f"⚠ Plugin discovery failed: {exc}", file=sys.stderr)
|
|
|
|
# Desktop chat uses the in-process /api/ws gateway (tui_gateway.server
|
|
# ._make_agent), which only snapshots the tool registry and never starts
|
|
# MCP discovery — so configured MCP servers would never connect. Spawn
|
|
# discovery in the background here so a slow/dead server can't block
|
|
# 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 web_server
|
|
# import and delays the READY sentinel; _make_agent's bounded
|
|
# wait_for_mcp_discovery covers a server still connecting at first turn.
|
|
# A standalone (non-Desktop) dashboard may sit idle and unvisited for days
|
|
# (#58733): it arms discovery instead and the first /api/ws client fires it.
|
|
desktop = _is_desktop_owned_backend()
|
|
if headless_backend and desktop:
|
|
return True
|
|
try:
|
|
from hermes_cli.mcp_startup import (
|
|
defer_background_mcp_discovery,
|
|
start_background_mcp_discovery,
|
|
)
|
|
|
|
if desktop:
|
|
start_background_mcp_discovery(logger=logger, thread_name="dashboard-mcp-discovery")
|
|
else:
|
|
defer_background_mcp_discovery(logger=logger, thread_name="dashboard-mcp-discovery", delay=None)
|
|
except Exception:
|
|
logger.debug(
|
|
"Background MCP tool discovery failed at dashboard startup",
|
|
exc_info=True,
|
|
)
|
|
return False
|
|
|
|
|
|
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)
|
|
_dashboard_lifecycle_flags(args, _token_file)
|
|
|
|
# `serve` is the headless backend: no UI build, no SPA mount, neutral
|
|
# ready sentinel. Resolved once and threaded through the re-exec, the
|
|
# build gate, and start_server.
|
|
_headless_backend = getattr(args, "headless_backend", False)
|
|
_ssh_owner_nonce = _dashboard_validate_serve_args(args, _headless_backend, _token_file)
|
|
_dashboard_sanitize_desktop_env(_headless_backend)
|
|
|
|
_attach_to_host_backend(args, _headless_backend)
|
|
_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()
|
|
|
|
_ssh_session_token = _read_ssh_session_token_file(_token_file) if _token_file else None
|
|
_mcp_discovery_after_bind = _dashboard_prepare_runtime(args, _headless_backend)
|
|
|
|
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 (embedded TUI over PTY/WebSocket) is always
|
|
# available — desktop and dashboard both rely on `/api/ws` + `/api/pty`.
|
|
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,
|
|
isolated=getattr(args, "isolated", False),
|
|
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 import completion
|
|
|
|
shell = getattr(args, "shell", "bash")
|
|
generate = {"zsh": completion.generate_zsh, "fish": completion.generate_fish}.get(
|
|
shell, completion.generate_bash
|
|
)
|
|
print(generate(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 known WITHOUT plugin discovery (which costs 500ms+ of
|
|
# eager plugin imports). Keep in sync with the add_parser calls in
|
|
# _build_cli_parser: a missing entry only costs a one-time discovery; an extra
|
|
# entry would let a plugin command silently fail to parse.
|
|
_BUILTIN_SUBCOMMANDS = frozenset(
|
|
{
|
|
"acp", "approvals", "auth", "backup", "bundles", "checkpoints", "claw", "codex-runtime", "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",
|
|
"usage", "vault",
|
|
"webhook", "whatsapp", "whatsapp-cloud", "worktree", "chat", "secrets", "security",
|
|
"browser",
|
|
"verify",
|
|
# Plugin commands missing from top-level --help is an accepted trade-off.
|
|
"help",
|
|
}
|
|
)
|
|
|
|
|
|
def _first_positional_argv() -> str | None:
|
|
"""First non-flag, non-flag-value token in ``sys.argv[1:]`` (skips values of known flags).
|
|
|
|
Not a full argparse simulation: an unknown ``--foo bar`` may classify
|
|
``bar`` as positional, which at worst forces a one-time plugin discovery.
|
|
"""
|
|
from hermes_cli._parser import command_argv
|
|
|
|
args = command_argv(sys.argv[1:])
|
|
return args[0] if args else None
|
|
|
|
|
|
def _plugin_cli_discovery_needed() -> bool:
|
|
"""True when the CLI might be invoking a plugin-registered subcommand.
|
|
|
|
False skips plugin discovery at argparse setup (~500-650ms). An unknown
|
|
first token could be a plugin command OR a chat prompt — either way
|
|
discovery is needed; for a prompt its cost amortizes over the agent run.
|
|
"""
|
|
first = _first_positional_argv() # None = bare ``hermes`` → chat
|
|
return first is not None and first not in _BUILTIN_SUBCOMMANDS
|
|
|
|
|
|
def _resolve_deferred_platform_cli_command(command_name: str | None) -> None:
|
|
"""Materialize the deferred platform whose top-level CLI command matches.
|
|
|
|
Bundled platforms are *deferred* entries (no gateway SDK imports at
|
|
startup), so a platform's ``register_cli_command`` side effect only runs
|
|
on import; ``discover_plugins()`` alone leaves ``hermes photon`` failing
|
|
with ``invalid choice``. Importing just the matching platform keeps
|
|
startup cheap.
|
|
|
|
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).
|
|
"""
|
|
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:
|
|
if getattr(args, "tui", False) or os.environ.get("HERMES_TUI") == "1":
|
|
return True
|
|
# The chat path decides TUI-vs-classic via _resolve_use_tui (--cli/--tui
|
|
# flags, TTY gate, HERMES_TUI env, display.interface config). Bare
|
|
# `hermes`/`hermes chat` with a TUI display config was previously missed
|
|
# here, so the wrapper pre-warmed its own MCP discovery while the TUI
|
|
# gateway (spawned moments later) ran a second one — an idle stdio MCP
|
|
# server copy held dead for the whole session. Only chat commands can
|
|
# launch the TUI; other commands (mcp serve, gateway, acp, cron) keep
|
|
# their own discovery behavior untouched.
|
|
if getattr(args, "command", None) not in {None, "chat"}:
|
|
return False
|
|
return _resolve_use_tui(args)
|
|
|
|
|
|
def _agent_subcommand_selected(args) -> bool:
|
|
"""True for ``cron run/tick``, ``gateway run``, ``mcp serve`` (see _AGENT_SUBCOMMANDS)."""
|
|
_sub_attr, _sub_set = _AGENT_SUBCOMMANDS.get(args.command, (None, None))
|
|
return bool(_sub_attr and getattr(args, _sub_attr, None) in _sub_set)
|
|
|
|
|
|
def _command_has_dedicated_mcp_startup(args) -> bool:
|
|
"""acp / gateway run / cron run|tick own their MCP startup on the runtime path."""
|
|
return args.command == "acp" or (
|
|
args.command != "mcp" and _agent_subcommand_selected(args)
|
|
)
|
|
|
|
|
|
def _should_background_mcp_startup(args) -> bool:
|
|
return not _is_tui_chat_launch(args) and 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: HERMES_YOLO_MODE must be set before any discovery
|
|
# below imports tools.approval, which freezes _YOLO_MODE_FROZEN at import.
|
|
# main() sets it earlier too, but other launchers (Termux fast-CLI) reach
|
|
# here directly, so the guarantee lives where the import is triggered.
|
|
# See #7994.
|
|
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)
|
|
|
|
if not (args.command in _AGENT_COMMANDS or _agent_subcommand_selected(args)):
|
|
return
|
|
|
|
_accept_hooks = bool(getattr(args, "accept_hooks", False))
|
|
if not _is_tui_chat_launch(args):
|
|
# The TUI backend does its own discovery; the launcher only spawns Node.
|
|
try:
|
|
from hermes_cli.plugins import start_background_plugin_discovery
|
|
|
|
# Daemon thread: ~150ms of manifest scanning overlaps the rest of
|
|
# startup. Every synchronous reader goes through discover_plugins(),
|
|
# which joins this thread first (incl. model_tools at import time).
|
|
start_background_plugin_discovery()
|
|
except Exception:
|
|
logger.warning(
|
|
"plugin discovery failed at CLI startup",
|
|
exc_info=True,
|
|
)
|
|
# -t/--toolsets narrows which configured MCP servers get spawned, on
|
|
# every discovery path (inline below, background thread, TUI/desktop
|
|
# deferred start). Built-in toolset names never match a server key, so
|
|
# `-t terminal` simply spawns nothing; `-t all` keeps the full set.
|
|
try:
|
|
from hermes_cli.mcp_startup import set_mcp_server_filter
|
|
|
|
set_mcp_server_filter(getattr(args, "toolsets", None))
|
|
except Exception:
|
|
logger.debug("MCP server filter setup failed", exc_info=True)
|
|
|
|
# TUI launches hand off to a startup path that backgrounds MCP discovery
|
|
# with a bounded join; acp/gateway/cron do their own on the runtime path.
|
|
_run_inline_mcp_discovery = not (
|
|
_is_tui_chat_launch(args) or _command_has_dedicated_mcp_startup(args)
|
|
)
|
|
if _run_inline_mcp_discovery and _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: # synchronous for entrypoints without a later bounded startup path
|
|
from hermes_cli.mcp_startup import get_mcp_server_filter
|
|
from tools.mcp_tool_discovery import discover_mcp_tools
|
|
|
|
_mcp_filter = get_mcp_server_filter()
|
|
if _mcp_filter is None:
|
|
discover_mcp_tools()
|
|
else:
|
|
discover_mcp_tools(allowed_mcp_names=_mcp_filter)
|
|
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)
|
|
# -z honors --resume/-c/--in exactly like chat (#105892): normalize BEFORE the
|
|
# oneshot exit path takes over, else the flags parse fine but silently do nothing
|
|
# and the turn starts a fresh session (every wire request loses all history).
|
|
_resolve_chat_session_args(args, use_tui=False)
|
|
_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),
|
|
resume=getattr(args, "resume", None),
|
|
reasoning=getattr(args, "reasoning", None),
|
|
)
|
|
|
|
|
|
def _light_chat_parser():
|
|
"""Top-level + chat parser only (no subcommand tree); chat dispatches to cmd_chat."""
|
|
from hermes_cli._parser import build_top_level_parser
|
|
|
|
parser, _subparsers, chat_parser = build_top_level_parser()
|
|
chat_parser.set_defaults(func=cmd_chat)
|
|
return parser
|
|
|
|
|
|
def _promote_top_level_resume(args) -> None:
|
|
"""Top-level --resume/--continue with no subcommand is a chat shortcut."""
|
|
if (args.resume or args.continue_last) and args.command is None:
|
|
args.command = "chat"
|
|
|
|
|
|
def _try_fast_serve_launch() -> bool:
|
|
"""Dispatch an unambiguous built-in ``serve`` without the full CLI tree.
|
|
|
|
Desktop runs this on every cold start; building every other parser costs
|
|
thousands of filesystem-backed lookups on Windows. Unknown or global
|
|
arguments fall back to normal parsing so error reporting is 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).
|
|
|
|
Building all ~40 subcommand parsers costs ~140ms the chat path never
|
|
uses. Bails out (False) whenever the invocation is not certainly a chat
|
|
launch — subcommand positional, ``--help``, unknown flags. Mirrors
|
|
``_try_termux_fast_cli_launch`` minus the Termux 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 routing must win: NixOS container mode forwards EVERY invocation.
|
|
try:
|
|
from hermes_cli.config import get_container_exec_info
|
|
if get_container_exec_info():
|
|
return False
|
|
except Exception:
|
|
return False
|
|
# TUI launches keep full dispatch outside Termux (own startup path).
|
|
if _wants_tui_early(argv):
|
|
return False
|
|
if _first_positional_argv() not in {None, "chat"}:
|
|
return False
|
|
|
|
parser = _light_chat_parser()
|
|
try:
|
|
args, unknown = parser.parse_known_args(_coalesce_session_name_args(argv))
|
|
except SystemExit:
|
|
return False
|
|
if unknown: # plugin subcommand or full-parser-only flag → 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)
|
|
|
|
_promote_top_level_resume(args)
|
|
_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
|
|
if _wants_tui_early(argv): # TUI fast path / full dispatch owns those
|
|
return False
|
|
|
|
if _startup_fast.is_global_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
|
|
|
|
parser = _light_chat_parser()
|
|
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)
|
|
|
|
_promote_top_level_resume(args)
|
|
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:
|
|
# Reach the prompt first; agent-only discovery on the first turn.
|
|
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 and the TUI immediately execs
|
|
Node, so the full parser's command-module imports are pure waste there.
|
|
"""
|
|
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
|
|
|
|
parser = _light_chat_parser()
|
|
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 cross-agent standard (huggingface_hub reads it); the
|
|
value must be our id in the public agent-harness registry
|
|
(``hermes-agent``) — matching is exact. ``HERMES_AGENT`` is the
|
|
Hermes-specific marker. setdefault: never clobber an outer harness.
|
|
|
|
``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. 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:
|
|
"""Register plugin-provided top-level commands (each plugin builds its own argparse tree).
|
|
|
|
Skipped when the invocation targets a known built-in — eagerly importing
|
|
every bundled plugin module 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()
|
|
# The invoked platform may still be a deferred entry; import it so its
|
|
# register_cli_command side effect runs before we read _cli_commands.
|
|
# See #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 _cmd_sessions_lazy(args, **kwargs):
|
|
"""``hermes sessions`` handler; sessions_cmd imports only when the subcommand runs."""
|
|
from hermes_cli.sessions_cmd import cmd_sessions
|
|
|
|
return cmd_sessions(args, **kwargs)
|
|
|
|
|
|
def _build_cli_parser():
|
|
"""Build the full ``hermes`` argparse tree -> ``(parser, subparsers)``.
|
|
|
|
Registration ORDER is the ``hermes --help`` order; keep it stable. Groups
|
|
live in ``hermes_cli/subcommands/<group>.py`` with handlers injected so
|
|
those modules never import main.
|
|
"""
|
|
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 egress firewall; ``hermes proxy`` (gateway group) is the INBOUND one.
|
|
build_egress_parser(subparsers)
|
|
build_migrate_parser(subparsers)
|
|
build_codex_runtime_parser(subparsers)
|
|
build_gateway_parser(
|
|
subparsers, cmd_gateway=cmd_gateway, cmd_proxy=cmd_proxy, cmd_gateway_enroll=cmd_gateway_enroll
|
|
)
|
|
|
|
# LSP is optional — a registration failure must not break the CLI.
|
|
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)
|
|
|
|
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)
|
|
|
|
from hermes_cli.subcommands.peer import build_peer_parser
|
|
build_peer_parser(subparsers)
|
|
|
|
from hermes_cli.portal_cli import add_parser as _add_portal_parser
|
|
_add_portal_parser(subparsers)
|
|
|
|
from hermes_cli.kanban import build_parser as _build_kanban_parser
|
|
_build_kanban_parser(subparsers).set_defaults(func=cmd_kanban)
|
|
|
|
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)
|
|
build_sessions_parser(subparsers, cmd_sessions=_cmd_sessions_lazy)
|
|
build_insights_parser(subparsers, cmd_insights=cmd_insights)
|
|
build_usage_parser(subparsers)
|
|
build_monitoring_parser(subparsers, cmd_monitoring=cmd_monitoring)
|
|
build_claw_parser(subparsers, cmd_claw=cmd_claw)
|
|
build_vault_parser(subparsers)
|
|
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" is canonical (Hermes-Setup.exe tells users to run it, so it
|
|
# must be the name --help shows); "gui" is a deprecated alias.
|
|
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.
|
|
|
|
On Python <3.11 argparse fails to route subcommand tokens when the parent
|
|
has nargs='?' optionals (--continue): "unrecognized arguments: model". When
|
|
argv holds a known subcommand token, set subparsers.required=True to force
|
|
routing; if that fails (``hermes -c model`` — 'model' is the session name)
|
|
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
|
|
if exc.code == 0: # help/version already printed; don't print twice
|
|
raise
|
|
# Subcommand consumed as a flag value (e.g. -c model): normal parse.
|
|
subparsers.required = False
|
|
args = parser.parse_args(_processed_argv)
|
|
return args
|
|
|
|
|
|
def _default_to_chat(args) -> None:
|
|
"""No subcommand given: run chat."""
|
|
_promote_top_level_resume(args)
|
|
_set_chat_arg_defaults(args)
|
|
cmd_chat(args)
|
|
|
|
|
|
def main():
|
|
"""Main entry point for hermes CLI."""
|
|
_set_process_title()
|
|
_warn_if_unsupervised_pid1()
|
|
_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
|
|
# A non-UTF-8 locale that the package import had to repair would crash Python children the
|
|
# same way. Only on that host, so a healthy UTF-8 locale keeps its children untouched.
|
|
from hermes_cli import _stdio_repaired
|
|
if _stdio_repaired:
|
|
os.environ.setdefault("PYTHONUTF8", "1")
|
|
os.environ.setdefault("PYTHONIOENCODING", "utf-8")
|
|
|
|
# One TLS authority: trust the OS store before any outbound call resolves a
|
|
# CA bundle (agent/ssl_verify.py). Never raises; False just means OpenSSL's paths.
|
|
from agent.ssl_verify import install_truststore
|
|
|
|
install_truststore()
|
|
|
|
# Sweep stale ``hermes.exe.old.*`` quarantine files from previous Windows
|
|
# updates. No-op elsewhere.
|
|
try:
|
|
_cleanup_quarantined_exes()
|
|
except Exception:
|
|
pass
|
|
|
|
# Checkout changed since last launch → sweep stale __pycache__ once so no
|
|
# process resolves fresh source against old bytecode. Never raises.
|
|
_sweep_stale_bytecode_if_checkout_changed()
|
|
|
|
# Dependency recovery already ran before imports. Report any fleet restart
|
|
# still owed by a previous update without restarting services here.
|
|
if "update" not in sys.argv[1:]:
|
|
try:
|
|
from hermes_cli.update_cmd_fleet import _warn_pending_fleet_restart_on_startup
|
|
|
|
_warn_pending_fleet_restart_on_startup()
|
|
except Exception:
|
|
pass
|
|
|
|
if _first_positional_argv() != "update":
|
|
from hermes_cli.boot_bootstrap import maybe_run_boot_bootstrap
|
|
from pm.paths import install_root
|
|
maybe_run_boot_bootstrap(install_root())
|
|
|
|
# Every dispatch, including fast chat/serve, gets one passive PM verdict.
|
|
try:
|
|
from hermes_cli.venv_sync import check_runtime
|
|
from pm.paths import install_root
|
|
|
|
problem = check_runtime(install_root())
|
|
if problem:
|
|
print(f"⚠ {problem}", file=sys.stderr)
|
|
except Exception:
|
|
import logging
|
|
|
|
logging.getLogger(__name__).debug("pm startup check failed", exc_info=True)
|
|
|
|
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()
|
|
|
|
# NixOS container mode routes ALL invocations into the managed container.
|
|
# MUST run before parse_args() so --help, unrecognised flags and every
|
|
# subcommand are forwarded instead of 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:])
|
|
sys.exit(1) # unreachable: execvp replaces the process or raises
|
|
|
|
args = _parse_cli_args(parser, subparsers, sys.argv[1:])
|
|
|
|
if args.version:
|
|
cmd_version(args)
|
|
return
|
|
|
|
# --yolo must be set *before* plugin discovery: tools.approval freezes
|
|
# _YOLO_MODE_FROZEN at import; set later (inside cmd_chat) it does nothing.
|
|
if getattr(args, "yolo", False):
|
|
os.environ["HERMES_YOLO_MODE"] = "1"
|
|
|
|
# Plugin discovery + shell hooks once, gated so introspection commands
|
|
# (hooks list, cron list, gateway status, ...) pay no discovery cost and
|
|
# trigger no 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
|
|
|
|
# A handler's int return code becomes the exit code (None = success).
|
|
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()
|
|
|
|
|
|
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
|
|
# Names external plugins imported from this module before the Sep 2026 decomposition.
|
|
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
|
|
# The whole block is removed by reverting the commit that added it.
|
|
import hashlib # noqa: F401,E402
|
|
import shlex # noqa: F401,E402
|
|
import stat # noqa: F401,E402
|
|
import tempfile # noqa: F401,E402
|
|
|
|
|
|
_PLUGIN_COMPAT_LAZY = {
|
|
'line_input': ('hermes_cli.cli_output', 'line_input'),
|
|
}
|
|
|
|
_plugin_compat_prev_getattr = __getattr__
|
|
|
|
|
|
def __getattr__(name): # PEP 562 — chained onto the module's own __getattr__
|
|
target = _PLUGIN_COMPAT_LAZY.get(name)
|
|
if target is None:
|
|
return _plugin_compat_prev_getattr(name)
|
|
import importlib
|
|
from hermes_cli.plugin_compat import warn_once
|
|
warn_once(__name__, name, *target)
|
|
return getattr(importlib.import_module(target[0]), target[1])
|
|
# ---- END PLUGIN-COMPAT ----
|