Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
166 changes: 118 additions & 48 deletions hermes_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,61 @@
import os
import sys


def _primary_command_early(argv: "list[str] | None" = None) -> str | None:
"""Return the first non-option command without importing argparse/config."""
if argv is None:
argv = sys.argv[1:]

value_flags = {
"-p", "--profile",
"-z", "--oneshot",
"-m", "--model",
"--provider",
"-t", "--toolsets",
"-r", "--resume",
"-s", "--skills",
"--usage-file",
}
optional_value_flags = {"-c", "--continue"}
i = 0
while i < len(argv):
arg = argv[i]
if arg == "--":
return argv[i + 1] if i + 1 < len(argv) else None
if arg.startswith("--profile="):
i += 1
continue
if "=" not in arg and arg in value_flags and i + 1 < len(argv):
i += 2
continue
if (
"=" not in arg
and arg in optional_value_flags
and i + 1 < len(argv)
and not argv[i + 1].startswith("-")
):
i += 2
continue
if arg.startswith("-"):
i += 1
continue
return arg
return None


def _windows_update_import_minimal() -> bool:
"""True while Windows `hermes update` must avoid target-venv imports.

Windows keeps native extensions (`*.pyd`) mapped while their importing
process is alive. The update command mutates the same venv it runs from, so
importing PyYAML/config before `uv pip install -e .[all]` can lock
`yaml/_yaml*.pyd` and strand the install half-updated. Keep startup on this
path dependency-light until the dependency sync has completed.
"""
return sys.platform == "win32" and _primary_command_early() == "update"


