`hermes update` only rebuilt Desktop when apps/desktop/release/ or dist/ existed, so an installed Hermes.app that only the checkout update refreshes (a bootstrap build) never got newer once release/ was gone or never built. Count such bundles as a Desktop to keep current. Ownership is read from the bundle's install-stamp.json: `updateMechanism: self` (or a stamp older than the field). Self-updating releases and commit builds are never rebuilt or copied over, and only the checkout an installed app actually runs (the one under the default Hermes home) claims it. The post-build install now uses the same set.
3720 lines
150 KiB
Python
3720 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
|
|
# Set only when -p/--profile was on argv. Sticky active_profile must not count:
|
|
# `hermes desktop` with no flag must not overwrite Desktop's stored profile.
|
|
_explicit_cli_profile: str | None = None
|
|
|
|
|
|
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 explicit_cli_profile() -> str | None:
|
|
"""Profile named by a consumed ``-p``/``--profile`` flag, else None.
|
|
|
|
Sticky ``active_profile`` is not explicit. Desktop launch must not overwrite
|
|
its stored profile when the user omitted the flag.
|
|
"""
|
|
return _explicit_cli_profile
|
|
|
|
|
|
def _apply_profile_override() -> None:
|
|
"""Pre-parse --profile/-p and set HERMES_HOME before imports."""
|
|
global _explicit_cli_profile
|
|
_explicit_cli_profile = None
|
|
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
|
|
if consume > 0:
|
|
_explicit_cli_profile = profile_name
|
|
# 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,
|
|
_installed_desktop_apps,
|
|
)
|
|
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 ----
|