# ── Startup fast-path bootstrap ─────────────────────────────────────────
# Two lines of inline path math so ``python hermes_cli/main.py`` (script
# mode — sys.path[0] is hermes_cli/, not the repo root) can import the
Expand Down Expand Up @@ -326,6 +381,8 @@ def _wants_tui_early(argv: "list[str] | None" = None) -> bool:
"""
if argv is None:
argv = sys.argv[1:]
if sys.platform == "win32" and _primary_command_early(argv) == "update":
return False
if "--cli" in argv:
return False
if os.environ.get("HERMES_TUI") == "1" or "--tui" in argv:
Expand Down Expand Up @@ -691,10 +748,12 @@ def _resolve_sudo_user_profile_env(name: str) -> str | None:

# 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
from hermes_constants import get_hermes_home

load_hermes_dotenv(project_env=PROJECT_ROOT / ".env")
if not _windows_update_import_minimal():
from hermes_cli.env_loader import load_hermes_dotenv

load_hermes_dotenv(project_env=PROJECT_ROOT / ".env")

# Bridge security.redact_secrets from config.yaml → HERMES_REDACT_SECRETS env
# var BEFORE hermes_logging imports agent.redact (which snapshots the flag at
Expand All @@ -707,6 +766,8 @@ def _resolve_sudo_user_profile_env(name: str) -> str | None:
# `load_config()` was doing a full deep-merge for one boolean lookup).
_FORCE_IPV4_EARLY = False
try:
if _windows_update_import_minimal():
raise RuntimeError("skip config yaml during Windows update pre-sync")
# Reuse read_raw_config()'s (mtime, size)-keyed cache instead of a bespoke
# yaml.load — the SAME parse then serves hermes_logging's
# _read_logging_config and any later raw reads in this process, collapsing
Expand Down Expand Up @@ -745,6 +806,8 @@ def _resolve_sudo_user_profile_env(name: str) -> str | None:
# Dashboard entrypoints bootstrap with GUI mode so gui.log is always present
# during GUI testing, including pre-dispatch startup failures.
try:
if _windows_update_import_minimal():
raise RuntimeError("skip logging setup during Windows update pre-sync")
from hermes_logging import setup_logging as _setup_logging

_setup_logging(
Expand Down Expand Up @@ -779,29 +842,30 @@ def _resolve_sudo_user_profile_env(name: str) -> str | None:
# Provider model-selection wizard flows extracted to hermes_cli/model_setup_flows.py
# (god-file decomposition Phase 2). Re-imported here so select_provider_and_model and
# existing test monkeypatches (hermes_cli.main._model_flow_*) keep resolving unchanged.
from hermes_cli.model_setup_flows import (
_prompt_auth_credentials_choice,
_model_flow_openrouter,
_model_flow_nous,
_model_flow_openai_codex,
_model_flow_xai_oauth,
_model_flow_qwen_oauth,
_model_flow_minimax_oauth,
_model_flow_custom,
_model_flow_azure_foundry,
_model_flow_named_custom,
_model_flow_copilot,
_model_flow_copilot_acp,
_model_flow_kimi,
_model_flow_stepfun,
_model_flow_bedrock_api_key,
_model_flow_bedrock,
_model_flow_vertex,
_model_flow_api_key_provider,
_model_flow_anthropic,
_model_flow_moa,
_model_flow_ai_gateway,
)
if not _windows_update_import_minimal():
from hermes_cli.model_setup_flows import (
_prompt_auth_credentials_choice,
_model_flow_openrouter,
_model_flow_nous,
_model_flow_openai_codex,
_model_flow_xai_oauth,
_model_flow_qwen_oauth,
_model_flow_minimax_oauth,
_model_flow_custom,
_model_flow_azure_foundry,
_model_flow_named_custom,
_model_flow_copilot,
_model_flow_copilot_acp,
_model_flow_kimi,
_model_flow_stepfun,
_model_flow_bedrock_api_key,
_model_flow_bedrock,
_model_flow_vertex,
_model_flow_api_key_provider,
_model_flow_anthropic,
_model_flow_moa,
_model_flow_ai_gateway,
)
logger = logging.getLogger(__name__)


Expand Down Expand Up @@ -5062,7 +5126,9 @@ def _clear_bytecode_cache(root: Path) -> int:
_npm_lockfile_changed,
_npm_manifest_paths,
_npm_manifests_digest,
_gateway_run_argv_minimal,
_pause_windows_gateways_for_update,
_pause_windows_gateways_for_update_minimal,
_print_curator_first_run_notice,
_print_curator_recent_run_notice,
_print_fts_optimize_available_notice,
Expand Down Expand Up @@ -9068,32 +9134,36 @@ def cmd_update(args):
runs the update, then restores stdio on the way out (even on
``sys.exit`` or unhandled exceptions).
"""
from hermes_cli.config import (
detect_install_method,
format_docker_update_message,
is_managed,
managed_error,
recommended_update_command_for_method,
minimal_windows_git_update = (
_windows_update_import_minimal() and (PROJECT_ROOT / ".git").exists()
)
if not minimal_windows_git_update:
from hermes_cli.config import (
detect_install_method,
format_docker_update_message,
is_managed,
managed_error,
recommended_update_command_for_method,
)

if is_managed():
managed_error("update Hermes Agent")
return
if is_managed():
managed_error("update Hermes Agent")
return

# Docker users can't ``git pull`` — the image excludes ``.git`` from
# the build context. Bail with a friendly explanation pointing at
# ``docker pull`` BEFORE any of the apply-path / check-path branches
# below get a chance to error out with misleading "Not a git
# repository" text. See format_docker_update_message() for the full
# rationale and tag-pinning / config-persistence notes.
install_method = detect_install_method(PROJECT_ROOT)
if install_method == "docker":
print(format_docker_update_message())
sys.exit(1)
# Docker users can't ``git pull`` — the image excludes ``.git`` from
# the build context. Bail with a friendly explanation pointing at
# ``docker pull`` BEFORE any of the apply-path / check-path branches
# below get a chance to error out with misleading "Not a git
# repository" text. See format_docker_update_message() for the full
# rationale and tag-pinning / config-persistence notes.
install_method = detect_install_method(PROJECT_ROOT)
if install_method == "docker":
print(format_docker_update_message())
sys.exit(1)

if install_method in {"nix", "nixos"}:
print(recommended_update_command_for_method(install_method))
sys.exit(1)
if install_method in {"nix", "nixos"}:
print(recommended_update_command_for_method(install_method))
sys.exit(1)

if getattr(args, "check", False):
# --check honors --branch so the "any new commits?" answer matches
Expand Down
Loading
Loading